객체와 관계형 데이터베이스 테이블이 어떻게 매핑되는지 이해를 목표로 정리해 보자.
JPA에서 가장 중요한 것을 뽑자면, "객체와 관계형 데이터베이스 테이블이 어떻게 매핑되는지를 이해하는 것"이다. 객체는 참조를 통해 연관 관계를 맺지만, 관계형 데이터베이스는 외래 키(Foreign Key)를 사용하여 관계를 표현한다. 이 차이를 올바르게 매핑하는 것이 JPA 연관 관계 매핑의 핵심이다.
우리는 프로그램을 작성할 때 객체(Object) 라는 기본 단위로 나누고, 이들의 상호 작용을 구현하여 애플리케이션을 개발한다. 또한, 클래스를 정의한 후 이를 인스턴스화(객체 생성) 하여 메모리에 저장한다. 그러나 이러한 객체는 메모리에 저장되므로, 애플리케이션이 종료되면 사라진다.
기본적으로 객체를 생성하면 객체의 참조값(주소)은 Stack에 저장되고, 실제 객체의 데이터는 Heap 메모리에 저장된다.
하지만, 애플리케이션이 종료된 후에도 데이터를 유지하려면 객체를 메모리가 아닌 영구 저장소(DB)에 저장해야 한다. 그렇다면, 메모리에 존재하는 인스턴스화된 객체가 어떻게 데이터베이스(RDB)에 저장될 수 있을까?
자바에서는 이러한 문제를 해결하기 위해 직렬화(Serialization)와 역직렬화(Deserialization) 를 제공한다.
이처럼 객체를 직렬화하면 데이터베이스가 아닌 파일 시스템에서도 영구적으로 저장할 수 있으며, 필요할 때 다시 객체로 변환하여 활용할 수 있다.
즉, 객체를 직렬화하여 파일 또는 데이터베이스에 저장한 뒤, 필요할 때 역직렬화하여 다시 메모리에 로드할 수 있다.
그렇다면, 파일 대신 데이터베이스에 객체를 그대로 저장할 수 있을까?
즉, 객체를 그대로 저장하는 것이 아니라, 객체의 속성(필드)만 테이블의 행(row) 형태로 변환하여 저장해야 한다.
관계형 데이터베이스는 데이터 중심으로 구조화되어 있으며, 객체 지향의 개념(추상화, 상속, 다형성)이 존재하지 않는다. 따라서, 객체와 데이터베이스는 서로 목적이 다르고 표현하는 방식이 다르므로, 객체를 데이터베이스의 테이블에 정확히 저장하는 것은 불가능하다.
이러한 차이로 인해, 객체와 데이터베이스를 올바르게 매핑하는 방법이 필요하다. 이를 해결하기 위해 JPA에서는 객체-관계 매핑(Object-Relational Mapping, ORM)을 제공한다.
이제 JPA 로 돌아와 객체 지향 프로그래밍과 데이터베이스 사이의 패러다임 불일치를 해결
을 위해서 어떻게 관계를 설정하는지 알아보자.
연관 관계를 매핑할 때, 생각해야 할 것은 크게 3가지가 있다.
관계형 데이터베이스에서는 외래 키(Foreign Key) 를 사용하여 두 테이블을 쉽게 조인할 수 있으므로, 단방향과 양방향을 구분할 필요가 없다. 하지만, 객체에서는 참조 필드를 가지고 있어야만 다른 객체를 참조할 수 있다.
엄밀히 따지면 객체에서는 양방향 관계라는 개념이 없고, 두 개의 단방향 관계가 존재할 뿐이다. 즉, A → B
, B → A
두 개의 단방향 관계를 가질 경우 양방향 관계처럼 사용할 수 있다.
비즈니스 로직에서 객체 간 참조가 필요한지 고려해야 한다. 필요하면 단방향 참조 추가, 불필요하면 참조하지 않기!
예를 들어,
Board → Post
단방향 참조Post → Board
단방향 참조만약 두 객체가 서로 참조를 가지게 된다면 양방향 연관 관계가 된다.
👉 NO, 객체 입장에서 양방향 매핑을 하면 오히려 복잡성이 증가할 수 있다.
User 엔티티가 여러 개의 엔티티와 관계를 맺고 있는 경우
모든 연관 관계를 양방향으로 설정하면, User 엔티티가 너무 많은 테이블과 연관 관계를 맺어 클래스가 복잡해지고 유지보수하기 어려워짐
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;
}
🤔 언제 사용해야 할까?
@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을 참조
@OneToOne 관계에서는 외래 키를 어느 테이블에 둘지 선택해야 한다. 위 코드에서 외래 키(profile_id)를 User 테이블에 둔 이유는 조회 성능과 관계의 주체성(누가 주인인지)을 고려한 것이다.
여기에서 외래 키를 User 테이블에 두는 이유는 다음과 같다.
SELECT * FROM user WHERE id = 1; -- profile_id를 함께 가져옴
SELECT * FROM profile WHERE id = (SELECT profile_id FROM user WHERE id = 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) 으로 설정해야 성능 최적화 가능🤔 언제 사용해야 할까?
@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;
}
JPA에서 연관 관계를 매핑할 때 단순히 @OneToOne, @OneToMany 같은 어노테이션만 붙이면 된다고 생각할 수 있지만, 실무에서는 연관 관계의 주인, 성능 최적화, 영속성 전이, 데이터 일관성 유지 등을 고려해야 한다.
연관 관계 매핑 시 발생할 수 있는 문제는 다음과 같다.
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;
}
List<Board> boards = entityManager.createQuery("SELECT b FROM Board b", Board.class).getResultList();
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)
...
연관 관계의 주인은? JPA에서 양방향 연관 관계를 사용할 때, 어느 엔티티가 외래 키를 관리할 것인지 지정하는 개념이다.
연관 관계의 주인만 INSERT, UPDATE, DELETE를 수행할 수 있으며, 반대편 엔티티는 mappedBy를 통해 조회만 가능하다.
영속성 전이(Cascade)란? 부모 엔티티가 저장될 때, 자식 엔티티도 함께 저장하고 싶다면 cascade 옵션을 사용한다.
예를 들어, 게시판(Board)과 게시글(Post) 관계에서 게시판을 삭제할 때, 모든 게시글도 자동 삭제하고 싶다면 CascadeType.ALL을 사용한다.
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
private List<Post> posts = new ArrayList<>();
고아 객체(Orphan Removal)란? 부모와 연관 관계가 끊어진 자식 엔티티를 자동으로 삭제하려면 orphanRemoval = true 설정
예를 들어, Board에서 posts.remove(post) 하면, 해당 Post 데이터가 DB에서도 삭제됨
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();