[JPA] 연관관계

keymu·2025년 1월 2일

JPA는 편리하지만 원치 않는 쿼리가 작동할 수 있고, 이는 잠재적 성능 이슈를 야기한다.

@OneToOne

@Data
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Entity
public class BookReviewInfo extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // BookReviewInfo: Book
    // 1:1 연결
//    private Long bookId;   // FK 역할.

    @OneToOne(optional = false)   // Book 과 1:1 로 relation
    // optional = false  <= Book(부모) 는 null 허용하지 않는다 (즉, 반드시 Book 을 참조)
    private Book book;   // 부모 Entity 참조
    // 기본적으로 Entity 에선 직접적으로 다른 Entity 를 직접 참조 못한다
    // @OneToOne 과 같은 relation 어노테이션을 지정해주어야 한다.

    // NULL 을 허용하면 wrapper 객체 사용
    // NULL 을 허용하지 않을거면 primitive 객체 사용 -> DDL 에 NOT NULL 부여됨
    // 이번예제에서 아래 두개값은 기본값 0 을 사용하기 위해 primitive 를 사용합니다.
    //   --> 굳이 null check 안해도 된다.
    private float averageReviewScore;
    private int reviewCount;

}
@OneToOne(mappedBy = "book")    // 해당 Entity 의 테이블에선 연관키를 가지지 않는다.
    @ToString.Exclude   // Lombok 의 ToString 에서 배제 (양방향에서의 순환참조 때문에)
    private BookReviewInfo bookReviewInfo;
@Test
    void crudTest3() {
        givenBookReviewInfo();

        // Book(부모)에서 BookReviewInfo(자식) 조회
        System.out.println("🎅".repeat(20));

        // @ OneToOne 양방향 참조
        BookReviewInfo result2 = bookRepository
                .findById(1L)   // @OneToOne 연결된 entity 와의 join 문 발생
                .orElseThrow(RuntimeException::new)
                .getBookReviewInfo()
                ;

        System.out.println(">>> " + result2);
    }

Test 결과:

Hibernate: 
    create table book_review_info (
        average_review_score float(24) not null,
        review_count integer not null,
        book_id bigint not null unique,
        created_at timestamp(6),
        id bigint generated by default as identity,
        updated_at timestamp(6),
        primary key (id)
    )
Hibernate: 
    create table t_user (
        created_at timestamp(6),
        id bigint generated by default as identity,
        updated_at timestamp(6),
        email varchar(255) unique,
        name varchar(255),
        gender enum ('FEMALE','MALE'),
        primary key (id)
    )
Hibernate: 
    create table user_history (
        created_at timestamp(6),
        id bigint generated by default as identity,
        updated_at timestamp(6),
        user_id bigint,
        email varchar(255),
        name varchar(255),
        primary key (id)
    )
Hibernate: 
    create index IDXg8gqk4e142wekcb1t6d3v2mwx 
       on t_user (name)
Hibernate: 
    alter table if exists book_review_info 
       add constraint FKp5fhkokpbtoxmc3mxo8ay9e5l 
       foreign key (book_id) 
       references book

----------------------------------------
[ crudTest3() ] 호출

Hibernate: 
    select
        bri1_0.id,
        bri1_0.average_review_score,
        bri1_0.book_id,
        b1_0.id,
        b1_0.author_id,
        b1_0.category,
        b1_0.created_at,
        b1_0.name,
        b1_0.publisher_id,
        b1_0.updated_at,
        bri1_0.created_at,
        bri1_0.review_count,
        bri1_0.updated_at 
    from
        book_review_info bri1_0 
    join
        book b1_0 
            on b1_0.id=bri1_0.book_id 
    where
        bri1_0.id=?
>>> Book(super=BaseEntity(createdAt=2024-12-30T09:28:23.398898, updatedAt=2024-12-30T09:28:23.398898), id=1, name=JPA 완전정복, category=null, authorId=1, publisherId=1)
🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅🎅
Hibernate: 
    select
        b1_0.id,
        b1_0.author_id,
        bri1_0.id,
        bri1_0.average_review_score,
        bri1_0.book_id,
        bri1_0.created_at,
        bri1_0.review_count,
        bri1_0.updated_at,
        b1_0.category,
        b1_0.created_at,
        b1_0.name,
        b1_0.publisher_id,
        b1_0.updated_at 
    from
        book b1_0 
    left join
        book_review_info bri1_0 
            on b1_0.id=bri1_0.book_id 
    where
        b1_0.id=?
>>> BookReviewInfo(super=BaseEntity(createdAt=2024-12-30T09:28:23.422437, updatedAt=2024-12-30T09:28:23.422437), id=1, book=Book(super=BaseEntity(createdAt=2024-12-30T09:28:23.398898, updatedAt=2024-12-30T09:28:23.398898), id=1, name=JPA 완전정복, category=null, authorId=1, publisherId=1), averageReviewScore=4.5, reviewCount=2)
----------------------------------------

@OneToMany

1. 기본 매핑 방식

중간 테이블 생성 방식:

@OneToMany만 사용하면 자동으로 중간 테이블이 생성됩니다.

@OneToMany
private List<UserHistory> userHistories = new ArrayList<>();    // NPE 방지

생성되는 테이블 구조:

 create table t_user_user_histories (
    user_histories_id bigint not null unique,
    user_id bigint not null
)

