[Backend/Spring] Spring Boot - @OneToMany 와 N+1 문제

2026. 3. 29. 20:43·Backend/Spring

@OneToMany

기존 Java 코드에서 회원 객체로 상품 목록에 접근하려면 문제가 생긴다.

// @OneToMany 추가 전 — Member 객체에서 상품 목록 접근 불가
member.getItems() // 이런 메서드 자체가 없음

// 상품 목록을 가져오려면 ItemRepository를 따로 써야 함
itemRepository.findBySellerId(memberId)

즉, 회원 객체에서 상품 목록에 바로 접근할 수가 없다.

이를 해결하기 위해 Member.java 에 @OneToMany 를 추가하자.

Member.java 수정

package com.seongmo.myshop.member;

import com.seongmo.myshop.item.Item;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor
public class Member {

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

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String nickname;

    @OneToMany(mappedBy = "seller", fetch = FetchType.LAZY)
    private List<Item> items = new ArrayList<>();

    public Member(String email, String password, String nickname) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
    }
}

기존에 Item.java에 @ManyToOne (Item → Member) 는 이미 있었고

이번에 Member.java에 @OneToMany (Member → Item) 를 추가함으로써, Item → Member (단방향)에서 Member → Item 도 추가해 양방향 연관관계가 완성됐다.


N+1 문제

전체 회원 조회 API 만들기

N+1 문제를 확인하기 위해 전체 회원 목록과 각 회원의 상품 목록을 함께 조회하는 API를 만들자.

MemberResponse.java 만들기

com.seongmo.myshop.member.dto 패키지 하위에 MemberResponse.java 를 추가하자.

package com.seongmo.myshop.member.dto;

import com.seongmo.myshop.member.Member;
import lombok.Getter;
import java.util.List;
import java.util.stream.Collectors;

@Getter
public class MemberResponse {
    private Long id;
    private String email;
    private String nickname;
    private List<String> itemTitles;

    public MemberResponse(Member member) {
        this.id = member.getId();
        this.email = member.getEmail();
        this.nickname = member.getNickname();
        this.itemTitles = member.getItems().stream()
                .map(item -> item.getTitle())
                .collect(Collectors.toList());
    }
}

MemberService.java 수정

전체 회원 조회 메서드를 추가하자.

...
import com.seongmo.myshop.member.dto.MemberResponse;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class MemberService {

    ...

    @Transactional(readOnly = true)
    public List<MemberResponse> getAllMembers() {
        return memberRepository.findAll()
                .stream()
                .map(MemberResponse::new)
                .collect(Collectors.toList());
    }
}

MemberController.java 수정

API를 추가하자.

...
import com.seongmo.myshop.member.dto.MemberResponse;
import com.seongmo.myshop.member.dto.MemberResponse;
import java.util.List;

...
public class MemberController {

    ...

    @GetMapping
    public ResponseEntity<List<MemberResponse>> getAllMembers() {
        return ResponseEntity.ok(memberService.getAllMembers());
    }
}

실행 및 확인

먼저 Postman으로 회원 2명을 등록했다.

POST http://localhost:8080/api/members
{"email": "user1@test.com", "password": "1234", "nickname": "유저1"}
{"email": "user2@test.com", "password": "1234", "nickname": "유저2"}

그 뒤에 각 회원에게 3개씩 상품을 등록했다.

POST http://localhost:8080/api/items
{"title": "아이폰", "description": "팝니다", "price": 500000, "memberId": 1} 
{"title": "맥북", "description": "팝니다","price": 1000000, "memberId": 1}
{"title": "애플워치", "description": "팝니다","price": 600000, "memberId": 1} 
{"title": "갤럭시", "description": "팝니다", "price": 300000, "memberId": 2} 
{"title":"패드", "description": "팝니다", "price": 200000, "memberId": 2}
{"title":"갤럭시버즈", "description": "팝니다", "price": 150000, "memberId": 2}

그 뒤에 전체 회원 조회 API를 호출해보자.

유저별로 등록된 상품이 잘 조회된 것을 확인할 수 있다.

이번에는 콘솔 출력 결과를 확인해보자.

Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.nickname,
        m1_0.password 
    from
        member m1_0

Hibernate: 
    select
        i1_0.member_id,
        i1_0.id,
        i1_0.description,
        i1_0.price,
        i1_0.status,
        i1_0.title 
    from
        item i1_0 
    where
        i1_0.member_id=?

Hibernate: 
    select
        i1_0.member_id,
        i1_0.id,
        i1_0.description,
        i1_0.price,
        i1_0.status,
        i1_0.title 
    from
        item i1_0 
    where
        i1_0.member_id=?

쿼리가 총 세번 발생했다.

  1. 전체 회원 조회
  2. 회원 1의 상품 조회
  3. 회원 2의 상품 조회

