정렬을 선언하지 않은 주문 목록
PeekCart 주문 서비스에는 로그인한 사용자의 주문을 20건씩 돌려주는 “내 주문 목록” API 가 있었습니다.
@GetMapping
public ResponseEntity<ApiResponse<Page<OrderResponse>>> getOrders(
@CurrentUser LoginUser loginUser,
@ParameterObject @PageableDefault(size = 20) Pageable pageable
) {
Page<OrderResponse> page = orderQueryService.getOrders(loginUser.userId(), pageable)
.map(OrderResponse::from);
return ResponseEntity.ok(ApiResponse.of(page));
}
조회는 Spring Data JPA 의 메서드 이름 규칙으로 만든 리포지터리 메서드 하나가 맡았습니다.
Page<Order> findByUserId(Long userId, Pageable pageable);
이 API 의 1페이지에는 어떤 주문 20건이 나올까요? 주문 목록이니 “가장 최근 주문 20건”이라고 답하기 쉽습니다.
코드 어디에도 정렬 조건은 없습니다. @PageableDefault 는 size 와 함께 sort 도 받는데 여기서는 비어 있습니다. Spring Data 는 요청에 sort 파라미터가 없으면 이 어노테이션 값을 기본값으로 쓰고, 그 기본값에도 정렬이 없으니 정렬 없는 Pageable 이 리포지터리까지 내려갑니다. 그래서 SQL 에는 WHERE user_id = ? 와 LIMIT/OFFSET 만 있고 ORDER BY 는 없습니다.
ORDER BY 가 없는 결과의 순서는 데이터베이스가 보장하지 않습니다. MySQL 은 ORDER BY 컬럼 값이 같은 행끼리도 실행계획에 따라 다른 순서로 돌려줄 수 있습니다(LIMIT Query Optimization). ORDER BY 가 아예 없으면 어떤 행의 순서도 정해지지 않습니다. 이 API 는 옵티마이저가 그때 고른 접근 경로가 행을 읽는 순서대로 응답하고 있었습니다.
반대 방향의 문제도 있었습니다. 요청에 sort 가 들어오면 Spring Data 는 어노테이션 기본값 대신 그 값을 씁니다. 허용할 필드 목록을 확인하지 않으므로 클라이언트가 ?sort=totalAmount,desc 처럼 주문 엔티티의 아무 필드로나 정렬할 수 있었습니다.
이 API 를 고치는 일은 “주문 조회 Cursor 기반 전환 검토”라는 작업으로 올라 있었습니다. 기존 계획은 offset 방식의 한계를 깊은 페이지의 성능으로 보았습니다. LIMIT 20 OFFSET 10000 이면 10,020행을 읽고 앞의 10,000행을 버리니, 마지막으로 조회한 주문 id 를 커서로 써서 WHERE id < :cursor LIMIT 20 으로 바꾸자는 것이었습니다. 착수 전에 코드를 열어 보니 성능보다 먼저 해결할 문제가 있었습니다. offset 이든 cursor 든 “다음 페이지”는 순서가 정해져 있어야 의미가 있는데 그 순서가 정해져 있지 않았습니다.
PeekCart 는 운영 트래픽이 없는 포트폴리오 프로젝트이고 프런트엔드도 없어서 이 API 로 페이지를 넘겨 본 사용자는 없습니다. 아래 내용은 코드 경로를 따라가 확인한 것, 작업 중 통합 테스트로 확인한 것, 그리고 작업을 마친 뒤 따로 돌린 재현 실험의 결과입니다. 재현 실험은 MySQL 8.0.46 일회용 컨테이너(격리 수준과 sql_mode 는 기본값)에서 돌렸습니다. 컨테이너에는 작업 전후의 마이그레이션을 적용했고 데이터는 실험용으로 만들었습니다. 실험마다 어느 시점의 코드에서 무엇을 바꿨는지는 해당 부분에 함께 적었습니다.
함께 손본 상품 조회 캐시도 사정이 비슷했습니다. Redis 가 죽거나 응답하지 않을 때 어떻게 할지 코드가 정하지 않았고 Spring 과 Redis 클라이언트의 기본값이 그 동작을 정하고 있었습니다. 주문 목록을 먼저 다루고 캐시는 뒤에서 다룹니다.
처음 세운 답
고칠 것은 두 가지입니다. 순서를 서버가 정하는 것과, “다음 페이지”를 어떻게 가리킬지 정하는 것입니다.
가장 짧은 수정은 어노테이션에 정렬을 채우는 한 줄입니다.
@PageableDefault(size = 20, sort = "orderedAt", direction = Sort.Direction.DESC)
기본 순서는 생기지만 요청에 sort 가 들어오면 여전히 그 값이 우선합니다. 다음 페이지도 계속 offset 으로 가리키니 깊은 페이지일수록 읽고 버리는 행이 늘어나는 문제도 남습니다. 그래서 cursor 페이지네이션으로 바꾸기로 했습니다. 페이지 번호 대신 마지막으로 받은 행의 위치를 다음 요청에 실어 보내고 서버는 그 위치 다음부터 읽는 방식입니다. 이를 위해 네 가지를 정해야 했고 처음 계획은 다음과 같았습니다.
- 무엇으로 정렬할까. 최신 주문이 먼저 나와야 하니 주문 시각
ordered_at의 내림차순입니다. 기존 계획은 정렬과 커서를 주문 id 하나로 처리하는 것이었습니다. id 는 auto increment 값이라 나중에 만든 주문일수록 크다고 보면WHERE id < :cursor한 줄로 최신순 커서가 됩니다. - 커서에 무엇을 담을까. 클라이언트가 내용을 해석하지 않는 opaque token 에 마지막 행의 정렬 키를 담습니다. 처음 계획은
ordered_at을 epoch 마이크로초(1970년 1월 1일부터 흐른 마이크로초 수)로 바꿔 주문 id 와 함께 base64url 로 인코딩하는 것이었습니다. - 응답은 어떻게 바꿀까. 기존 응답은
Page를 직렬화한 것이라totalElements,totalPages,number가 들어 있었습니다. cursor 방식에서는 이 값을 만들 수 없거나 별도의COUNT쿼리가 필요합니다. 새 응답은content,nextCursor,hasNext세 필드로 두기로 했습니다. 저장소 안에 이 응답을 읽는 클라이언트가 없어서 두 방식을 함께 지원하지 않고 한 번에 바꾸고, 더 이상 쓰지 않는page,sort파라미터가 들어오면 무시하고 200 을 주기로 했습니다. - 잘못된 입력은 어떻게 막을까.
size는 1에서 100 사이여야 하므로 Bean Validation 의@Min(1),@Max(100)을 붙여 범위를 벗어나면 400 을 받게 할 계획이었습니다. 인덱스는(user_id, ordered_at)로 하나 추가하고 cursor 가 빠르다는 것은 offset 과 응답 시간(p95)을 비교해 확인하기로 했습니다.
코드와 데이터베이스에서 하나씩 확인해 보니 대부분은 계획대로 동작하지 않았습니다.
해 보니 드러난 문제
고치기 전에는 어떤 순서로 응답했을까요?
재현 실험에서 고치기 전 스키마에 주문 1,000건(사용자 5명, 한 명당 200건)을 넣고 정렬 없는 질의를 실행했습니다. 주문 시각은 id 와 같은 순서로 1초씩 늘어나게 두었고 상태는 다섯 종류를 고르게 섞었습니다.
SELECT id, status FROM orders WHERE user_id = 1 LIMIT 0, 5;
옵티마이저는 (user_id, status) 인덱스를 골랐고 1페이지에는 그 사용자의 가장 오래된 CANCELLED 주문 다섯 건(id 5, 30, 55, 80, 105)이 나왔습니다. 인덱스를 따라 읽었으니 상태 이름의 알파벳 순서, 그 안에서는 id 순서입니다. 같은 SQL 에 IGNORE INDEX (idx_orders_user_id_status) 만 붙이자 이번에는 id 순서(5, 10, 15, 20, 25)로 상태가 섞여 나왔습니다. 처음 질문의 답은 “그때 고른 인덱스의 순서”였습니다.
순서가 정해지지 않으면 페이지를 넘기는 사이에 행이 다른 페이지로 옮겨 갈 수도 있습니다. 여기서는 상태가 순서를 정하고 있으니 상태가 바뀌면 행의 위치도 바뀝니다. 같은 실험에서 20건씩 3페이지를 받은 뒤 다른 주문 하나를 SHIPPED 에서 DELIVERED 로 바꾸자 4페이지 첫 줄에 3페이지의 마지막 주문이 다시 나왔습니다. 5페이지를 받은 뒤 주문 하나를 PAYMENT_COMPLETED 에서 PREPARING 으로 바꾸자 6페이지에서는 주문 하나가 어느 페이지에도 나오지 않았습니다. 두 상태 변경은 이 서비스의 상태 전이 규칙이 허용하는 범위에서 실험용으로 고른 예시입니다. 실제 장애에서 관찰한 사례는 없습니다.
ordered_at 으로 정렬하면 해결될까요?
정렬을 선언하면 첫 번째 문제는 사라집니다. 하지만 ordered_at 은 DATETIME(6) 이고 유니크 제약이 없습니다. 같은 사용자의 주문 두 건이 같은 마이크로초에 생기는 일은 드물지만 시각을 초 단위로 저장하는 시스템이나 데이터를 일괄로 넣는 경우라면 흔해집니다. 그리고 ORDER BY 값이 같은 행끼리의 순서는 실행계획에 따라 달라질 수 있습니다.
재현 실험에서 한 사용자의 주문 200건을 주문 시각 10종류로 바꿔 시각마다 20건씩 같게 만든 뒤 ORDER BY ordered_at DESC 로 10건씩 받았습니다. 아래는 그 첫 묶음 20건을 1페이지와 2페이지로 받은 결과입니다.
같은 시각의 20건을 10건씩 두 번 받으면
위 칸은 주문 시각이 모두 같은 주문 20건입니다. 1페이지와 2페이지가 각각 어떤 주문을 가져가는지, 두 페이지를 합치면 무엇이 남는지 보세요.
MySQL 8.0.46 재현 실험에서 한 사용자의 주문 시각을 20건씩 같게 만든 뒤, user_id 인덱스와 filesort 를 쓰는 실행계획에서 받은 첫 두 페이지입니다. 같은 조건을 두 번 반복했을 때 결과가 같았지만, 동률 행의 순서는 실행계획에 따라 달라지므로 다른 환경에서는 겹치는 id 가 다를 수 있습니다. 주문의 다른 컬럼은 그리지 않았습니다.
두 페이지가 같은 20건 안에서 10건씩 가져갔는데 다섯 건은 두 페이지에 모두 나왔고 다섯 건은 어느 페이지에도 없었습니다. 2페이지 요청은 같은 SQL 을 다시 실행해 앞의 10건을 건너뛸 뿐입니다. 동률 행들이 1페이지 때와 같은 순서로 정렬된다는 보장은 없습니다. 끝까지 넘기면 200건을 받았는데 서로 다른 주문은 177건이었습니다. 23건은 두 번 나왔고 23건은 한 번도 나오지 않았습니다. 같은 순회를 한 번 더 해도 결과가 똑같았습니다. 매번 같은 주문이 같은 방식으로 빠지니 개발 중에 몇 번 눌러 보는 것으로는 이상한 점을 알아채기 어렵습니다.
ORDER BY ordered_at DESC, id DESC 처럼 유일한 컬럼을 뒤에 덧붙이자 200건이 모두 한 번씩 나왔습니다. 정렬 컬럼을 더해 순서를 하나로 정하면 동률 행의 순서도 실행계획에 따라 바뀌지 않습니다(LIMIT Query Optimization). 참고로 나중에 만든 (user_id, ordered_at) 인덱스가 있는 상태에서는 id 를 덧붙이지 않아도 200건이 모두 한 번씩 나왔습니다. 그 인덱스를 거꾸로 읽어 우연히 id 순서까지 맞았기 때문입니다. 실행계획이 바뀌면 다시 틀릴 수 있습니다.
id 하나로 정렬하고 자르면 될까요?
기존 계획대로 id 하나로 정렬하고 WHERE id < :cursor 로 자르면 동률은 생기지 않습니다. 다만 그 순서가 최신순이라는 보장이 없습니다. 이 서비스의 주문 엔티티는 객체를 만들 때 orderedAt = LocalDateTime.now() 로 주문 시각을 정하고 id 는 나중에 INSERT 할 때 데이터베이스의 auto increment 값으로 받습니다. 두 요청이 거의 동시에 들어오면 먼저 시각을 받은 주문이 나중에 INSERT 되어 더 큰 id 를 받을 수 있습니다. 이 역전은 코드 경로에서 추론한 예시로, 실제로 관찰한 적은 없습니다. 그래도 id 순서는 PK 를 발급한 순서이고 화면에 보여 주는 주문 시각의 순서와 같다고 할 수 없습니다. 그래서 착수 전에 커서 키를 (ordered_at, id) 의 조합으로 바꿨습니다.
커서에 epoch 를 담으면 무엇이 문제일까요?
ordered_at 은 시간대 정보가 없는 DATETIME(6) 이고 Java 에서는 LocalDateTime 으로 읽습니다. 이것을 epoch 값으로 바꾸려면 어느 시간대 기준의 시각인지 정해야 하는데 이 프로젝트에는 JVM 기본 시간대를 고정하는 설정이 코드에도, 설정 파일에도, 이미지에도 없었습니다. 데이터베이스 연결 URL 의 serverTimezone=Asia/Seoul 은 JDBC 드라이버가 값을 변환할 때 쓰는 기준이고 JVM 기본 시간대는 바꾸지 않습니다.
JVM 기본 시간대로 변환한다고 가정하고 JDK 17 jshell 에서 예시를 만들어 보았습니다. 서울 기준 JVM 에서 2026-08-30T12:00 을 인코딩한 커서를 UTC 기준 JVM 이 디코딩하면 2026-08-30T03:00 이 됩니다. 커서가 9시간 전의 위치를 가리킵니다. 이 예시는 시간대를 명시하지 않은 변환이 어떻게 틀어지는지 보이려고 만들었고 처음 계획의 변환 코드를 그대로 옮기지는 않았습니다.
시간대 문제를 피하려고 LocalDateTime 을 문자열로 직렬화하자 이번에는 형식이 문제였습니다. 표준 포맷터 ISO_LOCAL_DATE_TIME 은 값에 따라 길이가 달라집니다. 초가 0이면 03:04:00, 마이크로초가 있으면 03:04:05.000001, 나노초가 있으면 03:04:05.123456789 가 됩니다. 컬럼은 마이크로초 6자리인데 커서는 9자리까지 담을 수 있어 자릿수가 맞지 않습니다. 그래서 형식을 uuuu-MM-dd'T'HH:mm:ss.SSSSSS 로 고정했습니다.
고정 패턴으로 바꾸자 파싱 과정에서 값이 바뀌는 문제도 있었습니다. DateTimeFormatter.ofPattern 의 기본 해석 방식인 ResolverStyle.SMART 는 존재하지 않는 날짜 2026-02-30 을 거부하지 않고 2026-02-28 로 바꿉니다. 조작된 커서가 오류 없이 다른 위치를 가리키게 됩니다. ResolverStyle.STRICT 를 지정하면 예외가 납니다. 다만 STRICT 로도 +10000 처럼 부호가 붙은 다섯 자리 연도는 통과하고 MySQL 은 그 값을 거부합니다. 따로 검사하지 않으면 형식이 잘못된 커서가 데이터베이스까지 내려가 500 이 됩니다.
@Min, @Max 를 붙였는데 왜 500 이 됐을까요?
처음에는 size 에 @Min(1), @Max(100) 을 붙이면 범위를 벗어난 값에 400 이 나간다고 보았습니다. 이 프로젝트의 전역 예외 핸들러(@RestControllerAdvice)에는 메서드 파라미터 검증이 던지는 HandlerMethodValidationException 을 처리하는 메서드가 없었고 대신 모든 예외를 받는 @ExceptionHandler(Exception.class) 가 있었습니다. 이 catch-all 핸들러가 Spring MVC 의 기본 처리(DefaultHandlerExceptionResolver)보다 먼저 예외를 받아 500 으로 응답합니다. 기본 처리였다면 이런 프레임워크 예외는 400 이 됩니다.
재현 실험에서는 @WebMvcTest 슬라이스에 현재 코드의 전역 예외 핸들러를 붙이고 실험용 컨트롤러를 두어 돌려 보았습니다. 전역 예외 핸들러는 이 작업 전후로 바뀌지 않았습니다.
@Min(1)을 어긴size=0,@Max(100)을 어긴size=101: 500int로 받는size에size=abc나int범위를 넘는size=2147483648:MethodArgumentTypeMismatchException으로 500- 상품 상세 API 의 경로 변수에 숫자가 아닌 값을 넣은
/api/v1/products/abc: 같은 이유로 500 - 비교를 위해 필수 파라미터를 빠뜨린 요청: 처리 메서드가 있어서 400
정렬 파라미터는 사정이 조금 다릅니다. 엔티티에 없는 필드로 ?sort= 를 보내면 Spring Data 가 질의를 만들 때 PropertyReferenceException 을 던지고, 상품 목록 API 에서 실행해 보니 500 이었습니다. 이 예외는 Spring MVC 가 400 으로 바꿔 주는 프레임워크 예외 목록에 없어서 catch-all 핸들러가 없어도 500 이 됩니다. 클라이언트에게 정렬을 열어 둔 것만으로 잘못된 요청이 서버 오류로 처리되는 것입니다. 주문 목록에서 직접 실행하지는 않았지만 같은 Spring Data 버전을 씁니다.
@RequestParam(defaultValue = "20") 에도 함정이 있었습니다. Spring MVC 는 파라미터가 없을 때뿐 아니라 빈 문자열일 때도 기본값을 씁니다. 같은 슬라이스 테스트에서 ?size= 가 오류 없이 200 과 함께 size=20 으로 처리됐습니다.
폐기한 파라미터를 무시하면
처음 계획대로 page, sort 를 무시하고 200 을 주면 아직 ?page=3 을 보내는 클라이언트는 오류 없이 항상 첫 페이지를 받습니다. 다음 페이지를 아무리 눌러도 같은 목록이 오는데 상태 코드는 정상입니다. 구 클라이언트가 잘못 동작하고 있어도 응답만 봐서는 알 수 없습니다.
매뉴얼의 범위와 실제 저장 범위가 달랐습니다
처음 계획은 MySQL 매뉴얼의 DATETIME 범위를 따라 커서 시각의 상한을 9999-12-31 23:59:59.499999 로 두는 것이었습니다(Date and Time Types). 하지만 MySQL 8.0.46 의 DATETIME(6) 컬럼은 .500000 과 .999999 도 그대로 저장하고 돌려줍니다. 상한을 .499999 로 두었다면 데이터베이스가 정상적으로 저장하고 돌려준 주문 시각이 담긴 커서를 서버가 형식 오류로 거부했을 것입니다. 계획 단계에서 값을 직접 넣어 보고 알았습니다. 소수 자릿수가 없는 DATETIME 컬럼에서는 .5 가 다음 초로 반올림되며 범위를 넘으므로, 매뉴얼의 상한은 어떤 정밀도의 컬럼에도 넘치지 않는 값으로 보입니다. 이 해석은 실험 결과에서 짐작한 것입니다.
인덱스를 추가하는 DDL 에서도 비슷한 일이 있었습니다. 마이그레이션이 테이블을 잠그지 않도록 ALGORITHM=INPLACE 와 LOCK=NONE 을 함께 지정했는데 ALTER TABLE 에서 쓰는 쉼표 형식을 CREATE INDEX 에 쓰면 문법 오류가 납니다. 계획 단계에서는 문법상 문제없다고 보고 넘어갔고 구현 중 실행해 보고서야 알았습니다. 재현 실험에서 같은 MySQL 버전으로 다시 실행한 출력 중 해당 줄만 옮기면 이렇습니다.
mysql> CREATE INDEX idx_tmp ON orders (user_id, ordered_at) ALGORITHM=INPLACE, LOCK=NONE;
ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ', LOCK=NONE' at line 1
같은 쉼표 형식이 ALTER TABLE orders ADD INDEX idx_tmp (user_id, ordered_at), ALGORITHM=INPLACE, LOCK=NONE 에서는 통과합니다.
고친 방법
정렬은 질의에 고정하고, 동률은 id 로 끊습니다
정렬은 요청이 바꿀 수 없도록 질의 안에 적었습니다. 첫 페이지와 다음 페이지를 두 메서드로 나누고 둘 다 같은 ORDER BY 를 씁니다.
@Query("SELECT o FROM Order o WHERE o.userId = :userId ORDER BY o.orderedAt DESC, o.id DESC")
List<Order> findFirstPageByUserId(@Param("userId") Long userId, Pageable limit);
@Query("SELECT o FROM Order o WHERE o.userId = :userId "
+ "AND (o.orderedAt < :orderedAt OR (o.orderedAt = :orderedAt AND o.id < :id)) "
+ "ORDER BY o.orderedAt DESC, o.id DESC")
List<Order> findPageByUserIdAfterCursor(@Param("userId") Long userId,
@Param("orderedAt") LocalDateTime orderedAt,
@Param("id") Long id,
Pageable limit);
ordered_at DESC, id DESC 는 최신 주문 먼저, 같은 시각이면 id 가 큰 쪽 먼저라는 뜻입니다. id 는 유일하므로 이 순서에는 동률이 없습니다. 커서 조건은 같은 순서를 비교식으로 쓴 것으로, 마지막으로 받은 행보다 주문 시각이 이르거나 시각이 같다면 id 가 더 작은 행부터 읽습니다. 시각만 비교하는 o.orderedAt < :orderedAt 은 마지막 행과 같은 시각을 가진 나머지 행을 건너뛰고 id 비교를 <= 로 쓰면 마지막 행을 다시 읽습니다.
Pageable 은 정렬 없이 몇 건을 읽을지만 전합니다. 서비스는 도메인 리포지터리를 거쳐 위 두 질의를 부릅니다. 요청한 것보다 한 건 더 읽은 뒤 그 한 건이 있으면 다음 페이지가 있다고 보고 잘라 냅니다.
PageRequest limit = PageRequest.of(0, size + 1);
List<Order> found = (cursor == null)
? orderRepository.findFirstPage(userId, limit)
: orderRepository.findPageAfterCursor(userId, cursor.orderedAt(), cursor.id(), limit);
boolean hasNext = found.size() > size;
List<Order> page = hasNext ? found.subList(0, size) : found;
반환 타입이 List 라서 Spring Data 가 COUNT 쿼리를 따로 보내지 않습니다. 재현 실험에서 Hibernate 통계를 켜 보니 커서 조회는 SQL 문 하나였습니다. 비교를 위해 Page 를 돌려주는 조회를 실행하면 COUNT 를 포함해 두 개였습니다.
앞의 동률 데이터 200건을 끝까지 넘겨 보면 세 방식의 차이가 보입니다.
200건을 끝까지 넘겨 보면
한 줄이 주문 시각 하나이고, 줄마다 같은 시각의 주문 20건이 id 가 큰 순서로 놓여 있습니다. 10건씩 끝까지 넘기는 동안 칸이 몇 번 칠해지는지 보세요.
MySQL 8.0.46 재현 실험(사용자 1명, 주문 시각 10종에 20건씩, 페이지 10건)의 합계를 옮겼습니다. ordered_at 만으로 정렬한 offset 에서 첫 줄은 실험의 1페이지와 2페이지 결과 그대로이고, 나머지 줄에서 어느 칸이 겹치고 빠지는지는 실험에서 따로 기록하지 않아 합계(중복 23건, 누락 23건)에 맞춰 모형이 배치했습니다. 동률을 20건씩 몰아 둔 것은 현상을 보이기 위한 분포이고, 실제 주문 데이터의 동률 빈도를 뜻하지 않습니다.
200건이 모두 한 번씩 나온다는 점만 보면 tie-break 를 넣은 offset 도 cursor 와 결과가 같습니다. 차이는 순회 도중에 데이터가 바뀔 때 생깁니다. 최신순 목록을 넘기는 사이에 새 주문이 들어오면 offset 은 기존 행이 한 칸씩 뒤로 밀려 직전 페이지의 마지막 주문이 다음 페이지에 다시 나옵니다. cursor 는 마지막으로 받은 행의 위치에서 이어 읽으므로 새 주문이 맨 앞에 들어와도 중복이 생기지 않습니다. 이 경우는 따로 실험하지 않았고, 두 방식의 동작에서 따라 나오는 결과입니다.
cursor 에도 한계는 있습니다. 주문 시각은 INSERT 전에 정해지므로, 늦게 커밋된 주문의 시각이 이미 지나간 커서 위치보다 이르면 그 순회에서는 보이지 않습니다. 앞에서 본 id 역전과 같은 원인입니다. 이번 작업에서는 이 경우를 따로 다루지 않았습니다.
인덱스에 id 를 적어야 할까요?
커서 질의를 range scan 만으로 처리하려면 (user_id, ordered_at, id) 순서로 정렬된 인덱스가 필요해 보입니다. 마이그레이션에는 두 컬럼만 적었습니다.
CREATE INDEX idx_orders_user_id_ordered_at ON orders (user_id, ordered_at)
ALGORITHM=INPLACE LOCK=NONE;
InnoDB 의 세컨더리 인덱스(기본 키가 아닌 인덱스)는 각 레코드에 그 행의 기본 키 컬럼을 함께 저장합니다(InnoDB 문서). 그래서 (user_id, ordered_at) 인덱스는 실제로는 (user_id, ordered_at, id) 순서로 정렬돼 있고 id 를 적지 않아도 동률 처리까지 인덱스 안에서 끝날 것으로 예상했습니다. 다만 이 예상은 검증 대상으로 남겼습니다. 실행계획에 filesort(인덱스 순서를 쓰지 못해 따로 정렬하는 단계)가 나오면 인덱스에 id 를 명시한다는 조건을 계획에 넣었고 결과는 뒤에서 다룹니다.
ALGORITHM=INPLACE LOCK=NONE 은 쉼표 없이 적었습니다. 이 옵션을 주면 테이블을 잠그지 않고는 인덱스를 만들 수 없을 때, 테이블을 잠그는 대신 마이그레이션이 실패합니다. 이 서비스는 새 버전 Pod 가 시작할 때 Flyway 가 마이그레이션을 실행합니다. 인덱스를 만드는 동안 테이블이 잠기면 그 사이 주문 쓰기가 막히고, 새 버전 Pod 도 마이그레이션이 끝날 때까지 뜨지 못합니다. 다만 LOCK=NONE 이어도 온라인 DDL 은 시작과 끝에 메타데이터 락을 잠깐 잡습니다. 그 테이블에 오래 열린 트랜잭션이 있으면 DDL 이 기다리고 뒤따르는 질의도 함께 막힐 수 있는데, 이 부분은 이번에 따로 확인하지 않았습니다. 롤백할 때도 인덱스는 남겨 둡니다. 구버전 질의는 이 인덱스를 쓰지 않고도 그대로 동작합니다.
커서에는 마지막 행의 위치만 담습니다
커서는 마지막 행의 ordered_at 과 id 를 uuuu-MM-dd'T'HH:mm:ss.SSSSSS|{id} 형식의 문자열로 만들어 base64url 로 인코딩합니다. 시간대가 개입하지 않고 길이는 컬럼과 같은 마이크로초 6자리로 고정됩니다. 디코딩은 ResolverStyle.STRICT 로 하고 결과가 0001-01-01T00:00:00.000000 부터 9999-12-31T23:59:59.999999 사이가 아니면 거부합니다. STRICT 를 통과하는 +10000 같은 연도도 이 범위 검사에서 걸립니다. 하한 0001-01-01 은 MySQL 매뉴얼의 범위(1000년부터)보다 낮지만, 8.0.46 의 DATETIME(6) 컬럼이 이 값도 저장하고 돌려주는 것을 확인해 그대로 두었습니다. 구조가 깨진 입력, 구분자가 없거나 둘 이상인 입력, 숫자가 아닌 id 도 모두 커서 형식 오류(400)로 돌려보냅니다.
클라이언트가 커서를 조작하거나 다른 사용자의 커서를 보낼 수 있으니 커서에 서명(HMAC)을 붙이자는 안도 있었습니다. 서명은 붙이지 않았습니다. 커서로 결정되는 범위를 줄이는 쪽을 택했습니다. 조회 대상 사용자는 언제나 인증된 사용자이고 커서에는 사용자 정보가 없습니다. 커서는 그 사용자의 주문 안에서 어디서부터 읽을지만 정합니다. 누군가 임의의 시각과 id 로 커서를 만들어 보내도 자기 주문 목록의 다른 위치를 볼 뿐 볼 수 있는 주문은 늘어나지 않습니다. 커서가 주는 권한이 없으니 서명으로 보호할 것도 없고 서명을 붙이면 키 관리 부담만 생긴다고 보았습니다.
잘못된 입력은 서비스를 부르기 전에 400 으로
입력 검증은 프레젠테이션 계층의 요청 객체 하나가 맡습니다. @RequestParam 을 쓰지 않고 HttpServletRequest 를 직접 읽습니다.
public static OrderPageQuery of(HttpServletRequest request) {
Map<String, String[]> params = request.getParameterMap();
List<String> removed = REMOVED_PARAMS.stream().filter(params::containsKey).toList();
if (!removed.isEmpty()) {
throw new OrderException(ErrorCode.ORD_012,
"지원하지 않는 파라미터입니다: " + String.join(", ", removed)
+ ". 커서 페이지네이션(cursor, size)을 사용하세요.");
}
return new OrderPageQuery(parseCursor(params.get(CURSOR)), parseSize(params.get(SIZE)));
}
@RequestParam 으로는 선언하지 않은 파라미터가 들어왔는지 알 수 없어서 폐기한 page, sort, offset 을 감지하려면 요청의 파라미터 전체를 봐야 했습니다. 이 셋 중 하나라도 있으면 지원하지 않는 이름을 메시지에 담아 400 을 돌려줍니다. 구 클라이언트는 실패 응답을 받으므로 첫 페이지만 계속 받는 상태에 머물지 않습니다. 거부 목록으로 만든 이유는, 허용 목록으로 만들면 캐시를 피하려고 붙이는 _=123 이나 추적용 utm_* 같은 무해한 파라미터까지 400 이 되기 때문입니다.
size 는 문자열로 받아 직접 숫자로 바꿉니다. 숫자가 아니거나 int 범위를 넘거나 1에서 100 사이가 아니거나 빈 문자열이면 400 이고 파라미터가 아예 없을 때만 20을 씁니다. ?size=20&size=abc 처럼 같은 이름이 두 번 오면 첫 값만 보고 통과하지 않도록 값이 정확히 하나인지도 검사합니다. 오류가 여러 개 섞여 있으면 폐기 파라미터, 커서, size 순서로 먼저 걸린 것을 돌려줍니다.
이 검사는 모두 서비스를 호출하기 전에 끝나므로 서비스는 검증된 커서와 int 만 받습니다. 전역 예외 핸들러에 프레임워크 예외 처리를 더하는 수정은 다섯 서비스가 함께 쓰는 모듈을 바꾸는 일이라 이번 범위에서 뺐습니다.
포기한 것
응답에서 totalElements 와 totalPages 가 빠졌으니 “총 N건”이나 진행률을 보여 줄 수 없고 ?page=57 처럼 임의의 페이지로 건너뛸 수도 없습니다. 주문 내역은 최신순으로 내려가며 보는 화면이라 순차 탐색으로 충분하다고 보았습니다. 사용 패턴을 측정해 보지는 않았습니다. 총 건수가 필요해지면 별도 API 로 분리하기로 했습니다. 없어진 COUNT 쿼리의 비용도 따져 보면, user_id 로 시작하는 인덱스가 있어 사용자별 COUNT 는 인덱스만 읽었습니다. 이번 변경으로 매 요청의 COUNT 쿼리 한 번과 그 사용자의 주문 수만큼 인덱스 항목을 세는 비용이 줄었습니다.
배포 중에는 두 응답 형식이 잠시 함께 노출됩니다. 쿠버네티스 롤링 업데이트가 새 Pod 를 하나 더 띄운 뒤 구 Pod 를 내리는 방식(maxSurge: 1)이라 그 사이에는 구 버전과 새 버전이 같은 Service 뒤에 함께 있습니다. 한 번에 전환하는 blue/green 배포는 모든 서비스 공통의 배포 방식을 바꾸는 일이고 이 API 를 쓰는 클라이언트가 확인되지 않아 도입하지 않았습니다.
같은 구조의 상품 목록 API 는 이번에 바꾸지 않았습니다. 상품 목록은 Redis 캐시 키가 페이지 번호와 크기에 묶여 있었고 그 캐시를 바로 앞서 고쳤기 때문입니다.
동작한다는 것을 어떻게 확인했나
주문 목록 API 는 GET /api/v1/orders?cursor={커서}&size={개수} 로 바뀌었고 응답은 content, nextCursor, hasNext 세 필드가 됐습니다. 첫 요청은 cursor 없이 보내고 이후에는 직전 응답의 nextCursor 를 그대로 돌려보냅니다. hasNext 가 false 이면 nextCursor 는 비어 있습니다. 잘못된 커서와 size, 폐기한 page, sort, offset 은 모두 400 이고 오류 코드로 서로 구분됩니다.
이 확인 과정이 이 작업에서 시간이 가장 오래 걸리고 가장 많이 고친 부분입니다.
인덱스에 붙은 id 가 실제로 쓰였을까요?
실행계획 테스트는 Hibernate 가 보낸 SQL 을 StatementInspector 로 가로채 같은 연결에서 EXPLAIN FORMAT=JSON 으로 실행합니다. 운영 코드가 만든 SQL 을 그대로 검사하기 위해서입니다. Testcontainers 의 MySQL 8.0.46 에 사용자 5명의 주문 5,000건을 넣고 돌린 결과입니다.
| 질의 | 사용한 인덱스 | 접근 방식 | used_key_parts | filesort |
|---|---|---|---|---|
| 첫 페이지 | idx_orders_user_id_ordered_at | ref | [user_id] | 없음 |
| 커서 페이지 | idx_orders_user_id_ordered_at | range | [user_id, ordered_at, id] | 없음 |
대조군 (IGNORE INDEX) | - | - | - | 있음 |
인덱스는 두 컬럼으로 만들었는데 커서 질의가 쓴 키는 id 를 포함한 세 개입니다. 세컨더리 인덱스에 붙은 기본 키가 ordered_at 동률을 끊는 데까지 쓰였습니다. 인덱스에 id 를 명시해야 하는 조건(filesort)은 나오지 않았습니다. 대조군은 같은 SQL 에 인덱스 사용 금지 힌트만 붙인 것인데 이 경우에는 같은 파서가 filesort 를 찾아냈습니다.
재현 실험에서는 MySQL 클라이언트로 같은 확인을 반복하면서 인덱스에 없는 order_number 로 동률을 끊도록 바꿔 보았습니다. 그러자 filesort 가 다시 나타났습니다. 세컨더리 인덱스에 함께 저장되는 컬럼은 기본 키뿐입니다.
깊은 페이지에서 정말 덜 읽을까요?
성능은 응답 시간 대신 MySQL 이 읽은 행 수로 비교했습니다. EXPLAIN ANALYZE 의 실행 트리에서 주문 인덱스를 읽는 노드 하나를 골라 그 노드의 행 수를 셉니다. 대상 사용자의 주문 1,000건 중 20번째, 400번째, 900번째 위치에서 다음 20건을 읽었습니다.
| 시작 위치 | cursor | offset |
|---|---|---|
| 20 | 20 | 40 |
| 400 | 20 | 420 |
| 900 | 20 | 920 |
offset 은 건너뛸 행까지 모두 읽으므로 깊어질수록 늘어나고 cursor 는 어디서 시작하든 20건만 읽습니다. 900번째 위치에서는 46배 차이였습니다. 처음 계획한 응답 시간(p95) 비교는 하지 않았습니다. 한쪽은 일정하고 다른 쪽은 선형으로 늘어난다는 것이 행 수로 이미 보였고 컨테이너에서 잰 시간은 측정 오차만 더한다고 보았습니다. 다만 이 비교는 cursor 로 900번째에 가려면 앞의 페이지를 차례로 넘겨야 한다는 사용 방식의 차이까지 반영하지는 않습니다.
동률 행이 빠지거나 겹치지 않는다는 것은 어떻게 확인했나요?
같은 ordered_at 을 가진 주문 다섯 건을 넣고 2건씩 끝까지 넘겨, 받은 id 가 넣은 id 와 정확히 같고 중복이 없으며 id 내림차순인지 확인했습니다. 시각이 섞인 경우는 한 건씩 넘기며 “새 주문, 동률 중 id 가 큰 것, 동률 중 작은 것, 오래된 주문” 순서인지 살폈습니다.
테스트가 통과한다고 해서 그 테스트가 결함을 잡는다고 볼 수는 없습니다. 그래서 질의를 한 군데씩 망가뜨려 각각 따로 실행했습니다. 재현 실험에서 현재 코드에 같은 변경을 다시 넣어 보니 커서 조건의 id 비교를 무력화하거나, < 를 <= 로 바꾸거나, id 정렬을 오름차순으로 뒤집을 때마다 동률 테스트가 실패했습니다. 세 변경은 한꺼번에 넣지 않았습니다. 함께 넣으면 서로의 효과가 섞여 셋 중 하나만 잡아도 테스트가 실패하기 때문입니다. 그러면 나머지 둘을 잡는지는 알 수 없습니다. 같은 변경을 앞의 200건 데이터에 넣고 MySQL 클라이언트로 끝까지 넘기면 각각 다른 방식으로 결과가 틀어집니다. id 비교를 지우면 같은 첫 페이지가 반복돼 서로 다른 주문이 10건에 그쳤고, <= 는 페이지마다 직전 마지막 행을 다시 읽어 22건이 중복됐고, id 를 오름차순으로 뒤집으면 145건이 빠졌습니다.
커서 조건을 한 군데씩 망가뜨리면
같은 200건을 정상 커서 조건과, 조건을 한 군데씩 바꾼 질의로 끝까지 넘깁니다. 어느 변경이 무엇을 깨는지 칸의 색과 커서 표시로 보세요.
MySQL 8.0.46 재현 실험에서 같은 SQL 을 MySQL 클라이언트로 손으로 순회한 결과입니다(주문 시각 10종에 20건씩, 페이지 10건, 다음 페이지 유무는 11건을 읽어 확인, 40페이지 상한). 칸의 위치는 각 조건이 돌려주는 행을 정렬 순서에서 계산한 것이고 합계는 실험 값과 같습니다. 애플리케이션 테스트는 동률 다섯 건으로 같은 변경을 잡았고, 그 결과는 통과와 실패로만 남아 있습니다.
다른 사용자의 커서로 남의 주문을 볼 수 없다는 것도 같은 방식으로 확인했습니다. 이 테스트는 데이터 배치와 페이지 크기를 고정해야 의미가 있었습니다. 사용자 B 의 주문 두 건 사이에 사용자 A 의 주문 한 건이 오도록 시각을 배치하고(B 의 최신 주문 > A > B 의 오래된 주문), B 의 최신 주문으로 만든 커서를 A 가 크기 2로 보냅니다. 정상이면 A 의 주문 한 건만 나옵니다. 질의에서 사용자 조건을 지우면 B 의 오래된 주문이 섞여 나와 테스트가 실패합니다. 페이지 크기를 A 의 주문 수보다 크게 잡지 않으면 사용자 조건을 지워도 A 의 주문이 한 페이지를 다 채웁니다. 그러면 B 의 주문이 결과에 들어오지 않아 테스트가 통과합니다.
테스트는 무엇을 재고 있었을까요?
여기까지는 완성된 테스트입니다. 처음 작성한 테스트는 달랐습니다. 그 테스트에는 문제가 다섯 가지 있었고, 모두 통과하면 안 되는 상황에서 통과하는 문제였습니다.
- 가로챈 SQL 을 직접 실행한 결과가 리포지터리 결과와 같은지 비교해야 했는데 결과가 비어 있지 않은지만 확인했습니다. 엉뚱한 SQL 을 가로채도 결과가 한 건이라도 있으면 통과합니다.
- 읽은 행 수를 주문 인덱스 노드 하나에서 세지 않고 실행 트리 전체에서 더했습니다. 주문 인덱스를 읽는 노드가 없어도 다른 노드만 있으면 값이 나옵니다.
- 실행계획 검사가 느슨했습니다. 두 성질이 겹쳐 있었습니다. 접근 방식은 계획의 기준대로
ref와range를 모두 허용하면서used_key_parts는 보지 않았고, 읽은 행 수는 운영 질의를 쓰지 않고 테스트 안에 손으로 쓴 커서 SQL 로 쟀습니다. - 계획보다 줄인 측정 규모를 기록하지 않았습니다.
- 기존 인덱스가 남아 있는지 확인하는 목록에서 하나가 빠졌습니다.
세 번째 문제가 어떤 회귀를 놓치는지 재현 실험으로 따져 보았습니다. 현재 코드에서 커서 조건을 뜻이 같은 행 생성자 비교 (o.orderedAt, o.id) < (:orderedAt, :id) 로 바꾸면 결과는 똑같고 동률 테스트와 권한 테스트도 모두 통과합니다. 그런데 MySQL 8.0 은 이 형태에서 인덱스 범위를 user_id 까지만 씁니다(Row Constructor Expression Optimization). 최종 테스트는 이 변경에서 두 곳이 실패했습니다. 커서 질의의 접근 방식이 range 여야 하는데 ref 였고(최종 테스트는 range 만 허용합니다) 커서가 읽은 행 수가 위치 20, 400, 900 에서 40, 420, 920 으로 offset 과 똑같았습니다.
처음 작성한 테스트의 원본은 남아 있지 않아서 세 번째 문제의 두 성질(접근 방식으로 ref 도 허용하고 used_key_parts 를 보지 않는 것, 행 수를 손으로 쓴 SQL 로 재는 것)만 되살린 버전을 따로 만들어 같은 변경을 넣었습니다. 이 재구성 버전은 네 테스트를 모두 통과했고 커서가 읽은 행 수를 20, 20, 20 으로 보고했습니다. 행 수를 테스트 안의 SQL 로 쟀으니 운영 질의가 바뀌어도 측정값은 그대로였습니다.
행 생성자로 바꾸면 두 테스트는 어떻게 될까
운영 질의의 커서 조건을 뜻이 같은 행 생성자로 바꿔 보세요. 두 테스트가 각각 어느 SQL 을 재는지 연결선을 따라가 보세요.
MySQL 8.0.46, 주문 5,000건(대상 사용자 1,000건) 한 환경의 재현 실험 값입니다. 처음 작성한 테스트는 원본이 남아 있지 않아, 본문의 세 번째 문제에 해당하는 두 성질(접근 방식으로 ref 도 허용하고 사용한 키를 보지 않음, 읽은 행 수를 손으로 쓴 SQL 로 잼)만 되돌려 재구성했습니다. 행 생성자로 바꾸면 실행계획 테스트에 고정해 둔 바인드 값 개수가 맞지 않아 먼저 JDBC 오류가 나는데, 장면은 바인드 개수를 맞춘 버전의 결과입니다. 결과 정확성을 보는 통합 테스트는 두 조건 모두에서 통과해 그리지 않았습니다.
다섯 가지를 고치는 과정에서 다른 종류의 문제 두 가지가 새로 생겼습니다. 이번에는 통과해야 하는 상황에서 실패할 수 있는 테스트였습니다.
- SQL 을 직접 실행한 결과에서 첫 번째 컬럼을 주문 id 로 읽었습니다. 리포지터리 질의는
SELECT o라서 Hibernate 가 컬럼을 어떤 순서로 나열할지는 리포지터리가 정하지 않습니다. 엔티티 매핑만 바뀌어도 정상 코드에서 테스트가 깨질 수 있어 결과 메타데이터에서id컬럼의 위치를 찾아 읽도록 고쳤습니다. - 기존 인덱스가 남아 있는지 보려던 assert 가 “주문 테이블의 인덱스는 정확히 이 여섯 개”를 요구했습니다. 나중에 필요한 인덱스를 하나 더해도 실패하므로 필요한 인덱스가 포함돼 있는지만 보도록 바꿨습니다.
앞의 다섯 가지는 확인해야 할 것을 덜 확인한 경우이고 뒤의 두 가지는 구현 세부까지 고정해 버린 경우입니다. 재현 실험에서 변경을 다시 넣다가 뒤쪽에 해당하는 사례를 하나 더 찾았습니다. 실행계획 테스트는 EXPLAIN 에 넣을 바인드 값을 원래 SQL 에 맞춰 다섯 개로 고정해 두었습니다. 그래서 행 생성자처럼 파라미터 수가 달라지는 재작성에서는 실행계획 assert 까지 가기 전에 JDBC 오류로 먼저 실패했습니다. 회귀는 잡지만 무엇이 잘못됐는지는 알려 주지 못합니다. 위 장면은 바인드 개수를 맞춘 버전의 결과입니다.
전체 테스트는 기존 통합 테스트들이 동시에 컨테이너를 띄우다 메모리가 부족해 로컬에서 끝까지 돌리지 못했습니다. 그래서 CI 통과를 머지 조건으로 걸었습니다.
같은 질문을 캐시에 던지면
주문 목록과 함께 상품 조회 캐시도 손봤습니다. 페이지네이션과는 다른 주제지만 질문은 같습니다. 코드가 정하지 않았을 때 무엇이 동작을 정하고 있었을까요?
Redis 장애 때의 동작을 정한 코드가 없었습니다
작업 목록에는 상품 변경 이벤트로 주문 서비스에 가격 사본을 두는 일까지 함께 올라 있었지만, 그 부분은 앞선 작업에서 이미 끝나 있었습니다. 남은 것은 상품 서비스가 Redis 에 두는 조회 캐시에 장애가 났을 때의 처리였습니다.
상품 서비스는 Spring Cache 의 @Cacheable 로 상품 상세(TTL 30분)와 목록(TTL 10분)을 Redis 에 캐시하고 상품을 만들거나 고치거나 판매를 중단할 때 @CacheEvict 로 무효화합니다. Redis 가 죽거나 응답하지 않을 때의 동작은 어디에도 정해져 있지 않았습니다. CacheErrorHandler 구현체도 Redis 명령 타임아웃 설정도 없었습니다.
정하지 않으면 기본값대로 동작합니다. Spring 의 기본 SimpleCacheErrorHandler 는 캐시에서 난 예외를 다시 던집니다. Redis 가 죽으면 DB 는 정상이어도 상품 조회가 500 으로 실패합니다. Redis 클라이언트 Lettuce 의 기본 명령 타임아웃은 60초여서 Redis 가 연결은 받고 응답하지 않으면 요청은 60초를 기다린 뒤에야 실패합니다. 원한 동작은 캐시가 실패해도 DB 에서 읽어 응답하는 것(fail-open)이었습니다.
에러 핸들러를 빈으로 등록하면 될까요?
캐시 오류를 처리하는 CacheErrorHandler 를 만들어 @Bean 으로 등록하는 것만으로는 동작하지 않습니다. Spring 은 이 핸들러를 CachingConfigurer 를 구현한 설정 빈에서만 가져옵니다. spring-context 6.2.17 의 AbstractCachingConfiguration 을 보면 에러 핸들러는 CachingConfigurer 목록을 받는 setConfigurers 에서만 가져옵니다. 빈으로만 등록한 핸들러는 오류 없이 무시됩니다.
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new ResilientCacheErrorHandler(meterRegistryProvider);
}
}
작업 중에 장애 주입 테스트로 확인했고 재현 실험에서 그때의 코드로 다시 돌려 보았습니다. errorHandler() 오버라이드를 일반 @Bean 메서드로 바꾸자 장애 주입 테스트 7건 중 6건이 실패했습니다. 연결을 거부하는 Redis 앞에서 상품 상세 API 가 500 을 돌려줬고 나머지는 QueryTimeoutException: Redis command timed out 이 처리되지 않고 테스트까지 올라왔습니다. Redis 가 정상인 대조군만 통과했습니다.
실패한 연산마다 결과가 달랐습니다
CacheErrorHandler 는 조회(get), 적재(put), 무효화(evict), 전체 무효화(clear) 실패를 따로 받습니다. 넷의 결과는 같지 않았습니다.
- 조회 실패는 캐시 미스처럼 처리돼 DB 를 읽고 정상 응답합니다. 원하던 동작입니다.
- 적재 실패는 이번 응답에는 영향이 없지만 캐시가 채워지지 않으니 다음 요청도 DB 로 갑니다. 처음 계획은 이것을 무해하다고 보았는데 장애 중에 같은 상품을 다섯 번 조회하자 DB 조회도 다섯 번이었습니다. Redis 가 정상이면 한 번입니다. Redis 장애가 DB 부하로 이어집니다.
- 무효화 실패가 가장 위험합니다. 상품 정보 변경은 DB 에 커밋됐는데 캐시만 지워지지 않았으므로 Redis 가 살아난 뒤에도 TTL 이 끝날 때까지 이전 가격과 상태가 나갑니다. 판매를 중단한 상품이 최대 30분 동안 보일 수 있습니다.
Redis 가 실패하면 요청은 어디로 갈까
상황을 고르면 같은 상품을 다섯 번 조회합니다. 요청이 Redis 와 MySQL 중 어디까지 가는지, 아래 숫자가 어떻게 쌓이는지 보세요.
장애는 Redis 가 연결을 거부하는 경우로 그렸습니다. 재현 실험에서는 연결이 끊긴 직후 첫 조회만 명령 타임아웃(약 0.5초)을 기다렸고 이후 조회는 바로 실패했는데, 장면에서는 첫 대기를 생략했습니다. 무응답일 때 걸리는 타임아웃 대기는 아래 시간축 장면에서 따로 보여 줍니다. 다섯 번 조회해 DB 조회도 다섯 번이 된 것은 장애 주입 테스트의 순차 조회 결과이고, 동시 요청이 몰리는 경우는 재지 않았습니다. 가격 10,000 원과 12,000 원은 무효화 실패를 보이려고 넣은 예시 값입니다. 무효화 실패 장면에서는 수정 요청 동안만 Redis 가 응답하지 않았다고 가정했습니다.
그래서 조회와 적재 실패는 로그 없이 넘기고 무효화 실패는 WARN 로그와 함께 cache_fallback_total{cache, operation} 카운터를 올리도록 나눴습니다. 목록 캐시를 통째로 지우는 @CacheEvict(allEntries = true) 의 실패는 전체 무효화 콜백으로 온다는 것도 이때 알게 되어 무효화 실패를 감지하는 테스트를 두 콜백으로 나눴습니다.
같은 Redis 를 쓰는 게이트웨이의 요청 수 제한은 Redis 가 죽으면 요청을 거부하도록(fail-closed) 설정했습니다. 요청 수 제한은 남용을 막는 장치라 Redis 와 함께 동작하지 않으면 제한 없이 요청이 통과합니다. 조회 캐시는 응답을 빠르게 하는 장치라 동작하지 않아도 DB 에서 읽을 수 있습니다. 기본값은 두 곳 모두 이 프로젝트가 원한 동작과 달랐습니다. Spring Cloud Gateway 4.3.0 의 기본 RedisRateLimiter 는 Redis 오류가 나면 로그만 남기고 요청을 허용하고, Spring Cache 의 기본은 예외를 던져 요청을 실패시킵니다. 그래서 두 곳 모두 기본값을 바꿔야 했습니다.
타임아웃은 요청 하나에 두 번 걸립니다
fail-open 은 빨리 실패해야 의미가 있습니다. 60초를 기다린 뒤 DB 로 넘어가면 사용자에게는 장애와 다르지 않습니다. 타임아웃은 Redis 가 응답하지 않을 때만의 문제도 아니었습니다. 재현 실험에서 프록시를 끈 직후 첫 조회는 525ms 가 걸렸고, 이어진 세 번의 조회는 19~36ms 였습니다. 연결이 끊긴 순간 보낸 명령은 명령 타임아웃(500ms)만큼 기다린 뒤 실패하고, 그다음부터는 바로 실패하는 것으로 보입니다. 한 번 잰 값이라 정확한 동작은 Lettuce 의 재연결 설정에 따라 다를 수 있습니다. 그래서 Redis 명령 타임아웃을 500ms, 연결 타임아웃을 300ms 로 설정 파일에 넣었습니다.
처음 계획은 1초였는데 계산이 맞지 않았습니다. @Cacheable 이 붙은 요청 하나는 Redis 가 응답하지 않으면 조회에서 한 번 타임아웃을 기다리고 DB 를 읽은 뒤 적재에서 또 한 번 기다립니다. 1초면 요청 하나가 최소 2초인데 검증 상한을 2초로 정해 두었으니 통과할 수 없었습니다. 500ms 로 낮추고 무응답 Redis 앞에서의 응답 시간 상한을 1.5초로 두었습니다. 같은 경로에 캐시가 하나 더 붙으면 이 계산도 다시 해야 합니다. 이후 상품 상세에 캐시가 하나 더 추가되면서 이 상한도 다시 계산했습니다.
타임아웃은 요청 하나에 두 번 걸린다
Redis 가 연결은 받고 응답하지 않을 때, 상품 조회 요청 하나가 끝나기까지의 시간을 세 가지 타임아웃 설정으로 동시에 재생합니다.
DB 조회 구간 0.1초는 그림을 위해 넣은 값이고 측정값이 아닙니다. 상한 2초와 1.5초는 무응답 장애 주입 테스트에 둔 검증 상한이며, 로컬 Docker 에서 잰 것입니다. 이후 같은 경로에 캐시가 하나 더 붙으면서 이 상한은 다시 계산했고, 장면은 이 작업 당시의 값입니다.
어떤 예외를 캐시 미스로 넘길까요?
핸들러가 어떤 예외를 캐시 미스로 넘길지 정하는 데 가장 오래 걸렸습니다. 처음 구현은 예외 종류를 보지 않고 전부 넘겼습니다. 그러면 연결은 정상인데 캐시가 고장 난 경우까지 캐시 미스로 처리됩니다. 캐시 값의 직렬화 형식이 배포 중에 바뀌었거나 Redis 권한 설정이 틀린 경우가 그렇습니다. 모든 요청이 DB 로 가는데 원인을 알 수 있는 단서가 남지 않습니다.
그래서 연결 실패와 타임아웃 계열만 넘기도록 범위를 줄였는데, 이 범위를 네 번 고쳤습니다. Lettuce 의 최상위 예외 RedisException 을 허용하면 서버가 명령을 거부한 경우까지 넘기게 됩니다. 연결 실패 하위 타입과 IOException 만 허용하면 이미 끊긴 연결로 들어온 명령이 500 이 됩니다. lettuce-core 6.6.0 은 그런 명령에 원인 예외 없이 "Connection is closed" 같은 메시지만 담은 RedisException 을 만들기 때문입니다. 게다가 RedisException 아래에는 프로토콜 오류, 클러스터 파티션 오류처럼 가용성과 상관없는 하위 타입도 있었습니다. 최종 분류에서는 거부 목록(서버가 거부한 명령, 스레드 인터럽트, 직렬화 오류)을 먼저 검사하고, 허용 목록에는 연결 실패와 타임아웃 계열, 그리고 클래스가 정확히 RedisException 인 경우만 두었습니다.
if (matchesAny(cause, NEVER_SWALLOWED)) {
throw exception;
}
if (matchesAny(cause, AVAILABILITY_FAULTS) || cause.getClass() == RedisException.class) {
availability = true;
}
허용 범위가 넓으면 캐시 고장이 캐시 미스로 처리되고 좁으면 Redis 장애가 500 이 됩니다. 고칠 때마다 직전 수정과 반대쪽 문제가 생겼고 이 반복은 예외 계층을 끝까지 열거한 뒤에야 멈췄습니다. 메시지 문자열까지 보고 범위를 더 줄이지는 않았습니다. 메시지는 Lettuce 버전에 따라 바뀔 수 있고 바뀌면 다시 500 이 나기 때문에 더 위험하다고 보았습니다.
장애는 어떻게 주입했나
검증에는 Toxiproxy 를 썼습니다. 애플리케이션과 Redis 사이에 프록시를 두고 연결 거부는 프록시를 끄는 것(disable())으로, 무응답은 연결을 유지한 채 응답만 막는 timeout toxic 으로 따로 만들었습니다. Redis 컨테이너를 정지(stop)하면 연결 거부만 재현되고, 한 번 정지하면 되돌리기 어려워 테스트 순서에 의존하게 됩니다. 무응답은 docker pause 로도 만들 수 있지만 이번에는 검토하지 않았습니다.
테스트 설정에 함정이 있었습니다. Redis 컨테이너에 Spring Boot 의 @ServiceConnection 을 붙이면 애플리케이션이 컨테이너 포트로 직접 연결해 프록시를 거치지 않습니다. 그러면 장애를 아무리 주입해도 애플리케이션은 정상 Redis 와 통신합니다. 재현 실험에서 당시 테스트의 Redis 컨테이너에 @ServiceConnection 만 붙여 다시 돌려 보니, 프록시 주소를 넣은 @DynamicPropertySource 가 있어도 애플리케이션은 컨테이너 포트로 연결했습니다. 장애 주입 테스트 7건 중 HTTP 응답이 200 인지만 보는 테스트와 Redis 가 정상인 대조군은 통과했고, fallback 카운터가 늘었는지까지 보는 나머지 5건은 실패했습니다. 응답만 확인하는 테스트였다면 우회를 알아채지 못했을 것입니다. 그래서 Redis 에는 @ServiceConnection 을 붙이지 않고 프록시 주소를 직접 연결했습니다. 그리고 첫 번째 테스트에서 “프록시를 끄면 조회 경로가 영향을 받는가”를 먼저 확인했습니다. 이 테스트가 실패하면 나머지 테스트의 결과도 믿을 수 없습니다.
프록시를 껐는데 Redis 가 멀쩡하다면
장애 주입 테스트의 연결 방식을 고르고 프록시를 꺼 보세요. 조회 요청이 어느 길로 가는지, 아래 두 테스트 결과가 어떻게 되는지 보세요.
- 첫 번째 테스트: 프록시를 끄면 조회 경로가 영향을 받는가대기
- 장애 주입 테스트: Redis 장애에도 200 으로 응답하는가대기
연결 거부를 프록시 끄기(disable())로 주입하는 경우만 그렸고, 무응답(timeout toxic)은 생략했습니다. "첫 번째 테스트"는 다른 테스트의 전제를 확인하려고 맨 앞에 둔 테스트이고, @ServiceConnection 을 붙인 상태에서는 이 테스트가 실패해 우회를 알려 줍니다. 재현 실험에서 Redis 에 @ServiceConnection 을 붙여 돌리면 응답이 200 인지만 보는 테스트(위 두 번째 줄)는 통과했고, fallback 카운터까지 보는 테스트는 첫 번째 테스트와 함께 실패했습니다.
돌아보며
여기부터는 작업이 끝난 뒤에 결과와 재현 실험을 놓고 다시 본 내용입니다. 아래 대안들은 작업 중에는 검토하지 않았습니다.
한 엔드포인트를 고쳐도 같은 구조의 API 는 남아 있었습니다
주문 목록의 정렬은 이제 그 컨트롤러 안에서 고정돼 있습니다. 질의에 ORDER BY 를 적고 요청 객체가 sort 를 거부합니다. 하지만 같은 서비스들 안에 같은 구조의 API 가 하나 더 있었습니다. 상품 목록은 지금도 @PageableDefault(size = 20) 에 정렬이 없습니다. 캐시 키를 만드는 코드는 컨트롤러가 고정 정렬만 쓴다고 가정하지만 그런 고정 정렬은 코드에 없습니다.
이 API 에는 주문 목록에 없던 문제가 하나 더 있었습니다. 상품 목록 응답은 Redis 에 캐시되는데 캐시 키가 카테고리, 페이지 번호, 페이지 크기만으로 만들어지고 정렬은 들어가지 않습니다. 재현 실험에서는 현재 코드에 @SpringBootTest 를 두고 MySQL, Redis, Kafka 를 Testcontainers 로 띄워 돌려 보았습니다. 캐시를 비운 상태에서 누군가 ?sort=price,desc 로 요청하면 그 결과가 공용 키에 저장됐고 그 뒤 정렬 없이 요청한 사람도 가격 내림차순 목록을 받았습니다. 순서를 반대로 하면 정렬을 요청한 사람이 정렬되지 않은 목록을 받았습니다. 목록 캐시의 TTL 이 10분이니, 한 사람의 요청이 최대 10분 동안 다른 사람의 응답 순서를 정합니다.
다르게 할 수 있었던 방법이 두 가지 있습니다. 하나는 응답을 바꾸는 요청 값을 모두 캐시 키에 넣는 것입니다. 정렬을 받는다면 키에 넣어야 하고 넣지 않으려면 정렬을 받지 않아야 합니다. 고정 정렬을 가정하는 것만으로는 둘 중 어느 쪽도 지켜지지 않았습니다. 다른 하나는 정렬 규칙을 엔드포인트마다 정하지 않고 요청 파라미터를 해석하는 단계에서 한 번에 정하는 것입니다. Spring Data 는 요청의 sort 를 허용 목록 없이 받아들이지만 그 해석기를 바꿀 수 있는 확장 지점(SortHandlerMethodArgumentResolverCustomizer, PageableHandlerMethodArgumentResolverCustomizer)을 제공합니다. 외부에 공개하는 API 가 정렬을 클라이언트에게 맡기지 않는다면 그 규칙을 한곳에 둘 수 있습니다. 다만 해 보지 않은 방법이라 어떤 형태가 맞는지까지는 말할 수 없습니다. 상품 목록의 문제는 후속 작업으로 남겼습니다.
catch-all 핸들러를 한곳에서 고치는 방법
잘못된 입력이 500 이 되는 문제는 주문 목록에서만 우회했습니다. size 를 문자열로 받아 직접 바꾸고 범위를 직접 검사했습니다. 전역 예외 핸들러는 공통 모듈이라 수정 범위에서 뺐는데 우회는 그 API 하나에만 적용됩니다. 같은 핸들러를 쓰는 다른 컨트롤러는 그대로여서 /api/v1/products/abc 는 지금도 500 입니다.
@ExceptionHandler(Exception.class) 같은 catch-all 핸들러는 예상하지 못한 예외를 일관된 형식으로 돌려주려고 둡니다. 이 핸들러는 Spring MVC 가 400 으로 바꿔 주던 프레임워크 예외까지 먼저 받습니다. catch-all 핸들러를 두면 프레임워크가 기본으로 해 주던 처리를 직접 다시 만들어야 합니다. 타입 변환 실패와 메서드 파라미터 검증 실패를 처리하는 메서드 두 개를 전역 예외 핸들러에 더하는 편이 전체로는 작은 변경이었을 것으로 보지만 직접 해 보지는 않았습니다. 이것도 후속 작업으로 남겼습니다.
커서 조건을 어떻게 쓰느냐에 따라 실행계획이 달라집니다
(ordered_at, id) < (?, ?) 는 ordered_at < ? OR (ordered_at = ? AND id < ?) 보다 짧고 의도도 잘 드러납니다. 하지만 MySQL 8.0 에서 두 조건은 결과가 같아도 실행이 달랐습니다. 행 생성자 쪽은 인덱스를 user_id 까지만 쓰고 그 사용자의 주문을 앞에서부터 읽어 내려가며 걸러 냅니다. 재현 실험에서는 커서가 offset 과 같은 수의 행을 읽었습니다. MySQL 8.0 은 행 생성자의 컬럼이 인덱스 앞부분 컬럼과 맞지 않으면 인덱스를 덜 쓸 수 있고, 같은 조건을 OR 로 풀어 쓰면 인덱스를 더 쓸 수 있습니다(Row Constructor Expression Optimization).
이번 작업은 처음부터 OR 로 풀어 썼고 실행계획 테스트가 used_key_parts 와 읽은 행 수를 확인하고 있어 행 생성자로 바뀌면 실패합니다. 결과적으로 문제는 없었지만 조건을 쓸 때는 이 차이를 모른 채 골랐습니다. 커서 조건을 다른 데이터베이스로 옮기거나 리팩터링할 때는 결과가 같은지와 함께 실행계획도 다시 확인해야 합니다. 다른 데이터베이스에서 행 생성자 비교가 인덱스를 어떻게 쓰는지는 이번에 확인하지 않았습니다.
Spring Data 의 Slice 와 keyset 스크롤
이번에는 커서 질의와 한 건 더 읽어 다음 페이지가 있는지 확인하는 코드를 직접 작성했습니다. Spring Data 에는 다음 페이지가 있는지만 알려 주는 Slice 반환 타입과, Spring Data 3.1 부터 들어온 keyset 스크롤(ScrollPosition.keyset(), Window<T>)이 있습니다. 이번 작업에서는 이 둘을 검토하지 않았습니다. keyset 스크롤을 쓴다면 Spring Data 가 만드는 커서 조건이 OR 로 풀어 쓴 형태인지 행 생성자 형태인지에 따라 앞에서 본 실행계획 차이가 나타날 수 있으므로, 쓰기 전에 실제 SQL 과 EXPLAIN 을 확인해야 합니다.
그 밖에
캐시 예외의 허용 범위는 네 번 고쳤습니다. 매번 눈앞에 드러난 사례 하나만 고쳤기 때문입니다. 예외 타입처럼 하나의 계층 안에서 범위를 정하는 문제라면, 첫 수정 전에 그 계층(여기서는 Lettuce 예외 계층)을 끝까지 확인하는 편이 빨랐을 것입니다. 그리고 DATETIME 의 상한과 CREATE INDEX 문법처럼 매뉴얼을 보거나 계획 단계에서 맞다고 본 사실이 실행해 보니 틀렸습니다. 재현 실험에서 이런 확인은 컨테이너 하나를 띄우고 SQL 몇 줄을 실행하는 것으로 끝났습니다.
기본값이 정하던 것
두 작업에서 코드가 정하지 않은 동작을 프레임워크와 라이브러리의 기본값이 정하던 곳을 모으면 다음과 같습니다. Spring Data Commons 3.5.10, Spring Framework 6.2.17, lettuce-core 6.6.0, Spring Cloud Gateway 4.3.0, MySQL 8.0.46 기준입니다.
| 항목 | 정하지 않았을 때 | 이 프로젝트가 정한 것 |
|---|---|---|
| 목록 정렬 | SQL 에 ORDER BY 가 없고 실행계획이 고른 인덱스 순서로 응답 | 질의에 ORDER BY ordered_at DESC, id DESC |
요청의 sort 파라미터 | 허용 목록 없이 엔티티의 아무 필드로나 정렬, 없는 필드면 예외 | sort 가 오면 400 |
| 정렬 키 동률 | 동률 행의 순서가 실행계획에 따라 달라져 offset 페이지 사이에서 빠지거나 겹침 | 유일한 id 로 동률을 끊고 같은 순서로 커서 조건 작성 |
| 프레임워크의 입력 오류 | catch-all 핸들러가 먼저 받아 500 | 요청 객체에서 직접 검사해 400 |
| 캐시 오류 | SimpleCacheErrorHandler 가 예외를 다시 던져 500 | CachingConfigurer 로 등록한 핸들러가 가용성 예외만 캐시 미스로 처리 |
| Redis 명령 타임아웃 | Lettuce 기본 60초 | 명령 500ms, 연결 300ms |
| 게이트웨이 rate limit 의 Redis 오류 | 기본 RedisRateLimiter 는 요청을 허용 | 요청 거부(fail-closed) |
정리하며
주문 목록 API 는 순서를 정하지 않은 상태였고, 정렬을 선언해도 정렬 키가 유일하지 않으면 offset 페이지 사이에서 행이 빠지거나 겹쳤습니다. (ordered_at, id) 로 정렬하고 같은 순서를 커서 조건으로 옮기자 동률에서도 누락과 중복이 없었고, InnoDB 세컨더리 인덱스에 기본 키가 함께 저장돼 인덱스에는 두 컬럼만 적으면 됐습니다. 900번째 위치에서 읽는 행은 20 이었습니다(offset 은 920). 상품 캐시에서는 에러 핸들러를 CachingConfigurer 로 등록해야 했고, 실패한 연산마다 결과가 달랐으며, 어떤 예외를 캐시 미스로 넘길지는 예외 계층을 끝까지 확인한 뒤에 정했습니다.
두 작업 모두 결함을 일부러 넣었을 때 테스트가 실패하는지를 검증의 기준으로 삼았습니다. 실행계획 테스트가 운영 SQL 을 재지 않고 테스트 안의 SQL 을 재면 결과는 같고 인덱스만 덜 쓰는 회귀를 놓쳤습니다. 구현 세부까지 고정한 테스트는 정상적인 변경에서도 깨질 수 있었습니다.
남겨 둔 것도 있습니다.
- 상품 목록은 지금도 정렬 규칙이 없고 캐시 키에 정렬이 빠져 있어 한 요청의 정렬이 다른 요청의 응답에 섞입니다.
- 전역 예외 핸들러는 타입 변환 실패와 메서드 파라미터 검증 실패를 여전히 500 으로 돌려줍니다. 주문 목록만 우회했습니다.
- Redis 장애 중 동시 요청이 한꺼번에 DB 로 몰리는 것(cache stampede)을 막는 장치는 없습니다. 허용할 동시성과 응답 목표를 먼저 정해야 설계할 수 있다고 보고 카운터로 감시하는 데까지만 했습니다.
- 캐시 fallback 카운터에는 alert 가 없습니다. 조회 fallback 이 10분 계속되면 Redis 장애로 보고 무효화 fallback 이 한 번이라도 오르면 바로 확인한다는 수동 감시 기준만 있습니다.
@CacheEvict와 트랜잭션 커밋의 순서는 검토하지 않았습니다. 무효화가 커밋보다 먼저 실행되면 그 사이에 다른 요청이 이전 값을 다시 캐시에 적재할 수 있습니다.RedisCacheManager의transactionAware()같은 설정이 이 문제와 관련이 있습니다.- 배포 중에는 잠시 구 버전과 새 버전의 응답 형식이 함께 노출됩니다. 이 API 를 쓰는 클라이언트가 확인되지 않아 감수했습니다.
- **무응답 Redis 앞에서의 응답 시간 상한(1.5초)**은 로컬 Docker 에서 잰 값이고 클러스터에서는 재지 않았습니다.
이 글에서 “동작한다”고 쓴 것은 모두 테스트와 재현 실험의 범위 안에서입니다. 운영 트래픽이 없는 프로젝트라 실제 사용자가 페이지를 넘기거나 Redis 가 실제로 죽은 적은 없습니다.