JPA · 연관관계 매핑
테이블끼리 외래키(FK)로 이어진 관계를, Java 엔티티? 객체끼리 서로 참조하는 모양으로 옮기는 일입니다. 주문이 사용자를 가리키고, 사용자는 자기 주문 목록을 가지는 식으로요.
Jakarta Persistence 3.1(import jakarta.persistence.*) · Hibernate? 6.x · Spring Boot? 3.3.1 · Java? 17 기준입니다.
연관관계 3요소
연관관계 하나를 정할 때는 세 가지를 결정합니다.
| 요소 | 고르는 것 | 설명 |
|---|---|---|
| 방향 | 단방향 / 양방향 | 한쪽만 상대를 참조하면 단방향, 양쪽이 서로 참조하면 양방향. DB 테이블은 방향이 없고(외래키 하나뿐), 방향은 오직 객체 세계의 이야기입니다. |
| 다중성 | N:1 · 1:N · 1:1 · N:M | "몇 대 몇"인가. 주문 여러 개가 사용자 한 명을 보면 N:1. 사용자 한 명이 주문 여러 개를 보면 1:N. |
| 주인 | owner / 반대편 | 외래키 컬럼을 실제로 가진 쪽이 연관관계의 주인입니다. 주인만 FK 값을 INSERT·UPDATE 할 수 있어요. |
연관관계의 주인과 mappedBy
이게 JPA 연관관계에서 가장 헷갈리는 부분입니다. 양방향에서 두 객체가 서로를 가리키지만, DB에는 외래키 컬럼이 딱 하나뿐입니다. 그래서 "둘 중 누가 그 FK를 책임지는가"를 정해야 해요. 그게 주인(owner)입니다.
- 주인 = 외래키를 가진 쪽. 보통 N:1의 N쪽(예: 주문). 주인 쪽 변경만 DB FK에 반영됩니다.
- 반대편(주인이 아닌 쪽)은
mappedBy로 "나는 주인이 아니고, 저쪽 필드가 주인이야"라고 알려줍니다. 반대편은 읽기 전용(조회용 거울)이에요.
mappedBy = "user" 는 "이 관계의 외래키는 상대 엔티티의 user 필드가 관리한다"는 뜻입니다. 거울에 비친 모습(반대편)을 아무리 바꿔도 실물(주인)이 안 바뀌면 DB는 그대로예요.mappedBy 가 붙은 컬렉션에만 값을 넣고 주인 쪽(order.user)을 안 채우면, DB의 외래키는 갱신되지 않습니다. 주인 쪽을 반드시 세팅해야 저장됩니다.
@ManyToOne — 가장 많이 쓰는 외래키 매핑
"여럿이 하나를 본다"(N:1). 주문 여러 개가 사용자 한 명을 가리키는, 실무에서 제일 자주 쓰는 매핑입니다. 외래키를 가진 쪽이라 항상 연관관계의 주인이에요.
@Entity
@Table(name = "tb_order", schema = "lds")
public class Order {
@Id @GeneratedValue
private UUID id;
// 주문 여럿(N) → 사용자 하나(1). 이 쪽이 외래키 owner.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "user_id", // 외래키 컬럼 이름
nullable = false, // FK 컬럼에 NOT NULL
foreignKey = @ForeignKey(name = "fk_order_user")) // FK 제약 이름
private User user;
}| 속성 | 의미 |
|---|---|
@JoinColumn(name) | 이 엔티티 테이블에 만들 외래키 컬럼 이름. 생략하면 필드명_참조PK명 규칙으로 자동 생성. |
nullable | FK 컬럼이 NULL 허용인지(DDL의 NOT NULL 여부). |
foreignKey | 생성될 FK 제약조건 이름 지정. 관리·디버깅 편의용. |
optional | @ManyToOne 자체 속성. false 면 "이 연관은 반드시 존재"(NOT NULL과 같은 맥락). 기본값 true. |
@OneToMany — 컬렉션으로 1:N
"하나가 여럿을 본다"(1:N). 사용자 한 명이 자기 주문 목록을 가지는 쪽입니다.
보통 @ManyToOne 의 반대편(양방향) 으로 씁니다. 외래키는 N쪽(주문)에 있으므로,
이 쪽은 주인이 아니라서 mappedBy 를 붙여 줍니다.
@Entity
@Table(name = "tb_user", schema = "lds")
public class User {
@Id @GeneratedValue
private UUID id;
// mappedBy = "user": 외래키는 Order.user 필드가 관리한다(나는 주인 아님).
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}컬렉션 타입은 List<Order> 또는 Set<Tag> 를 씁니다. 순서가 중요하면 List, 중복 없는 집합이면 Set이 자연스러워요.
mappedBy 없이 @OneToMany 에 @JoinColumn 만 붙이면(단방향 1:N), 외래키가 N쪽 테이블에 있는데 주인은 1쪽이라 어긋납니다. 그러면 Hibernate가 주문을 저장한 뒤 별도 UPDATE 문으로 FK를 또 채우는 비효율이 생겨요. 가능하면 양방향(@ManyToOne 주인 + @OneToMany mappedBy) 으로 푸는 것을 권장합니다.
@OneToOne — 1:1
"하나가 하나를 본다"(1:1). 사용자와 사용자 상세정보처럼 일대일로 짝지을 때 씁니다. 외래키를 어느 테이블에 둘지(주 테이블 / 대상 테이블) 선택할 수 있고, FK를 가진 쪽이 주인입니다.
@Entity
public class User {
@Id @GeneratedValue
private UUID id;
// 주인: profile_id 외래키를 User 테이블에 둠
@OneToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "profile_id")
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id @GeneratedValue
private UUID id;
// 반대편(주인 아님): 외래키는 User.profile 이 관리
@OneToOne(mappedBy = "profile", fetch = FetchType.LAZY)
private User user;
}- 주 테이블에 FK: 위 예처럼 자주 접근하는 쪽 테이블에 외래키를 두는 방식. 가장 흔합니다.
- 대상 테이블에 FK: 반대로 두면 향후 1:N 으로 확장할 때 유리할 수 있습니다.
optional = false로 두면 짝이 반드시 존재한다는 뜻이고, 이 경우 LAZY 가 더 잘 동작합니다(NULL 가능성이 있으면 프록시 판단이 어려워 즉시 조회가 끼어들 수 있어요).
@ManyToMany — N:M (실무에선 풀어쓰기 권장)
"여럿이 여럿을 본다"(N:M). 사용자와 태그처럼 양쪽 다 여러 개를 가질 때입니다.
관계형 DB에는 N:M 을 직접 표현할 수 없어서 중간(연결) 테이블이 필요한데,
@ManyToMany + @JoinTable 이 그 중간 테이블을 자동으로 만들어 줍니다.
@Entity
public class User {
@Id @GeneratedValue
private UUID id;
@ManyToMany
@JoinTable(
name = "tb_user_tag", // 연결 테이블
joinColumns = @JoinColumn(name = "user_id"), // 내 쪽 FK
inverseJoinColumns = @JoinColumn(name = "tag_id")) // 상대 쪽 FK
private Set<Tag> tags = new HashSet<>();
}자동 생성된 연결 테이블에는 두 외래키만 들어갑니다. 그런데 현실에서는 "언제 연결됐는지(가입일)", "권한 수준" 같은 추가 컬럼이 거의 항상 필요해져요. 그러면 @ManyToMany 로는 표현이 안 됩니다.
그래서 처음부터 중간 엔티티(예: UserTag)를 만들고, 그 안에 @ManyToOne 두 개(User, Tag)를 두는 방식이 권장됩니다. N:M 을 1:N + N:1 두 개로 풀어쓰는 거예요. 관리·확장 모두 편합니다.
@Entity
@Table(name = "tb_user_tag", schema = "lds")
public class UserTag {
@Id @GeneratedValue
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
private Tag tag;
@Column
private Instant linkedAt; // 연결 테이블에 추가 컬럼을 자유롭게!
}FetchType — 언제 가져올까 (LAZY vs EAGER)
연관된 객체를 언제 DB에서 조회할지 정하는 옵션입니다.
- LAZY(지연 로딩): 일단 가짜 객체(프록시)만 넣어 두고, 그 필드를 실제로 꺼내 쓰는 순간 DB를 조회합니다. 안 쓰면 조회도 안 해요.
- EAGER(즉시 로딩): 부모를 조회할 때 연관된 것까지 곧바로 함께 조회합니다. 안 써도 무조건 가져옵니다.
각 연관관계의 기본 fetch 값(JPA 표준):
| 애너테이션 | 기본 FetchType |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
외우는 법: ~ToOne 은 EAGER, ~ToMany 는 LAZY 가 기본입니다.
EAGER 는 안 쓰는 데이터까지 끌어오고, N+1 문제(아래)의 단골 원인이라 예측이 어렵습니다. 그래서 실무에서는 모든 연관관계를 명시적으로 fetch = FetchType.LAZY 로 지정하는 것이 정석입니다. 필요한 곳에서만 fetch join·EntityGraph 로 함께 가져오면 돼요.
cascade — 영속성 전이
부모 엔티티에 한 작업(저장·삭제 등)을 자식 엔티티에도 자동으로 전파하는 기능입니다.
부모만 save 했는데 자식까지 같이 저장되게 하는 식이에요.
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<Order> orders = new ArrayList<>();
| CascadeType | 전파되는 작업 |
|---|---|
PERSIST | 저장(persist)을 자식에게도 |
MERGE | 병합(merge)을 자식에게도 |
REMOVE | 삭제(remove)를 자식에게도 |
REFRESH | 새로고침(refresh)을 자식에게도 |
DETACH | 분리(detach)를 자식에게도 |
ALL | 위 전부 |
cascade 기본값은 "아무것도 전파 안 함"(빈 값)입니다. 필요할 때만 켜세요.
orphanRemoval 과의 차이
둘 다 자식을 삭제할 수 있어 헷갈리지만, 작동 조건이 다릅니다.
CascadeType.REMOVE: 부모를 삭제할 때 자식도 같이 삭제. 부모가 사라져야 동작합니다.orphanRemoval = true: 부모의 컬렉션에서 자식을 빼내기만 해도(고아가 되면) 그 자식을 DELETE. 부모는 그대로 살아 있어도 됩니다. (user.getOrders().remove(order)→ order 행 삭제)
REMOVE 는 "집을 허물 때 가구도 같이 버림". orphanRemoval 은 "집에서 가구 하나만 내놨더니 곧장 폐기됨". 부모(집)와의 연결이 끊긴 자식은 버려진다는 점이 핵심입니다.주문에서 사용자로 가는 @ManyToOne 에 REMOVE/ALL 을 걸면, 주문 하나 지우다가 사용자까지 삭제되는 사고가 납니다. cascade·orphanRemoval 은 부모가 자식을 소유하는 방향(보통 @OneToMany)에만 거세요.
N+1 문제와 해결책
연관관계를 다룰 때 가장 자주 만나는 성능 함정입니다.
원인: 사용자 목록 N명을 한 번에 조회한 뒤, 반복문에서 각 사용자의 orders(LAZY)에 접근하면, 사용자마다 주문 조회 SQL이 따로 나갑니다. 결국 목록 1번 + 각 건별 N번 = 총 N+1번의 쿼리가 실행돼요. 100명이면 101번입니다.
// 사용자 100명 조회 → 쿼리 1번
List<User> users = userRepo.findAll();
for (User u : users) {
u.getOrders().size(); // 사용자마다 주문 조회 → 쿼리 100번 추가! (총 101)
}해결책 (EAGER 로 바꾸는 건 답이 아닙니다 — 오히려 항상 N+1 을 유발):
| 방법 | 설명 |
|---|---|
| fetch join (JPQL) | join fetch 로 연관 엔티티를 한 번의 JOIN 쿼리로 같이 조회. 가장 직접적인 해결. |
@EntityGraph | 리포지토리 메서드에 어떤 연관을 함께 가져올지 선언. JPQL 안 쓰고 fetch join 효과. |
| batch fetch size | Hibernate 설정(hibernate.default_batch_fetch_size). LAZY 컬렉션들을 모아 IN (...) 으로 한 번에 조회. N+1 을 1+1 수준으로 완화. |
@BatchSize | 특정 엔티티/컬렉션에만 batch 크기를 지정. 위 전역 설정의 개별 버전. |
// 1) fetch join — JPQL
@Query("select u from User u join fetch u.orders")
List<User> findAllWithOrders();
// 2) @EntityGraph — 함께 가져올 연관을 선언
@EntityGraph(attributePaths = "orders")
List<User> findAll();
// 3) 전역 batch fetch size — application.properties
// spring.jpa.properties.hibernate.default_batch_fetch_size=100
// 4) @BatchSize — 컬렉션/엔티티에 직접
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Order> orders = new ArrayList<>();양방향 편의 메서드 + 무한루프 주의
양방향에서는 객체 두 개가 서로를 가리켜야 일관성이 맞습니다. 한쪽만 세팅하면 메모리상의 객체 상태가 어긋나요. 그래서 양쪽을 한 번에 맞춰 주는 편의 메서드를 두는 게 정석입니다.
@Entity
public class User {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
// 편의 메서드: 양쪽을 동시에 동기화
public void addOrder(Order order) {
orders.add(order); // 1쪽 컬렉션에 추가
order.setUser(this); // 주인(N쪽) FK 필드도 세팅 → DB 반영의 핵심!
}
}양방향이면 User → orders → 각 Order → user → orders … 로 서로를 끝없이 참조합니다. toString() 이나 JSON 직렬화에서 이걸 그대로 타면 StackOverflow / 무한루프가 나요.
대책: 한쪽 필드를 toString()·equals()에서 제외하고, JSON 응답에는 엔티티를 직접 내보내지 말고 DTO 로 변환하세요. 부득이하면 한쪽에 @JsonIgnore(또는 @JsonManagedReference/@JsonBackReference)를 답니다.
한눈에 — 연관관계 카탈로그
| 애너테이션 | 다중성 | 기본 fetch | 주요 속성 |
|---|---|---|---|
@ManyToOne | N : 1 | EAGER | @JoinColumn, optional, cascade (항상 FK 주인) |
@OneToMany | 1 : N | LAZY | mappedBy, cascade, orphanRemoval |
@OneToOne | 1 : 1 | EAGER | @JoinColumn / mappedBy, optional |
@ManyToMany | N : M | LAZY | @JoinTable(joinColumns/inverseJoinColumns), mappedBy |
1) N+1 을 항상 의식하라. 컬렉션을 반복하며 LAZY 필드를 건드리면 쿼리가 폭발합니다. fetch join·EntityGraph·batch size 로 막으세요.
2) EAGER 는 지양하라. ~ToOne 기본이 EAGER 라 무심코 두면 안 쓰는 조회가 따라옵니다. 전부 FetchType.LAZY 로 명시하는 습관을.
이 프로젝트의 엔티티들도 위 규칙 그대로 매핑합니다. 단순 조회는 메서드 이름 쿼리로, 여러 테이블을 동적으로 조인해 가져오는 복잡한 조회는 QueryDSL 로 처리해 N+1 을 피합니다. 실제 코드와 데이터 접근 계층은 be-04 데이터 접근 에서 다룹니다.
다음 단계
- 매핑한 엔티티를 저장·조회하는 리포지토리? → JPA 리포지토리
- JPA 전반 개념 다시 보기 → JPA
- 동적 조인·복잡한 조회의 실제 코드 → be-04 데이터 접근