DB/JPA

[Hibernate/JPA] 관계

꾸준함. 2025. 6. 2. 11:25

1. 관계형 데이터베이스의 테이블 관계와 JPA 매핑

  • 관계형 데이터베이스에는 대표적으로 일대다 (One-to-Many), 일대일 (One-to-One) 그리고 다대다 (Many-to-Many) 세 가지 테이블 관계가 존재함
  • 이러한 관계는 외래 키를 통해 정의되며, JPA와 Hibernate에서는 객체지향 모델의 단방향·양방향 관계에 맞춰 다양한 매핑 방법을 제공함

 

일대다 관계

  • 단방향: 자식(@ManyToOne), 부모(@OneToMany) 또는 부모의 @ElementCollection
  • 양방향: 자식(@ManyToOne), 부모(@OneToMany(mappedBy = ...))

 

일대일 관계

  • 단방향: 자식(@OneToOne)
  • 양방향: 자식(@OneToOne), 부모(@OneToOne(mappedBy = ...))

 

다대다 관계

  • 단방향: 한쪽(@ManyToMany)
  • 양방향: 양쪽 모두 @ManyToMany(mappedBy = ...)
  • 조인 테이블은 엔티티 레벨에서 숨겨짐

 

2. 컬렉션 매핑의 실무적 고려사항

  • JPA는 @OneToMany, @OneToOne과 같은 컬렉션 매핑을 지원하지만, 모든 도메인 모델에 무작정 사용할 경우 성능 문제가 발생할 수 있음
    • 컬렉션을 매핑하면 해당 컬렉션을 전체 로드하게 되며 수천 ~ 수만 개의 자식 엔티티를 한 번에 불러와 심각한 성능 저하가 발생할 수 있음
    • Hibernate의 @Filter 등으로 동적 필터링은 가능하지만, 컬렉션 로딩 시 페이징은 불가능

 

실무 권장사항

  • 따라서 컬렉션 크기가 작을 때만 컬렉션 매핑을 사용하는 것을 권장
  • 수백 ~ 수만 건의 대규모 데이터는 JPQL/SQL 쿼리로 직접 자식 엔티티를 Fetch 하는 것을 권장
    • 이때는 where, limit, offset 등으로 페이징 및 필터링 가능

 

  • 데이터가 얼마나 많아질지 불확실하다면, 컬렉션 매핑을 최대한 뒤로 미루고 상황에 따라 쿼리 기반으로 설계하는 것을 권장

 

3. equals와 hashCode 구현시 주의점

  • 엔티티를 Set, Map의 key 등으로 쓰거나, 컬렉션의 contains/remove 같은 연산에 사용하려면 equals, hashCode를 반드시 올바르게 구현해야 함
    • 엔티티 상태 전이(Transient→Managed→Detached→Removed) 과정에서도 동일한 객체는 항상 자기 자신과 동등(equal)하고, 같은 hashCode를 가져야 함

 

  • Lombok, IDE에서 자동으로 생성하는 코드를 부작정 쓰지 말고, 엔티티 생명주기를 고려한 구현 고려 필요
    • 따라서 기본 키가 할당된 이후에는 기본 키 기반으로 equals 및 hashCode 구현하는 것을 권장
    • 기본 키가 없는 Transient 상태에서는 객체 참조 (==) 또는 비즈니스 키로 비교하는 것을 권장

 

  • 자바 명세에 따른 equals의 네 가지 속성은 다음과 같음
    • Reflexive(반사적): x.equals(x) == true
    • Symmetric(대칭적): x.equals(y) == y.equals(x)
    • Transitive(추이적): x.equals(y) && y.equals(z) → x.equals(z)
    • Consistent(일관적): 여러 상태 전환 (영속화, 분리, 병합, 삭제 등)에도 항상 동등성과 해시코드가 일치해야 함

 

4. JPA 엔티티의 equals와 hashCode 구현 실전 가이드

 

4.1 JPA 엔티티의 equals 및 hashCode 일관성 테스트의 중요성

  • JPA 엔티티는 생명주기가 다양하게 변함
    • Transient (new) → Managed (persisted) → Detached → Merged → Removed

 

  • 앞서 언급했다시피 equals/hashCode 구현이 올바르지 않으면, HashSet/HashMap 등 컬렉션에서 엔티티를 찾거나 제거할 때 예상치 못한 문제가 발생할 수 있음
  • 테스트 코드를 통해 엔티티를 여러 상태로 전이시키며, 항상 HashSet에서 자신을 제대로 찾을 수 있는지 검증 필요

 

