개발이 취미인 사람

[JPA] - 양방향 연관관계 주의사항 5가지 본문

백앤드(Back-End)/Jpa

[JPA] - 양방향 연관관계 주의사항 5가지

RyanSin 2025. 5. 1. 19:21
반응형

개요

안녕하세요. 이번 시간에는 JPA에서 양방향 연관관계 설정 시 주의해야 할 5가지 사항에 대해 알아보겠습니다.혹시 이전 시간에 내용을 학습하고 오시지 못 하신 분들은 학습하고 오시는 걸 추천드리겠습니다.

 

[JPA] - Entity 관계 설정 방법

- 개요안녕하세요. 이번 시간에는 Entity 간에 관계 설정에 대해 알아보겠습니다. 혹시 이전 시간에 내용을 학습하고 오시지 못 하신 분들은 학습하고 오시는 걸 추천드리겠습니다.[JPA] - Entity 정

any-ting.tistory.com

 

- 양방향 연관관계 주의사항

양방향 연관관계는 객체 지향적인 도메인 모델을 설계할 때 유용하지만, 여러 가지 주의해야 할 점이 있습니다. 이번 시간에는 양방향 매핑에서 자주 발생하는 5가지 문제점과 해결책에 대해 알아보겠습니다.

1. 연관관계의 주인 설정 오류

양방향 연관관계에서는 연관관계의 주인을 정해야 합니다. 연관관계의 주인만이 외래 키를 관리(등록, 수정, 삭제)할 수 있고, 주인이 아닌 쪽은 읽기만 가능합니다.

문제 상황

예를 들어, 기존 MemberEntity와 MemberProfileEntity의 관계를 양방향으로 설정한다고 가정해보겠습니다.

 

@Entity
@Table(name = "member")
class MemberEntity(
    // ...
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_profile_id", referencedColumnName = "id")
    val profile: MemberProfileEntity,
    // ...
) : BaseEntity()

@Entity
@Table(name = "member_profile")
class MemberProfileEntity(
    // ...
    @OneToOne(mappedBy = "profile")
    val member: MemberEntity? = null,
    // ...
) : BaseEntity()

 

기서 MemberEntity 외래 키(member_profile_id)를 가지고 있으므로 연관관계의 주인입니다. 그런데 아래와 같이 주인이 아닌 MemberProfileEntity 쪽의 관계만 설정하면 어떻게 될까요?

 

// 잘못된 방법
val profile = MemberProfileEntity(gender = Gender.MALE, age = 30)
val member = MemberEntity(
    email = "test@example.com",
    password = "password",
    name = "테스트",
    company = company,
    profile = null // 주인 쪽에 설정하지 않음
)

// 주인이 아닌 쪽만 설정 (가정)
profile.member = member 

em.persist(profile)
em.persist(member)

이렇게 하면 다음과 같이 외래 키가 설정되지 않아 연과관계가 맺어지지 않습니다.

 

// 올바른 방법 1: 양쪽 모두 설정
val profile = MemberProfileEntity(gender = Gender.MALE, age = 30)
val member = MemberEntity(
    email = "test@example.com",
    password = "password",
    name = "테스트",
    company = company,
    profile = profile // 주인 쪽에 설정
)
profile.member = member // 주인이 아닌 쪽에도 설정

// 올바른 방법 2: 연관관계 편의 메서드 사용
fun MemberEntity.connectProfile(profile: MemberProfileEntity) {
    this.profile = profile
    profile.member = this
}

// 사용
member.connectProfile(profile)

 

2. 객체 그래프 탐색 불일치 문제

양방향 관계에서 한쪽만 연관관계를 설정하면 객체 탐색 결과가 달라지는 문제가 발생합니다.

 

문제 상황

기존 코드에서 MemberEntity와 CompanyEntity 간의 관계를 양방향으로 확장해보겠습니다.

@Entity
@Table(name = "member")
class MemberEntity(
    // ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id", referencedColumnName = "id")
    val company: CompanyEntity
    // ...
) : BaseEntity()

@Entity
@Table(name = "company")
class CompanyEntity(
    // ...
    @OneToMany(mappedBy = "company")
    val members: MutableList<MemberEntity> = mutableListOf()
    // ...
) : BaseEntity()

 

이제 멤버를 생성할 때 한쪽 관계만 설정하면:

