@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번 + 각 회원의 상품 조회 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 |