원티드 포텐업

[2편 2단위 팀프로젝트 기술-공통] JPA 양방향 매핑과 Soft Delete의 덫: LXP 프로젝트를 지탱한 5가지 기술적 의사결정

hoya1122 2026. 6. 8. 14:16

💡 [Java/Spring] LXP 회원 및 수강 신청 도메인 구축 시리즈
1편 (종합 회고): [ERD에서 도메인 중심으로: LXP 수강 신청 시스템 개발 및 KPT]
2편 (기술-공통): [JPA 양방향 매핑과 Soft Delete의 덫: LXP 프로젝트를 지탱한 5가지 기술적 의사결정] (현재 글)
3편 (기술-개인): [수강 도메인 분투기: Stacked Branch 활용과 개인 트러블슈팅]


1. 🎯 배경: RDBMS 패러다임과 객체지향 패러다임의 간극

처음 프로젝트를 설계할 때 팀원들을 가장 괴롭혔던 것은 '도메인 중심(객체지향) 설계'에 대한 모호함이었습니다. RDBMS는 데이터 간의 연관관계를 파악하고 엑셀과 같은 행열 중심의 데이터를 적재하는 관점을 가지는 반면, 도메인 중심 설계는 다소 뜬구름 잡는 듯한 추상적인 느낌이었습니다.

특히 강좌를 수강할 때 '수강 도메인'이 별도로 생성된다는 개념이 가장 와닿지 않았습니다. 하지만 단순한 이력 추가가 아니라, 도메인 자체가 수강이라는 행위를 책임지는 주체라고 이해하고 접근하니 객체지향적 설계가 점차 수월해졌습니다. 이번 2편에서는 객체지향과 JPA 패러다임으로 전환하는 과정에서 마주했던 5가지 핵심 트러블슈팅을 공유합니다.


2. 🛠️ 트러블슈팅 1: N+1 문제와 MultipleBagFetchException

🚨 문제 상황

학습 과정 중 강의(Course) ➔ 섹션(Section) ➔ 강의자료(Material)로 이어지는 3개 도메인 관계에서 강의 조회 시 N+1 문제가 발생했습니다. 이를 해결하고자 @EntityGraph(Fetch Join)를 도입했으나, 이번에는 MultipleBagFetchException이 발생했습니다.

원인을 분석해 보니, 3개 도메인의 연관관계에 Fetch Join을 중첩 적용할 경우 카테시안 곱(Cartesian Product)이 발생하여 데이터 불일치 및 심각한 성능 저하가 우려되었고, Hibernate가 이를 막기 위해 의도적으로 예외를 던지는 것이었습니다.

💡 대안 탐색 및 해결 과정

Course-Section 간의 관계를 지연 로딩(Lazy Loading)으로 유지하고, Section-Material 하위 로딩 시점에 프록시 객체들을 메모리에 모아두었다가 최대 100개씩 묶어서 조회해오는 @BatchSize(size=100)를 적용하여 문제를 해결했습니다.

💻 핵심 코드 스니펫

