JPA 연관관계가 뭐야?!

이성민·2025년 11월 14일

woowacourse

목록 보기
8/12
post-thumbnail

이 글을 쓰게 된 이유

이번 프로젝트에서 StoredBook, StoredBooks, Reservation, Reservations를 먼저 구현하고 이제 Book을 만들던 중 고민이 생겼다.

예를 들어 “자바의 정석 - 남궁성”이라는 책이 있다고 하면 이 책은 여러 권의 소장본을 가질 수 있다. 그렇다면 관리자가 책을 등록할 때 이 책의 소장본들을 함께 등록해야 할 텐데 그 과정을 코드로 어떻게 표현해야 할지 막막했다.

“책이 여러 소장본을 가진다”는 건 너무 자연스러운 문장이지만 막상 구현하려 하니 책과 소장본 중 어느 쪽이 관계를 맺고 누가 누구를 생성해야 하는지가 불분명했다. 그래서 처음에는 단순하게 StoredBookBook을 주입받아 관계를 맺는 방식을 떠올렸다. 즉, 소장본이 자신이 속한 책을 알고 있으면 되지 않을까? 하는 생각이었다.

하지만 곧 “이 방식이 데이터베이스에서도 올바르게 연관관계로 적용될까?”라는 의문이 생겼다. 그 이유는 내가 객체 간 관계를 단순히 자바 코드 내부의 참조 수준으로만 이해하고 있었기 때문이다. 객체 간 연결이 실제로 DB에서는 외래키로 어떻게 저장되고 관리되는지 몰랐다.

즉, “객체 간의 연결이 실제 데이터로 어떻게 매핑되는지”에 대한 이해가 부족했다.
이 깨달음이 나를 JPA 연관관계 매핑 공부로 이끌었다.

결국 이 글은 단순히 JPA 연관관계를 정리하기 위한 글이 아니라 “이해하지 못한 채 코드를 작성했던 과거의 나”를 돌아보고 앞으로 더 나은 설계를 하기 위한 성장의 기록이다.


JPA 연관관계 매핑이란?

객체 모델의 참조(Reference)와 데이터베이스의 외래키(Foreign Key)를 논리적으로 연결하는 기술이다.

간단히 말하면 자바 객체끼리는 “참조(Reference)”로 연결되고 데이터베이스 테이블은 “외래키(Foreign Key)”로 연결된다.

이 둘은 표현 방식이 다르기 때문에 JPA가 두 세계를 자동으로 변환(매핑) 해주는 역할을 한다.

JPA에서 연관관계를 매핑할 때는 3가지 핵심 개념을 반드시 고려해야 한다.

  1. 방향 (Direction)
  2. 다중성 (Multiplicity)
  3. 연관관계의 주인 (Owner)

1. 방향(Direction) - 단방향 vs 양방향

객체는 참조를 통해 서로를 바라본다. 하지만 DB 테이블은 항상 양방향으로 참조할 수 없다.
그래서 객체 간 “참조 방향”은 설계자가 명시적으로 정해야 한다.

2. 다중성(Multiplicity) - 일대일, 일대다, 다대일, 다대다

연관관계는 실제 데이터 모델링 구조와 직결된다.

  • 1:1 (OneToOne) → 예: 여권 ↔ 사람
  • 1:N (OneToMany) → 예: 책 ↔ 소장본
  • N:1 (ManyToOne) → 예: 소장본 ↔ 책
  • N:N (ManyToMany) → 예: 학생 ↔ 과목 (하지만 실무에서는 중간 테이블로 풀어낸다)

3. 연관관계의 주인 (Owner)

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 :
    이 코드는 BookJPA 연관관계의 비주인이라는 선언이다.
    외래키는StoredBook 테이블에 존재하기 때문에 Book 엔티티는 단순히 읽기 전용으로 관계를 본다는 뜻이다.

  • cascade = CascadeType.ALL :
    이건 영속성 전이(Persistence Cascade) 개념이다.
    Book이 영속화되면 그 안에 포함된 모든 StoredBook도 자동으로 영속 상태가 된다.

  • orphanRemoval = true :
    이건 말 그대로 고아 객체 제거(Orphan Removal) 기능이다.
    BookStoredBook의 관계가 끊어지면 해당 StoredBook 엔티티를 자동으로 DB에서 DELETE 한다.


Fetch 전략 — 언제 데이터를 로딩할까?

- LAZY(지연 로딩)

@ManyToOne(fetch = FetchType.LAZY)
private Book book;

JPA는 실제로 Book이 필요할 때까지 DB 쿼리를 날리지 않는다.
이 상태에서는 book 필드에 프록시 객체(가짜 객체)가 들어있고 book.getTitle()호출되는 시점에 SELECT 쿼리가 실행된다.

  • 장점: 성능 최적화, 필요할 때만 로딩
  • 단점: 영속성 컨텍스트 밖에서 접근하면 LazyInitializationException 발생 가능

즉, LAZY는 기본이고, 필요한 시점에만 fetch join으로 조정하는 것이 실무 표준이다.

- EAGER(즉시 로딩)

@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과의 다대일 연관관계다.
  • 실제 DB 컬럼은 book_id로 만들어야 한다.
  • StoredBookpersist될 때 book_id FK를 자동으로 세팅해야 한다.

그래서 DB에도 정확히 외래키가 저장된다.


새롭게 궁금해진 점

연관관계 매핑을 공부하면서 자연스럽게 이런 의문이 들었다.

“JPA는 도대체 어떤 시점에 어떤 기준으로 엔티티의 필드 값을 DB에 반영할까?”

이 과정을 따라가다 보니 처음으로 영속성 컨텍스트(Persistence Context)라는 개념에 도달하게 되었다.

자세한 동작 원리는 이 글의 범위를 넘어서기에 다음 글에 이어서 작성하겠다.

JPA 영속성 컨텍스트는 또 뭐야..?


참조

Spring Data JPA 정리3_연관관계 매핑(일대일, 다대일, 일대다, 다대다)
[Spring Boot] Concept part - JPA 연관관계 매핑 기초
[JPA] 연관 관계 매핑

profile
BE 개발자

0개의 댓글