JPA 연관관계 매핑 설계

임동혁 Ldhbenecia·2025년 6월 27일

SpringBoot

목록 보기
18/28
post-thumbnail

개발을 진행하며 DB Table 구조를 설계할 때 깊은 고민을 하지 않고 양방향과 연관성이 보이면 무조건 외래키를 걸어서 구현한 결과, 규모가 커질수록 미친듯한 불편함과 문제점이 발생해서 연관관계 매핑에 대한 학습을 진행하고 정리한다.

연관관계 매핑의 기본 원칙

1. OneToMany, OneToOne 매핑은 지양한다

OneToManyOneToOne 관계는 성능상 이슈를 야기할 수 있어 되도록 사용하지 않는다.
특히 OneToMany의 경우 N+1 문제나 추가적인 UPDATE 쿼리가 발생할 가능성이 높다.

2. 양방향 매핑은 사용하지 않는다

양방향 매핑은 순환 참조 문제와 객체 관계의 복잡성을 증가시킨다. 단방향 매핑으로도 충분히 비즈니스 요구사항을 만족할 수 있다.

3. 언제 연관관계를 매핑하는가?

라이프사이클이 완전히 동일한 경우에만 연관관계 매핑을 고려한다.
이마저도 신중하게 검토한 후 단방향 ManyToOne 관계만을 적용한다.

실전 예제 1: Review와 ReviewImage

비즈니스 요구사항 분석

- 리뷰는 무조건 리뷰 이미지를 가져야 하는가? → X
- 리뷰를 호출할 때 무조건 이미지도 있어야 하는가? → X
- 리뷰 이미지는 리뷰가 있어야 하는가? → O

이 분석을 바탕으로 ReviewImageEntity에서 ReviewEntity로 향하는 단방향 ManyToOne 관계만 설정한다.

@Entity
@Table(name = "review_image")
class ReviewImageEntity(
    val reviewId: Long,// ManyToOne 관계를 위한 외래키
    private var imageUrl: String,
    private var order: Int
) : BaseEntity()

ReviewEntity에서는 ReviewImage에 대한 직접적인 참조를 두지 않는다. 필요시 별도 쿼리로 조회한다.

실전 예제 2: Question과 Answer

Entity 설계

@Entity
@Table(name = "qna_question")
class QuestionEntity(
    val providerId: Long,
    val userKey: String,
    val targetKey: String,
    private var content: String,
    private var imageUrlJson: String,
    private var providerCreatedAt: LocalDateTime,
    private var providerUpdatedAt: LocalDateTime
) : BaseEntity() {

    constructor(
        provider: Provider,
        target: UserWithTargetKey,
        content: QuestionContent,
        dateTime: DefaultDateTime,
    ) : this(
        providerId = provider.id,
        userKey = target.userKey,
        targetKey = target.targetKey,
        content = content.content,
        imageUrlJson = JsonUtil.to(content.imageUrls),
        providerCreatedAt = dateTime.createdAt,
        providerUpdatedAt = dateTime.updatedAt,
    )

    fun toQnaQuestion(): Question {
        return Question(
				// 도메인 객체 변환 로직
        )
    }
}
@Entity
@Table(name = "qna_answer")
class AnswerEntity(
    val providerId: Long,
    val userKey: String,
    val questionId: Long,// Question에 대한 외래키
    private var content: String,
    private var imageUrlJson: String,
    private var providerCreatedAt: LocalDateTime,
    private var providerUpdatedAt: LocalDateTime
) : BaseEntity() {

    constructor(
        provider: Provider,
        userKey: String,
        questionId: Long,
        content: QuestionContent,
        dateTime: DefaultDateTime,
    ) : this(
        providerId = provider.id,
        userKey = userKey,
        questionId = questionId,
        content = content.content,
        imageUrlJson = JsonUtil.to(content.imageUrls),
        providerCreatedAt = dateTime.createdAt,
        providerUpdatedAt = dateTime.updatedAt,
    )

    fun toQnaAnswer(): Answer {
        return Answer(
            id!!,
						// 도메인 객체 변환 로직
        )
    }
}

관계 설정 근거

Question이 있다고 해서 무조건 Answer가 존재하는 것은 아니다.
질문에 대한 답변은 다음과 같은 특성을 갖는다:

  • 답변 개수: 0개일 수도 있고, 1개 이상일 수도 있다
  • 비즈니스 정책에 따라 답변 전략이 달라질 수 있다
  • 사장님이 답변하지 않으면 답변이 없는 상태로 유지된다

따라서 AnswerEntity에서는 questionId를 통해 Question을 참조하지만, QuestionEntity에서는 Answer에 대한 직접적인 참조를 두지 않는다.

Kotlin & SpringBoot Entity 설계 원칙

1. data class 대신 class 사용

Entity는 JPA의 프록시 객체 생성과 상속 구조를 고려하여 일반 class로 선언한다.

2. 불변성과 가변성 구분

  • val 사용: 변경되지 않는 ID, Key 값들
  • private var 사용: 변경 가능한 content, imageUrl, createdAt 등의 필드

3. 생성자 오버로딩

Entity의 기본 생성자 외에 도메인 객체를 받는 보조 생성자를 제공하여 객체 생성의 편의성을 높인다.

결론

JPA 연관관계 매핑은 신중하게 접근해야 한다. 무분별한 양방향 매핑이나 OneToMany 관계는 성능 문제를 야기할 수 있다. 비즈니스 요구사항을 정확히 분석하고, 정말 필요한 경우에만 단방향 ManyToOne 관계를 설정하는 것이 바람직하다. 대부분의 경우 외래키를 통한 별도 조회로도 충분히 요구사항을 만족할 수 있다.

학습 출처

https://www.youtube.com/watch?v=vgNHW_nb2mg

0개의 댓글