
이번 프로젝트에서 StoredBook, StoredBooks, Reservation, Reservations를 먼저 구현하고 이제 Book을 만들던 중 고민이 생겼다.
예를 들어 “자바의 정석 - 남궁성”이라는 책이 있다고 하면 이 책은 여러 권의 소장본을 가질 수 있다. 그렇다면 관리자가 책을 등록할 때 이 책의 소장본들을 함께 등록해야 할 텐데 그 과정을 코드로 어떻게 표현해야 할지 막막했다.
“책이 여러 소장본을 가진다”는 건 너무 자연스러운 문장이지만 막상 구현하려 하니 책과 소장본 중 어느 쪽이 관계를 맺고 누가 누구를 생성해야 하는지가 불분명했다. 그래서 처음에는 단순하게 StoredBook이 Book을 주입받아 관계를 맺는 방식을 떠올렸다. 즉, 소장본이 자신이 속한 책을 알고 있으면 되지 않을까? 하는 생각이었다.
하지만 곧 “이 방식이 데이터베이스에서도 올바르게 연관관계로 적용될까?”라는 의문이 생겼다. 그 이유는 내가 객체 간 관계를 단순히 자바 코드 내부의 참조 수준으로만 이해하고 있었기 때문이다. 객체 간 연결이 실제로 DB에서는 외래키로 어떻게 저장되고 관리되는지 몰랐다.
즉, “객체 간의 연결이 실제 데이터로 어떻게 매핑되는지”에 대한 이해가 부족했다.
이 깨달음이 나를 JPA 연관관계 매핑 공부로 이끌었다.
결국 이 글은 단순히 JPA 연관관계를 정리하기 위한 글이 아니라 “이해하지 못한 채 코드를 작성했던 과거의 나”를 돌아보고 앞으로 더 나은 설계를 하기 위한 성장의 기록이다.
객체 모델의 참조(Reference)와 데이터베이스의 외래키(Foreign Key)를 논리적으로 연결하는 기술이다.
간단히 말하면 자바 객체끼리는 “참조(Reference)”로 연결되고 데이터베이스 테이블은 “외래키(Foreign Key)”로 연결된다.
이 둘은 표현 방식이 다르기 때문에 JPA가 두 세계를 자동으로 변환(매핑) 해주는 역할을 한다.
JPA에서 연관관계를 매핑할 때는 3가지 핵심 개념을 반드시 고려해야 한다.
객체는 참조를 통해 서로를 바라본다. 하지만 DB 테이블은 항상 양방향으로 참조할 수 없다.
그래서 객체 간 “참조 방향”은 설계자가 명시적으로 정해야 한다.
연관관계는 실제 데이터 모델링 구조와 직결된다.
JPA에서 “주인”이라는 개념은 객체지향보다 데이터베이스 설계 관점에 가깝다.
연관관계의 주인 = 외래키를 직접 가지고 있는 엔티티이다.
@JoinColumn이 붙은 쪽이 주인DB의 외래키 값을 변경할 수 있다.mappedBy)은 읽기 전용이다.내 코드를 예시로 보이겠다.
- 주인 코드
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private Book book;
name = "book_id" : stored_book 테이블에 생성될 실제 외래키 컬럼 이름을 book_id로 지정한다.
nullable = false : 외래키 컬럼(book_id)이 null이면 안 된다는 뜻이다. 즉, 모든 StoredBook은 반드시 Book에 속해야 한다.
fetch는 뒤에서 따로 다루도록 하겠다.
- 비주인 코드
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true)
private List<StoredBook> storedBooks = new ArrayList<>();
mappedBy = book :
이 코드는 Book이 JPA 연관관계의 비주인이라는 선언이다.
외래키는StoredBook 테이블에 존재하기 때문에 Book 엔티티는 단순히 읽기 전용으로 관계를 본다는 뜻이다.
cascade = CascadeType.ALL :
이건 영속성 전이(Persistence Cascade) 개념이다.
Book이 영속화되면 그 안에 포함된 모든 StoredBook도 자동으로 영속 상태가 된다.
orphanRemoval = true :
이건 말 그대로 고아 객체 제거(Orphan Removal) 기능이다.
Book과 StoredBook의 관계가 끊어지면 해당 StoredBook 엔티티를 자동으로 DB에서 DELETE 한다.
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
JPA는 실제로 Book이 필요할 때까지 DB 쿼리를 날리지 않는다.
이 상태에서는 book 필드에 프록시 객체(가짜 객체)가 들어있고 book.getTitle()이 호출되는 시점에 SELECT 쿼리가 실행된다.
LazyInitializationException 발생 가능즉, LAZY는 기본이고, 필요한 시점에만 fetch join으로 조정하는 것이 실무 표준이다.
@ManyToOne(fetch = FetchType.EAGER)
private Book book;
엔티티를 조회할 때마다 항상 JOIN 쿼리로 함께 가져온다.
편리하지만, 관계가 많을수록 성능이 급격히 떨어지고 N+1 쿼리 문제가 쉽게 발생한다.
public class StoredBook {
private long id;
private StoredBookStatus status;
private Book book;
private StoredBook(Book book, StoredBookStatus status) {
this.book = book;
this.status = status;
}
public static StoredBook createAvailable(Book book) {
return new StoredBook(book, StoredBookStatus.AVAILABLE);
}
public static StoredBook createOnHold(Book book) {
return new StoredBook(book, StoredBookStatus.ON_HOLD);
}
}
당시에는 생성자에서 this.book = book으로 연결하면 “자바 객체에서 연결되니 DB에서도 자동으로 외래키가 저장되겠지?”라고 생각했다.
하지만 실제 실패 원인은 생성자 방식이 아니라 연관관계 매핑 어노테이션이 없었다는 점이었다.
@Entity
public class StoredBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Enumerated(EnumType.STRING)
private StoredBookStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private Book book;
protected StoredBook() {}
private StoredBook(Book book, StoredBookStatus status) {
this.book = book;
this.status = status;
}
public static StoredBook createAvailable(Book book) {
return new StoredBook(book, StoredBookStatus.AVAILABLE);
}
public static StoredBook createOnHold(Book book) {
return new StoredBook(book, StoredBookStatus.ON_HOLD);
}
}
이제 JPA는 다음을 정확히 인식한다.
Book과의 다대일 연관관계다.book_id로 만들어야 한다.StoredBook이 persist될 때 book_id FK를 자동으로 세팅해야 한다.그래서 DB에도 정확히 외래키가 저장된다.
연관관계 매핑을 공부하면서 자연스럽게 이런 의문이 들었다.
“JPA는 도대체 어떤 시점에 어떤 기준으로 엔티티의 필드 값을 DB에 반영할까?”
이 과정을 따라가다 보니 처음으로 영속성 컨텍스트(Persistence Context)라는 개념에 도달하게 되었다.
자세한 동작 원리는 이 글의 범위를 넘어서기에 다음 글에 이어서 작성하겠다.
Spring Data JPA 정리3_연관관계 매핑(일대일, 다대일, 일대다, 다대다)
[Spring Boot] Concept part - JPA 연관관계 매핑 기초
[JPA] 연관 관계 매핑