
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 등으로 해결 |
위와 같은 비교를 바탕으로, 저희 팀에서는 모든 연관관계를 일단 단방향으로 설계하고, 추후 필요 시 양방향으로 변경하는 전략을 채택하였습니다.
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 쿼리가 발생
단방향 관계에서도 동일하게 발생하는 문제입니다.
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로만 탐색이 가능하여 객체 지향적 흐름이 어려워집니다.
복잡한 쿼리나 페이징, 다중 정렬, 필드 선택 등을 유연하게 처리하기 위해 QueryDSL을 도입했습니다.
✅ 장점
// 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 조회가 가능해졌습니다.💪