본문 바로가기
Back-end & 알고리즘

JPA 연관관계 매핑의 늪에서 벗어나는 실전 설계 단순화 가이드

by CodeByJin 2026. 5. 25.
반응형

프로젝트 오픈 직전, 무심코 날린 주문 조회 API 한 줄에 수십 개의 하위 쿼리가 폭포수처럼 쏟아지는 걸 보며 식은땀을 흘려본 적이 있을 것이다. JPA는 달콤하지만 그 이면에 숨겨진 엔티티 연관관계의 늪은 생각보다 깊고 어둡다. 처음엔 객체지향이라는 화려한 명분에 취해 @OneToMany@ManyToOne을 사방에 얽어매지만, 요구사항이 두 가지만 바뀌어도 엔티티 그래프는 누구도 손댈 수 없는 폭탄으로 변한다.

 

실무에서 발생하는 JPA 관련 장애의 90%는 객체 모델과 관계형 데이터베이스의 패러다임 불일치를 무시한 채, 무턱대고 양방향 매핑을 난사하는 데서 시작된다. 15년 넘게 백엔드를 아키텍칭하면서 숱하게 겪었던 고통과 이를 해결하기 위해 뼈를 깎아내며 정립한 연관관계 단순화 전략을 날것 그대로 공유한다. 이론서에 나오는 뻔한 이야기는 접어두고, 당장 내일 출근해서 운영 코드를 뜯어고칠 수 있는 실전 가이드다.

1. 양방향 매핑이라는 환상 버리기: 단방향 우선 원칙

신입 개발자들이 가장 자주 범하는 실수가 "객체는 양쪽에서 서로를 참조하는 게 자연스럽다"는 생각으로 모든 관계를 양방향으로 묶어버리는 것이다. 단언컨대, 실무 시스템 개발에서 처음부터 양방향 매핑이 필요한 경우는 거의 없다. 양방향 매핑을 맺는 순간, 우리는 객체 양쪽의 상태를 동기화해야 하는 고단한 짐을 짊어지게 된다.

 

데이터베이스 관점에서 생각해보자. 외래 키(FK)는 언제나 '다(Many)' 측에 하나만 존재한다. 데이터베이스는 단 하나의 외래 키로 양방향 조인을 자유자재로 수행하는데, 객체라는 이유로 억지로 양쪽에 참조를 두는 것은 패러다임의 왜곡이다. 양방향 관계가 되면 연관관계의 주인을 지정해야 하고, 데이터 변경 시 어느 쪽을 수정해야 할지 헷갈리기 시작하며, 롬복(Lombok)의 @ToString이나 JSON 직렬화 과정에서 순환 참조로 인한 스택 오버플로우(StackOverflowError)를 맛보게 된다.

 

설계의 기본값은 무조건 'N:1 단방향'이어야 한다. 회원이 주문을 참조하거나, 주문이 상품을 참조하는 구조면 충분하다. "주문에서 주문한 회원 정보가 필요한데, 회원 쪽에서 주문 목록을 보고 싶으면 어떡하나요?"라는 질문이 나올 수 있다. 그때는 회원 엔티티에 @OneToMany를 걸 게 아니라, 주문 리포지토리(OrderRepository)에서 findByMemberId(Long memberId) 쿼리를 날리는 게 맞다. 엔티티 그래프를 타고 들어가는 조회는 도메인 맥락을 흐리고, 영속성 컨텍스트에 불필요한 객체를 가득 채우는 주범이다.

서버 연결선
서버 연결선 - 생성형 ai 이미지

2. @ManyToMany는 재앙의 씨앗: 중간 엔티티 승격 전략

JPA 명세에 존재한다는 이유로 @ManyToMany를 실무 코드에 방치하는 것은 시한폭탄의 타이머를 누르는 것과 같다. 이 매핑은 개발자가 모르는 사이에 중간 매핑 테이블을 자동으로 생성해주므로 처음에 코딩할 때는 굉장히 편해 보인다. 하지만 현업의 요구사항은 결코 머무르지 않는다.

 

예를 들어, 상품(Product)과 주문(Order)이 다대다 관계라고 치자. 처음에는 단순 연결로 끝날 것 같지만, 일주일만 지나면 "주문한 수량", "주문 당시의 할인 가격", "상태 값" 같은 데이터가 중간 테이블에 들어가야 하는 상황이 반드시 발생한다. 하지만 @ManyToMany로 생성된 숨겨진 중간 테이블에는 개발자가 임의로 컬럼을 추가할 수 없다. 결국 기존 매핑을 다 뜯어내고 데이터를 마이그레이션해야 하는 대공사가 벌어진다.

 

