[스프링] JPA 연관관계 매핑

2025. 7. 25. 14:56·스프링

JPA는 객체 중심의 개발을 가능하게 해주는 ORM 기술이다.
하지만 객체와 관계형 데이터베이스는 연관관계를 표현하는 방식이 전혀 다르다.

  • 객체는 참조(reference)를 통해 다른 객체를 연결한다. → order.getMember()
  • 데이터베이스는 외래 키(foreign key)를 사용해 테이블 간 관계를 표현한다. → ORDER.member_id

따라서 JPA에서는 이 둘을 어떻게 매핑할지 정확하게 설정해야 한다.
매핑을 잘못하면 다음과 같은 문제가 발생할 수 있다.

  • 의도하지 않은 SQL 쿼리 실행
  • 불필요한 UPDATE
  • N+1 문제
  • 성능 저하
  • 유지보수 어려움

오늘은 그래서 JPA에서 가장 핵심적인 개념 중 하나인 ‘연관관계 매핑’에 대해 정리한다.
외래 키의 위치, 연관관계의 주인, 단방향/양방향의 차이, 그리고 실무에서 어떤 매핑을 써야 하는지를 예제를 통해 쉽게 설명한다.

 

 

연관관계 매핑에서 반드시 고려해야 할 요소

JPA에서 연관관계를 매핑할 때 고려해야 할 대표적인 요소는 다음과 같다.

  1. 외래 키는 어느 테이블에 존재하는가
  2. 연관관계의 주인은 어느 엔티티인가
  3. 단방향으로 충분한가, 아니면 양방향이어야 하는가
  4. 실무에 적합한 구조인가

그중에서도 가장 중요한 것은 연관관계의 주인(owner) 을 정확히 설정하는 것이다.

 

정답은 항상 연관관계 주인을 외래키를 관리하는 Many쪽으로 설정하면 된다.

 

외래 키를 관리하는 “주인”은 왜 Many 쪽이어야 하는가?

예를 들어 회원(Member)과 주문(Order)이 1:N 관계라고 하자.
하나의 회원은 여러 개의 주문을 가질 수 있다.

이때 실제 데이터베이스에서는 Order 테이블에 member_id 외래 키가 들어간다.
그 이유는 간단하다.

데이터베이스의 컬럼 하나에는 하나의 값만 들어갈 수 있기 때문이다.

  • 만약 Member 테이블에 외래 키를 둔다면, 여러 개의 주문 ID를 하나의 컬럼에 저장해야 하는데, 이는 정규화된 관계형 데이터베이스에서는 불가능하다.
  • 반면 Order 테이블은 각각의 주문 행마다 하나의 member_id 값을 가지면 되므로, 자연스럽고 유효한 구조가 된다.

즉, 여러 개의 주문이 하나의 회원을 참조하는 구조이기 때문에, 외래 키는 항상 다(N) 쪽인 주문 테이블에 있어야 한다.

 

 

JPA에서도 이 구조를 그대로 따른다.
외래 키를 가진 엔티티만이 외래 키 값을 설정할 수 있기 때문에, 외래 키가 있는 다(N) 쪽 엔티티가 연관관계의 주인(owner) 이 된다.

만약 외래 키가 없는 1 쪽(Member) 엔티티에서 관계를 관리하려고 하면, JPA는 다음과 같은 과정을 거친다.

  1. 먼저 Order를 INSERT한다. → 이때는 외래 키가 비어 있음
  2. 그리고 나서 JPA가 외래 키를 채우기 위해 UPDATE 쿼리를 한 번 더 실행한다.

→ 이처럼 불필요한 SQL이 발생하게 되며, 성능상으로도 비효율적이다.

 

 

연관관계 매핑 방식별 정리

1. 다대일 [N:1] 양방향 매핑

가장 일반적인 연관관계이다. 외래 키는 다(N) 쪽에 있고, 해당 엔티티가 연관관계의 주인이 된다.

@Entity
public class Order {
    @ManyToOne
    @JoinColumn(name = "member_id") // 외래 키
    private Member member;
}

 

Order가 member_id 외래 키를 가지므로, 연관관계의 주인이다.

반대편인 Member 엔티티는 조회용으로만 사용되며, mappedBy로 주인이 아님을 명시한다.

