[JPA] 연관 관계 매핑

이영재·2025년 2월 6일
0

Spring

목록 보기
14/15

객체와 관계형 데이터베이스 테이블이 어떻게 매핑되는지 이해를 목표로 정리해 보자.

JPA 연관 관계 매핑

1. 연관 관계 매핑이란?

JPA에서 가장 중요한 것을 뽑자면, "객체와 관계형 데이터베이스 테이블이 어떻게 매핑되는지를 이해하는 것"이다. 객체는 참조를 통해 연관 관계를 맺지만, 관계형 데이터베이스는 외래 키(Foreign Key)를 사용하여 관계를 표현한다. 이 차이를 올바르게 매핑하는 것이 JPA 연관 관계 매핑의 핵심이다.

1.1 객체와 관계형 데이터베이스의 차이

우리는 프로그램을 작성할 때 객체(Object) 라는 기본 단위로 나누고, 이들의 상호 작용을 구현하여 애플리케이션을 개발한다. 또한, 클래스를 정의한 후 이를 인스턴스화(객체 생성) 하여 메모리에 저장한다. 그러나 이러한 객체는 메모리에 저장되므로, 애플리케이션이 종료되면 사라진다.

기본적으로 객체를 생성하면 객체의 참조값(주소)은 Stack에 저장되고, 실제 객체의 데이터는 Heap 메모리에 저장된다.

하지만, 애플리케이션이 종료된 후에도 데이터를 유지하려면 객체를 메모리가 아닌 영구 저장소(DB)에 저장해야 한다. 그렇다면, 메모리에 존재하는 인스턴스화된 객체가 어떻게 데이터베이스(RDB)에 저장될 수 있을까?

자바에서는 이러한 문제를 해결하기 위해 직렬화(Serialization)와 역직렬화(Deserialization) 를 제공한다.

📌 직렬화(Serialization)와 역직렬화(Deserialization)

  • 직렬화(Serialization): 객체를 바이트(byte) 형태로 변환하여 파일이나 DB에 저장할 수 있도록 하는 과정
  • 역직렬화(Deserialization): 저장된 바이트 데이터를 다시 객체로 변환하여 애플리케이션에서 활용할 수 있도록 하는 과정

이처럼 객체를 직렬화하면 데이터베이스가 아닌 파일 시스템에서도 영구적으로 저장할 수 있으며, 필요할 때 다시 객체로 변환하여 활용할 수 있다.

즉, 객체를 직렬화하여 파일 또는 데이터베이스에 저장한 뒤, 필요할 때 역직렬화하여 다시 메모리에 로드할 수 있다.

💡 파일 대신 데이터베이스에 객체를 저장한다면?

그렇다면, 파일 대신 데이터베이스에 객체를 그대로 저장할 수 있을까?

  • NoSQL 데이터베이스(예: MongoDB, Redis)에서는 객체를 JSON/BSON 형태로 저장하여, 직렬화 없이도 객체의 구조를 유지할 수 있다.
  • 하지만 관계형 데이터베이스(RDBMS)는 객체를 직접 저장할 수 없으며, 필드 단위로 테이블에 매핑해야 한다.

즉, 객체를 그대로 저장하는 것이 아니라, 객체의 속성(필드)만 테이블의 행(row) 형태로 변환하여 저장해야 한다.

관계형 데이터베이스는 데이터 중심으로 구조화되어 있으며, 객체 지향의 개념(추상화, 상속, 다형성)이 존재하지 않는다. 따라서, 객체와 데이터베이스는 서로 목적이 다르고 표현하는 방식이 다르므로, 객체를 데이터베이스의 테이블에 정확히 저장하는 것은 불가능하다.

이러한 차이로 인해, 객체와 데이터베이스를 올바르게 매핑하는 방법이 필요하다. 이를 해결하기 위해 JPA에서는 객체-관계 매핑(Object-Relational Mapping, ORM)을 제공한다.

2. 연관 관계의 종류