// Course.java (상위 엔티티)
@BatchSize(size = 100)
@OneToMany(mappedBy = "course", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Section> sections = new ArrayList<>();

// Section.java (중간 엔티티)
@BatchSize(size = 100)
@OneToMany(mappedBy = "section", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Material> materials = new ArrayList<>();

// CourseRepository.java
@Query("SELECT c FROM Course c " +
       "LEFT JOIN FETCH c.sections " +
       "WHERE c.id = :id AND c.deletedAt IS NULL")
Optional<Course> findByIdWithCurriculum(@Param("id") Long id);

3. 🛠️ 트러블슈팅 2: Soft Delete와 Composite Unique Key 충돌

🚨 문제 상황

수강 도메인에서 중복 수강을 방지하기 위해 강좌(Course)와 회원(Member) ID를 복합 Unique Key로 설정했습니다. 또한 데이터 보호를 위해 BaseEntity를 상속받아 Soft Delete 정책을 적용 중이었습니다.
이때 동일 수강생이 수강을 취소(Soft Delete)한 후 해당 강좌를 다시 재수강 신청할 때, 이미 DB에 삭제 처리된 기존 이력이 남아 있어 Unique Key 제약 조건(DataIntegrityViolationException) 충돌이 발생했습니다.

💡 대안 탐색 및 해결 과정

이 문제를 해결하기 위해 팀 내에서 두 가지 방법이 도출되었습니다.

  • 방법 1: Restore 방식 (초기 적용 - Bad)
    • 기존에 Soft Delete된 데이터가 있는지 먼저 조회(Optional)한 뒤, 데이터가 있으면 deleted_at 컬럼을 null로 업데이트하는 restore() 메서드를 호출했습니다.
    • 단점: 매번 조회가 선행되어야 하며, 중복 방어를 위한 try-catch와 분기 로직으로 인해 서비스 코드가 매우 비대해졌습니다.
🚨 [방법 1] 비대해졌던 기존 Restore 방식 전체 코드 보기 (클릭하여 펼치기)
// common/domain/BaseEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime modifiedAt;

    @Column
    private LocalDateTime deletedAt;

    public boolean isDeleted() {
        return deletedAt != null;
    }

    public void delete() {
        if (this.deletedAt == null) {
            this.deletedAt = LocalDateTime.now();
        }
    }

    public void restore() {
        this.deletedAt = null;
    }
}

// EnrollmentService.java
@Service
@Transactional(readOnly = true)
public class EnrollmentService {

    /**
     * 수강신청
     * @return Long enrollmentId
     */
    @Transactional
    public Long enroll(EnrollmentRequest request) {

        // 1. 회원(Learner) 유효성 검증: 존재 여부 및 Soft Delete 여부 확인
        Member learner = memberRepository.findById(request.learnerId())
            .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND, "/courses/" + request.courseId()));
        if (learner.isDeleted()) {
            throw new CustomException(ErrorCode.MEMBER_ALREADY_WITHDRAWN); // 탈퇴한 회원 예외
        }

        // 2. 강의(Course) 유효성 검증: 존재 여부 및 Soft Delete 여부 확인
        Course course = courseRepository.findById(request.courseId())
            .orElseThrow(() -> new CustomException(ErrorCode.COURSE_NOT_FOUND, "/courses/" + request.courseId()));
        if (course.isDeleted()) {
            throw new CustomException(ErrorCode.COURSE_ALREADY_WITHDRAWN); // 폐강된 강의 예외
        }

        // 3. 회원과 강의는 있는 것으로 확인 수강 로직 진행
        // upsert로 진행(soft delete로 수강-회원이 남아있을 경우 재수강이 불가 함)
        Optional<Enrollment> enrollmentOpt = enrollmentRepository.findByLearnerIdAndCourseId(
            request.learnerId(), request.courseId());

        Long enrollmentId;
        if (enrollmentOpt.isPresent()) {
            Enrollment enrollment = enrollmentOpt.get();

            // 3-1. 이미 정상적으로 수강 중인 경우 (중복 신청 방어)
            if (!enrollment.isDeleted()) {
                throw new CustomException(
                    ErrorCode.ENROLLMENT_ALREADY_EXISTS_SKIPPED, "/courses/" + request.courseId());
            }

            // 3-2. Soft Delete된 데이터가 있는 경우 -> 복구 (Update)
            enrollment.restore();
            enrollmentId = enrollment.getId();
        } else {
            // 3-3. 기존 데이터가 아예 없는 경우 -> 신규 생성 (Insert)
            Enrollment enrollment = Enrollment.createEnrollment(learner, course);

            try {
                Enrollment savedEnrollment = enrollmentRepository.save(enrollment);
                enrollmentRepository.flush();

                enrollmentId = savedEnrollment.getId();
            } catch (DataIntegrityViolationException e) {
                throw new CustomException(
                    ErrorCode.ENROLLMENT_ALREADY_EXISTS_SKIPPED, "/courses/" + request.courseId());
            } catch (Exception e) {
                // JPA에서의 문제 발생 및 기타 에러
                throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "/courses/" + request.courseId());
            }
        }

        cartService.deleteCartItemsByCourseIds(request.learnerId(), List.of(request.courseId()));
        return enrollmentId;
    }
}
  • 방법 2: Generated Column 방식 (최종 채택 - Good)
    • 팀 회고를 통해 DB의 Generated Column을 활용하는 것이 더 낫다는 결론을 내렸습니다.
    • case when deleted_at is null then email else null end와 같이 데이터가 활성화된 상태일 때만 고유 값을 가지게 설정했습니다. DB는 Unique 제약 조건에서 NULL 중복을 허용하므로, 앞선 1번 방법의 지저분한 try-catch나 restore 로직을 전부 걷어내고 우아하게 제약 조건을 우회할 수 있었습니다.