이와 같이 회원 조회 1번 + 각 회원의 상품 조회 2번 => N+1 문제가 발생하게 된다.

이 문제는 N의 값이 커지면 커질수록 상당한 성능 저하를 일으킬 것이다.

 

N+1 문제가 발생하는 원인은 FetchType.LAZY 때문이라고 한다. LAZY는 연관 데이터를 즉시 가져오지 않고 실제로 젖ㅂ근할 때 가져온다. 그래서 member.getItems() 를 호출하는 순간 각 회원마다 상품을 조회하는 ㅜ커리가 추가로 실행되는 것이다.

 

그럼 어떻게 해결할 수 있을까?


fetch join 쿼리

MemberRepository.java 수정

MemberRepository.java 에 fetch join 쿼리를 추가해보자.

package com.seongmo.myshop.member;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {
    boolean existsByEmail(String email);

    @Query("SELECT DISTINCT m FROM Member m LEFT JOIN FETCH m.items")
    List<Member> findAllWithItems();
}
  • LEFT JOIN FETCH 는 회원을 조회할 때 상품 목록을 한 번의 쿼리로 같이 가져온다. LAZY여도 이미 데이터를 가져왔기 때문에 추가 쿼리가 발생하지 않는다.
  • DISTINCT 는 JOIN으로 인해 중복된 회원이 생기는 것을 방지한다.

MemberService.java 수정

...
    @Transactional(readOnly = true)
    public List<MemberResponse> getAllMembers() {
        return memberRepository.findAllWithItems()
                .stream()
                .map(MemberResponse::new)
                .collect(Collectors.toList());
    }
}
  • 기존의 findAll() 메서드를 findAllWithItems() 로 수정한다.
  • 조회 메서드에 readOnly = true 를 붙이는 이유는 읽기 전용으로 설정해야 JPA가 변경 감지(Dirty Checking)를 하지 않아서 성능이 향상되기 때문이다. 데이터를 수정하지 않는 조회 메서드에서는 항상 붙이는 것이 좋다.

실행 및 확인

이제 아까 처럼 회원 정보를 등록하고 회원별로 상품을 등록해서 쿼리 실행 횟수를 확인해보자.

이번에는 회원을 3명 등록하고 각 회원 당 2개의 상품을 등록했다.

Hibernate: 
    select
        distinct m1_0.id,
        m1_0.email,
        i1_0.member_id,
        i1_0.id,
        i1_0.description,
        i1_0.price,
        i1_0.status,
        i1_0.title,
        m1_0.nickname,
        m1_0.password 
    from
        member m1_0 
    left join
        item i1_0 
            on m1_0.id=i1_0.member_id

이번에는 회원 수를 하나 늘렸음에도 불구하고 한번의 쿼리만 발생했다.

저작자표시 (새창열림)

'Backend > Spring' 카테고리의 다른 글

[Backend/Spring] Spring Boot - Spring Security + JWT  (0) 2026.03.31
[Backend/Spring] Spring Boot - 예외 처리  (0) 2026.03.29
[Backend/Spring] Spring Boot - JPA 연관관계  (0) 2026.03.29
[Backend/Spring] Spring Boot - JPA와 MySQP 연동  (0) 2026.03.29
[Backend/Spring] Spring Boot - MVC 계층 구조와 API  (0) 2026.03.29
'Backend/Spring' 카테고리의 다른 글
  • [Backend/Spring] Spring Boot - Spring Security + JWT
  • [Backend/Spring] Spring Boot - 예외 처리
  • [Backend/Spring] Spring Boot - JPA 연관관계
  • [Backend/Spring] Spring Boot - JPA와 MySQP 연동
Study with Me!
Study with Me!
Study with Me!
  • Study with Me!
    Seongmo
    Study with Me!
  • 전체
    오늘
    어제
    • Computer (147) N
      • Computer Science (61)
        • Data Structure (51)
        • Algorithm (6)
        • 선형대수 with C++ (4)
      • Backend (11) N
        • 백엔드 취업을 위해.. (1)
        • Spring (10) N
        • Database (0)
        • Testing (0)
        • Infra & DevOps (0)
      • Arm Architecture (1)
        • Register (0)
        • Assembly Instruction (1)
      • Linux (32)
        • Linux Kernel (4)
        • 라이브러리 함수 구현하기 (0)
        • 쉘, 쉘 명령어 구현하기 (15)
        • Ubuntu (13)
      • Cloud Infrastructure (8)
        • Kubernetes (7)
        • OpenStack Magnum (1)
      • AWS (3)
      • Baekjoon (18)
      • Tools (6)
        • Git & Github (5)
        • Vim (1)
      • 개발 환경 (7)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    STL
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Study with Me!
[Backend/Spring] Spring Boot - @OneToMany 와 N+1 문제
상단으로

티스토리툴바