이를 방지하려면 처음부터 다대다 관계를 1:N, N:1 관계로 쪼개고, 중간 테이블을 아예 핵심 도메인 엔티티(예: OrderItem)로 승격시켜야 한다.

// 잘못된 방법: @ManyToMany의 유혹
@Entity
public class Order {
    @ManyToMany
    @JoinTable(name = "order_product")
    private List products = new ArrayList<>();
}

// 올바른 방법: 중간 엔티티를 명시적으로 선언
@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
    private List orderItems = new ArrayList<>();
}

@Entity
public class OrderItem {
    @Id @GeneratedValue
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;
    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;
    private int count; // 추가 요구사항을 유연하게 수용 가능
    private int orderPrice;
}

중간 엔티티를 명시적으로 두면 비즈니스 로직이 흘러갈 공간이 생긴다. 주문 아이템의 수량을 변경하거나, 상품별 할인을 적용하는 도메인 로직을 OrderItem 내부에 깔끔하게 캡슐화할 수 있다. 구조가 명확해지니 유지보수 난이도는 급격히 떨어진다.

3. 영속성 전이(CascadeType.ALL)의 배신과 통제 방법

CascadeType.ALLorphanRemoval = true 조합은 부모를 저장할 때 자식도 같이 저장되고, 부모를 지우면 자식까지 깔끔하게 정리되어 코드가 우아해 보인다. 게시글과 댓글 관계 같은 전형적인 부모-자식 관계에서는 잘 작동한다. 하지만 이 편리함에 중독되어 아무 엔티티에나 ALL을 남발하는 순간, 데이터가 통째로 증발하는 대참사를 겪게 된다.

 

실무에서 영속성 전이를 사용할 때는 딱 한 가지만 기억해야 한다. **"자식 엔티티의 생명주기를 관리하는 부모가 단 하나뿐인가?"** 즉, 해당 자식 엔티티를 참조하는 곳이 오직 그 부모 엔티티 하나일 때만 영속성 전이를 허용해야 한다. 만약 자식 객체가 다른 도메인이나 다른 엔티티에서도 참조된다면, 절대 CascadeType.ALL이나 REMOVE를 걸어서는 안 된다. 부모 객체 하나 지웠는데, 엉뚱한 비즈니스 영역에서 참조하던 데이터까지 데이터베이스에서 함께 DELETE 쿼리가 나가는 광경을 보게 될 것이다.

 

현업에서는 안전을 위해 CascadeType.PERSISTCascadeType.MERGE만 제한적으로 명시하는 방식을 권장한다. 삭제 프로세스는 전이 기능에 기대지 않고, 비즈니스 서비스 레이어에서 명시적으로 하위 엔티티를 먼저 지우거나 벌크 연산으로 처리하는 것이 예기치 못한 데이터 유실을 막는 가장 확실한 방어벽이다.

4. N+1 문제의 근본적 해결책: LAZY 기본 장착과 Fetch Join의 한계

JPA 성능 저하의 주범인 N+1 문제는 연관관계가 복잡할 때 시스템을 마비시키는 주된 원인이다. @ManyToOne의 기본 전략은 FetchType.EAGER(즉시 로딩)다. 이걸 그대로 두면 엔티티 하나를 조회할 때 무조건 조인 쿼리가 나가거나 연관된 데이터를 가져오기 위한 추가 쿼리가 발생한다. 프로젝트 내의 모든 연관관계는 예외 없이 FetchType.LAZY(지연 로딩)로 설정해야 한다.

 

하지만 지연 로딩을 설정해도 루프를 돌며 연관 객체에 접근하는 순간 N+1 문제는 어김없이 고개를 든다. 이를 해결하기 위해 가장 흔히 쓰는 방법이 JPQL의 fetch join이다. 그러나 Fetch Join이 만병통치약은 아니다. 실무 관점에서 Fetch Join은 다음과 같은 명확한 한계와 트레이드오프를 가진다.

  • 컬렉션 페치 조인 시 페이징 불가능: 1:N 관계에서 컬렉션을 페치 조인하면 데이터가 뻥튀기(카테시안 곱)된다. 이 상태에서 페이징 처리(setFirstResult, setMaxResults)를 시도하면, 하이버네이트는 데이터베이스가 아닌 어플리케이션 메모리로 데이터를 전부 끌어올려 페이징을 시도한다. 아차 하는 순간 OutOfMemoryError로 서버가 뻗는다.
  • 둘 이상의 컬렉션 페치 조인 불가: 한 엔티티 안에 두 개 이상의 자식 컬렉션을 동시에 페치 조인하면 MultipleBagFetchException이 발생한다. 데이터 정합성을 보장할 수 없기 때문이다.