💻 개선된 핵심 코드 스니펫

@Entity
@Table(name = "members", uniqueConstraints = @UniqueConstraint(name = "unique_active_email", columnNames = "active_email"))
public class Member extends BaseEntity {

    // [핵심] 탈퇴 회원의 재가입 허용: 활동 중이면 email, 탈퇴 시 NULL 할당으로 UNIQUE 제약 우회
    @Column(name = "active_email", insertable = false, updatable = false,
            columnDefinition = "varchar(100) generated always as (case when deleted_at is null then email else null end)")
    private String activeEmail;
}

4. 🛠️ 트러블슈팅 3: @Formula 활용과 오버페칭

🚨 문제 상황

강좌(Course) 도메인에서 현재 수강생 수를 조회하기 위해 엔티티 내부에 @Formula 어노테이션을 활용해 서브쿼리를 삽입했습니다. 그러나 수강생 수가 굳이 필요 없는 가벼운 강의 목록을 조회할 때조차 무조건 카운트 쿼리가 발생하여 오버페칭(Over-fetching)이 일어났습니다.

💡 대안 탐색 및 해결 과정

JPA의 엔티티 영속성 구조에 얽매이지 않고, 뷰(View)에 필요한 데이터만 정확히 매핑하는 JPA Projection (Class 기반) 방식을 도입했습니다. DTO 클래스(CourseResponse)를 생성하고 JPQL에서 new 키워드를 사용해 필요한 순간에만 카운트 쿼리가 실행되도록 최적화했습니다.

💻 핵심 코드 스니펫

// CourseResponse.java
public record CourseResponse(
    Long id,
    String title,
    String instructor,
    String description,
    String thumbnailUrl,
    Integer learnerCount,
    List<SectionResponse> curriculum
) {
    public static CourseResponse of(Course course) {
        return new CourseResponse(
            course.getId(),
            course.getTitle(),
            course.getInstructorInfo().getName(),
            course.getDescription(),
            course.getThumbnailUrl(),
            course.getLearnerCount(),
            List.of()
        );
    }
}

// CourseRepository.java
@Query("""
       SELECT new wanted.jjsbd.lxpmvc.course.dto.CourseResponse(
           c.id, c.title, c.description, 
           (SELECT COUNT(e) FROM Enrollment e WHERE e.course.id = c.id)
       ) 
       FROM Course c
       """)
List<CourseResponse> findCoursesWithLearnerCount();

5. 🛠️ 트러블슈팅 4: Global Exception Handler 설계 및 고려사항

🚨 문제 상황

에러 코드(ErrorCode)는 공통 Enum으로 설계했으나, 팀원들이 예외를 던지는 방식이 파편화되어 있었습니다. 더욱이 SSR(Thymeleaf) 환경이었기 때문에, 특정 예외 발생 시 에러 메시지뿐만 아니라 사용자를 적절한 화면으로 리다이렉트(Redirect) 시켜야 하는 요구사항이 있었습니다.

💡 대안 탐색 및 해결 과정

CustomException 클래스 내에서 분기 처리를 할지 고민했으나 코드가 비대해질 것을 우려하여, 팀 상의 끝에 @ControllerAdvice를 활용한 Global Exception Handler를 적용했습니다.
커스텀 예외 발생 시 redirectUrl 파라미터를 담아 보내면, 핸들러에서 이를 캐치해 RedirectAttributes에 에러 메시지를 담아 지정된 주소로 안전하게 라우팅하도록 일원화했습니다.

