CS: JPA와 Entity 연관관계

hyeppy·2025년 11월 9일

CS

목록 보기
8/11
post-thumbnail

할 때마다 느끼는 건데 ERD 설계 정말 어렵다… 정규화도 고려해야 하고, 연관관계를 어떻게 걸어 놓느냐에 따라 로직을 더 작성해야 하는지 아님 좀 덜 작성해도 되는지도 갈린다. 더 중요한 것은 연관관계가 어떻게 걸려 있느냐에 따라 유지보수의 난이도가 달라진다는 점이다. 당장 작성해야 하는 로직의 줄 수만 고려해 연관관계를 설정하면 이후 유지보수할 때 고통이 따를 수 있기 때문에 연관관계는 신중하게 설계해야 한다.


JPA와 연관관계

JPA란?

JPA(Java Persistence API)는 Java 애플리케이션에서 관계형 데이터베이스의 데이터를 관리하는 데 필요한 객체와 관계를 매핑하는 API다. 개발자는 SQL을 직접 작성하지 않고도 객체 지향적인 방식으로 데이터베이스를 다룰 수 있다.

연관관계란?

연관관계는 JPA 엔티티 간의 관계를 정의하는 것으로, 한 엔티티가 다른 엔티티와 어떻게 연결되어 있는지를 설명한다. 예를 들어, 학생(Student)과 수강신청(Enrollment)의 관계, 주문(Order)과 주문상품(OrderItem)의 관계 등이 있다.

객체 참조 vs DB 외래키

데이터베이스 테이블 관점:

-- 부서 테이블
CREATE TABLE department (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100)
);

-- 직원 테이블
CREATE TABLE employee (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    department_id BIGINT,  -- 외래키
    FOREIGN KEY (department_id) REFERENCES department(id)
);

데이터베이스는 외래키(department_id)를 통해 양쪽 테이블 간의 조인이 가능하다. 즉, 직원 → 부서, 부서 → 직원 모두 조회할 수 있다.

JPA 객체 관점 (단방향):

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;  // 직원은 자신이 속한 부서를 안다
}

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    // 부서는 자신에게 속한 직원들을 모른다
}

DB 테이블에서 외래키를 기준으로 조인하는 것으로 두 테이블 간의 연관된 데이터를 조회할 수 있는 것과 달리, JPA에서 단방향 연관관계가 정의된 상태에서는 한쪽 엔티티만 연관 엔티티를 조회할 수 있고, 반대쪽 엔티티는 어떤 엔티티가 존재하는지도 알 수 없다.

연관관계에 대한 올바른 이해는 다음과 같은 이유로 필수적이다.

  1. 성능 향상: 적절한 Fetch 전략과 연관관계 설정으로 N+1 문제를 예방하고 불필요한 쿼리를 줄일 수 있음
  2. 데이터 무결성: Cascade와 orphanRemoval 옵션을 통해 부모-자식 관계의 데이터 정합성을 유지할 수 있음
  3. 효율적인 데이터 관리: 연관관계를 활용하면 복잡한 비즈니스 로직을 객체 지향적으로 표현할 수 있음
  4. 유지보수성: 잘 설계된 연관관계는 코드 가독성을 높이고 변경에 유연하게 대응할 수 있게 함
  5. 견고한 아키텍처: 도메인 모델을 명확하게 표현함으로써 애플리케이션의 구조를 튼튼하게 만듦

연관관계의 방향성

단방향 연관관계

보통 JPA에서는 두 엔티티 사이의 연관관계를 정의할 때 기본적으로 단방향으로 정의한다. 참조하는 쪽만 부모를 알게 하고, 부모는 자식을 모르는 구조가 좋은 구조라고 할 수 있다.

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;  // 주문은 주문한 회원을 안다
    
    private LocalDateTime orderDate;
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String email;
    // 회원은 자신의 주문 목록을 모른다
}

이 경우 주문(Order)에서 회원(Member)을 조회할 수 있지만, 회원(Member)에서는 자신의 주문 목록을 직접 조회할 수 없다. 필요하다면 OrderRepository를 통해 조회해야 한다.

단방향의 장점:

  • 의존성이 한쪽 방향으로만 흐름
  • 코드가 단순하고 이해하기 쉬움
  • 순환 참조 문제가 발생하지 않음
  • 유지보수가 쉬움

양방향 연관관계

양방향 연관관계는 양쪽 엔티티가 서로를 참조하는 구조다.

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")  // 연관관계의 주인
    private Member member;
    
    private LocalDateTime orderDate;
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String email;
    
    @OneToMany(mappedBy = "member")  // 연관관계의 주인이 아님을 명시
    private List<Order> orders = new ArrayList<>();
}

연관관계의 주인 (Owner)

이전에 1차 프로젝트 게시물을 작성하면서 연관관계의 주인에 대해 간략히 언급했던 적이 있는데, 양방향 연관관계에서 가장 중요한 개념이 바로 연관관계의 주인이다. 데이터베이스 테이블은 외래키 하나로 양쪽 조인이 가능하지만, 객체는 양쪽에서 서로를 참조해야 양방향이 된다. 이때 둘 중 누가 외래키를 관리할지 정해야 한다.

연관관계 주인의 규칙:

  • 외래키가 있는 곳을 주인으로 정함 (보통 Many 쪽)
  • 주인만이 외래키를 관리(등록, 수정, 삭제)할 수 있음
  • 주인이 아닌 쪽은 읽기만 가능함
  • 주인이 아닌 쪽에 mappedBy 속성으로 주인을 지정함

위의 예시 코드들에서는 Order가 연관관계의 주인이다. Order 테이블에 member_id라는 외래키가 있기 때문이다.

양방향 연관관계 설정 시 주의사항

public class Member {
    // 연관관계 편의 메서드
    public void addOrder(Order order) {
        orders.add(order);
        order.setMember(this);  // 양쪽 모두 설정
    }
}

양방향 연관관계에서는 양쪽 객체를 모두 설정해 줘야 한다. 하나만 설정하면 영속성 컨텍스트 내에서 데이터 불일치가 발생할 수 있다.


연관관계의 종류

JPA는 네 가지 연관관계 타입을 제공한다.

1. @OneToOne (일대일)

한 엔티티가 정확히 하나의 다른 엔티티와 매핑되는 관계다. 일대일 관계는 주 테이블이나 대상 테이블 중 어느 곳에나 외래키를 둘 수 있다는 특징이 있다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String username;
    
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "user_detail_id")  // 외래키를 User가 관리
    private UserDetail userDetail;
}

@Entity
public class UserDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String address;
    private String phoneNumber;
    
    @OneToOne(mappedBy = "userDetail")
    private User user;
}

주민등록번호를 예시로 들자면, 한 사람은 하나의 주민등록번호를 가지고 한 주민등록번호는 한 사람에게만 속한다. 또 다른 예로는 사용자와 사용자 상세 정보, 직원과 사원증, 자동차와 차량번호판 등이 있다.

하지만 @OneToOne은 지연 로딩이 제대로 작동하지 않는 경우가 있기 때문에 주의해야 하고, 연관관계의 주인은 가능한 외래키를 가진 쪽에 설정하는 것이 좋다.

2. @ManyToOne (다대일)

여러 개의 엔티티가 하나의 엔티티를 참조하는 관계다. 가장 많이 사용되는 연관관계이며, 데이터베이스의 다대일 관계와 가장 잘 매칭된다.

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    // 여러 직원(Many) → 하나의 부서(One)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")  // 외래키 컬럼명
    private Department department;
}

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String departmentName;
}

직원과 부서를 예시로 들면, 여러 명의 직원(Many)이 하나의 부서(One)에 속할 수 있다. 다른 예로는 주문과 회원(여러 주문이 한 회원에게 속함), 댓글과 게시글(여러 댓글이 하나의 게시글에 속함), 책과 저자(여러 책이 한 저자에게 속함) 등이 있다.

ManyToOne는 단방향 관계에서 가장 자연스러운 형태이다. 외래키는 항상 Many 쪽에 존재하며, Fetch 전략의 기본값이 EAGER(즉시 로딩)인데, 이는 이후 설명할 N+1 문제를 유발할 수 있다.

3. @OneToMany (일대다)

하나의 엔티티가 여러 개의 엔티티를 참조하는 관계다. 보통 @ManyToOne의 반대편에서 사용되며, 양방향 관계를 구성할 때 함께 쓰인다.

양방향 @OneToMany (권장)

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;
    private String content;
    
    // 하나의 게시글(One) → 여러 댓글(Many)
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();
    
    // 연관관계 편의 메서드
    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }
    
    public void removeComment(Comment comment) {
        comments.remove(comment);
        comment.setPost(null);
    }
}

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")  // 연관관계의 주인
    private Post post;
    
    public void setPost(Post post) {
        this.post = post;
    }
}

이 구조에서 Comment가 연관관계의 주인이며, post_id 외래키를 관리한다. Post의 comments 필드는 읽기 전용이다.

단방향 @OneToMany의 문제점

@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany
    @JoinColumn(name = "team_id")  // Member 테이블의 team_id를 관리
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String username;
    // Team 참조 없음
}

이 경우 Team 엔티티가 Member 테이블의 외래키를 관리해야 하는데, 이는 다른 테이블의 외래키를 관리하는 것이므로 추가적인 UPDATE 쿼리가 발생한다. 따라서 단방향 @OneToMany보다는 양방향 @ManyToOne + @OneToMany를 사용하는 것이 권장된다.

4. @ManyToMany (다대다)

여러 개의 엔티티가 여러 개의 다른 엔티티와 관계를 맺는다. 관계형 데이터베이스에서는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없기 때문에 중간에 연결 테이블을 추가해야 한다.

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @ManyToMany
    @JoinTable(
        name = "student_course",  // 중간 테이블
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses = new ArrayList<>();
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String courseName;
    
    @ManyToMany(mappedBy = "courses")
    private List<Student> students = new ArrayList<>();
}

학생과 강의의 관계를 보면, 한 학생은 여러 강의를 수강하고, 한 강의는 여러 학생이 수강한다. 다른 예로는 상품과 카테고리(하나의 상품이 여러 카테고리에 속하고, 하나의 카테고리에 여러 상품), 사용자와 역할(한 사용자가 여러 역할을 가지고, 한 역할은 여러 사용자가 가짐) 등이 있다.

실무에서는 이 @ManyToMany를 거의 사용하지 않는데, 이유는 중간 테이블에 추가 정보를 넣을 수 없고 중간 테이블은 보통 숨겨져 있기 때문에 예상치 못한 쿼리가 발생할 수 있기 때문이다. 해결 방법으론 중간 엔티티를 생성하는 방법이 있는데, 예시 코드는 아래와 같다.

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "student")
    private List<Enrollment> enrollments = new ArrayList<>();
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String courseName;
    
    @OneToMany(mappedBy = "course")
    private List<Enrollment> enrollments = new ArrayList<>();
}

@Entity
public class Enrollment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id")
    private Student student;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    private Course course;
    
    private LocalDateTime enrollmentDate;  // 추가 정보
    private String grade;  // 추가 정보
}

N+1 문제와 해결 방법

연관관계를 사용할 때 가장 흔하게 발생하는 성능 문제가 N+1 문제다. 이는 첫 번째 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티의 연관된 데이터를 사용하는 시점에 N번의 추가 쿼리가 발생하는 현상이다.

예를 들어 100개의 주문을 조회한 후 각 주문의 회원 정보를 출력하면, 주문 조회 1번 + 회원 조회 100번으로 총 101번의 쿼리가 실행되어 심각한 성능 저하를 일으킨다. 중요한 점은 EAGER 로딩으로 바꾼다고 해결되는 것이 아니라는 것이다. Fetch Join, @EntityGraph, @BatchSize 등을 사용해 한 번의 쿼리 또는 소수의 쿼리로 필요한 데이터를 모두 가져오는 방식으로 해결해야 한다.

N+1 문제란?

// 주문 목록 조회 (1번의 쿼리)
List<Order> orders = orderRepository.findAll();

// 각 주문의 회원 정보 출력 (N번의 추가 쿼리)
for (Order order : orders) {
    System.out.println(order.getMember().getName());  // LAZY 로딩 발생
}

위와 같은 코드를 통해 10개의 주문을 조회하면,

  1. 주문 목록 조회 ⇒ 1번

  2. 각 주문의회원 조회 ⇒ 10번

    의 과정을 거쳐 총 11번의 쿼리가 실행되는데, 이것이 N+1 문제이다.

해결 방법 1: Fetch Join

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();
}

하지만 이 Fetch Join을 사용할 때 주의할 점이 있는데,

  1. 페이징 불가능 문제: 대량 데이터 조회 시 OutOfMemoryError 발생 위험
  2. 조인 대상이 많아질수록 데이터 중복 증가: 성능 저하 + 메모리 낭비 + 예상보다 훨씬 많은 Row 전송
  3. 데이터 중복으로 인한 결과 왜곡: 같은 부모 엔티티가 여러 번 반복되어 반환될 가능성 있음
  4. 한 쿼리에서 두 개 이상의 컬렉션 조인 불가능
  5. 쿼리 복잡성 증가 및 유지보수 어려움

결론적으로 다대일(@ManyToOne), 일대일(@OneToOne) 관계에서는 Fetch Join을 사용해도 괜찮지만, 일대다(@OneToMany)에서는 Fetch Join이 아닌 아래 두 개의 방식을 사용하는 것이 권장된다.

해결 방법 2: @EntityGraph

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @EntityGraph(attributePaths = {"member"})
    List<Order> findAll();
}

해결 방법 3: @BatchSize

@Entity
public class Member {
    @Id
    private Long id;
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

application.yml 또는 application.properties에 BatchSize 관련 설정을 할 경우, 엔티티마다 @BatchSize 어노테이션을 일일이 붙이지 않아도 모든 Lazy 연관관계에 공통 적용된다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

연관관계 설정 시 고려사항

1. Fetch 전략 (LAZY vs EAGER)

앞서 ManyToOne 연관관계를 설명할 때, Fetch 전략을 반드시 LAZY로 수정해야 한다고 작성했다. 이는 Fetch 전략을 EAGER로 설정할 경우 N+1 문제를 유발할 수 있기 때문이다. 하지만 LAZY로 설정해도 EAGAR보다 빈도가 줄어들 뿐이지 N+1 문제는 여전히 발생할 수 있다는 점을 명심하자.

EAGER (즉시 로딩):

@ManyToOne(fetch = FetchType.EAGER)
private Department department;
  • 엔티티를 조회할 때 연관된 엔티티도 함께 조회
  • 불필요한 데이터까지 조회하여 성능 저하 일으킴
  • N+1 문제를 유발

LAZY (지연 로딩):

@ManyToOne(fetch = FetchType.LAZY)
private Department department;
  • 연관된 엔티티를 실제 사용하는 시점에 조회함
  • 프록시 객체를 반환하고, 실제 사용 시 초기화됨
  • 실무에서는 항상 LAZY 사용

2. Cascade (영속성 전이)

Cascade 속성은 부모 엔티티의 상태 변화가 자식 엔티티에 전파되는 것을 의미하는데, JPA에서 cascade와 관련된 옵션은 다음과 같다.

  • CascadeType.ALL: 모든 작업 전이
  • CascadeType.PERSIST: 저장 시 함께 저장
  • CascadeType.REMOVE: 삭제 시 함께 삭제
  • CascadeType.MERGE: 병합 시 함께 병합
  • CascadeType.REFRESH: 새로고침 시 함께 새로고침
  • CascadeType.DETACH: 준영속 상태 전환 시 함께 전환

이 Cascade를 사용할 때 주의해야 할 점은, 부모와 자식의 생명 주기가 거의 동일할 때만 사용해야 한다는 점이고 자식이 여러 부모를 가질 수 있다면 사용하지 말아야 한다는 것이다.

3. orphanRemoval

부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 옵션이다. @OneToMany, @OneToOne처럼 참조하는 곳이 하나일 때만 사용할 수 있다.

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToMany(mappedBy = "post", 
               cascade = CascadeType.ALL, 
               orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();
    
    public void removeComment(Comment comment) {
        comments.remove(comment);
        comment.setPost(null);
        // orphanRemoval = true이므로 Comment가 자동으로 삭제됨
    }
}

orphanRemoval vs CascadeType.REMOVE:

  • CascadeType.REMOVE: 부모를 삭제할 때 자식도 삭제
  • orphanRemoval: 부모와의 관계가 끊어지면 자식을 삭제

4. 기본은 단방향, 필요시에만 양방향

단방향 연관관계로 먼저 설계하고, 반대 방향 조회가 정말! 필요할 때만 양방향으로 전환하는 방식을 많이 이용하자. 앞서 언급했던 것처럼 양방향 연관관계가 주는 장점도 있겠지만, 순환 참조나 설정 누락 시 영속성 컨텍스트에서 데이터 불일치가 발생할 수 있으며, 특히 특정 버전 이후부터의 Spring boot는 순환 참조 발생 시 서버 실행조차 되지 않기 때문에 주의해야 한다.

5. 엔티티를 직접 노출하지 않기

// 나쁜 예
@GetMapping("/orders")
public List<Order> getOrders() {
    return orderRepository.findAll();  // 엔티티 직접 반환
}

// 좋은 예
@GetMapping("/orders")
public List<OrderDto> getOrders() {
    return orderRepository.findAll()
        .stream()
        .map(OrderDto::from)  // DTO로 변환
        .collect(Collectors.toList());
}

엔티티를 직접 노출하면 양방향 연관관게에서 순환 참조가 발생하고, API 스펙이 엔티티 변경에 종속되기 때문에 좋은 설계가 아니다. 가능한 DTO를 이용하여 엔티티를 직접 노출하지 않는 방식으로 설계하자.


JPA 연관관계는 객체 지향적으로 데이터베이스를 다루는 핵심 개념이다. 하지만 잘못 사용하면 오히려 성능 문제와 유지보수의 어려움을 초래할 수 있다.

핵심 정리:

  • 기본적으로 단방향 연관관계로 설계하고, 정말 필요할 때만 양방향을 고려한다
  • 모든 연관관계에 LAZY 로딩을 사용하고, 필요한 곳에서만 Fetch Join으로 최적화한다
  • Cascade와 orphanRemoval은 부모-자식 생명주기가 같을 때만 신중하게 사용한다
  • @ManyToMany는 실무에서 사용하지 않고, 중간 엔티티를 만들어 해결한다
  • 양방향 연관관계에서는 연관관계 편의 메서드를 작성하여 양쪽을 모두 설정한다
  • 엔티티를 직접 노출하지 않고 DTO로 변환하여 반환한다
profile
Backend

0개의 댓글