4.2 엔티티 상태 변화와 equals 및 hashCode의 역할

  • Transient 상태: 엔티티가 새로 생성되어 아직 DB에 저장되지 않음
  • Persist(Managed) 상태: 엔티티가 영속화되어 식별자(PK)가 할당됨
  • Detached/Removed/Merged 상태: 영속성 컨텍스트에서 분리되거나, 병합되거나, 삭제된 상태
  • 각 상태마다 엔티티의 hashCode 및 equals 일관성이 유지되어야 HashSet 등에서 올바르게 검색됨

 

4.3 구현 전략별 장단점 비교

 

가. 자연 식별자(비즈니스 키) 기반 equals/hashCode

  • 책의 ISBN, 주민번호 등 절대 변하지 않는 고유 속성만 사용하며 equals/hashCode는 해당 속성만 비교
  • 장점
    • 상태 전이와 무관하게 항상 일관성 유지
    • 영속화 전후, 병합, 분리 등 모든 상황에서 동일하게 작동

 

  • 단점
    • 시스템에서 유일하고 null이 아닌 속성이 반드시 필요

 

 

나. equals/hashCode 미구현 (Object 기본 구현 사용)

  • 자바 기본 동작은 객체 참조(==) 비교
  • 단점
    • merge 등으로 새로운 엔티티 인스턴스가 만들어지면, 원본과 다른 객체로 간주되어 HashSet에서 찾을 수 없음
    • 실제로 테스트에서 일부 상태 전이 후 HashSet에서 엔티티를 찾지 못해 실패

 

다. 식별자(PK) 기반 equals/hashCode (IDE 자동 생성 코드 등)

  • 영속성 식별자(PK)만 비교하고 해시코드로 사용
  • 단점
    • 엔티티가 transient 상태에서 HashSet에 저장된 후, persist 되어 PK가 부여되면 hashCode/equals가 바뀌어 HashSet에서 찾지 못함
    • 영속화 전/후의 equals 및 hashCode 불일치

 

 

라. 개선된 식별자 기반 구현

  • hashCode는 상수 반환
  • equals는 식별자가 null이면 객체 참조(==) 비교하며 식별자가 있으면 식별자 비교
  • 장점
    • 모든 상태 전이에서 일관성 보장
    • merge 등으로 새 인스턴스가 생겨도 올바르게 동등성 판단

 

  • 단점
    • hashCode가 상수라 HashSet 성능이 저하될 수 있으나, OLTP 환경에서는 실제로 큰 문제가 아님

 

 

4.4 실전 적용 팁

  • 가장 이상적인 방식: 자연식별자(비즈니스 키, 자연 ID)가 있다면 이를 기반으로 equals/hashCode 구현 (i.g. ISBN, 주민번호, 이메일 등)
  • 비즈니스 키가 없다면: 상수 hashCode + 식별자 기반 equals 패턴을 사용
    • PK가 null이면 참조 비교, PK가 있으면 PK 비교

 

  • IDE 자동 생성 코드나 lombok의 @EqualsAndHashCode는 조심해서 써야 하며, JPA 엔티티의 상태 전이와 일관성을 반드시 테스트해야 함
  • 상수 해시코드의 단점은 대량 데이터에서 체감될 수 있으나 대부분의 실무 OLTP 환경에서는 큰 문제가 되지 않음

 

5. @ManyToOne & @OneToMany 관계 실전 정리

 

5.1 @ManyToOne JPA 매핑

  • @ManyToOne 관계는 외래 키(FK) 관계를 가장 자연스럽게 매핑하는 방법
    • 아래 예제에서 자식(PostComment) 엔티티에서 부모(Post)를 참조하며
    • 실제 DB에는 post_comment.post_id 외래 키 컬럼이 생성되고
    • Hibernate는 적절한 INSERT, UPDATE, DELETE SQL을 효율적으로 만들어 냄

 

 

장점

  • SQL이 효율적 (불필요한 조인/중간 테이블 없음)
  • 외래 키 제약, 관계 무결성 관리가 자연스러움

 

5.2 @OneToMany JPA 매핑

 

가. 양방향 OneToMany

  • 부모 엔티티는 자식 컬렉션(@OneToMany(mappedBy = ...)), 자식 엔티티는 부모 참조(@ManyToOne)를 갖는 구조
    • add/remove 유틸리티 메서드를 만들어 양쪽의 연관관계 동기화를 보장해야 함
    • 연관관계의 실질적 제어권은 자식(@ManyToOne)에 있음 (즉, 외래 키 값의 변경/삭제가 자식 엔티티를 통해 이루어짐)
    • orphanRemoval=true로 부모에서 컬렉션 요소를 제거하면, 자식 엔티티가 실제로 DB에서 삭제됨

 

 

장점

  • 탐색, 데이터 변경이 양쪽에서 모두 자연스럽고 일관적
  • SQL이 효율적 (중간 조인 테이블 불필요)
  • 상태 동기화(양쪽 컬렉션/참조)만 신경 쓰면 됨

 