// 데이터베이스에는 연관관계가 정상적으로 설정됨
val member = MemberEntity(
    email = "test@example.com",
    password = "password",
    name = "테스트",
    company = company,
    profile = profile
)
// company.members에는 추가되지 않음

// 이후 코드에서...
// company.members 컬렉션에는 member가 없어 문제 발생!
assertEquals(0, company.members.size) // 예상과 다르게 통과함!

 

제 데이터베이스 관계는 정상이지만, 객체 그래프를 탐색할 때 일관성이 없어 혼란을 일으킵니다.

 

해결책

양방향 관계를 설정할 때는 항상 양쪽에 모두 관계를 설정해주는 연관관계 편의 메서드를 사용합니다.

// CompanyEntity에 추가
fun addMember(member: MemberEntity) {
    members.add(member)
    // member.company = this는 이미 생성자에서 설정되었다면 필요 없음
}

// 사용
company.addMember(member)

 

3. 무한 순환 참조 문제

양방향 연관관계가 있는 엔티티를 JSON으로 직렬화할 때 서로를 계속 참조하는 무한 순환 참조 문제가 발생할 수 있습니다.

문제 상황

CompanyEntity와 CategoryEntity는 다대다 관계로 설정되어 있습니다.

@Entity
@Table(name = "company")
class CompanyEntity(
    // ...
    @ManyToMany
    @JoinTable(
        name = "company_category_mapping",
        joinColumns = [JoinColumn(name = "company_id")],
        inverseJoinColumns = [JoinColumn(name = "category_id")]
    )
    val categories: MutableSet<CategoryEntity> = mutableSetOf()
    // ...
) : BaseEntity()

@Entity
@Table(name = "category")
class CategoryEntity(
    // ...
    @ManyToMany(mappedBy = "categories")
    val companies: MutableSet<CompanyEntity> = mutableSetOf()
    // ...
) : BaseEntity()

 

 엔티티들을 API 응답으로 직접 반환하면:

@RestController
class CompanyController(private val companyRepository: CompanyRepository) {
    @GetMapping("/companies/{id}")
    fun getCompany(@PathVariable id: Long): CompanyEntity {
        return companyRepository.findById(id).orElseThrow()
        // 무한 순환 참조 발생!
        // CompanyEntity -> categories -> CategoryEntity -> companies -> CompanyEntity -> ...
    }
}

해결책

방법 1: Jackson 어노테이션 사용

@Entity
@Table(name = "company")
class CompanyEntity(
    // ...
    @JsonManagedReference
    @ManyToMany
    @JoinTable(
        name = "company_category_mapping",
        joinColumns = [JoinColumn(name = "company_id")],
        inverseJoinColumns = [JoinColumn(name = "category_id")]
    )
    val categories: MutableSet<CategoryEntity> = mutableSetOf()
) : BaseEntity()

@Entity
@Table(name = "category")
class CategoryEntity(
    // ...
    @JsonBackReference
    @ManyToMany(mappedBy = "categories")
    val companies: MutableSet<CompanyEntity> = mutableSetOf()
) : BaseEntity()

 

방법 2: @JsonIgnore 사용

@Entity
@Table(name = "category")
class CategoryEntity(
    // ...
    @JsonIgnore
    @ManyToMany(mappedBy = "categories")
    val companies: MutableSet<CompanyEntity> = mutableSetOf()
) : BaseEntity()

 

방법 3: DTO 사용 (가장 권장)

data class CompanyDTO(
    val id: Long,
    val name: String,
    val status: CompanyStatus,
    val address: AddressDTO,
    val categories: List<CategoryDTO>
)

data class CategoryDTO(
    val id: Long,
    val name: String
)

@RestController
class CompanyController(private val companyRepository: CompanyRepository) {
    @GetMapping("/companies/{id}")
    fun getCompany(@PathVariable id: Long): CompanyDTO {
        val company = companyRepository.findById(id).orElseThrow()
        return CompanyDTO(
            id = company.id!!,
            name = company.name,
            status = company.status,
            address = AddressDTO(
                city = company.address.city,
                street = company.address.street,
                zipcode = company.address.zipcode
            ),
            categories = company.categories.map { 
                CategoryDTO(it.id!!, it.name) 
            }
        )
    }
}

 

4. 영속성 전이(cascade)와 고아 객체 제거(orphanRemoval) 주의점

양방향 관계에서 영속성 전이와 고아 객체 제거 옵션을 사용할 때 주의해야 합니다.