이제 JPA 로 돌아와 객체 지향 프로그래밍과 데이터베이스 사이의 패러다임 불일치를 해결을 위해서 어떻게 관계를 설정하는지 알아보자.

2.0 연관 관계 정의 규칙

연관 관계를 매핑할 때, 생각해야 할 것은 크게 3가지가 있다.

  • 방향 : 단방향, 양방향 (객체 참조)
  • 연관 관계의 주인 : 양방향일 때, 연관 관계에서 관리 주체
  • 다중성 : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)

2.1 단방향 vs 양방향

관계형 데이터베이스에서는 외래 키(Foreign Key) 를 사용하여 두 테이블을 쉽게 조인할 수 있으므로, 단방향과 양방향을 구분할 필요가 없다. 하지만, 객체에서는 참조 필드를 가지고 있어야만 다른 객체를 참조할 수 있다.

📌 객체와 데이터베이스의 연관 관계 차이

  • 데이터베이스: 외래 키 하나만 있으면 양쪽 테이블을 조인 가능 (단방향, 양방향 개념 없음)
  • 객체: 한 객체만 참조 필드를 가지면 단방향 관계, 양쪽 객체가 각각 참조 필드를 가지면 양방향 관계

엄밀히 따지면 객체에서는 양방향 관계라는 개념이 없고, 두 개의 단방향 관계가 존재할 뿐이다. 즉, A → B, B → A 두 개의 단방향 관계를 가질 경우 양방향 관계처럼 사용할 수 있다.

2.2 단방향 vs 양방향 연관 관계 선택 기준

비즈니스 로직에서 객체 간 참조가 필요한지 고려해야 한다. 필요하면 단방향 참조 추가, 불필요하면 참조하지 않기!

예를 들어,

  • Board.getPosts() 처럼 Board가 Post 목록을 가져와야 한다면 Board → Post 단방향 참조
  • Post.getBoard() 처럼 Post가 속한 Board를 알아야 한다면 Post → Board 단방향 참조

만약 두 객체가 서로 참조를 가지게 된다면 양방향 연관 관계가 된다.

❓ 그럼 무조건 양방향으로 하면 편하지 않을까?

👉 NO, 객체 입장에서 양방향 매핑을 하면 오히려 복잡성이 증가할 수 있다.

📌 양방향 매핑이 오히려 복잡해지는 예시

User 엔티티가 여러 개의 엔티티와 관계를 맺고 있는 경우
모든 연관 관계를 양방향으로 설정하면, User 엔티티가 너무 많은 테이블과 연관 관계를 맺어 클래스가 복잡해지고 유지보수하기 어려워짐

📌 그래서 올바른 연관 관계 매핑 방법은?

  • 기본적으로 단방향 매핑을 사용한다.
  • 나중에 객체 탐색이 꼭 필요하다고 느껴질 때, 역방향 참조를 추가한다.
  • 양방향 매핑이 무조건 좋은 것이 아니라, 복잡성을 고려하여 필요한 경우에만 추가해야 한다.

2.3 연관 관계의 주인(Owner) 개념

JPA에서 양방향 관계를 사용할 경우, 반드시 "연관 관계의 주인"을 지정해야 한다.

👉 연관 관계의 주인이란?

  • 두 객체(A ↔ B)가 양방향 관계를 가질 때, 어느 객체에서 연관 관계를 관리할지 JPA에 명확히 알려주는 개념
  • 연관 관계의 주인은 조회, 저장, 수정, 삭제를 제어할 수 있음
  • 반면, 연관 관계의 주인이 아닌 객체는 조회만 가능

📌 연관 관계의 주인은 어떻게 정할까?

💡 "외래 키가 있는 곳이 연관 관계의 주인이다!" (무조건!)

연관 관계의 주인 설정 예제

@Entity
public class Board {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @OneToMany(mappedBy = "board")  // 연관 관계의 주인이 아님
    private List<Post> posts = new ArrayList<>();
}

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne
    @JoinColumn(name = "board_id")  // 외래 키가 있는 곳 → 연관 관계의 주인
    private Board board;
}
  • Post 엔티티가 board_id 외래 키를 가지므로, 연관 관계의 주인은 Post
  • Board 쪽에서 mappedBy = "board"를 설정하여, 연관 관계의 주인이 아님을 명시