@Entity
public class Member {
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

 

mappedBy = "member"는 “Order 엔티티의 member 필드가 이 관계의 주인이다”라는 뜻이다.
Member는 단지 읽기 전용이다.

 

 

2. 일대다 [1:N] 단방향 매핑

JPA에서 사용은 가능하지만 실무에서는 거의 쓰이지 않는 방식이다.
이 방식은 다음과 같은 구조로 작성된다.

@Entity
public class Member {
    @OneToMany
    @JoinColumn(name = "member_id") // Order 테이블에 외래 키 생성
    private List<Order> orders = new ArrayList<>();
}

겉보기엔 간단하지만 문제는 심각하다.

  • Order가 외래 키를 갖고 있는데도, Member가 관계를 관리하려고 시도한다.
  • JPA는 처음 Order를 INSERT할 때 외래 키 값을 알 수 없기 때문에 null로 저장한다.
  • 이후에 다시 UPDATE 쿼리를 한 번 더 실행해서 외래 키를 채운다.

즉, 이 방식은 INSERT → UPDATE 두 번의 SQL이 발생하며, 이는 성능과 관리 양쪽에서 모두 비효율적이다.

→ 해결책: 다대일(N:1) 양방향 매핑으로 구조를 변경해야 한다.

 

 

3. 일대일 [1:1] 매핑

일대일은 양쪽 모두 하나의 레코드만 참조하는 관계이다.
JPA에서는 외래 키를 어느 쪽에 둘지 선택할 수 있다.

 

(1) 주 테이블에 외래 키

@Entity
public class Member {
    @OneToOne
    @JoinColumn(name = "locker_id")
    private Locker locker;
}
  • JPA에서 많이 쓰는 방식이다.
  • 외래 키를 가진 쪽이 연관관계의 주인이 된다.
  • Member만 조회해도 Locker 존재 여부를 확인할 수 있다.

(2) 대상 테이블에 외래 키

@Entity
public class Locker {
    @OneToOne
    @JoinColumn(name = "member_id")
    private Member member;
}
  • 전통적인 DB 설계 방식이다.
  • 나중에 일대다로 바꾸기 쉬운 구조다.
  • 단점: 지연 로딩 설정해도 실제로는 즉시 로딩된다 (프록시 불가).

 

4. 다대다 [N:M] 매핑

관계형 데이터베이스는 직접 다대다를 표현할 수 없기 때문에, 연결 테이블이 반드시 필요하다.

 

(1) @ManyToMany 직접 매핑

@Entity
public class Student {
    @ManyToMany
    @JoinTable(name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id"))
    private List<Course> courses;
}

 

이 방식은 코드상 간단해 보이지만, 실무에서는 거의 쓰이지 않는다.

이유: 연결 테이블에 수강신청일, 성적, 출석여부 등의 정보가 추가되는 경우가 많기 때문이다.

 

(2) 연결 테이블을 엔티티로 분리

@Entity
public class Enrollment {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Student student;

    @ManyToOne
    private Course course;

    private LocalDate enrollDate;
    private String status;
}

이렇게 하면 다대다 관계를 일대다+다대일로 풀어낼 수 있으며, 추가 컬럼도 자유롭게 사용할 수 있다.

 

결론

  • 외래 키는 항상 N(다) 쪽에 있어야 한다.
  • 외래 키를 가진 엔티티가 연관관계의 주인이 되어야 한다.
  • 일대다 단방향은 INSERT 이후 UPDATE가 발생하므로 실무에서 피해야 한다.
  • 다대다 관계는 연결 테이블을 엔티티로 분리해서 일대다/다대일 구조로 바꾸는 것이 실무 방식이다.

 

추가적으로 연관관계 매핑 시 자주 사용하는 주요 어노테이션 속성을 설명한다.

 

JPA에서 연관관계를 매핑할 때 사용하는 대표적인 애너테이션은 @JoinColumn, @ManyToOne, @OneToMany이다.이들에는 여러 속성이 존재하며, 일부는 실무에서 꼭 신경 써야 할 중요한 동작 방식과 직접 연결된다.

그중 대표적인 것이 fetch(패치 전략) 이다.

 

 

1. @JoinColumn – 외래 키 매핑

@JoinColumn은 현재 엔티티가 참조하는 대상과의 관계에서 외래 키를 어떤 컬럼으로 만들지 지정하는 애너테이션이다.

예를 들어 다음처럼 쓰면:

@ManyToOne
@JoinColumn(name = "member_id")
private Member member;

→ Order 테이블에 member_id라는 외래 키 컬럼이 생성된다.
@JoinColumn이 없으면 JPA가 자동으로 필드명 + "_id" 형태로 컬럼명을 만든다.

추가로 nullable, unique, insertable, updatable, foreignKey 같은 속성도 지정할 수 있는데, 이 중 가장 자주 쓰는 것은 nullable이다.
→ nullable = false로 설정하면 해당 외래 키가 반드시 존재해야 함을 의미한다.

 

2. @ManyToOne – 다대일 관계 매핑

@ManyToOne은 다수의 엔티티가 하나의 엔티티를 참조하는 관계를 매핑할 때 사용한다.

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id")
private Member member;

 

여기서 가장 중요하게 봐야 할 속성은 fetch이다.

 

 

fetch – 연관된 엔티티를 언제 로딩할 것인가?

JPA에서 fetch 속성은 연관된 엔티티를 언제 로딩할지 결정한다.
즉, 즉시 로딩(EAGER)으로 미리 가져올지, 지연 로딩(LAZY)으로 나중에 필요할 때 가져올지를 선택하는 것이다.

 

기본값 정리

