스파르타클럽 | AI시대, 미래를 돌파하는 힘
누구나 잠재력을 깨워 나아가도록. IT 커리어의 모든 성장 과정을 스파르타클럽에서
academia.spartaclub.kr
🧩 페이징 및 정렬
Key Parameters
- 클라이언트 → 서버
| 페이징 | 정렬 |
|
|
- 서버 → 클라이언트
number: 조회된 페이지 번호content: 조회된 아이템 정보 (배열)size: 한 페이지에 보여줄 아이템 개수numberOfElements: 실제 조회된 아이템 개수totalElements: 전체 아이템 개수totalPages: 전체 페이지 수first/last: 첫번째/마지막 페이지 여부 (boolean)
totalPages = totalElement / size 결과를 소수점 올림
1 / 10 = 0.1 => 총 1 페이지
9 / 10 = 0.9 => 총 1페이지
10 / 10 = 1 => 총 1페이지
11 / 10 => 1.1 => 총 2페이지
Pageable
Spring Data JPA에서 페이징 및 정렬 기능을 제공하기 때문에 페이징 및 정렬을 쉽게 구현할 수 있다.
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<Product> products = productRepository.findAllByUser(user, pageable);
Pageable: 페이징 및 정렬 처리를 위해 제공되는 인터페이스PageRequest:Pageable인터페이스의 구현체- 파라미터로 현재 페이지, 데이터 노출 개수, 정렬 방법(오름차순/내림차순)을 전달함
- 생성된
Pageable구현 객체를 Spring Data JPA의 Query Method 파라미터에 함께 전달하면 페이징 및 정렬 처리가 완료된 데이터를 Page 타입으로 반환함 Page타입에는 클라이언트에 전달해야 할 데이터인totalPages,totalElements등이 포함됨
🧰 연관관계 수정
어제 구현한 JPA 연관관계와 관련해서 중대한 이슈를 발견해서, 페이지네이션 구현에 앞서 먼저 수정부터 시작해야 한다😨
UNIQUE 제약조건
상품이 null인 주문에 대해 다른 상품으로 변경해주기 위해 update()를 구현했다. 물론 실제 서비스였다면 이미 결제된 주문을 수정하는 일은 있을 수 없으므로 그냥 개발용으로 보면 되겠다.
/* 주문 수정 */
@PatchMapping ("/{id}")
public Order update(@PathVariable Long id, @RequestBody OrderRequest orderRequest) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Product product = productRepository.findById(orderRequest.getProductId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
order.setProduct(product);
return orderRepository.save(order);
}
그런데 여기서 404도 아니고 뜬금 없이 500 에러가 발생했다.
[spring-order] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.DataIntegrityViolationException: could not execute statement [Unique index or primary key violation: "PUBLIC.UQ_ORDERS_PRODUCT_ID INDEX PUBLIC.UQ_ORDERS_PRODUCT_ID_INDEX_8 ON PUBLIC.ORDERS(PRODUCT_ID NULLS FIRST) VALUES ( /* 2 */ CAST(1 AS BIGINT) )"; SQL statement: update orders set product_id=? where id=? [23505-240]] [update orders set product_id=? where id=?]; SQL [update orders set product_id=? where id=?]; constraint [PUBLIC.UQ_ORDERS_PRODUCT_ID INDEX PUBLIC.UQ_ORDERS_PRODUCT_ID_INDEX_8]] with root cause
위 로그에서 핵심은 이 부분이다: constraint [PUBLIC.UQ_ORDERS_PRODUCT_ID ...] 즉, 현재 테이블 구조는 orders.product_id 값이 주문마다 중복되면 안 되도록 설계가 되어 있다. 그런데 이미 다른 주문이 쓰고 있는 product_id로 바꾸려고 해서 23505(중복키 위반) 에러가 발생한 것이다.
분명 코드에서 unique = true와 같은 설정을 하지 않았기 때문에 대체 어디서 UNIQUE 제약조건이 발생했는지 찾아봤는데, 어제 상품-주문 관계를 @OneToOne으로 설정했던 것이 문제였다는 것을 알게 되었다.
@OneToOne
@JoinColumn(name = "product_id")
private Product product;
명시적으로 UNIQUE를 설정하지 않았음에도, @OneToOne으로 설정하면 JPA가 자동으로 상품 하나가 주문 하나에만 연결된다고 판단한다고 한다.
- 테이블 생성 SQL: 어제 코드를 다시 보니 테이블 생성 SQL문에서도
product_id에 UNIQUE 제약 조건이 걸려 있었다.어제 대체 뭔 생각이었던 걸까...
DROP TABLE orders;
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT,
CONSTRAINT fk_orders_product
FOREIGN KEY (product_id) REFERENCES products(id)
);
Order엔티티 수정:@OneToOne에서@ManyToOne으로 수정했다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;

