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 해결 전략(우선순위와 트레이드오프)
- Fetch Join (가장 직관적)
- 장점: 1쿼리로 즉시 로딩
- 주의: @OneToMany 컬렉션 fetch join + 페이징 금지(카티션 폭발/중복). 루트 중복 시 distinct 필요
-
// JPQL @Query("select s from Schedules s join fetch s.patient where s.delYn = 'N'") List<Schedules> findAllWithPatient();
- EntityGraph
- 선언적으로 읽기 패턴 제어. 복잡한 다단계 그래프는 관리 난도↑
-
@EntityGraph(attributePaths = "patient") @Query("select s from Schedules s where s.delYn = 'N'") List<Schedules> findAllWithPatient();
- Batch Size (부분 해결)
- N개의 단건 조회 → IN 쿼리 묶음으로 감소
- 페이징과 병행 유리, 다층 그래프에도 무난
-
// 전역: spring.jpa.properties.hibernate.default_batch_fetch_size=100~500 권장 @BatchSize(size = 100) @ManyToOne(fetch = FetchType.LAZY) private Patient patient;
- 서브셀렉트/두 단계 조회 패턴
- 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: 상위 삭제 시 하위 도미노 삭제 위험 → 경계 명확히