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번의 쿼리가 실행되어 성능 저하의 원인이 됩니다.
해결 방안:
- Fetch Join (가장 일반적이고 강력한 방법):
JPQL(Java Persistence Query Language)에서 JOIN FETCH를 사용하여 초기 쿼리 시점에 연관된 엔티티를 함께 로딩합니다. 이는 쿼리 수를 1개로 줄여줍니다.이 방법을 사용하면 findAllWithMembersFetchJoin() 호출 시 단 1번의 쿼리로 Crew와 모든 Member 정보를 가져오므로 N+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(); } - CrewRepository.java
- @EntityGraph (Fetch Join과 유사, 더 유연한 방법):
@EntityGraph 어노테이션을 사용하여 미리 정의된 로딩 전략을 적용합니다. JPQL을 직접 작성하지 않고도 Fetch Join과 유사한 효과를 얻을 수 있습니다.JavaattributePaths에 로딩할 연관 엔티티의 필드명을 지정합니다.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); } - CrewRepository.java
- @BatchSize (컬렉션 지연 로딩 최적화):
지연 로딩을 유지하면서 N+1 문제를 부분적으로 해결하는 방법입니다. @BatchSize 어노테이션을 @OneToMany 필드에 적용하면, 연관된 엔티티들을 조회할 때 설정된 size만큼 미리 로딩해줍니다.@BatchSize는 N+1 쿼리 수를 N에서 N/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<>(); - 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 문제를 해결하는 데 도움이 되기를 바랍니다!
'개발일지 > SPRINGBOOT' 카테고리의 다른 글
| springboot , record 를 통한 dto (0) | 2025.12.16 |
|---|---|
| UUID vs Sequential ID: 데이터베이스 설계에서 무엇을 선택할까? (0) | 2025.10.24 |
| Spring Data Envers와 RevisionRepository로 엔티티 변경 이력 관리하기 (0) | 2025.10.08 |
| Spring Boot에서 Swagger로 API 문서 자동화하기 (2) | 2025.08.02 |
| JPA 매핑 및 서비스 로직 사고력 기르기 (feat. GPT) (6) | 2025.07.01 |