💡 적용된 Global Exception Handler 전체 코드 보기 (클릭하여 펼치기)
// common/exception/CustomException.java
@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;
    private final String redirectUrl;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.redirectUrl = null;
    }

    // URL을 지정할 수 있는 생성자
    public CustomException(ErrorCode errorCode, String redirectUrl) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.redirectUrl = redirectUrl;
    }
}

// common/GlobalExceptionHandler.java
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public String handleCustomException(
        CustomException ex,
        HttpServletRequest request,
        HttpServletResponse response,
        Model model,
        RedirectAttributes redirectAttributes
    ) {
        ErrorCode errorCode = ex.getErrorCode();
        log.debug("CustomException: code={}, path={}", errorCode.getCode(), request.getRequestURI(), ex);

        if (ex.getRedirectUrl() != null) {
            redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());

            String redirectUrl = ex.getRedirectUrl();
            if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) {
                return "redirect:" + redirectUrl;
            }
            log.warn("허용되지 않은 redirectUrl: {}", redirectUrl);
            addErrorAttributes(model, request, response, ErrorCode.INVALID_INPUT, "허용되지 않은 리다이렉트 경로입니다.");
            return ERROR_VIEW;
        }

        addErrorAttributes(model, request, response, errorCode, ex.getMessage());
        return ERROR_VIEW;
    }
}

6. 🛠️ 트러블슈팅 5: Checkstyle을 활용한 CI 파이프라인 컴파일 브레이크

🚨 문제 상황

CI 파이프라인에 Naver Checkstyle을 연동했으나, 팀원이 올린 PR이 명백히 CI 규칙(컨벤션)을 위반했음에도 빌드가 무사히 통과되는 심각한 사례가 발견되었습니다.

💡 대안 탐색 및 해결 과정

분석 결과, 상당수의 포맷팅 위반 규칙이 단순 '경고(Warning)' 레벨로 설정되어 있어 빌드 실패를 유발하지 않았습니다. 이를 엄격히 통제하기 위해 build.gradle에 maxWarnings = 0 속성을 설정했습니다.
이제는 사소한 컨벤션 경고 1개만 발생해도 CI 검증이 실패(❌)하도록 강제하여, 견고한 코드 품질을 유지할 수 있게 되었습니다.

▲ Checkstyle maxWarnings = 0 재적용 이후, entity id 규칙 적용 등 컨벤션을 위반한 커밋(❌)이 즉시 차단되고 수정 후 통과(✅)되는 실제 CI 파이프라인 동작 화면


7. ✨ 마무리 및 배운 점

  • 기술적 트레이드 오프에 대한 통찰: 양방향 매핑에서 발생하는 N+1 문제를 해결할 때 @EntityGraph와 @BatchSize의 차이점을 명확히 인지하게 되었으며, @Formula 같이 간편한 기능의 이면에 숨겨진 오버페칭 리스크를 고려하게 되었습니다.
  • DB와 애플리케이션의 조화: Soft Delete 환경에서 발생한 복합 Unique 제약 조건 충돌을 Restore 방식과 DB의 Generated Column 방식으로 비교해 보며 실무적인 트레이드 오프를 학습했습니다.
  • 협업의 사이드 이펙트 인지: Checkstyle 검증 강제화 및 Global Exception 처리를 통해, 혼자 개발할 때는 생각하지 못했던 '내 코드가 팀원에게 미치는 영향력'을 심도 있게 고민하고 적용하는 계기가 되었습니다.

🔗 다음 이야기
기술적인 트러블슈팅 외에도, 협업 과정에서 발생한 '도메인 개발 지연' 사태를 Git 브랜치 전략으로 현명하게 극복해 낸 이야기는 👉 [3편 2단위 팀프로젝트 기술-개인] 수강 도메인 분투기: Stacked Branch 활용과 개인 트러블슈팅에서 확인해 주세요!