3. 연관 관계 매핑의 유형

1:1 (OneToOne) 관계

🤔 언제 사용해야 할까?

  • 한 개의 엔티티가 다른 한 개의 엔티티와 1:1 관계를 가질 때 사용.
  • ex) 사용자(User)와 프로필(Profile) 관계 (한 사용자는 하나의 프로필을 가짐)

1:1 관계 매핑 방법

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToOne
    @JoinColumn(name = "profile_id") // 외래 키 위치
    private Profile profile;
}

@Entity
public class Profile {
    @Id @GeneratedValue
    private Long id;
    private String bio;
}

👉 외래 키를 User 테이블에 저장하여 User에서 Profile을 참조

📌 1:1 관계에서 외래 키 위치 선택

@OneToOne 관계에서는 외래 키를 어느 테이블에 둘지 선택해야 한다. 위 코드에서 외래 키(profile_id)를 User 테이블에 둔 이유는 조회 성능과 관계의 주체성(누가 주인인지)을 고려한 것이다.

여기에서 외래 키를 User 테이블에 두는 이유는 다음과 같다.

  • 조회 성능이 더 유리하다.
    • 사용자(User)는 대부분 자주 조회되므로, 조회 성능을 고려하면 외래 키를 User에 두는 것이 유리함
     SELECT * FROM user WHERE id = 1;  -- profile_id를 함께 가져옴
     SELECT * FROM profile WHERE id = (SELECT profile_id FROM user WHERE id = 1);
  • User가 Profile의 주인이다.
    • 비즈니스 로직상 User가 존재해야 Profile이 존재할 수 있음
    • User가 Profile을 관리하는 것이 자연스러움

1:N / N:1 (OneToMany / ManyToOne)

🤔 언제 사용해야 할까?

  • 한 개의 엔티티가 여러 개의 다른 엔티티와 관계를 맺을 때
  • 데이터가 부모-자식 관계를 가지는 경우
  • ex) 게시판(Board)와 게시글(Post), 주문(Order)과 주문 항목(OrderItem)

1:N, N:1 관계 매핑 코드

@Entity
public class Board {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
    private List<Post> posts = new ArrayList<>();
}

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY) // 다대일 관계 설정
    @JoinColumn(name = "board_id")  // 외래 키 위치
    private Board board;
}

⚠️ 주의 할점

  • @OneToMany는 반드시 mappedBy를 설정해야 함 (외래 키 관리 주체는 @ManyToOne 쪽)
  • @OneToMany는 기본적으로 지연 로딩(LAZY) 으로 설정해야 성능 최적화 가능

N:M (ManyToMany)

🤔 언제 사용해야 할까?

  • 하나의 엔티티가 여러 개의 다른 엔티티와 관계를 맺고, 반대로도 마찬가지인 경우
  • ex) 학생(Student)과 강의(Course) (학생은 여러 강의를 수강할 수 있고, 강의도 여러 학생이 들을 수 있음)

N:M (ManyToMany) 관계 매핑 코드

@Entity
public class Student {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses = new ArrayList<>();
}

@Entity
public class Course {
    @Id @GeneratedValue
    private Long id;
    private String title;
}

⚠️ 실무에서는 @ManyToMany를 하지 않는다.

  • @ManyToMany는 중간 테이블을 자동으로 생성하지만, 중간 테이블에 추가적인 정보(등록 날짜, 상태 등)를 저장할 수 없음
  • 실무에서는 @ManyToMany 대신 중간 엔티티(연결 테이블)를 따로 만들어 1:N, N:1 관계로 풀어야 함

4. 연관 관계 매핑 시 고려해야 할 사항

