JPA 심화 - Entity의 연관 관계

StrayCat·2026년 2월 22일

JPA 심화 - Entity 연관 관계 정리 (1:1, N:1, 1:N, N:M)

이번 포스팅에서는 JPA에서 Entity 간 연관 관계를 어떻게 표현하는지, DB 테이블과 어떻게 다른지를 정리합니다.
예제는 고객(User) - 음식(Food) - 주문(Order) 앱을 기반으로 설명합니다.


📌 들어가기 전에 — DB 테이블 vs Entity

JPA를 제대로 이해하려면, DB 테이블과 Java Entity의 연관 관계 차이를 먼저 알아야 합니다.

구분DB 테이블Java Entity
연관 맺기외래 키(FK)로 연결상대 Entity를 필드로 참조
방향성없음 (양쪽에서 자유롭게 JOIN 가능)있음 (참조하지 않으면 조회 불가)
관계 종류FK 컬럼 하나로 표현애너테이션으로 명시

핵심 포인트
DB는 JOIN만 잘 쓰면 어느 테이블 기준으로든 조회가 가능합니다. (방향 개념 없음)
반면 Entity는 상대 Entity를 필드로 들고 있지 않으면 그쪽 방향으로 조회할 방법이 없습니다.
그래서 Entity에는 단방향 / 양방향이라는 방향의 개념이 존재합니다.


📌 외래 키의 주인이란?

양방향 관계에서는 두 Entity가 서로를 참조합니다. 그런데 실제 FK를 누가 관리할지 지정해야 합니다.

  • 외래 키의 주인: 실제 FK 컬럼을 등록/수정/삭제할 수 있습니다. → @JoinColumn 사용
  • 주인이 아닌 쪽: FK를 읽기만 할 수 있습니다. → mappedBy 옵션으로 주인을 가리킵니다

일반적으로 N(다) 쪽 Entity가 외래 키의 주인이 됩니다.


1 대 1 관계 — @OneToOne

한 고객이 하나의 음식만 선택할 수 있는(1:1) 상황을 예로 들어 보겠습니다.

단방향 — Food가 외래 키 주인

@Entity
@Table(name = "food")
public class Food {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    // food 테이블에 user_id FK 컬럼이 생성됩니다.
    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;
}

@Entity
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    // User는 Food를 참조하지 않으므로 Food를 통해서만 조회 가능
}

단방향 저장 테스트

@Test
@Rollback(value = false)
@DisplayName("1대1 단방향 테스트")
void test1() {
    User user = new User();
    user.setName("Grace");

    Food food = new Food();
    food.setName("후라이드 치킨");
    food.setPrice(15000);
    food.setUser(user); // 외래 키의 주인인 Food에서 연관 관계 설정

    userRepository.save(user);
    foodRepository.save(food);
}

양방향 — User에서도 Food를 참조

@Entity
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    // mappedBy = "user" → Food Entity의 'user' 필드가 FK 주인임을 선언
    @OneToOne(mappedBy = "user")
    private Food food;

    // 편의 메서드: User 쪽에서도 쉽게 연관 관계를 설정할 수 있도록 제공
    public void addFood(Food food) {
        this.food = food;
        food.setUser(this); // 실제 FK 설정은 주인인 Food에서 해야 저장됨
    }
}

⚠️ 주의: 외래 키의 주인이 아닌 쪽(User)에서 setFood()만 하면 FK가 저장되지 않습니다.
반드시 FK 주인(Food)의 필드에도 값을 세팅해야 합니다.
이 번거로움을 해결하기 위해 위처럼 편의 메서드를 만들어 사용합니다.


N 대 1 관계 — @ManyToOne

실제 개발에서 가장 자주 사용하는 관계입니다. 한 고객이 여러 음식을 주문할 수 있고, 반대로 하나의 음식은 하나의 고객에게 속하는(N:1) 상황입니다.

단방향 — Food(N) → User(1)

@Entity
@Table(name = "food")
public class Food {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    // food 테이블에 user_id FK 컬럼 생성. N 쪽이 FK를 가집니다.
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}

@Entity
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    // 단방향이므로 User는 Food를 모름
}

단방향 저장 테스트

@Test
@Rollback(value = false)
@DisplayName("N대1 단방향 테스트")
void test1() {
    User user = new User();
    user.setName("Grace");

    Food food = new Food();
    food.setName("후라이드 치킨");
    food.setPrice(15000);
    food.setUser(user); // FK 주인인 Food에서 연관 관계 설정

    Food food2 = new Food();
    food2.setName("양념 치킨");
    food2.setPrice(20000);
    food2.setUser(user);

    userRepository.save(user);
    foodRepository.save(food);
    foodRepository.save(food2);
}

양방향 — User(1)에서도 Food(N) 목록 조회 가능

@Entity
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    // mappedBy = "user" → FK 주인이 Food의 'user' 필드임을 선언
    // 실제 DB 컬럼은 생성되지 않으며, 읽기 전용입니다.
    @OneToMany(mappedBy = "user")
    private List foodList = new ArrayList<>();

    // 편의 메서드
    public void addFoodList(Food food) {
        this.foodList.add(food);
        food.setUser(this); // FK 주인인 Food에도 연관 관계 세팅
    }
}