직접 외래키 방식:

  • 중간 테이블 없이 직접 외래키를 사용하려면 @JoinColumn을 추가합니다.
@OneToMany
@JoinColumn(name = "user_id")  // user_id 컬럼을 외래키로 사용
private List<UserHistory> userHistories = new ArrayList<>();

2. Fetch 전략 (+추가 공부)

@OneToMany의 EAGER 사용시 주의사항:

@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private List<UserHistory> userHistories = new ArrayList<>();

N+1 문제 발생 위험:

  • 성능 저하 가능성
  • 대안: LAZY + 필요시 fetch join 사용
@Query("SELECT u FROM User u JOIN FETCH u.userHistories")
List<User> findAllWithHistories();

3. 양방향 매핑시 읽기 전용 설정

// User 엔티티
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id",    // Entity가 어떤 컬럼으로 join할지 지정
        insertable = false, updatable = false // read only 설정
)
private List<UserHistory> userHistories = new ArrayList<>();

// UserHistory 엔티티
@Column(name="user_id", insertable = false, updatable = false)
private Long userId;

@ManyToOne

1. 양방향 관계에서의 순환참조 문제

// Book 엔티티
class Book {
    @OneToOne(mappedBy = "book")
    private BookReviewInfo bookReviewInfo;
}

// BookReviewInfo 엔티티
class BookReviewInfo {
    @OneToOne
    private Book book;
}

순환참조 해결:

@ToString.Exclude   // Lombok의 ToString에서 배제
private BookReviewInfo bookReviewInfo;

@OneToMany, @ManyToOne 어느 Entity에서 연관 Entity가 필요한가에 따라 둘 중 어느것을 쓸 지가 달라진다.


각 연관관계 fetch type의 default 속성

@OneToMany, @ManyToMany: LAZY
@ManyToOne, @OneToOne: EAGER

@Test
    @Transactional
    void bookRelationTest() {

        // 테스트용 데이터 입력(Publisher, Book, Review)
        givenBookAndReview();

        // 특정 User
        User user = userRepository.findByEmail("martin@redknight.com");

        System.out.println("👇👇👇👇👇👇여기가 무시무시한 query 생성지 👇👇👇👇👇👇");
        // 특정 User 가 남긴 Review 정보들 가져오기
        System.out.println("Review: " + user.getReviews());     // getxxx() 시점에서 outer join들을 사용하여 읽어들임

        System.out.println("😁".repeat(20));
        // 특정 User 가 남긴 Review 중 첫번째 Review의 Book 정보 가져오기
        System.out.println("Book: " + user.getReviews().get(0).getBook());

        System.out.println("😇".repeat(20));
        // 특정 User 가 남긴 Review 중 첫번째 Review의 Book의 Publisher 정보 가져오기
        System.out.println("Publisher: " + user.getReviews().get(0).getBook().getPublisher());
    }
👇👇👇👇👇👇여기가 무시무시한 query 생성지 👇👇👇👇👇👇
Hibernate: 
    select
        r1_0.user_id,
        r1_0.id,
        b1_0.id,
        b1_0.author_id,
        bri1_0.id,
        bri1_0.average_review_score,
        bri1_0.book_id,
        bri1_0.created_at,
        bri1_0.review_count,
        bri1_0.updated_at,
        b1_0.category,
        b1_0.created_at,
        b1_0.name,
        p1_0.id,
        p1_0.created_at,
        p1_0.name,
        p1_0.updated_at,
        b1_0.updated_at,
        r1_0.content,
        r1_0.created_at,
        r1_0.score,
        r1_0.title,
        r1_0.updated_at 
    from
        review r1_0 
    left join
        book b1_0 
            on b1_0.id=r1_0.book_id 
    left join
        book_review_info bri1_0 
            on b1_0.id=bri1_0.book_id 
    left join
        publisher p1_0 
            on p1_0.id=b1_0.publisher_id 
    where
        r1_0.user_id=?
Review: [Review(super=BaseEntity(createdAt=2024-12-30T12:18:33.006304, updatedAt=2024-12-30T12:18:33.006304), id=1, title=내 인생을 바꾼 책, content=너무너무 재미있고 즐거운 책이었어요, score=5.0, user=User(super=BaseEntity(createdAt=2024-12-30T12:18:32.669926, updatedAt=2024-12-30T12:18:32.669926), id=1, name=martin, email=martin@redknight.com, gender=null), book=Book(super=BaseEntity(createdAt=2024-12-30T12:18:33.003352, updatedAt=2024-12-30T12:18:33.003352), id=1, name=JPA 완전정복, category=null, authorId=null))]
😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁😁
Book: Book(super=BaseEntity(createdAt=2024-12-30T12:18:33.003352, updatedAt=2024-12-30T12:18:33.003352), id=1, name=JPA 완전정복, category=null, authorId=null)
😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇😇
Publisher: Publisher(super=BaseEntity(createdAt=2024-12-30T12:18:32.990396, updatedAt=2024-12-30T12:18:32.990396), id=1, name=K-출판사, books=[])

JPA의 장점:
getter만 썼을 뿐인데, 알아서 query가 실행된다는 점


@ManyToMany

보통 잘 안 쓰고, 1:N, M:1로 별도의 테이블을 빼서 만든다.

profile
Junior Backend Developer

0개의 댓글