  • @ManyToOne은 기본값이 FetchType.EAGER
  • @OneToMany는 기본값이 FetchType.LAZY

따라서 @ManyToOne은 설정하지 않으면 기본적으로 즉시 로딩된다.
하지만 실무에서는 대부분 fetch = FetchType.LAZY로 설정하는 것을 권장한다. (무조건 지연로딩 하는 것을 권장)

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

지연 로딩으로 설정하면 해당 필드에 실제 접근하기 전까지는 연관 객체를 조회하지 않는다.
필드에 접근하는 순간 JPA가 데이터베이스에서 값을 조회해 채운다.

 

 

프록시로 지연 로딩을 구현하는 방식

지연 로딩은 내부적으로 프록시(proxy) 객체를 통해 구현된다.
프록시(가짜 객체)는 실제 객체처럼 보이지만, 내부에는 아직 데이터가 없다.
필드에 접근하는 순간 JPA가 쿼리를 실행해 진짜 데이터를 불러온다.

Order order = em.find(Order.class, 1L);
Member member = order.getMember(); // 아직 쿼리 실행 안 됨
String name = member.getUsername(); // 이 시점에 SELECT 쿼리 발생

프록시는 보통 실제 클래스의 하위 클래스 형태로 만들어지며, member.getClass()를 출력해보면 Member$HibernateProxy 같은 이름이 뜬다.

 

주의할 점은 프록시 객체는 영속성 컨텍스트가 닫힌 상태에서는 초기화할 수 없다는 것이다.
즉, 트랜잭션 밖에서 접근하면 LazyInitializationException이 발생할 수 있다.

 

 

즉시 로딩을 피해야 하는 이유

즉시 로딩(EAGER)은 연관된 모든 객체를 즉시 조회해버린다.
하나만 조회하려 해도 연관된 모든 데이터가 함께 쿼리되기 때문에 불필요한 SQL이 실행될 수 있다.

 

예를 들어 Order 하나를 조회했을 뿐인데, Member, Delivery, Coupon 등 여러 엔티티가 즉시 로딩되어 함께 조회된다면 쿼리 수는 순식간에 늘어난다.

 

즉시 로딩은 예측 불가능한 SQL을 만들고, 성능 저하나 튜닝 어려움으로 이어질 수 있다.
그래서 기본 전략은 LAZY로 설정하고, 필요한 경우에만 명시적으로 fetch join을 사용하는 게 좋다.

 

 

LAZY도 완벽하진 않다: 1+N 문제

지연 로딩을 설정해도 상황에 따라 1+N 문제가 발생할 수 있다.

예를 들어, 회원 리스트를 조회한 뒤 각 회원의 주문 목록을 순회하면서 접근할 경우:

List<Member> members = memberRepository.findAll();

for (Member member : members) {
    System.out.println(member.getOrders().size());
}

이렇게 되면 회원 한 번 조회(1) + 회원 수만큼의 주문 조회(N) = 총 N+1번의 쿼리가 실행된다.
이 문제는 fetch join을 사용하거나 @BatchSize 옵션을 통해 해결할 수 있다. 이 내용은 추후 따로 설명할 예정이다.

 

 

optional = false

@ManyToOne(optional = false)는 연관된 엔티티가 반드시 존재해야 한다는 뜻이다.
이 설정은 데이터베이스에서 해당 외래 키 컬럼에 NOT NULL 제약조건으로 반영된다.

@ManyToOne(optional = false)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

실제 비즈니스 로직상 연관 엔티티가 필수라면 optional = false를 명시해주는 것이 안전하다.

 

 

3. @OneToMany – 일대다 관계 매핑

@OneToMany는 하나의 엔티티가 여러 개의 다른 엔티티를 참조할 때 사용한다.
이때 주로 mappedBy를 함께 사용해서 외래 키를 가진 쪽이 연관관계의 주인임을 명시한다.

@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
  • mappedBy = "member"는 “이 연관관계의 주인은 Order 엔티티의 member 필드”라는 뜻이다.
  • fetch는 기본값이 LAZY이므로 일반적으로 따로 설정하지 않아도 된다.

 

4. cascade – 영속성 전이 설정

cascade는 연관된 엔티티를 함께 저장하거나 삭제할 수 있도록 전파하는 기능이다.
즉, 부모 엔티티에 수행한 작업을 자식 엔티티에도 자동으로 적용할 수 있게 한다.

예를 들어, Member가 여러 개의 Order를 가지고 있을 때 Member를 저장하거나 삭제할 때마다
그 안의 Order도 같이 저장되거나 삭제되게 만들 수 있다.

@OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();

이 설정이 있으면 Member를 persist() 할 때, Order도 자동으로 persist() 된다.
또한 remove() 하면 Order도 같이 삭제된다.

 

cascade에서 자주 쓰는 옵션

옵션명                                            설명
PERSIST 부모 저장 시 자식도 자동 저장됨
REMOVE 부모 삭제 시 자식도 자동 삭제됨
MERGE 병합 시 같이 병합됨
ALL 위 모든 동작을 전파함
 

주의할 점