이 한계를 돌파하는 가장 세련된 무기가 바로 hibernate.default_batch_fetch_size 설정이다. 이 옵션을 주면 지연 로딩된 컬렉션이나 프록시 객체를 조회할 때, 지정한 사이즈만큼 IN 절을 사용해 한 번에 묶어서 가져온다.

spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100

이렇게 설정해 두면 1:N 관계의 데이터를 페이징하면서도 N+1 문제를 N/100 수준으로 떨어뜨릴 수 있다. 페치 조인은 단건 연관관계(XToOne)에 집중하고, 컬렉션 연관관계(XToMany)는 배치를 활용해 넘기는 것이 대규모 트래픽을 견뎌내는 아키텍처의 정석이다.

5. 엔티티와 DTO의 철저한 격리: 네트워크 전송량과 결합도 최소화

API 컨트롤러 레이어에서 JPA 엔티티를 외부로 직접 노출하는 것은 아키텍처 관점에서 최악의 선택이다. 엔티티는 데이터베이스 스키마와 밀접하게 결합된 녀석인데, 외부 API 스펙이 엔티티 구조를 그대로 반영하게 되면 도메인 리팩토링 시 화면이나 프론트엔드 API가 통째로 깨져버린다. 뿐만 아니라 양방향 연관관계가 걸려있을 때 Jackson 라이브러리가 객체를 JSON으로 바인딩하다가 서로를 무한 참조하여 서버가 다운되는 현상도 흔하다.

 

단순히 표현 계층 분리만을 위해 DTO를 쓰는 게 아니다. 성능 최적화 관점에서도 DTO 직접 조회가 핵심적인 역할을 한다. 데이터가 복잡하게 얽혀있고 화면에서 요구하는 필드가 제한적일 때는, 엔티티 그래프를 영속성 컨텍스트에 올리는 비용 자체가 낭비다. QueryDSL이나 JPQL의 select new 문법을 사용해 필요한 컬럼만 콕 집어서 DTO로 조회해야 한다.

// QueryDSL을 활용한 DTO 직접 조회 예시
public List searchOrders() {
    return queryFactory
    .select(Projections.constructor(OrderResponseDto.class,
        order.id,
        member.name,
        order.orderDate
    ))
    .from(order)
    .join(order.member, member)
    .fetch();
}

이 방식을 사용하면 대형 텍스트나 불필요한 연관 데이터 컬럼을 완전히 배제하고 딱 필요한 데이터만 네트워크 파이프라인으로 전송하므로, 메모리 사용량과 데이터베이스 I/O 부담을 획기적으로 줄일 수 있다. 수정 가능성이 없는 단순 조회 화면이라면 엔티티 조회는 사치다. DTO 조회를 기본 노선으로 잡아야 한다.

6. "JPA 연관관계를 끊으면 조인은 어떻게 하나요?" — 실무에서 가장 많이 묻는 질문

연관관계를 단순화하라고 조언하면 많은 개발자가 "객체 간의 관계를 끊어버리면 복잡한 조건의 조인 쿼리는 어떻게 작성하나요?"라며 당황해한다. 어플리케이션 레이어에서 모든 걸 해결하려다 보니 코드가 더 지저분해질 것 같다는 우려다.

 

결론부터 말하자면, 엔티티 간의 객체 참조 관계가 없어도 데이터베이스 조인은 아무런 제약 없이 수행할 수 있다. JPQL이나 QueryDSL은 연관관계 매핑(mappedBy 등)이 되어 있지 않아도 외래 키 값을 기준으로 명시적 조인(Explicit Join)을 지원한다.

// 연관관계 매핑이 전혀 없는 상태에서 QueryDSL로 조인 처리
List result = queryFactory
.selectFrom(order)
.join(member).on(order.memberId.eq(member.id)) // ON 절을 활용한 명시적 조인
.where(member.name.eq("홍길동"))
.fetch();

