본문 바로가기
카테고리 없음

JPA (양방향) 연관관계

by sudong 2025. 11. 12.

1) 🧭 개요: 단방향 vs 양방향, 언제 무엇을 쓰나

  • 단방향: 한쪽만 FK를 참조. 단순하고 안전. 조회 방향이 한쪽으로 고정될 때 유리.
  • 양방향: 양쪽에서 서로 탐색 가능. 객체 그래프 탐색이 편하지만 일관성 관리 책임이 생김.(연관관계 편의 메서드 사용해야함)
  • 원칙: 기본은 단방향 → 반대편 탐색이 비즈니스에 “자주” 필요할 때만 양방향로 확장.

2) 🔗 단방향 연관관계

  • 모델링(예): Schedule 이 patient_seq(FK)를 가짐 → Schedule → Patient 단방향.
  • 조회 한계: Patient 기준으로 Schedules를 모으려면 리포지토리 쿼리 필요.
  • 언제 좋나: 읽기 방향이 명확하고, 반대편 컬렉션 탐색 빈도가 낮을 때.
List<Schedules> schedules = scheduleRepository.findByPatient(patient);
// 또는
List<Schedules> schedules = scheduleRepository.findByPatientSeq(patient.getSeq());

3) 🔁 양방향 연관관계

  • 모델링(예):
  • 장점: patient.getSchedules()로 그래프 탐색 즉시 가능 → 도메인 로직 응집도↑
// Patient.java
@OneToMany(mappedBy = "patient")
private List<Schedules> schedules = new ArrayList<>();
// Schedules.java
@ManyToOne(fetch = FetchType.LAZY) // ← 명시적 LAZY 권장(아래 참고)
@JoinColumn(name = "patient_seq")
private Patient patient;

4) 🧠 언제 양방향을 쓸 것인가

  • 엔티티 내부 비즈니스 로직에서 반대편 참조/컬렉션을 즉시 활용해야 할 때
    • 예: Patient가 자신의 Schedules 상태를 종합해 도메인 규칙을 계산

5) 🧳 언제 굳이 양방향이 아닐까

  • DTO 프로젝션으로 화면/응답을 조립하는 경우
  • 서비스/도메인 서비스 레이어에서 로직을 주로 처리하는 경우
  • 반대편 탐색이 드문 경우 → 단방향 + 쿼리로 해결이 더 단순/안전

6) ⚠️ 양방향 사용 시 주의점

  • 순환참조(직렬화 루프):
    • 해결책: @JsonManagedReference/@JsonBackReference, @JsonIgnore, 또는 DTO 계층 분리(권장)
    • 대안: @JsonIdentityInfo(id 기반 식별)
  • 연관관계의 주인(Owner): FK 가진 쪽(N)이 주인 → DB 변경은 주인만 반영
  • 동기화 책임: 편의 메서드로 양쪽 컬렉션/참조 일관성 유지(아래 12절 참고)

7) 🐢 지연 로딩(LAZY)과 N+1 문제

  • 중요한 사실: JPA 스펙 기본은 @ManyToOne(fetch = EAGER) 이지만, 실무에서는 반드시 LAZY로 명시하세요.
  •  
    @ManyToOne(fetch = FetchType.LAZY) private Patient patient;
  • N+1 발생 패턴: 컬렉션/연관 엔티티를 루프 내에서 지연 로딩 접근
  • 기본 전략: ManyToOne/OneToOne는 LAZY 명시, 필요 시에만 명시적 JOIN

8) 🚀 N+1 해결 전략(우선순위와 트레이드오프)

  1. Fetch Join (가장 직관적)
    • 장점: 1쿼리로 즉시 로딩
    • 주의: @OneToMany 컬렉션 fetch join + 페이징 금지(카티션 폭발/중복). 루트 중복 시 distinct 필요
  2.  
    // JPQL @Query("select s from Schedules s join fetch s.patient where s.delYn = 'N'") List<Schedules> findAllWithPatient();
  3. EntityGraph
    • 선언적으로 읽기 패턴 제어. 복잡한 다단계 그래프는 관리 난도↑
  4.  
    @EntityGraph(attributePaths = "patient") @Query("select s from Schedules s where s.delYn = 'N'") List<Schedules> findAllWithPatient();
  5. Batch Size (부분 해결)
    • N개의 단건 조회 → IN 쿼리 묶음으로 감소
    • 페이징과 병행 유리, 다층 그래프에도 무난
  6.  
    // 전역: spring.jpa.properties.hibernate.default_batch_fetch_size=100~500 권장 @BatchSize(size = 100) @ManyToOne(fetch = FetchType.LAZY) private Patient patient;
  7. 서브셀렉트/두 단계 조회 패턴
    • 1단계: 페이지 아이디 목록 조회 → 2단계: 연관 엔티티 일괄 조회 후 매핑
    • 대량 리스트 + 페이징에서 가장 안전