💻 페이지네이션 구현
feat: 주문 목록 조회 구현 · qkrwns1478/spring-order@76787d4
@@ -12,4 +12,8 @@ public OrderResponse(Long id, String name) {
github.com
- DB 초기화 SQL문: 데이터 삭제 및 ID 1로 초기화 (
TRUNCATE TABLE 테이블이름 RESTART IDENTITY;로 하면 된다는 말도 있지만 잘 적용되지 않았다.)
DELETE FROM PRODUCTS;
ALTER TABLE PRODUCTS ALTER COLUMN ID RESTART WITH 1;
DELETE FROM ORDERS;
ALTER TABLE ORDERS ALTER COLUMN ID RESTART WITH 1;
TestDataRunner: 최초 실행 후 DB에 데이터가 생성되었다면 코드 전체를 주석 처리하면 된다.
package com.example.springorder.util;
import com.example.springorder.Order.Order;
import com.example.springorder.Order.OrderRepository;
import com.example.springorder.Product.Product;
import com.example.springorder.Product.ProductRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
public class TestDataRunner implements ApplicationRunner {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public TestDataRunner(OrderRepository orderRepository, ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
@Override
public void run(ApplicationArguments args) throws Exception {
String[] products = {"신발", "과자", "키보드", "휴지", "휴대폰", "앨범",
"헤드폰", "이어폰", "노트북", "무선 이어폰", "모니터"};
for (String product : products) {
createProduct(product);
}
for (int i = 0; i < products.length; i++) {
createOrder(i+1);
}
}
private void createProduct(String name) {
Product product = new Product();
product.setName(name);
int price = getRandomNumber(100, 10000);
int quantity = getRandomNumber(100, 10000);
product.setPrice(price);
product.setQuantity(quantity);
productRepository.save(product);
}
private void createOrder(long productId) {
Order order = new Order();
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
order.setProduct(product);
orderRepository.save(order);
}
public int getRandomNumber(int min, int max) {
return (int) Math.floor(Math.random() * (max - min + 1)) + min;
}
}
OrderPageRequest: 페이지네이션 요청 DTO를 새로 만들었다. 만약 상품 조회에도 페이지네이션을 적용한다면 별도의 패키지에 리팩토링하는 것을 고려할 수도 있겠다.
package com.example.springorder.Order;
import lombok.Getter;
import lombok.Setter;
@Setter @Getter
public class OrderPageRequest {
private int page;
private int size;
private String sortBy;
private boolean isAsc;
}
OrderController: 주문 전체 조회 수정
/* 주문 전체 조회 */
@GetMapping
public Page<Order> getOrders(@RequestBody OrderPageRequest orderPageRequest) {
int page = orderPageRequest.getPage();
int size = orderPageRequest.getSize();
String sortBy = orderPageRequest.getSortBy();
boolean isAsc = orderPageRequest.isAsc();
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
return orderRepository.findAll(pageable);
}

OrderResponse:from메서드 추가
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getProduct().getName());
}
OrderController:getOrders()수정
/* 주문 전체 조회 */
@GetMapping
public List<OrderResponse> getOrders(@RequestBody OrderPageRequest orderPageRequest) {
int pageNo = orderPageRequest.getPage();
int size = orderPageRequest.getSize();
String sortBy = orderPageRequest.getSortBy();
boolean isAsc = orderPageRequest.isAsc();
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(pageNo, size, sort);
Page<OrderResponse> page = orderRepository.findAll(pageable).map(OrderResponse::from);
return page.getContent();
}

