1. 개요
김영한님의 실전! '스프링 부트와 JPA 활용2 - API 개발과 성능 최적화' 강의를 수강하면서 개인적으로 중요하게 생각하는 핵심 부분과 새롭게 알게된 내용을 정리한 포스팅입니다
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의 | 김영한 - 인프런
김영한 | , 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길러보세요. 🚩 본 강의는 로드맵 과정입니다. 본 강의는 자바 백엔드 개발의 실전 코스의 2번째 강의입니다.
www.inflearn.com
2. 엔티티를 노출 API에 노출하면 안되는 이유
API를 개발할 때 엔티티를 직접 노출하는 경우 별도의 클래스를 만들 필요가 없어 편할 수는 있지만, 엔티티를 API에 직접 노출하는 경우 많은 문제들이 발생할 수 있다.
엔티티를 API에 노출하면 발생하는 문제
- 엔티티의 필드명이 수정될 경우 API 스펙도 함께 변경되는 경우가 발생한다.
- 불필요한 필드가 함께 노출된다.
- @JsonIgnore를 사용하면 노출되는 문제를 방지할 수는 있지만, 모든 API 상황을 반영하기 어렵고, 응답 스펙이 달라질 경우 대응하기가 어렵다.
- 유효성 검증 책임이 엔티티에 침투한다.
- @NotEmpty 등의 검증 어노테이션이 엔티티에 들어가면, API 마다 요구하는 검증 로직을 반영하기 어렵고, SRP 책임을 위배한다.
- Jackson으로 JSON 직렬화 과정에서 예상치 못한 문제 발생
- 엔티티 연관관계로 인해 예상치 못한 쿼리발생과 순환참조 문제 발생
- 연관관계 엔티티가 지연 로딩일 경우 프록시 객체(Bytebuddy)을 사용하는데, 프록시 객체를 직렬화할 때 문제 발생
꼭 API 스펙에 맞는 DTO 클래스를 정의하여 엔티티와 API 스펙을 분리하여 사용하자!!!
엔티티를 직접 반환하는 경우(권장 X)
@Getter
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty // API 스펙에 맞는 유효성 검증
private String name;
@Embedded
private Address address;
@JsonIgnore // API 스펙에서 제외할 필드
@OneToMany(mappedBy = "member") // 연관관계 주인 설정(Order.member로 주인 설정)
private List<Order> orders = new ArrayList<>();
}
@RequiredArgsConstructor
@RestController
public class MemberApiController {
private final MemberService memberService;
@PostMapping("/api/v1/members")
public Member saveMemberV1(@RequestBody @Valid Member member) {
Member member = memberService.join(member);
return member; // 엔티티를 직접 반환
}
}
API 스펙에 맞는 별도의 DTO를 정의하여 노출하는 경우(권장 O)
@RequiredArgsConstructor
@RestController
public class MemberApiController {
private final MemberService memberService;
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
Member member = memberService.join(member);
return new CreateMemberResponse(member.getId());
}
}
// API 요청 스펙에 맞는 DTO
@Data
public class CreateMemberRequest {
@NotEmpty private String name;
}
// API 응답 스펙에 맞는 DTO
@AllArgsConstructor
@Data
public class CreateMemberResponse {
private Long id;
}
3. N + 1 문제
JPA를 지연 로딩 전략을 사용할 경우, 연관관계가 엔티티를 실제 호출 시점에 별도로 쿼리하게 된다.
이로 인해, 하나의 조회 쿼리 후 연관된 데이터를 N 번 추가로 조회하게 되는데, 이를 N + 1 문제라고 한다.
예시 시나리오
- 그룹은 여러명의 회원을 포함하고 있고, 회원은 하나의 그룹에 속한다.
- 양방향 연관관계이며, 모두 지연 로딩 전략을 사용하고 있다.
@Setter
@Getter
@Entity
@Table(name = "groups")
public class Group {
@Id
@GeneratedValue
@Column(name = "group_id")
private Long id;
private String groupName;
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL)
private List<Member> members = new ArrayList<>();
}
@Setter
@Getter
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
private Group group;
}
N + 1 문제 예제
- 지연 로딩의 경우 실제 호출 시점까지 쿼리를 실행하지 않는다.
- member.getName() 호출 시점에 프록시 객체 초기화하기 위해 쿼리를 실행한다.
@RequiredArgsConstructor
@Repository
public class GroupRepository {
private final EntityManager em;
public List<Group> findAll() {
return em.createQuery("select g from Group g", Group.class)
.getResultList();
}
}
@Transactional
@SpringBootTest
class GroupRepositoryTest {
@Autowired private GroupRepository groupRepository;
@Test
void nPlus1_Problem() {
List<Group> groups = groupRepository.findAll();
for (Group group : groups) {
List<Member> members = group.getMembers();
for (Member member : members) {
String name = member.getName();// N + 1 문제 발생
System.out.println("name = " + name);
}
}
}
}
쿼리 결과
- 그룹이 2개일 경우
- select * from groups (1회)
- select * from member where group_id = ? (2회, 그룹 수만큼 실행)
- 그룹의 개수(N)만큼 쿼리가 추가적으로 발생한다고 해서 N + 1 문제라고 한다.
- 그룹이 1000개인 경우 최대 1001번의 쿼리가 발생한다. => 성능 저하