이처럼 객체 내부에는 상대 엔티티의 참조 대신 Long memberId 같은 식별자 값만 들고 있고, 쿼리 시점에 명시적으로 조인해 사용하면 도메인 간의 결합도가 혁신적으로 낮아진다. 영속성 컨텍스트가 관리해야 하는 객체의 깊이가 얕아지니 부수 효과(Side Effect)가 일어날 확률도 차단된다.

7. 대규모 데이터 처리와 복잡한 비즈니스를 위한 최종 병기

시스템 규모가 커지고 정산이나 대용량 통계 배치 같은 도메인이 끼어들기 시작하면 JPA의 수명은 한계에 다다른다. 수만, 수백만 건의 데이터를 엔티티로 올려서 영속성 컨텍스트의 스냅샷 비교(변경 감지) 기능을 태우는 순간, CPU는 비명을 지르고 GC(Garbage Collection)는 멈추지 않는다. 이때는 미련 없이 JPA의 손을 놓아야 한다.

 

대량 데이터 삽입/수정이나 복잡한 집계 쿼리에는 **Spring JdbcTemplate**이나 **Native SQL**을 적재적소에 섞어 쓰는 게 맞다. JPA는 단건 행위 기반의 도메인 로직 처리와 정밀한 트랜잭션 관리에 최적화된 도구지, 무거운 데이터 파이프라인 처리에 적합한 도구가 아니다. 하나의 기술에 매몰되어 억지로 모든 걸 해결하려 하지 말고, 데이터베이스 특성에 맞는 도구를 조합하는 시야가 필요하다.

8. 설계 단순화를 위한 영속성 아키텍처 체크리스트

새로운 기능을 개발하거나 기존의 꼬인 연관관계를 리팩토링할 때, 다음 체크리스트를 템플릿 삼아 코드를 검증해보라. 복잡성의 절반은 걷어낼 수 있다.

체크 항목 이상적인 설계 기준 위반 시 발생하는 리스크
연관관계 방향성 무조건 단방향으로 시작하고 역방향은 쿼리로 해결 양방향 동기화 오류, 순환 참조 장애
다대다(@ManyToMany) 금지. 식별자를 가진 중간 엔티티로 승격하여 분할 테이블 확장 불가능, 마이그레이션 비용 발생
로딩 전략 모든 연관관계에 FetchType.LAZY 명시적 선언 예상치 못한 조인 쿼리로 인한 운영 서버 마비
영속성 전이(Cascade) 단일 부모 소유주인 자식에만 한정적으로 적용 타 도메인 연관 데이터 무단 삭제(Data loss)
인터페이스 노출 컨트롤러 레이어 반환 시 반드시 DTO로 변환 도메인-API 스펙 결합, 내부 데이터 원치 않는 노출

결국 어떤 선택을 해야 하는가?

우리가 마주하는 수많은 JPA의 복잡성은 기술 자체의 결함이라기보다, 도메인 간의 경계를 명확히 획정하지 못한 설계의 부실함에서 기인한다. 시스템 전체를 아우르는 거대한 엔티티 지도를 그리겠다는 욕심을 내려놓아야 한다. 비즈니스의 단위(Bounded Context)를 쪼개고, 각 도메인 영역의 중심이 되는 뿌리 엔티티(Aggregate Root) 간에는 객체 참조 대신 가벼운 'ID 참조'로 관계를 단절시키는 것이 아키텍처를 가볍게 유지하는 핵심 비결이다.

 

JPA는 객체 그래프를 완벽하게 표현하기 위한 수단이 아니라, 관계형 데이터베이스의 데이터를 객체로 안전하게 변환해주는 도구일 뿐이다. 가급적 단방향 매핑으로 설계를 단순하게 닫아두고, 성능 최적화가 필요한 조회 영역은 DTO 직접 조회와 배치 패치 사이즈 옵션으로 풀어내라. 시스템의 복잡도가 낮아질 때 유지보수의 생산성이 올라가고, 인프라 비용과 버그 발생률은 자연스럽게 곤두박질친다. 단순함이야말로 고도화된 아키텍처의 최종 도달점이다.

 

 

코딩테스트 DP(동적계획법) 뇌정지 탈출법: 실전에서 점화식이 안 떠오를 때 던져야 할 7가지 질

코딩테스트장에서 DP(동적 계획법) 문제를 만나면 숨이 턱 막힙니다. 머릿속으로는 '이거 분명 어디서 본 패턴인데' 싶으면서도, 정작 손은 키보드 위에서 갈 길을 잃고 헤매죠. 시간에 쫓기다 보

byteandbit.tistory.com

 

반응형