JPA 연관관계와 N+1 (Feat. QueryDSL) - Workaway

chaean·2025년 4월 16일

Workaway

목록 보기
1/11

단방향 vs 양방향 연관관계 고민

TRIO 팀 프로젝트에 참여하면서 실무에 가까운 설계 고민을 많이 하게 되었습니다.
그 중 가장 먼저 마주했던 주제는 JPA에서 단방향과 양방향 연관관계 중 어떤 것을 선택할 것인가였습니다.

초기에는 모든 Entity가 양방향으로 관계를 맺고 있었지만, 아래와 같은 이유로 설계를 재검토하게 되었습니다.

구분단방향 연관관계양방향 연관관계
장점- 구조가 단순하고 명확
- 무한루프 문제 없음 (toString, equals, JSON 직렬화 안전)
- 연관관계 주인 설정 불필요
- 코드 유지보수가 쉬움
- 데이터 흐름이 명확함
- 객체 그래프 탐색이 자유로움
- 양쪽 방향 데이터 접근이 편리함
- 컬렉션 정렬, 필터링, 집계 쉬움
- fetch join 사용이 직관적
단점- 역방향 탐색 불가 (예: guesthouse.getReviews() 불가)
- 컬렉션 조회/정렬/집계가 어려움
- 양쪽 데이터를 화면에서 동시에 사용하려면 추가 쿼리 필요
- 실시간 탐색을 위해 fetch join 또는 DTO 필요
- 연관관계 주인 설정 필요 (mappedBy)
- 양쪽 상태 동기화 필요 (편의 메서드 필수)
- 무한루프 위험 (toString, JSON 등)
- 중복 조회, 카티션 곱 등 성능 문제 가능성
단점 해결법- 필요한 경우 fetch join, @EntityGraph 사용
- DTO Projection 활용
- QueryDSL로 커스텀 조회 작성
- 편의 메서드 구현 (addReview 등)
- 순환 방지: @JsonManagedReference, @JsonBackReference, @ToString.Exclude 등 사용
- 페이징 문제: QueryDSL 등으로 해결
Markdown 어렵다 😅

위와 같은 비교를 바탕으로, 저희 팀에서는 모든 연관관계를 일단 단방향으로 설계하고, 추후 필요 시 양방향으로 변경하는 전략을 채택하였습니다.


N+1 문제와 해결

📌 N+1 문제란?

JPA에서 연관된 엔티티를 지연 로딩(LAZY)할 경우, 1차 조회 이후 연관 데이터를 개별로 추가 조회하는 문제입니다.

예시:

@Entity
public class Review {
    @ManyToOne(fetch = FetchType.LAZY)
    private Guesthouse guesthouse;
}
List<Review> reviews = reviewRepository.findAll(); // 1개의 쿼리
for (Review review : reviews) {
    System.out.println(review.getGuesthouse().getName()); // N개의 쿼리 발생
}
•	reviewRepository.findAll() → 리뷰 10건 조회 (1번 쿼리)
•	review.getGuesthouse().getName() 호출 시마다 → 추가 쿼리 10번 발생

🔥 결과적으로 총 N + 1 쿼리가 발생

단방향 관계에서도 동일하게 발생하는 문제입니다.

해결 방법

1. @EntityGraph 사용

public interface ReviewRepository extends JpaRepository<Review, Long> {
    @EntityGraph(attributePaths = "guesthouse")
    List<Review> findAllByGuesthouse(Guesthouse guesthouse);
}
•	연관 필드를 명시적으로 fetch join하여 N+1 해결
•	단, @OneToMany + @EntityGraph + 페이징 조합은 성능 문제가 발생할 수 있음
•	연관 필드가 엔티티 내에 선언되어 있어야 적용 가능

예: Guesthouse가 reviews를 가지고 있지 않다면, EntityGraph로 접근이 불가능합니다.
이 경우 Review → Guesthouse로만 탐색이 가능하여 객체 지향적 흐름이 어려워집니다.

2. QueryDSL

복잡한 쿼리나 페이징, 다중 정렬, 필드 선택 등을 유연하게 처리하기 위해 QueryDSL을 도입했습니다.

✅ 장점

  • Fetch Join으로 N+1 문제 해결
  • 페이징, 정렬 등 복합 조건 구현 용이
  • 필요한 필드만 DTO로 조회 가능 → 성능 최적화

설정 (Java 17, Spring Boot 3.4.4)

// build.gradle
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile

sourceSets {
    main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}

clean.doLast {
    file(querydslDir).deleteDir()
}

clean {
    delete file('src/main/generated')
}
  • build/generated/querydsl에 Q타입 생성 확인
@Configuration
@RequiredArgsConstructor
public class QueryDSLConfig {

    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

🧾 결론

복잡한 테이블 간 연관관계가 많은 상황에서
양방향 설계는 직렬화 이슈, 순환 참조, 연관관계 관리 복잡도 등의 문제가 발생할 수 있습니다.

따라서 초기에는 단방향 관계로 설계하여 단순하고 유지보수하기 쉬운 구조를 만들고,
필요한 경우에만 명시적으로 양방향 관계를 추가하기로 결정했습니다.

또한 N+1 문제 해결을 위해 EntityGraph만으로는 한계가 있었고,
QueryDSL 도입을 통해 유연한 fetch join + 페이징 + DTO 조회가 가능해졌습니다.💪

profile
백엔드 개발자

0개의 댓글