나. 단방향 OneToMany

  • 부모 엔티티만 자식 컬렉션을 가지고 자식은 부모를 모르는 구조
  • 해당 구조는 여러 문제점을 동반함
    • Hibernate/JPA는 중간 조인 테이블을 자동 생성 (실제 DB에는 두 개의 FK가 있음 → 다대다 테이블 구조와 유사)
    • INSERT, DELETE, UPDATE SQL이 더 많이 발생
    • 요소 삭제 시 모든 중간 테이블 레코드를 삭제한 뒤, 남은 레코드를 다시 삽입하는 비효율적 구조
    • 조인 테이블의 인덱스 구조도 비효율적 (메모리, 성능 부담)

 

  • 컬렉션 타입에 따라 동작 및 성능 차이는 다음과 같음
    • Set: 중복 없이, 삭제 시 두 번의 DELETE
    • List(Bag, 미정렬): 삭제시 조인 테이블 전체 삭제 후 재삽입
    • List(Ordered, @OrderColumn): 요소 위치값 컬럼 생성, 리스트 앞부분 삭제시 update 쿼리 증가
    • @JoinColumn과 함께 사용: 조인 테이블 대신 자식 테이블 FK 사용 가능, 그래도 update 쿼리 추가됨

 

  • @JoinColumn(nullable = false) 설정 시, insert와 동시에 FK가 설정되어 일부 update를 줄일 수 있음
    • 하지만 여전히 양방향 OneToMany보다 SQL이 더 많이 발생

 

 

5.3 실무 적용 팁

  • 가장 효율적인 관계 매핑은 양방향 OneToMany 구조
    • 자식이 FK 관리
    • 부모는 컬렉션 관리
    • SQL 최소화

 

  • 단방향 OneToMany는 최대한 피하는 것을 권장
    • 불필요한 조인 테이블 생성, SQL 과다, 인덱스·메모리 비효율
    • 정말 단방향이 필요하다면 @JoinColumn 활용할 수 있지만 여전히 양방향 OneToMany보다 SQL이 더 많이 발생하는 문제 발생

 

  • 컬렉션 타입 및 순서에 따라 SQL 동작이 크게 달라지므로 테스트 코드를 통해 검증 필요
  • 자식 요소가 많은 경우, OneToMany 컬렉션 매핑 대신 JPQL/쿼리로 자식 엔티티를 직접 조회하는 것이 더 효율적

 

6. @OneToOne 관계 매핑 실전 정리

 

6.1 단방향 @OneToOne 매핑과 @MapsId

  • 기본 단방향 일대일 매핑: 자식 엔티티에서 부모 엔티티를 @OneToOne으로 참조하고, 외래키(FK)는 자식 테이블에 존재
    • i.g. PostDetails → Post
    • 자식 테이블에 기본키(PK)와 FK가 분리되어 두 개의 컬럼 및 인덱스가 필요하며, 이는 성능상 비효율적이라는 문제점 존재


 

  • @MapsId를 활용할 경우 자식 엔티티의 PK와 FK를 동일한 컬럼으로 설정 가능하여 앞서 언급한 문제점을 해결할 수 있음
    • 해당 방식은 자식 테이블의 PK가 곧 부모 테이블의 PK(FK)와 같아지고, 단일 컬럼/인덱스만 필요함
    • 부모 엔티티의 PK만 알면 자식 엔티티를 바로 가져올 수 있어 실무에서 선호되는 방법


 

6.2 양방향 @OneToOne 매핑과 N + 1 쿼리 문제

  • 양방향 @OneToOne 매핑을 적용 시
    • 부모 엔티티는 @OneToOne(mappedBy = ...)로 자식 참조
    • 자식 엔티티는 @OneToOne으로 부모를 참조
    • setter에서 양쪽 연관관계 동기화 필요
    • 부모에서 자식 연관 필드를 fetch=LAZY로 지정해도, Hibernate는 실제로 EAGER처럼 추가 SELECT 쿼리를 실행하기 때문에 N + 1 문제가 발생하는 문제 존재 (1:1 관계의 특성상 프록시 할당 (지연 로딩)을 단순히 null로 할 수 없기 때문)

 

6.3 N + 1 문제 해결 방법: Bytecode Enhancement

  • Bytecode Enhancement는 Hibernate가 엔티티의 getter/setter 바이트코드를 조작해 실제 속성 접근 시점에만 지연 로딩 쿼리를 실행하도록 해주는 기술
    • 부모 쪽 @OneToOne에 @LazyToOne(LazyToOneOption.NO_PROXY) 지정
    • 자식 쪽에는 @MapsId 미적용
    • 빌드 시 Maven Hibernate Enhance 플러그인 활성화 필요

 

https://sehyeona.tistory.com/31

 

[JPA] bytecode instrumentation 을 이용한 lazy loading 활성화-2