  • cascade는 편리하지만, 관계가 복잡하거나 엔티티가 재사용되는 구조에서는 예기치 않은 부작용이 발생할 수 있다.
  • 특히 삭제(REMOVE) 시에는, 실수로 여러 엔티티가 같이 삭제되는 문제가 생길 수 있다.

예시를 들면 다음과 같은 상황이다.

@Entity
class Member {
    @ManyToOne(cascade = CascadeType.ALL)
    private Team team;
}

이때는 member를 삭제하면 JPA가 cascade 설정에 따라
연관된 team까지 함께 삭제(remove) 하게 된다.

 

즉 멤버를 삭제했지만 연관된 팀 전체가 삭제 되는 것이다. 이렇게 된다면 다른 멤버에 팀이 사라지게 되는 것이다.

 

마무리하며

연관관계 매핑은 처음엔 복잡하게 느껴질 수 있지만, 외래 키의 위치와 연관관계의 주인 개념을 제대로 이해하고 나면 훨씬 명확해진다.

 

이 글에서 소개한 내용을 바탕으로 연관관계를 매핑할 때 “왜 이렇게 설계해야 하는지”를 고민하며 코드를 작성한다면,
JPA의 복잡함도 훨씬 덜 느껴질 것이다.

 

감사합니다.

 

'스프링' 카테고리의 다른 글

[스프링] JPA에서 제공하는 쿼리 방법  (1) 2025.07.26
[스프링] JPA 상속관계 매핑  (7) 2025.07.25
[스프링] JPA 기본 매핑 어노테이션  (4) 2025.07.24
[스프링] JPA란?  (1) 2025.07.24
[스프링] 빈 스코프  (1) 2025.07.24
'스프링' 카테고리의 다른 글
  • [스프링] JPA에서 제공하는 쿼리 방법
  • [스프링] JPA 상속관계 매핑
  • [스프링] JPA 기본 매핑 어노테이션
  • [스프링] JPA란?
0kingki_
0kingki_
자바 + 스프링 웹 개발
  • 0kingki_
    0kingki_
    0kingki_
  • 전체
    오늘
    어제
    • 분류 전체보기 (134)
      • 코딩 테스트 (54)
      • 자바 (21)
      • 스프링 (27)
      • 타임리프 (16)
      • 스프링 데이터 JPA (8)
      • 최적화 (2)
      • QueryDSL (4)
      • AWS (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    컬렉션
    spring
    JPA
    쿼리
    dfs
    mvc
    thymeleaf
    예외처리
    스프링
    LocalDateTime
    자바
    SpringDataJpa
    객체지향
    재귀
    쿼리dsl
    코딩테스트
    BFS
    백준
    fetch join
    불변객체
    SOLID
    QueryDSL
    최적화
    예외 처리
    스프링 컨테이너
    타임리프
    Java
    코딩 테스트
    스프링 데이터 JPA
    다형성
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
0kingki_
[스프링] JPA 연관관계 매핑
상단으로

티스토리툴바