추천 조합

  • 다대일 탐색: Fetch Join or EntityGraph
  • 컬렉션(1:N): 페이징 시 BatchSize or 두 단계 조회 (ID 수집 → IN 조회)

9) 🧪 성능 개선 사례(관측치)

  • findDetailListByDate: N+1 제거 후 200ms → 100ms
  • findByDate: 컬렉션 조회 튜닝으로 800ms → 500ms
  • ID 수집 → IN 일괄 조회 → 메모리 매핑 패턴: 2초 → 1초
    • 더 이상의 이득은 필드 수/전송량/네트워크 한계에서 결정될 가능성↑

10) 🧭 조회 전략 패턴 정리

  • DB 단일 JOIN으로 해결 가능: 가능하면 JOIN 한 방으로 끝내기(특히 다대일)
  • 복잡 로직/복수 소스:
    ① 대상 ID 수집 → ② IN 일괄 조회(여러 리포지토리) → ③ 메모리 매핑
  •  
    List<Long> patientIds = patients.stream().map(Patient::getSeq).toList(); List<Schedules> schedules = scheduleRepo.findByPatientSeqIn(patientIds); Map<Long, List<Schedules>> scheduleMap = schedules.stream().collect(groupingBy(s -> s.getPatient().getSeq()));

11) 📚 용어 정리

  • @ManyToOne = 나는 Many, 상대는 One
  • @OneToMany = 나는 One, 상대는 Many
  • 연관관계의 주인 = FK 직접 관리(보통 N쪽)
  • mappedBy = "patient" = “이 필드는 상대 엔티티의 patient에 의해 매핑만 되고, DB 변경은 하지 않음”

12) 🛠️ 연관관계 편의 메서드(일관성 유지의 핵심)

 
// Schedules.java public void changePatient(Patient newPatient) { if (this.patient != null) { this.patient.getSchedules().remove(this); } this.patient = newPatient; if (newPatient != null && !newPatient.getSchedules().contains(this)) { newPatient.getSchedules().add(this); } }
  • 왜 필요한가: DB는 FK만 맞으면 되지만, 메모리 내 객체 그래프는 양쪽을 맞춰줘야 탐색/로직에서 오류가 없음.
  • 권장 위치: 주인(N쪽, Schedule) 에 배치하여 변이 지점을 단일화.

13) ✅ 베스트 프랙티스 체크리스트

  • 🔸 ManyToOne/OneToOne는 LAZY 명시(EAGER 기본 피하기)
  • 🔸 양방향은 필요할 때만. 단방향+쿼리로 해결되면 그게 정답
  • 🔸 페이징 + 컬렉션 Fetch Join 금지 → BatchSize/두 단계 조회 사용
  • 🔸 distinct/중복 제거: Fetch Join으로 루트 중복 시 select distinct 또는 QueryDSL의 distinct()
  • 🔸 equals/hashCode 주의
    • 영속성 전 id가 null → 식별자 기반 비교는 시점 이슈
    • 실무 권장: 단순 식별자 기반, id가 없으면 동일성(==) 기준
    • Lombok: @EqualsAndHashCode(onlyExplicitlyIncluded = true)로 최소 필드만
  • 🔸 컬렉션 초기화: NPE 방지 위해 new ArrayList<>() 기본값
  • 🔸 직렬화 루프 방지: DTO 분리 또는 @JsonIgnore/@JsonManagedReference 조합
  • 🔸 캐스케이드/고아 제거는 “애그리게이트 경계” 기준으로만
    • 정말로 Patient 생명주기에 Schedules를 종속시키고 싶을 때만 적용
  •  
    @OneToMany(mappedBy="patient", cascade = CascadeType.ALL, orphanRemoval = true) private List<Schedules> schedules;
  • 🔸 전역 배치 크기: hibernate.default_batch_fetch_size=100~500
  • 🔸 쿼리 수 vs 전송량 트레이드오프를 항상 수치로 확인(실측 기반)

14) ❓FAQ/자주 하는 실수

  • 일대다 일방향 남발: 조인 테이블 강제/UPDATE 폭증 → 보통 다대일 단방향이 더 단순/효율
  • 연관관계 주인 오해: mappedBy 쪽에서 setXxx() 해도 DB 반영 안 됨(주인만 반영)
  • 편의 메서드 미구현: 저장은 되는데 메모리 그래프 불일치 → 로직 버그
  • 페이징+컬렉션 Fetch Join: 중복/카티션/메모리 폭증 → 배치/두 단계 조회로 전환
  • EAGER 기본 방치: 예상 못한 즉시 로딩 연쇄 → 명시적 LAZY가 안전
  • 무분별한 Cascade: 상위 삭제 시 하위 도미노 삭제 위험 → 경계 명확히