JPA에서 연관 관계를 매핑할 때 단순히 @OneToOne, @OneToMany 같은 어노테이션만 붙이면 된다고 생각할 수 있지만, 실무에서는 연관 관계의 주인, 성능 최적화, 영속성 전이, 데이터 일관성 유지 등을 고려해야 한다.

연관 관계 매핑 시 발생할 수 있는 문제는 다음과 같다.

N+1 문제

N+1 문제란? 하나의 조회(Query)로 인해 추가적인 N개의 Query가 발생하는 문제이다. 기본적으로 JPA의 기본 설정이 FetchType이 Lazy(지연 로딩)이기 때문에 발생한다. 연관된 엔티티를 조회할 때, 개별적으로 SELECT 쿼리가 반복 실행됨

📌 예제 (게시판 - 게시글 연관 관계)

@Entity
public class Board {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @OneToMany(mappedBy = "board", fetch = FetchType.LAZY) // 기본적으로 지연 로딩(LAZY)
    private List<Post> posts = new ArrayList<>();
}
@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;
}

📌 JPA에서 모든 Board를 조회하는 코드

List<Board> boards = entityManager.createQuery("SELECT b FROM Board b", Board.class).getResultList();

📌 실행되는 SQL 쿼리

SELECT * FROM board;  -- (1) Board 전체 조회 (기본 조회)
SELECT * FROM post WHERE board_id = 1; -- (N) 각 Board에 대한 Post 조회
SELECT * FROM post WHERE board_id = 2; -- (N)
SELECT * FROM post WHERE board_id = 3; -- (N)
...
  • 문제점: Board가 N개라면, Post 조회 쿼리가 N번 추가 발생!
  • Board 10개를 조회하면, Post를 조회하는 추가 쿼리가 10번 실행됨
  • 따라서 "1 + 10 = 11개의 쿼리"가 실행됨 → 성능 저하

연관 관계의 주인

연관 관계의 주인은? JPA에서 양방향 연관 관계를 사용할 때, 어느 엔티티가 외래 키를 관리할 것인지 지정하는 개념이다.
연관 관계의 주인만 INSERT, UPDATE, DELETE를 수행할 수 있으며, 반대편 엔티티는 mappedBy를 통해 조회만 가능하다.

  • 연관 관계의 주인은 "외래 키가 있는 엔티티"로 설정하는 것이 원칙
  • 왜 연관 관계의 주인을 지정해야 하는가?
    • JPA가 데이터 변경을 한 곳에서만 관리하도록 하기 위해
    • 불필요한 추가 쿼리 실행을 방지하기 위해
  • 연관 관계의 주인을 설정하지 않으면?
    • JPA가 INSERT, UPDATE 시 불필요한 추가 쿼리를 실행할 수도 있음

영속성 전이(Cascade)와 고아 객체

영속성 전이(Cascade)란? 부모 엔티티가 저장될 때, 자식 엔티티도 함께 저장하고 싶다면 cascade 옵션을 사용한다.
예를 들어, 게시판(Board)과 게시글(Post) 관계에서 게시판을 삭제할 때, 모든 게시글도 자동 삭제하고 싶다면 CascadeType.ALL을 사용한다.

📌 영속성 전이 설정 (CascadeType.ALL)

@OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
private List<Post> posts = new ArrayList<>();
  • CascadeType.ALL → 부모(Board)를 저장하면 자식(Post)도 함께 저장됨
  • CascadeType.REMOVE → 부모를 삭제하면 자식도 함께 삭제됨

고아 객체(Orphan Removal)란? 부모와 연관 관계가 끊어진 자식 엔티티를 자동으로 삭제하려면 orphanRemoval = true 설정
예를 들어, Board에서 posts.remove(post) 하면, 해당 Post 데이터가 DB에서도 삭제됨

📌 고아 객체 제거 설정 (orphanRemoval = true)

@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
  • orphanRemoval = true를 설정하면, 연관 관계가 끊긴 엔티티는 자동 삭제됨
  • CascadeType.ALL과 함께 사용하면 부모 삭제 시 자식도 자동 삭제됨

0개의 댓글

관련 채용 정보