0. bytecode instrumentation jpa 의 프록시는 bytecode 조작으로 만들어집니다. 포스트의 최종목적은 OneToOne 의 양방향관계에서 프록시를 만들기 위해 발생하는 N+1 문제를 해결하는 것 입니다. 따라서 byteco

sehyeona.tistory.com

 

6.4 실무 적용 팁

  • 단방향 @OneToOne + @MapsId
    • 바이트코드 향상 없이도 성능이 우수하고, PK=FK로 설계되어 쿼리와 인덱스 효율이 매우 높음
    • 부모 PK만 알면, 자식 엔티티 즉시 조회 가능

 

  • 양방향 @OneToOne
    • 정말로 부모→자식 탐색이 꼭 필요할 때만 사용
    • N+1 문제에 주의할 것, 가능하면 Bytecode Enhancement 통해 최적화 필요 

 

  • 부득이하게 N+1 문제를 피할 수 없는 구조라면
    • JPQL의 JOIN FETCH로 한 번에 연관 엔티티를 미리 로딩하거나
    • 실제로 필요한 데이터만 쿼리로 직접 가져오는 설계 필요

 

7. @ManyToMany 관계 매핑 실전 정리

 

7.1 @ManyToMany 개요

  • 다대다 관계는 두 테이블이 서로 여러 개의 엔티티와 연관될 수 있을 때 사용
  • 데이터베이스에서는 조인 테이블을 통해 구현
  • JPA에서는 @ManyToMany 어노테이션으로 매핑 가능

 

7.2 단방향 vs 양방향 @ManyToMany 매핑

 

가. 단방향

  • 한쪽(i.g. Post)만이 연관된 Tag 컬렉션을 가짐
  • 조인 테이블(i.g. post_tag)을 자동 생성하며, 양쪽에서 탐색은 불가


 

나. 양방향

  • 양쪽 (Post, Tag) 모두 상대방의 컬렉션을 가짐
  • 한쪽은 @ManyToMany(mappedBy = ...)로 매핑 (보통 Tag 쪽)
  • 연관관계 편의 메서드 (addTag, removeTag)를 만들어 양쪽 컬렉션을 항상 동기화해야 함

 

 

7.3 요소 추가/삭제 및 컬렉션 타입의 차이

  • 요소 추가: Post와 Tag 엔티티를 persist 하면, 중간 테이블 (post_tag)에 INSERT가 자동 발생
  • 요소 삭제 (List vs Set)
    • List: Hibernate는 해당 post의 모든 post_tag 레코드를 삭제 후, 남은 것만 다시 삽입 (비효율적)
    • Set: post_tag 조인 테이블에서 해당 관계만 삭제 (가장 효율적, 단일 DELETE)

 

  • 따라서 다대다 관계에서는 List보다 Set을 사용하는 것이 SQL, 성능 모두에서 유리

 

7.4 조인 테이블을 엔티티로 매핑

  • 조인 테이블(post_tag)에 추가 컬럼을 매핑하거나, 더 유연한 연관관계 관리가 필요할 때 Join Entity 패턴을 사용
    • 해당 패턴을 사용하면 Post와 Tag는 각각 @OneToMany로 PostTag를 매핑 (양방향 일대다/다대일 구조로 변환)
    • 제거 편의 메서드도 반드시 구현해야 함


 

7.5 실무 적용 팁

  • 관리 복잡도가 높거나 조인 테이블에 추가 정보가 필요하면 Join Entity 패턴(@ManyToOne, @OneToMany로 분해)을 적용
    • 웬만하면 실무에서는 @ManyToMany 대신 양방향 @OneToMany로 쪼개는 것을 권장
    • 대용량 데이터/복잡한 관계에서는 Join Entity 패턴이 확장성과 성능 모두에 유리

 

  • 다대다 관계에서는 @ManyToMany + Set 컬렉션 조합이 가장 효율적
  • 항상 연관관계 편의 메서드(add/remove)로 양쪽 컬렉션 동기화하는 것을 권장
  • CascadeType.REMOVE, orphanRemoval은 다대다 매핑 관계에서는 주의할 필요가 있음
    • 잘못 사용하면 양쪽 parent 엔티티가 서로 cascade remove를 반복해 전체 데이터가 삭제될 수 있음
    • 다대다에서는 persist/merge만 cascade, remove/orphanRemoval은 피하는 것이 안전

 

참고

인프런 - 고성능 JPA & Hibernate (High-Performance Java Persistence)

 

반응형

'DB > JPA' 카테고리의 다른 글

[Hibernate/JPA] 영속성 컨텍스트  (0) 2025.06.04
[Hibernate/JPA] 상속  (0) 2025.06.02
[Hibernate/JPA] 식별자 생성 최적화 전략  (0) 2025.05.30
[Hibernate/JPA] 타입  (0) 2025.05.29
[Hibernate/JPA] Connection  (0) 2025.05.29