4. N + 1 해결 방법
N + 1 문제는 지연 로딩 전략을 사용해서 발생하는 문제이다.
이 문제를 단순히 즉시 로딩(Eager)을 변경하여 해결하려는 시도는 오히려 더 큰 문제를 야기한다.
예를 들어 즉시로딩으로 설정할 경우, Order 조회시 Member를 함께 조회하게 되는데, Member와 연관된 엔티티도 조회해버리는 문제가 발생한다. 즉 Order를 조회했는데, Member 관련된 여러 엔티티가 함께 조회되는 문제가 발생한다.
연관관계는 로딩전략은 무조건 지연로딩으로 설정하는 것을 추천한다.
그럼 해결방법을 알아보자.
N + 1 문제를 해결하기 위해서 fetch join 또는 DTO projection을 적용하여 해결할 수 있다.
EntityGraph를 사용해서 N + 1 문제를 해결할 수 있지만, 이 강의에서 다루지 않았기 때문에 언급하지 않겠다.
1. fetch join 적용 예제
@RequiredArgsConstructor
@Repository
public class GroupRepository {
private final EntityManager em;
public List<Group> findAllWithMembers() {
return em.createQuery("select g from Group g join fetch g.orders", Group.class)
.getResultList();
}
}
쿼리 결과

fetch join 장점
- N + 1 문제 해결(1번의 쿼리로 연관된 데이터 한번에 조회)
- 코드 재사용성이 높다.
fetch join 단점
- 모든 연관 객체를 즉시 조회(불필요한 필드까지 모두 로딩 => 메모리 낭비)
2. JPA 에서 직접 DTO로 바로 조회(DTO Projection)
@RequiredArgsConstructor
@Repository
public class GroupRepository {
private final EntityManager em;
public List<GroupMemberDto> findGroupMemberDto() {
return em.createQuery("select new com.example.dto.GroupMemberDto(g.groupName, m.name) " +
"from Group g join g.members m", GroupMemberDto.class)
.getResultList();
}
}
쿼리 결과