문제 상황

CategoryEntity는 자기 참조 관계를 가지고 있습니다:

@Entity
@Table(name = "category")
class CategoryEntity(
    // ...
    @ManyToOne
    @JoinColumn(name = "parent_id", referencedColumnName = "id")
    val parent: CategoryEntity?,

    @OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
    val childCategories: MutableList<CategoryEntity> = mutableListOf()
    // ...
) : BaseEntity()

이 설정의 의미는 다음과 같습니다:

  • cascade = [CascadeType.ALL]: 부모 카테고리를 저장/수정/삭제하면 자식 카테고리도 함께 저장/수정/삭제됨
  • orphanRemoval = true: 부모 카테고리의 childCategories 컬렉션에서 자식 카테고리가 제거되면 해당 자식 카테고리도 DB에서 삭제됨

주의 사항

 

1. 상위 엔티티 삭제 시 모든 하위 엔티티도 함께 삭제

 
 
// 부모 카테고리 삭제 시 모든 자식 카테고리도 삭제됨
em.remove(parentCategory)

 

2. 컬렉션 요소 제거 시 DB에서도 삭제

// 컬렉션에서 제거되면 DB에서도 삭제됨
parentCategory.childCategories.removeAt(0)

 

3. 연관관계 변경 시 삭제 발생 가능

// 이렇게 하면 orphanRemoval로 인해 자식 카테고리가 DB에서 삭제됨!
childCategory.parent = null

 

해결책

1. 핵심 엔티티가 아닌 경우 cascade 옵션 사용 제한하기

@OneToMany(mappedBy = "parent", cascade = [CascadeType.PERSIST, CascadeType.MERGE])
val childCategories: MutableList<CategoryEntity> = mutableListOf()

 

2. 연관관계 편의 메서드로 관계 변경 로직 캡슐화

fun addChildCategory(child: CategoryEntity) {
   childCategories.add(child)
   child.parent = this
}

fun removeChildCategory(child: CategoryEntity) {
   childCategories.remove(child)
   // 필요에 따라 child.parent = null 설정 또는 추가 로직
}

 

5. 지연 로딩(Lazy Loading)과 N+1 문제

양방향 관계에서 지연 로딩(LAZY)을 사용할 때 발생하는 N+1 쿼리 문제입니다.

문제 상황

CompanyEntity와 MemberEntity 간의 관계에서:

// 모든 회사 조회 (1번 쿼리)
val companies = companyRepository.findAll()

// 각 회사의 직원 목록에 접근 (N번의 추가 쿼리)
companies.forEach { company ->
    println("${company.name}의 직원 수: ${company.members.size}")
    
    // 각 직원 정보에 접근할 때마다 추가 쿼리 발생
    company.members.forEach { member ->
        println(" - 직원: ${member.getName()}")
    }
}

 

회사가 100개라면, 회사 조회 쿼리 1번 + 각 회사의 직원 목록을 가져오는 쿼리 100번 = 총 101번의 쿼리가 실행됩니다!

 

해결책

1. 페치 조인(Fetch Join) 사용

@Query("SELECT c FROM CompanyEntity c LEFT JOIN FETCH c.members")
fun findAllWithMembers(): List<CompanyEntity>

 

2. EntityGraph 사용

@EntityGraph(attributePaths = ["members"])
@Query("SELECT c FROM CompanyEntity c")
fun findAllWithMembers(): List<CompanyEntity>

 

3. BatchSize 설정

@Entity
@Table(name = "company")
class CompanyEntity(
    // ...
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "company")
    val members: MutableList<MemberEntity> = mutableListOf()
) : BaseEntity()

 

또는 application.properties에 글로벌 설정:

spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

6. 저장 로직 순서가 올바르지 않을 경우 update query 실행

문제 상황

기존 MemberEntity는 MemberProfileEntity와 CompanyEntity를 참조하고 있습니다.

@Entity
@Table(name = "member")
class MemberEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(name = "email", nullable = false, unique = true)
    val email: String,

    @Column(name = "password", nullable = false)
    val password: String,

    @Column(name = "name", nullable = false)
    private var name: String,

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_profile_id", referencedColumnName = "id")
    val profile: MemberProfileEntity,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id", referencedColumnName = "id")
    val company: CompanyEntity
) : BaseEntity()

 

올바른 저장 순서

