일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- file upload
- 반복문
- state
- Sequelize
- java
- restful api
- vue
- SWIFT
- It
- 개발이 취미인 사람
- AWS
- Kotlin
- front-end
- component
- spring boot
- class
- back-end
- swagger
- javascript
- props
- jpa
- react
- Nest.js
- 조건문
- node.js
- kafka
- 상속
- Producer
- 개발자
- 코틀린
- Today
- Total
개발이 취미인 사람
[JPA] - 양방향 연관관계 주의사항 5가지 본문
개요
안녕하세요. 이번 시간에는 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) 호출 시점에:
- member 엔티티가 영속화됩니다.
- GenerationType.IDENTITY 전략으로 인해 즉시 insert 쿼리가 실행됩니다.
- 이 시점에 profile과 company는 아직 영속화되지 않았으므로 ID가 없습니다.
- 따라서 member 테이블의 company_id와 member_profile_id는 NULL로 저장됩니다.
이후 company와 profile이 영속화되면:
- company와 profile이 저장되고 ID가 할당됩니다.
- member 객체는 이미 이 두 객체를 참조하고 있으므로, 참조하는 객체의 ID가 이제 존재합니다.
- JPA의 변경 감지(dirty checking)는 member 객체의 company와 profile 참조가 이제 실제 ID를 가진 것을 감지합니다.
- 트랜잭션 커밋 또는 flush() 시점에 변경된 외래 키를 업데이트하기 위한 update 쿼리가 실행됩니다.
이번 시간에는 양방향 연관관계 주의사항에 대해 알아봤습니다. 실제 실습을 통해 꼭 학습하시는 걸 추천드리겠습니다.
'백앤드(Back-End) > Jpa' 카테고리의 다른 글
[JPA] - Entity 관계 설정 방법 (1) | 2024.12.24 |
---|---|
[JPA] - Entity 정의 방법 (3) | 2024.12.14 |
[JPA] - 영속성 컨텍스트(2) (2) | 2024.11.29 |
[JPA] - 영속성 컨텍스트(1) (1) | 2024.11.24 |
[JPA] - 기본 동작 방식과 단순 CRUD (0) | 2024.11.19 |