자바 백엔드 입문 46편. JPA 연관관계 매핑의 4가지 @ManyToOne·@OneToMany·@OneToOne·@ManyToMany와 양방향·연관관계 주인·Cascade를 학교 학급 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 46편이에요. 45편 Entity·Repository 에서 짧게 만난 연관관계를 본격적으로 풀어 가요. JPA의 가장 어려운 영역.
연관관계가 어렵게 들리는 이유
@OneToMany·@ManyToOne·@JoinColumn·mappedBy·Cascade — 한꺼번에 쏟아져요. 또 "양방향 연관관계" 라는 표현이 안 잡혀요.
이 글에서는 학교 학급 비유로 풀어요. 학급(Class) — 학생(Student) 관계. 학급은 학생 여럿(@OneToMany), 학생은 학급 하나(@ManyToOne). 끝까지 따라오시면 4가지 관계가 한 그림에 들어와요.
4가지 연관관계
DB 관계는 4가지로 분류.
| 관계 | 의미 | 예 |
|---|---|---|
| @ManyToOne | 여럿이 하나에 속함 | 학생 → 학급 |
| @OneToMany | 하나가 여럿을 가짐 | 학급 → 학생들 |
| @OneToOne | 일대일 | 회원 → 회원 프로필 |
| @ManyToMany | 다대다 | 학생 ↔ 동아리 |
가장 자주 만나는 = @ManyToOne. 거의 모든 "외래 키" 가 여기.
@ManyToOne — 가장 흔한 단방향
학생은 학급 하나에 속해요.
@Entity
@Table(name = "students")
@Getter @Setter @NoArgsConstructor
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "class_id") // 외래 키 컬럼 이름
private SchoolClass schoolClass;
}
DB로 표현하면 — students 테이블에 class_id 라는 외래 키 컬럼이 박혀요. 자바 코드에서는 student.getSchoolClass() 로 부모 객체 접근.
fetch = FetchType.LAZY 거의 항상 박기 — 51편 영속성 컨텍스트 의 N+1 함정 회피.
@OneToMany — 양방향의 반대편
학급도 "내 학생들" 을 가지려면.
@Entity
@Table(name = "school_classes")
@Getter @Setter @NoArgsConstructor
public class SchoolClass {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "schoolClass") // ← 핵심
private List<Student> students = new ArrayList<>();
}
mappedBy = "schoolClass" 가 핵심. "이 관계의 주인은 Student의 schoolClass 필드야" 선언.
양방향과 연관관계 주인
양방향 연관관계 — Student → SchoolClass 도 있고, SchoolClass → Student 도 있는 상태. 이때 연관관계 주인(Owning Side) 결정이 필수.
- 주인 (Owning Side) = 외래 키를 가진 쪽. 보통
@ManyToOne쪽 - 반대편 (Inverse Side) =
mappedBy박은 쪽. 읽기 전용
// Student (주인) — 외래 키 가짐
@ManyToOne
@JoinColumn(name = "class_id")
private SchoolClass schoolClass;
// SchoolClass (반대편) — mappedBy
@OneToMany(mappedBy = "schoolClass")
private List<Student> students;
변경은 주인 쪽에만. 학생을 학급에 추가하려면:
// ✓ 주인 쪽 (Student) — DB에 반영됨
student.setSchoolClass(class1);
// ❌ 반대편만 (SchoolClass) — DB에 안 반영
class1.getStudents().add(student);
연관관계 편의 메서드 — 양쪽 다 일관되게 관리하려면 도우미 메서드.
public class Student {
public void setSchoolClass(SchoolClass newClass) {
// 기존 학급에서 제거
if (this.schoolClass != null) {
this.schoolClass.getStudents().remove(this);
}
this.schoolClass = newClass;
// 새 학급에 추가
if (newClass != null && !newClass.getStudents().contains(this)) {
newClass.getStudents().add(this);
}
}
}
다소 번거롭지만 — 메모리 객체 그래프 일관성 보장.
단방향 vs 양방향 — 무엇을?
단방향이 기본 권장. 양방향은 "진짜 필요할 때만".
- 학급에서 "이 학급 학생들 다 보기" 가 필요하면 → 양방향 OK
- 학생 → 학급만 알면 충분하면 → 단방향 (Student의
@ManyToOne만)
양방향이 많아질수록 — JSON 직렬화 무한 루프·연관관계 편의 메서드·동등성 문제가 늘어요. 가능한 단방향으로 시작.
Cascade — 부모 작업이 자식에 전파
부모를 저장하면 자식도 자동 저장. 부모를 삭제하면 자식도 자동 삭제.
@OneToMany(mappedBy = "schoolClass", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Student> students = new ArrayList<>();
CascadeType 종류:
| 타입 | 전파 |
|---|---|
PERSIST |
저장 (save) |
MERGE |
병합 |
REMOVE |
삭제 |
REFRESH |
새로고침 |
DETACH |
분리 |
ALL |
위 전부 |
orphanRemoval = true — 부모의 컬렉션에서 자식을 빼면 그 자식 자동 삭제. 부모-자식 관계가 "진짜 소유" 일 때 유용.
주의 — Cascade는 "부모가 자식을 진짜 소유" 할 때만. 학급-학생은 미묘 (학생은 독립 엔티티). 보통 주문-주문항목 같은 "부모 없이는 자식이 의미 없는" 관계에 적용.
@OneToOne — 일대일
@Entity
public class User {
@Id @GeneratedValue
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id @GeneratedValue
private Long id;
private String bio;
@OneToOne(mappedBy = "profile")
private User user;
}
@ManyToOne 과 거의 비슷한 패턴. 외래 키 가진 쪽이 주인. 일대일은 사실 자주 안 만남 — 보통 "User 하나에 Profile 컬럼들" 같이 한 테이블로 합치는 게 더 자연스러움.
@ManyToMany — 권장 X
학생 ↔ 동아리는 다대다.
@Entity
public class Student {
@ManyToMany
@JoinTable(name = "student_club",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "club_id"))
private List<Club> clubs;
}
JPA가 자동으로 "중간 테이블" 만들어요. 다만 — 실무에서 거의 안 써요. 이유: - 중간 테이블에 추가 정보 박을 수 없음 (가입 날짜·역할 등) - 동작이 예측 어려움
대안 — 중간 엔티티 명시.
@Entity
public class StudentClub {
@Id @GeneratedValue private Long id;
@ManyToOne private Student student;
@ManyToOne private Club club;
private LocalDateTime joinedAt;
private String role;
}
두 개의 @ManyToOne 으로 다대다를 풀어요. 한국 회사 표준.
자주 만나는 패턴 — 주문·주문항목
회사 시스템에서 가장 흔한 연관관계.
@Entity
public class Order {
@Id @GeneratedValue private Long id;
private int totalAmount;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
private User user;
}
@Entity
public class OrderItem {
@Id @GeneratedValue private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
private int quantity;
private int price;
}
- 주문은 항목 여러 개 (
@OneToMany+ cascade) - 항목은 주문 하나에 속함 (
@ManyToOne) - 주문은 사용자 하나에 속함 (
@ManyToOne)
회사 시스템 거의 모든 "부모-자식" 패턴이 이 모양.
1️⃣ 모든 연관관계는 LAZY 시작. 2️⃣ 단방향이 기본, 양방향은 필요할 때만. 3️⃣ @ManyToMany 안 씀, 중간 엔티티로 풀기. 4️⃣ Cascade는 진짜 소유 관계에만. 5️⃣ Fetch Join으로 N+1 회피.
한 줄 정리 — JPA 연관관계 4가지 = @ManyToOne(가장 흔함)·@OneToMany·@OneToOne·@ManyToMany(안 씀). 외래 키 가진 쪽이 주인, mappedBy 가 반대편. LAZY + 단방향 + 중간 엔티티가 안전 룰.
시험 직전 한 번 더 — 연관관계 입문자가 매번 헷갈리는 것
- 연관관계 4가지 =
@ManyToOne·@OneToMany·@OneToOne·@ManyToMany @ManyToOne이 가장 흔함 — 모든 외래 키@JoinColumn(name = "...")= 외래 키 컬럼명- 모든 연관관계 LAZY 권장 (N+1 회피)
- 연관관계 주인 = 외래 키 가진 쪽 (보통
@ManyToOne) mappedBy= 반대편. "주인은 저기" 선언- 변경은 주인 쪽에만 — 반대편 컬렉션 수정은 DB 반영 X
- 양방향 vs 단방향 = 단방향 기본 권장
- 양방향 함정 = JSON 무한 루프 + 편의 메서드 필요
- DTO 변환이 양방향 함정 회피
- Cascade = 부모 작업이 자식에 전파
CascadeType.ALL+orphanRemoval = true= 진짜 소유 관계@OneToOne= 자주 안 만남 — 보통 한 테이블로 합침@ManyToMany권장 X — 중간 엔티티(@ManyToOne두 개)로 풀기- 중간 엔티티 = 추가 정보(가입 날짜·역할) 박을 수 있음
- 회사 표준 패턴 = 주문-주문항목 (
@OneToMany+ cascade) - 양방향 편의 메서드 = 양쪽 일관성 유지
equals/hashCode= PK만 (양방향 무한 루프 회피)- Fetch Join = 연관 객체 같이 가져와 N+1 회피
- 한국 회사 표준 = LAZY + 단방향 우선 + 양방향은 진짜 필요할 때만
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 41편 — JDBC와 DataSource
- 42편 — JdbcTemplate으로 SQL 다루기
- 43편 — @Transactional의 원리
- 44편 — JPA Hibernate Spring Data JPA 셋의 관계
- 45편 — @Entity Repository JPA 두 축
다음 글: