개발일지/SPRINGBOOT

JPA에서 @ManyToOne과 @OneToMany, 그리고 양방향 매핑의 차이

recording or reCoding 2025. 10. 11. 15:32

PA 연관관계의 이해: @ManyToOne @OneToMany로 1:N 관계 마스터하기

안녕하세요! 효율적인 데이터베이스 연동과 객체 지향 설계를 위해 JPA(Java Persistence API)를 사용하시는 개발자 여러분들을 위한 포스팅입니다. 오늘은 JPA의 핵심 개념 중 하나인 연관관계 매핑에 대해 깊이 있게 다뤄보겠습니다. 특히, 실무에서 가장 자주 마주치는 1:N(일대다) 관계 @ManyToOne @OneToMany 어노테이션을 통해 어떻게 모델링하고 활용하는지, 그리고 발생할 수 있는 N+1 문제와 해결 방안까지 함께 알아보겠습니다.

1. 1:N 관계란? (예시: 팀과 회원)

데이터베이스 세계에서 1:N 관계는 하나의 엔티티가 여러 개의 다른 엔티티와 연관되는 관계를 의미합니다. 예를 들어:

  • 하나의 팀(Team)은 여러 명의 회원(Member) 을 가질 수 있습니다.
  • 하나의 상품(Product)은 여러 개의 주문 상세(OrderItem) 를 가질 수 있습니다.
  • 하나의 게시글(Post) 은  여러 개의 댓글(Comment) 을 가질 수 있습니다.

이번 블로그에서는 팀(Crew) 과  회원(Member) 의 관계를 예시로 들어 설명하겠습니다.

2. @ManyToOne: N 쪽에서 1을 바라보는 관계

먼저 N에 해당하는 엔티티, 즉 Member 입장에서 Crew를 바라보는 관계부터 살펴보겠습니다. 여러 명의 Member가 하나의 Crew에 속하므로, Member Crew에 대해 @ManyToOne 관계를 가집니다.

Member.java

package com.spring.jpa.npuls1.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter @Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // N:1 관계에서 N(Member)이 1(Crew)을 바라보는 단방향 관계
    // fetch = FetchType.LAZY: 지연 로딩 (기본값)
    // @JoinColumn: 외래 키 컬럼 지정. 생략 시 필드명_id로 자동 생성
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "crew_id")
    private Crew crew; // Member는 하나의 Crew에 속한다

    // Member 생성자 및 편의 메서드 (생략)
}

핵심:

  • @ManyToOne: Member 입장에서 여러 Member가 하나의 Crew에 속한다는 의미입니다.
  • fetch = FetchType.LAZY: Member를 조회할 때 crew 정보는 바로 가져오지 않고, crew 객체에 실제로 접근할 때(예: member.getCrew().getName()) 조회합니다. 이는 성능 최적화를 위한 중요한 설정이며, 기본값이기도 합니다.
  • @JoinColumn(name = "crew_id"): member 테이블에 crew_id라는 외래 키 컬럼이 생성됩니다. 이 컬럼이 member crew 테이블을 연결합니다.

3. @OneToMany: 1 쪽에서 N을 바라보는 관계

이제 1에 해당하는 엔티티, 즉 Crew 입장에서 Member들을 바라보는 관계를 살펴보겠습니다. 하나의 Crew가 여러 Member를 가지므로, Crew Member들에 대해 @OneToMany 관계를 가집니다.

Crew.java

package com.spring.jpa.npuls1.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter @Setter
public class Crew {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // 1:N 관계에서 1(Crew)이 N(Member)을 바라보는 양방향 관계
    // mappedBy = "crew": Member 엔티티의 "crew" 필드가 연관관계의 주인임을 명시
    //                   Crew 엔티티는 외래 키를 가지지 않고 읽기 전용으로 사용
    // cascade = CascadeType.ALL: Crew 엔티티의 변경이 Member 엔티티에도 전파
    // orphanRemoval = true: 컬렉션에서 Member가 제거되면 DB에서도 삭제
    @OneToMany(mappedBy = "crew", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Member> members = new ArrayList<>(); // NullPointerException 방지 초기화

    // 편의 메서드: 양방향 연관관계 설정 시 사용 (필수 권장)
    public void addMember(Member member) {
        members.add(member);
        member.setCrew(this);
    }

    public void removeMember(Member member) {
        members.remove(member);
        member.setCrew(null);
    }
}

핵심:

  • @OneToMany: Crew 입장에서 하나의 Crew가 여러 Member를 가질 수 있다는 의미입니다.
  • mappedBy = "crew": 이 부분이 매우 중요합니다! JPA에서는 양방향 관계일 때 연관관계의 주인을 정해야 합니다. 외래 키를 관리하는 쪽이 주인이며, @ManyToOne을 가진 Member crew 필드가 주인입니다. 따라서 Crew 엔티티에서는 mappedBy를 사용하여 자신이 주인이 아님을 명시합니다. (Crew 테이블에는 member_id와 같은 외래 키 컬럼이 생성되지 않습니다.)
  • List<Member> members = new ArrayList<>(): NullPointerException을 방지하고, 컬렉션을 안전하게 다루기 위해 필드를 미리 초기화하는 것이 좋은 습관입니다.
  • 편의 메서드(addMember, removeMember): 양방향 관계를 설정할 때에는 항상 양쪽의 연관 관계를 동시에 설정해주는 편의 메서드를 만들어 사용하는 것이 좋습니다. 이는 데이터의 일관성을 유지하는 데 도움이 됩니다.

4. 1 관계에서 N의 엔티티를 List로 뽑는 개념

이제 위에서 정의한 @OneToMany 관계를 활용하여 Crew 엔티티에서 해당 Crew에 속한 모든 Member List 형태로 조회하는 방법을 살펴보겠습니다.

// 예시: CrewService 또는 Controller에서 Crew 조회 후 Member 목록 접근
@Service
public class CrewService {
    @Autowired
    private CrewRepository crewRepository;

    @Transactional // 트랜잭션 내에서 지연 로딩 객체 접근 가능
    public Crew getCrewWithMembers(Long crewId) {
        Crew crew = crewRepository.findById(crewId)
                          .orElseThrow(() -> new IllegalArgumentException("Crew not found"));
        
        // 이 시점에 crew.getMembers()를 호출하면 N+1 문제가 발생할 수 있음
        // fetch = FetchType.LAZY 이므로, members 리스트에 접근할 때 쿼리 발생
        System.out.println("크루 이름: " + crew.getName());
        System.out.println("크루에 속한 회원들:");
        for (Member member : crew.getMembers()) { // <-- 이 지점에서 Member 조회 쿼리 발생
            System.out.println(" - " + member.getName());
        }
        return crew;
    }
}

위 코드처럼 crew.getMembers()를 호출하면, @OneToMany의 기본 FetchType.LAZY 설정 때문에 Member 목록에 접근하는 시점에 각 Crew에 대한 Member 조회 쿼리가 별도로 발생할 수 있습니다. 이것이 바로 N+1 문제입니다.

5. N+1 문제와 해결 방안

N+1 문제란?
Crew 목록을 1번의 쿼리로 가져온 후, 각 Crew에 대해 연관된 Member 목록을 가져오기 위해 N번의 추가 쿼리가 발생하는 상황을 말합니다. 총 1 + N번의 쿼리가 실행되어 성능 저하의 원인이 됩니다.

해결 방안:

  1. Fetch Join (가장 일반적이고 강력한 방법):
    JPQL(Java Persistence Query Language)에서 JOIN FETCH를 사용하여 초기 쿼리 시점에 연관된 엔티티를 함께 로딩합니다. 이는 쿼리 수를 1개로 줄여줍니다.
    package com.spring.jpa.npuls1.repository;
    
    import com.spring.jpa.npuls1.entity.Crew;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.jpa.repository.Query;
    
    import java.util.List;
    
    public interface CrewRepository extends JpaRepository<Crew, Long> {
        // Crew와 연관된 Member들을 한 번의 쿼리로 함께 가져오는 Fetch Join
        // DISTINCT를 사용하면 Crew 객체 중복을 제거할 수 있습니다.
        @Query("select distinct c from Crew c join fetch c.members")
        List<Crew> findAllWithMembersFetchJoin();
    }
    이 방법을 사용하면 findAllWithMembersFetchJoin() 호출 시 단 1번의 쿼리로 Crew와 모든 Member 정보를 가져오므로 N+1 문제가 해결됩니다.
  2. CrewRepository.java
  3. @EntityGraph (Fetch Join과 유사, 더 유연한 방법):
    @EntityGraph 어노테이션을 사용하여 미리 정의된 로딩 전략을 적용합니다. JPQL을 직접 작성하지 않고도 Fetch Join과 유사한 효과를 얻을 수 있습니다.
    Java
     
    import org.springframework.data.jpa.repository.EntityGraph;
    // ...
    public interface CrewRepository extends JpaRepository<Crew, Long> {
        @EntityGraph(attributePaths = "members")
        List<Crew> findAll(); // 기존 findAll 메서드를 @EntityGraph로 오버라이드
        
        // 또는 특정 메서드에 적용
        @EntityGraph(attributePaths = "members")
        List<Crew> findByNameContaining(String name);
    }
    attributePaths에 로딩할 연관 엔티티의 필드명을 지정합니다.
  4. CrewRepository.java
  5. @BatchSize (컬렉션 지연 로딩 최적화):
    지연 로딩을 유지하면서 N+1 문제를 부분적으로 해결하는 방법입니다. @BatchSize 어노테이션을 @OneToMany 필드에 적용하면, 연관된 엔티티들을 조회할 때 설정된 size만큼 미리 로딩해줍니다.
    import org.hibernate.annotations.BatchSize;
    // ...
    @OneToMany(mappedBy = "crew", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 100) // 100개씩 묶어서 N+1 쿼리 발생
    private List<Member> members = new ArrayList<>();
    @BatchSize는 N+1 쿼리 수를 N에서 N/size로 줄여주지만, 여전히 여러 번의 쿼리가 발생할 수 있습니다.
  6. Crew.java (부분 수정)

6. 결론

JPA에서 @ManyToOne @OneToMany는 1:N 관계를 표현하는 데 필수적인 어노테이션입니다. Member Crew를 아는 단방향 관계(@ManyToOne)와 Crew가 속한 Member들을 List로 관리하는 양방향 관계(@OneToMany)를 이해하는 것은 JPA 모델링의 기본입니다.

특히 @OneToMany 관계에서 연관된 엔티티 컬렉션을 조회할 때는 N+1 문제가 발생하기 쉽습니다. 이를 효과적으로 해결하기 위해 FETCH JOIN 이나 @EntityGraph 를 적극적으로 활용하여 쿼리 수를 최적화하는 것이 중요합니다. @BatchSize는 차선책으로 고려할 수 있습니다.

올바른 연관관계 매핑과 성능 최적화 전략을 통해 더욱 견고하고 효율적인 JPA 애플리케이션을 개발하시길 바랍니다!


이 블로그 게시물이 JPA의 @ManyToOne @OneToMany 관계를 이해하고 N+1 문제를 해결하는 데 도움이 되기를 바랍니다!