DTO Projection 장점
- 필요한 필드만 조회하기 => 메모리 효율적
DTO Projection 단점
- 재사용이 어렵다.
5. fetch join 주의점
ManyToOne, OneToOne 관계는 fetch join을 사용해도 아무런 문제가 없다.
하지만, XXXToMany 관계(컬렉션)는 fetch join을 사용 때 주의해야 한다.
데이터 중복 문제
- 컬렉션 관계를 조회할 때 조인 결과로 인해, 데이터 뻥튀기(중복) 문제가 발생한다. => 동일한 Group이 여러개 조회됨.
- distint 키워드를 사용하여 중복 엔티티를 제거할 수 있다.
@RequiredArgsConstructor
@Repository
public class GroupRepository {
private final EntityManager em;
public List<Group> findAllWithMembers() {
return em.createQuery("select distinct g from Group g join fetch g.members", Group.class)
.getResultList();
}
}
※ hibernate6부터는 distinct 없이도 자동으로 중복을 제거해주도록 패치되었다.
페이징 문제
- 컬렉션 연관관계에서 fetch join을 사용할 경우, 정상적인 페이징 처리가 불가능하다.
- DB 레벨이 아닌 메모리에서 페이징 처리하기 때문에 Out Of Memory가 발생할 수 있다.
@RequiredArgsConstructor
@Repository
public class GroupRepository {
private final EntityManager em;
public List<Group> findAllWithMembers(int offset, int limit) {
return em.createQuery("select distinct g from Group g join fetch g.members", Group.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
}
쿼리 결과
- 쿼리 결과를 보면 offset, fecth 적용없이 모든 결과를 조회하는 쿼리가 발생하는 것을 볼 수 있다.
- hibernate에서 warn 로그로 firstResult/maxResults specified with collection fetch; applying in memory 알려준다.

페이징 문제 해결
컬렉션 관계에서 페이징 쿼리가 필요한 경우에는 fetch join을 사용하지 않고, 기본적으로 지연로딩 전략을 사용하되 Batch Fetch Size를 설정해서 쿼리를 최적화할 수 있다.
Batch Fetch Size를 설정하는 2가지 방법이 있다.
1. application.properties설정(애플리케이션 공통 설정)
spring.jpa.properties.hibernate.default_batch_fetch_size=100
2. @BatchSize 어노테이션 활용(개별 적용)
@BatchSize(size = 100)
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL)
private List<Member> members = new ArrayList<>();
Batch Fetch Size는 100 ~ 1000개로 설정하는 것을 권장한다.
지연로딩 + Batch Fetch Size 적용 예시
public List<Group> findAllWithMembers(int offset, int limit) {
return em.createQuery("select g from Group g", Group.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
쿼리 결과
- IN 절로 한번의 쿼리로 모든 데이터를 조회하는 것이 확인할 수 있다.
- N + 1 쿼리에서 1 + 1 쿼리로 최적화 되었다.

6. 쿼리 설계 전략
김영한님이 권장하는 쿼리 설계 전략에 대해서 정리해보았다.
- 기본적으로 API에 엔티티를 직접 노출하지 않고 엔티티를 DTO로 변환해서 제공한다.
- 모든 엔티티의 연관관계는 지연 로딩으로 설정한다.
- N + 1 문제 등 쿼리 최적화가 필요한 경우 Fetch Join으로 최적화한다.
- 컬렉션 관계에서 DB 페이징 쿼리를 사용할 때 경우에 지연로딩 전략과 Batch Fetch Size를 적용하여 최적화한다.
- 컬럼수가 많거나 최적화가 필요한 경우에는 DTO Projection 적용을 고려한다.
- 최후의 수단으로 Native SQL, JDBC Template 적용
7. OSIV(Open Session in View)
OSIV는 Spring JPA에서 HTTP 요청의 시작부터 응답이 완료될 때까지 연속성 컨텍스트를 열어두는 전략이다.
기본값은 ON이며, View 렌더링 단계에서도 지연 로딩된 데이터를 사용할 수 있다.
OSIV 예시
서비스 계층에서 트랜잭션이 적용되어 있다.
컨트롤러에서 프록시 객체를 초기화 하는 코드이다.
@RequiredArgsConstructor
@Service
public class GroupService {
private final GroupRepository groupRepository;
@Transactional(readOnly = true)
public List<Group> findAll() {
return groupRepository.findAll();
}
}
@RequiredArgsConstructor
@RestController
public class GroupController {
private final GroupService groupService;
@GetMapping("/groups")
public String getGroups() {
List<Group> orders = groupService.findAll();
for (Group order : orders) {
order.getMembers().get(0).getName(); // Lazy 로딩
}
return "hello";
}
}
OSIV ON인 경우
컨트롤러에서 영속성 컨텍스트가 유지되고 있기 때문에 프록시 객체를 초기화 하는데 성공한다.
spring.jpa.open-in-view=true # OSIV 활성화

OSIV OFF인 경우
서비스 계층에서만 영속성 컨텍스트가 유지되기 때문에 컨트롤러에서 프록시 객체를 초기화 할 경우 LazyInitializationException이 발생한다.
spring.jpa.open-in-view=false # OSIV 비활성화

OSIV 정리
| OSIV ON(기본값) | OSIV OFF | |
| 영속성 컨텍스트 | 요청 - 응답 전체 생명주기 동안 유지 | 트랜잭션 범위 내에서 유지 |
| 지연 로딩 사용 가능 범위 | Controller, Service, View 등 전체 | 트랜잭션 범위 내(Service 계층) |
| DB 커넥션 | 점유 시간이 길어짐 | 트랜잭션 범위 내(Service 계층) |
| 편리성 | Controller, View에서 Lazy 접근 가능 | Lazy 접근 시 예외 발생 |
| 권장 대상 | 트래픽이 적고 단순한 서비스 | 트래픽 많고 안정성, 성능이 중요한 서비스 |
'Programming > Spring' 카테고리의 다른 글
| [Spring 테스트] Mockito ArguementCaptor란? (1) | 2025.08.10 |
|---|---|
| [Spring 테스트] 테스트 간 데이터 충돌 문제 해결(@Sql 이해) (0) | 2025.07.11 |
| [JPA] 실전! 스프링 부트와 JPA 활용1 강의 핵심 내용 정리 (1) | 2025.06.06 |
| [SpringBoot] Spring Boot 404 응답 커스터마이징 (0) | 2025.04.03 |
| [Spring Boot] Spring Boot + InfluxDB 연동 (0) | 2025.02.06 |