💡 N:1 vs 1:N 선택 기준
@ManyToOne(N:1) 단방향이 가장 기본적이고 권장되는 방식입니다.
User에서도 Food 목록이 필요하다면 @OneToMany를 추가해 양방향으로 만들면 됩니다.


1 대 N 관계 — @OneToMany

1에 해당하는 Entity가 N에 해당하는 Entity의 목록을 관리하는 방식입니다.
구조적으로 약간 어색한 부분이 있어서 N:1 양방향을 더 많이 씁니다.

단방향

@Entity
@Table(name = "food")
public class Food {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    // users 테이블에 food_id FK 컬럼을 만들고, Food가 이를 관리
    // 단, 실제 FK는 users 테이블에 있어서 INSERT 후 UPDATE 쿼리가 추가 발생함
    @OneToMany
    @JoinColumn(name = "food_id")
    private List userList = new ArrayList<>();
}

⚠️ 1:N 단방향의 단점
FK가 상대(users) 테이블에 존재하는데, 주인은 Food입니다.
따라서 Food를 저장(INSERT)할 때 users 테이블에 대한 UPDATE 쿼리가 추가로 발생합니다.
이 비효율 때문에 @ManyToOne 양방향을 더 권장합니다.

양방향은 공식적으로 존재하지 않음

@ManyToOne에는 mappedBy 속성이 없기 때문에, 1:N 관계의 양방향은 공식 지원되지 않습니다.
억지로 구성하려면 아래처럼 insertable = false, updatable = false를 사용할 수 있지만 권장하지 않습니다.

// User 쪽에서 food_id를 읽기 전용으로만 참조하는 방식 (비권장)
@ManyToOne
@JoinColumn(name = "food_id", insertable = false, updatable = false)
private Food food;

N 대 M 관계 — @ManyToMany

고객 한 명이 여러 음식을 주문하고, 하나의 음식이 여러 고객에게 주문될 수 있는 관계입니다.
N:M 관계는 중간 테이블이 필요합니다.

단방향 (@ManyToMany + @JoinTable)

@Entity
@Table(name = "food")
public class Food {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    @ManyToMany
    @JoinTable(
        name = "orders",                                       // 중간 테이블 이름
        joinColumns = @JoinColumn(name = "food_id"),          // Food → 중간 테이블 FK
        inverseJoinColumns = @JoinColumn(name = "user_id")    // User → 중간 테이블 FK
    )
    private List userList = new ArrayList<>();
}

⚠️ @ManyToMany의 문제점
JPA가 자동으로 중간 테이블을 생성해주지만, 컬럼을 추가하거나 커스터마이징하기 어렵습니다.
예를 들어 주문 날짜 컬럼을 추가하고 싶어도 @ManyToMany로는 불가합니다.

👍 권장사항 : 중간 테이블을 직접 Entity로 분리

@ManyToMany 대신 중간 테이블을 직접 Entity(Order)로 만들고, Food/User와 각각 N:1 관계로 연결하는 것이 훨씬 유연합니다.

// 중간 테이블을 별도 Entity로 관리
@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 음식 : 주문 = 1 : N → Order가 FK 주인
    @ManyToOne
    @JoinColumn(name = "food_id")
    private Food food;

    // 고객 : 주문 = 1 : N → Order가 FK 주인
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    // 필요한 컬럼을 자유롭게 추가 가능!
    @CreatedDate
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime orderDate;
}
// Food Entity - 주문 목록을 역참조
@OneToMany(mappedBy = "food")
private List orderList = new ArrayList<>();

// User Entity - 주문 목록을 역참조
@OneToMany(mappedBy = "user")
private List orderList = new ArrayList<>();

중간 테이블 저장 테스트

@Test
@Rollback(value = false)
@DisplayName("중간 테이블 Order Entity 테스트")
void test1() {
    User user = new User();
    user.setName("Grace");

    Food food = new Food();
    food.setName("후라이드 치킨");
    food.setPrice(15000);

    // FK 주인인 Order에서 user, food 연관 관계 설정
    Order order = new Order();
    order.setUser(user);
    order.setFood(food);

    userRepository.save(user);
    foodRepository.save(food);
    orderRepository.save(order);
}

📋 전체 요약

애너테이션관계FK 주인특징
@OneToOne1:1직접 지정 필요FK 주인에 @JoinColumn, 반대쪽에 mappedBy
@ManyToOneN:1N 쪽 (현재 Entity)실무에서 가장 많이 사용, FK를 자신이 가짐
@OneToMany1:N1 쪽이 N의 FK 관리INSERT 후 UPDATE 추가 발생, 단방향만 존재
@ManyToManyN:M@JoinTable로 설정중간 테이블 자동 생성, 실무에서는 직접 Entity 분리 권장

💡 핵심 정리

  1. 단방향 vs 양방향: 필요에 따라 선택합니다. 반대 방향에서 조회가 필요 없으면 단방향으로 충분합니다.
  2. FK의 주인은 항상 명확하게: 주인만 FK를 수정할 수 있습니다. 주인이 아닌 쪽에서 세팅하면 DB에 반영되지 않습니다.
  3. 편의 메서드: 양방향 관계에서 양쪽 필드를 한번에 세팅하는 메서드를 만들어 사용하면 실수를 줄일 수 있습니다.
  4. @ManyToMany는 가급적 지양: 중간 테이블을 직접 Entity로 만들어 N:1로 풀어내는 것이 훨씬 유연하고 확장성이 좋습니다.
profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글