정상적인 경우, profile과 company를 먼저 저장하고 그 다음 member를 저장해야 합니다:

// 올바른 순서로 저장하는 예제
fun saveCorrectOrder() {
    // 1. 먼저 Company와 Profile 저장
    val company = CompanyEntity(
        name = "테스트 회사",
        status = CompanyStatus.ACTIVE,
        // 다른 필드들...
    )
    em.persist(company)
    
    val profile = MemberProfileEntity(
        gender = Gender.MALE,
        age = 30
    )
    em.persist(profile)
    
    // 2. 이제 Member 저장
    val member = MemberEntity(
        email = "user@test.com",
        password = "password",
        name = "사용자",
        profile = profile,  // 이미 저장된 객체 (ID 존재)
        company = company   // 이미 저장된 객체 (ID 존재)
    )
    
    em.persist(member)
    em.flush()
}

// 실행된 쿼리
// em.persist(company) 호출 시
// Hibernate: insert into company (...) values (...)

// em.persist(profile) 호출 시
// Hibernate: insert into member_profile (...) values (...)

// em.persist(member) 호출 시
// Hibernate: insert into member (company_id, created_at, deleted_at, email, member_profile_id, name, password, updated_at) values (?, ?, NULL, ?, ?, ?, ?, ?)

 

잘못된 저장 순서

하지만 실수로 member를 먼저 생성하고 저장한 다음, profile과 company를 나중에 저장하면 어떻게 될까요?

// 잘못된 순서로 저장하는 예제
fun saveIncorrectOrder() {
    // 1. Company와 Profile 객체 생성
    val company = CompanyEntity(
        name = "테스트 회사",
        status = CompanyStatus.ACTIVE,
        businessNumber = "123-45-67890",
        ceoName = "대표자",
        foundedDate = Instant.now(),
        email = "company@test.com",
        phoneNumber = "02-123-4567",
        address = Address("서울", "강남", "12345")
    )
    
    val profile = MemberProfileEntity(
        gender = Gender.MALE,
        age = 30
    )
    
    // 2. Member 생성 및 저장 시도
    val member = MemberEntity(
        email = "user@test.com",
        password = "password",
        name = "사용자",
        profile = profile,  // 아직 영속화되지 않은 객체
        company = company   // 아직 영속화되지 않은 객체
    )
    
    em.persist(member)  // 여기서 member insert 쿼리 발생
    em.persist(company) // 여기서 company insert 쿼리 발생
    em.persist(profile) // 여기서 profile insert 쿼리 발생
    
    em.flush() // 여기서 member update 쿼리 발생
}

// 실행된 쿼리
// em.persist(member) 호출 시
// Hibernate: insert into member (company_id, created_at, deleted_at, email, member_profile_id, name, password, updated_at) values (NULL, ?, NULL, ?, NULL, ?, ?, ?)

// em.persist(company) 호출 시
// Hibernate: insert into company (...) values (...)

// em.persist(profile) 호출 시
// Hibernate: insert into member_profile (...) values (...)

// em.flush() 또는 트랜잭션 커밋 시
// Hibernate: update member set company_id=?, member_profile_id=?, updated_at=? where id=? and deleted_at is NULL

왜 update 쿼리가 발생하는가?

위 코드에서 em.persist(member) 호출 시점에:

  1. member 엔티티가 영속화됩니다.
  2. GenerationType.IDENTITY 전략으로 인해 즉시 insert 쿼리가 실행됩니다.
  3. 이 시점에 profile과 company는 아직 영속화되지 않았으므로 ID가 없습니다.
  4. 따라서 member 테이블의 company_id와 member_profile_id는 NULL로 저장됩니다.

이후 company와 profile이 영속화되면:

  1. company와 profile이 저장되고 ID가 할당됩니다.
  2. member 객체는 이미 이 두 객체를 참조하고 있으므로, 참조하는 객체의 ID가 이제 존재합니다.
  3. JPA의 변경 감지(dirty checking)는 member 객체의 company와 profile 참조가 이제 실제 ID를 가진 것을 감지합니다.
  4. 트랜잭션 커밋 또는 flush() 시점에 변경된 외래 키를 업데이트하기 위한 update 쿼리가 실행됩니다.

이번 시간에는 양방향 연관관계 주의사항에 대해 알아봤습니다. 실제 실습을 통해 꼭 학습하시는 걸 추천드리겠습니다.