자바 백엔드 입문 46편 — JPA 연관관계 @OneToMany @ManyToOne

2026-05-17자바 백엔드 입문

자바 백엔드 입문 46편. JPA 연관관계 매핑의 4가지 @ManyToOne·@OneToMany·@OneToOne·@ManyToMany와 양방향·연관관계 주인·Cascade를 학교 학급 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 46편 — JPA 연관관계 @OneToMany @ManyToOne

이 글은 자바 백엔드 입문 시리즈 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 + 단방향 우선 + 양방향은 진짜 필요할 때만

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!