🧩 N+1 문제
N+1 문제란 JPA에서 연관 관계가 설정된 엔티티를 조회할 때, 1번의 쿼리로 N개의 데이터를 가져온 후 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 성능 저하 문제를 말한다.
개요
N+1 문제는 처음에 데이터를 1번 조회한 후 연관된 데이터를 가져오려고 추가 쿼리가 N번 더 실행되는 현상이다. 예를 들어 Order가 여러 개 있고, 각 Order가 Product를 참조한다고 하자.
List<Order> orders = orderRepository.findAll();
이 코드로 주문 목록을 가져 오면 처음에 주문을 조회하는 쿼리가 1번 나간다.
for (Order order : orders) {
System.out.println(order.getProduct().getName());
}
그런데 이후에 반복문에서 이렇게 연관 객체에 접근하면 주문이 10개일 때 product를 하나씩 따로 조회해서 상품 조회 쿼리 10번이 추가로 실행될 수 있다.
즉, 주문 조회 1번 + 상품 조회 N번 이렇게 되어 총 N+1번의 쿼리가 나가므로 N+1 문제라고 한다. 이는 JPA가 연관 객체를 바로 한 번에 가져오지 않고 필요한 시점에서 각각 따로 조회하기 때문이다. 특히 지연 로딩(LAZY)일 때 자주 보이지만, 즉시 로딩(EAGER)이라고 해서 자동으로 해결되는 것은 아니다.
해결 방법
fetch join사용: 주문과 상품을 한 번의 쿼리로 가져올 수 있다.
@Query("select o from Order o join fetch o.product")
List<Order> findAllWithProduct();
EntityGraph사용: 연관 객체를 함께 조회하도록 지정할 수 있다.
@EntityGraph(attributePaths = "product")
List<Order> findAll();
- 배치 크기 설정: 연관 객체를 여러 개씩 묶어서 조회하게 함으로써 쿼리 수를 줄일 수 있다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
N+1 문제 확인하기
방금 구현한 주문 전체 조회 GET /orders에서 N+1 문제가 발생한다.
Order의product가@ManyToOne(fetch = FetchType.LAZY)라서 주문을 읽을 때 상품을 같이 들고 오지 않는다.-
getOrders()가orderRepository.findAll(pageable)로 주문 목록을 가져온다.- 바로 다음에
.map(OrderResponse::from)으로 주문마다 DTO 변환을 한다. - 그런데
OrderResponse.from()안에서order.getProduct().getName()을 호출한다. - 이 때 각 주문의
product에 접근하면서 상품 조회 쿼리가 주문 수 만큼 추가로 나갈 수 있다.
- 주문 목록 조회 1번 + 각 주문의 상품 조회 N번 발생
@Setter @Getter
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
public Order() {}
@Builder
public Order(Product product) {
this.product = product;
}
}
@GetMapping
public List<OrderResponse> getOrders(@RequestBody PageRequest pageRequest) {
int pageNo = pageRequest.getPage();
int size = pageRequest.getSize();
String sortBy = pageRequest.getSortBy();
boolean isAsc = pageRequest.isAsc();
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNo, size, sort);
Page<OrderResponse> page = orderRepository.findAll(pageable).map(OrderResponse::from);
return page.getContent();
}
package com.example.springorder.Order;
import lombok.Getter;
import lombok.Setter;
@Setter @Getter
public class OrderResponse {
private Long id;
private String productName;
public OrderResponse(Long id, String name) {
this.id = id;
this.productName = name;
}
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getProduct().getName());
}
}
진짜 N+1 문제가 발생하는지 로그를 찍어서 확인해 보았다.
application.properties: SQL 로그를 보기 위한 설정 추가
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
GET /orders실행더보기Hibernate: select o1_0.id, o1_0.product_id from orders o1_0 order by o1_0.id desc offset ? rows fetch first ? rows only Hibernate: select count(o1_0.id) from orders o1_0 Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.name, p1_0.price, p1_0.quantity from products p1_0 where p1_0.id=?
위 로그를 보면 다음과 같은 흐름으로 나타난다.
- 주문 목록 조회 1번: 아래 쿼리로 주문 10개를 먼저 가져왔다. (
offset=0,limit=10)
select
o1_0.id,
o1_0.product_id
from
orders o1_0
order by
o1_0.id desc
offset ? rows fetch first ? rows only
- count 조회 1번: N+1 문제와는 별개로 페이징 때문에 붙는 정상적인 count 쿼리이므로 여기서는 무시해도 된다.
select count(o1_0.id) from orders o1_0
- 상품 조회 10번: 이 형태가
id=11, 10, ..., 3, 2로 계속 반복된다. 주문 10개를 가져온 뒤 각 주문의product를 하나씩 따로 조회했다.
select ... from products p1_0 where p1_0.id=?
N+1 문제 해결하기
실제로 N+1 문제가 발생한 것을 확인했으니 N+1 문제를 해결해보자.
OrderRepository:@EntityGraph사용 →findAll(pageable)호출 시product도 같이 조회하려고 시도한다.
package com.example.springorder.Order;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order,Long> {
@EntityGraph(attributePaths = "product")
Page<Order> findAll(Pageable pageable);
}
GET /orders실행
Hibernate:
select
o1_0.id,
p1_0.id,
p1_0.name,
p1_0.price,
p1_0.quantity
from
orders o1_0
left join
products p1_0
on p1_0.id=o1_0.product_id
order by
o1_0.id desc
offset
? rows
fetch
first ? rows only
Hibernate:
select
count(o1_0.id)
from
orders o1_0
SQL 로그가 이전보다 확연히 줄어든 것을 볼 수 있다. 주문 10개를 읽어도 더 이상 select ... from products where id=?가 10번 반복되지 않으니 N+1 문제가 해결되었음을 확인할 수 있다.
💭 느낀 점
오늘 작업이 생각보다 오래 걸렸는데, 페이지네이션 때문이 아니라 어제 작업했던 연관관계 매핑이 개판이었어서 그것을 수정하느라 그랬다😓 만약 이게 실제 실무 코드였고 퇴근 후에 이런 문제가 터졌더라면 정말 끔찍한 시간을 보냈을 것이다. 테스트 코드 작성 및 피어 코드 리뷰의 소중함을 몸소 느낀 하루였다.
📋 내일 할 일
- 상품 재고 차감 구현
- 원자성, 영속성 관련 강의 수강
'내일배움캠프' 카테고리의 다른 글
| [내일배움캠프] IoC, DI, Bean (0) | 2026.04.06 |
|---|---|
| [내일배움캠프] Spring 입문 1주차 (0) | 2026.04.06 |
| [내일배움캠프 사전캠프] 동시성 이슈 해결하기 (0) | 2026.04.03 |
| [내일배움캠프 사전캠프] JPA 연관관계 매핑 (0) | 2026.04.01 |
| [내일배움캠프 사전캠프] H2와 JPA (0) | 2026.03.31 |