[사이드 프로젝트] QueryDSL과 타입 안정성을 고려한 커서 설계기

개요

이 글은 이전 글 "정렬 하나에도 설계가 들어간다"의 연장선이다.

 

Offset 기반 정렬 구조에서 성능 한계와 유지보수성 문제를 마주했고,
이를 개선하기 위해 Keyset 기반 커서 방식으로 구조를 리팩토링하게 되었다.

특히 QueryDSL 기반의 동적 쿼리 생성
타입 안정성을 보장하는 범용 커서 설계를 어떻게 구현할 수 있을지에 대해 집중했다.

 

이 과정에서 필자는 스스로에게 다음과 같은 질문을 던지게 되었다:

  • 다중 정렬이 필요하다면, 커서 값은 어떻게 쿼리에 반영되어야 할까?
  • 커서 값이 동일한 데이터가 여러 개인 경우, 중복 조회 문제는 어떻게 해결할 수 있을까?
  • 커서 방식의 동적 쿼리를 어떤 책임 단위로 나누는 것이 좋을까?
  • QueryDSL 쿼리 구조와 정렬 기준을 어떻게 추상화할 수 있을까?
  • 커서 타입이 다를 때에도 공통 구조로 표현하기 위한 타입 일치는 어떻게 설계해야 할까?

이 글은
이러한 질문들을 실제 구현 과정을 통해 어떻게 하나씩 해결해 나갔는지,

그리고 그 과정에서 설계한
CursorField, CursorPathType, Cursor, CursorUtils 구조를 중심으로
QueryDSL 기반 커서 페이징 구조를 범용적이고 안정적으로 설계하는 방법을 정리한 글이다.

 

 

 

설계된 전체 구조를 먼저 설명한다

Keyset 기반 커서 방식은 기존 Offset 구조보다  
더 많은 설계 요소와 컴포넌트가 필요했다.

복잡한 구현 흐름을 읽기 전에  
전체 구조를 먼저 한눈에 정리하면  
각 파트의 역할과 관계를 이해하는 데 도움이 될 것 같아  
설계된 구조를 먼저 소개하려 한다.

 

📌 CursorField 

커서 필드 그 자체를 뜻 할 CusorField는 다음과 같은 역할을 맡는다

  • 엔티티의 필드 이름 (fieldName)
  • 엔티티의 필드 타입 (fieldType)
  • QClass 기준 필드의 패스 타입 (pathType)
  • 문자열 값을 엔티티 필드 타입으로 변환 (parseCursor)
  • 다음 커서 기준 값을 문자열 타입으로 변환 (resolveNextCursorValue)
public interface CursorField<T> {
    String fieldName();
    
    Class<? extends Comparable> fieldType();
    
    CursorPathType pathType();
    
    Comparable parseCursor(String stringValue);
    
    String resolveNextCursorValue(T lastRow);
}

구현체는 기본적으로 enum이며,

아래는 admin 모듈에서 Terms 도메인에 특화된 커서 필드를 나타낸다. 

public enum AdminTermsCursorField implements CursorField<AdminTermsDto> {
     EXPOSED_FROM("exposedFrom", LocalDateTime.class, CursorPathType.DATE_TIME), // 노출 시작일
     EXPOSED_TO("exposedToOrNull", LocalDateTime.class, CursorPathType.DATE_TIME), // 노출 종료일
     CREATED_AT("createdAt", LocalDateTime.class, CursorPathType.DATE_TIME), // 생성일
     TITLE("title", String.class, CursorPathType.STRING), // 제목
     ID("id", Long.class, CursorPathType.NUMBER); // ID;

     private final String fieldName;
     private final Class<? extends Comparable> fieldType;
     private final CursorPathType cursorPathType;

     AdminTermsCursorField(String fieldName, Class<? extends Comparable> fieldType, CursorPathType cursorPathType) {
          this.fieldName = fieldName;
          this.fieldType = fieldType;
          this.cursorPathType = cursorPathType;
     }

     @Override
     public String fieldName() {
          return fieldName;
     }

     @Override
     public Class<? extends Comparable> fieldType() {
          return fieldType;
     }

     @Override
     public CursorPathType pathType() {
          return this.cursorPathType;
     }

     public Comparable parseCursor(String rawCursor) {
          if (rawCursor == null) {
               return null;
          }
          if (this.fieldType == String.class) {
               return rawCursor;
          }
          if (this.fieldType == LocalDateTime.class) {
               return LocalDateTime.parse(rawCursor);
          }
          if (this.fieldType == Long.class) {
               return Long.parseLong(rawCursor);
          }
          throw ErrorCode.CONFLICT.exception("지원하지 않는 커서 타입입니다.");
     }

     public String resolveNextCursorValue(AdminTermsDto lastRow) {
          switch (this) {
               case EXPOSED_FROM -> {
                    return lastRow.getExposedFrom().toString();
               }
               case TITLE -> {
                    return lastRow.getTitle();
               }
               case ID -> {
                    return String.valueOf(lastRow.getId());
               }
               case CREATED_AT -> {
                   return lastRow.getCreatedAt().toString();
               }
               case EXPOSED_TO -> {
                   return lastRow.getExposedToOrNull() != null ? lastRow.getExposedToOrNull().toString() : null;
               }
          }
          throw ErrorCode.CONFLICT.exception("지원하지 않는 커서 타입입니다.");
     }
}

 

📌 Cursor

커서 그 자체라고 볼 수 있는 Cursor는 다음과 같은 역할을 맡는다

  • 커서 필드 (cursorField)
  • 커서 값 (value)
  • 커서 방향 (direction)
public interface Cursor {
    CursorField cursorField();
    
    String value();
    
    Direction direction();
}

구현체는 기본적으로 record이며,
아래는 admin 모듈에서 Terms 도메인에 특화된 커서를 나타낸다.

public record AdminTermsCursor(
    @Nonnull AdminTermsCursorField cursorField,
    @Nonnull Direction direction,
    @Nullable String value
) implements Cursor {
}

 

📌 CursorPathType

CursorPathType은 QueryDSL 구조상, 커서가 될 수 있는 PathType을 규정하는 역할을 한다.

커서는 기본적으로 WHERE 조건에 `> 또는 <`로 값을 비교할 수 있는 타입 이어야 하기 때문이다.

후에 자세히 설명할 예정이나, 먼저 아래와 같이 역할을 정리한다

  • 다중 정렬이 필요할 때, 앞선 커서(정렬 기준)의 쿼리 WHERE 조건을 반환한다 (getBeforeCursorExpression)
  • 현재 커서의 쿼리 WHERE 조건을 반환한다 (getCurrentCursorExpression)
  • 커서의 쿼리 Order 조건을 반환한다 (getOrderSpecifier)
public enum CursorPathType {
    BOOLEAN,
    DATE_TIME,
    STRING,
    NUMBER,
    COMPARABLE,
    ENUM,
    DATE;

    public BooleanExpression getBeforeCursorExpression(PathBuilder pathBuilder, Cursor cursor) {
        CursorField cursorField = cursor.cursorField();
        String propertyName = cursorField.fieldName();
        Class<? extends Comparable> propertyClass = cursorField.fieldType();
        Comparable propertyValue = cursorField.parseCursor(cursor.value());

        switch (this) {
            case DATE_TIME -> {
                return pathBuilder.getDateTime(propertyName, propertyClass).eq(propertyValue);
            }
            case STRING -> {
                return pathBuilder.getString(propertyName).eq((Expression<String>)propertyValue);
            }
            case NUMBER -> {
                return pathBuilder.getNumber(propertyName, propertyClass).eq(propertyValue);
            }
            case COMPARABLE -> {
                return pathBuilder.getComparable(propertyName, propertyClass).eq(propertyValue);
            }
            case ENUM -> {
                return pathBuilder.getEnum(propertyName, propertyClass).loe(propertyValue);
            }
            case DATE -> {
                return pathBuilder.getDate(propertyName, propertyClass).loe(propertyValue);
            }
            case BOOLEAN -> {
                return pathBuilder.getBoolean(propertyName).loe((Expression<Boolean>)propertyValue);
            }
            default -> throw ErrorCode.INTERNAL_SERVER_ERROR.exception("커서 타입이 잘못되었습니다.");
        }
    }

    public BooleanExpression getCurrentCursorExpression(PathBuilder pathBuilder, Cursor cursor) {
        CursorField cursorField = cursor.cursorField();
        String propertyName = cursorField.fieldName();
        Class<? extends Comparable> propertyClass = cursorField.fieldType();
        Comparable propertyValue = cursorField.parseCursor(cursor.value());
        Direction direction = cursor.direction();

        switch (this) {
            case DATE_TIME -> {
                return direction.isAscending() ?
                    pathBuilder.getDateTime(propertyName, propertyClass).goe(propertyValue) :
                    pathBuilder.getDateTime(propertyName, propertyClass).loe(propertyValue);
            }
            case STRING -> {
                return direction.isAscending() ?
                    pathBuilder.getString(propertyName).goe((Expression<String>)propertyValue) :
                    pathBuilder.getString(propertyName).loe((Expression<String>)propertyValue);
            }
            case NUMBER -> {
                return direction.isAscending() ?
                    pathBuilder.getNumber(propertyName, propertyClass).goe((Number)propertyValue) :
                    pathBuilder.getNumber(propertyName, propertyClass).loe((Number)propertyValue);
            }
            case COMPARABLE -> {
                return direction.isAscending() ?
                    pathBuilder.getComparable(propertyName, propertyClass).goe(propertyValue) :
                    pathBuilder.getComparable(propertyName, propertyClass).loe(propertyValue);
            }
            case ENUM -> {
                return direction.isAscending() ?
                    pathBuilder.getEnum(propertyName, propertyClass).goe(propertyValue) :
                    pathBuilder.getEnum(propertyName, propertyClass).loe(propertyValue);
            }
            case DATE -> {
                return direction.isAscending() ?
                    pathBuilder.getDate(propertyName, propertyClass).goe(propertyValue) :
                    pathBuilder.getDate(propertyName, propertyClass).loe(propertyValue);
            }
            case BOOLEAN -> {
                return direction.isAscending() ?
                    pathBuilder.getBoolean(propertyName).goe((Expression<Boolean>)propertyValue) :
                    pathBuilder.getBoolean(propertyName).loe((Expression<Boolean>)propertyValue);
            }
            default -> throw ErrorCode.INTERNAL_SERVER_ERROR.exception("커서 타입이 잘못되었습니다.");
        }
    }

    public OrderSpecifier getOrderSpecifier(PathBuilder pathBuilder, Cursor cursor) {
        CursorField cursorField = cursor.cursorField();
        String propertyName = cursorField.fieldName();
        Class<? extends Comparable> propertyClass = cursorField.fieldType();
        Direction direction = cursor.direction();

        switch (this) {
            case DATE_TIME -> {
                return direction.isAscending() ?
                    pathBuilder.getDateTime(propertyName, propertyClass).asc() :
                    pathBuilder.getDateTime(propertyName, propertyClass).desc();
            }
            case STRING -> {
                return direction.isAscending() ?
                    pathBuilder.getString(propertyName).asc() :
                    pathBuilder.getString(propertyName).desc();
            }
            case NUMBER -> {
                return direction.isAscending() ?
                    pathBuilder.getNumber(propertyName, propertyClass).asc() :
                    pathBuilder.getNumber(propertyName, propertyClass).desc();
            }
            case COMPARABLE -> {
                return direction.isAscending() ?
                    pathBuilder.getComparable(propertyName, propertyClass).asc() :
                    pathBuilder.getComparable(propertyName, propertyClass).desc();
            }
            case ENUM -> {
                return direction.isAscending() ?
                    pathBuilder.getEnum(propertyName, propertyClass).asc() :
                    pathBuilder.getEnum(propertyName, propertyClass).desc();
            }
            case DATE -> {
                return direction.isAscending() ?
                    pathBuilder.getDate(propertyName, propertyClass).asc() :
                    pathBuilder.getDate(propertyName, propertyClass).desc();
            }
            case BOOLEAN -> {
                return direction.isAscending() ?
                    pathBuilder.getBoolean(propertyName).asc() :
                    pathBuilder.getBoolean(propertyName).desc();
            }
            default -> throw ErrorCode.INTERNAL_SERVER_ERROR.exception("커서 타입이 잘못되었습니다.");
        }
    }
}

 

📌 CursorUtils

도메인별 특화된 전체 커서 쿼리를 만드는 커서 유틸이다.

  • 전체 커서의 WHERE 조건을 반환한다 (getCursorPredicate)
  • 전체 커서의 Order 조건을 반환한다 (getOrderSpecifiers)
public class CursorUtils {
    public static <T> BooleanBuilder getCursorPredicate(List<? extends Cursor> cursors, Class<T> domainClass,
        String alias) {
        BooleanBuilder cursorPredicate = new BooleanBuilder();
        PathBuilder<T> pathBuilder = new PathBuilder<>(domainClass, alias);

        int cursorCount = cursors.size();
        for (int i = 0; i < cursorCount; i++) {
            Cursor currentCursor = cursors.get(i);
            if (currentCursor.value() == null) {
                continue;
            }
            BooleanBuilder addCursorPredicate = new BooleanBuilder();

            for (int j = 0; j < i; j++) {
                // 이전 커서 조건 eq 반복
                Cursor beforeCursor = cursors.get(j);
                if (beforeCursor.value() == null) {
                    continue;
                }
                CursorPathType pathType = beforeCursor.cursorField().pathType();
                BooleanExpression beforeCursorExpression = pathType.getBeforeCursorExpression(pathBuilder,
                    beforeCursor);

                addCursorPredicate.and(beforeCursorExpression);
            }

            // 현재 커서 조건 설정
            CursorPathType pathType = currentCursor.cursorField().pathType();
            BooleanExpression currentCursorExpression = pathType.getCurrentCursorExpression(pathBuilder, currentCursor);

            addCursorPredicate.and(currentCursorExpression);

            // 커서 조건 하나 세팅
            cursorPredicate.or(addCursorPredicate);
        }

        return cursorPredicate;
    }

    public static <T> List<OrderSpecifier> getOrderSpecifiers(List<? extends Cursor> cursors,
        Class<T> domainClass,
        String alias) {
        PathBuilder<T> pathBuilder = new PathBuilder<>(domainClass, alias);
        return cursors.stream()
            .map(cursor -> cursor.cursorField().pathType().getOrderSpecifier(pathBuilder, cursor))
            .toList();
    }
}

 

📌 설계 활용 예시

위와 같은 설계로 모듈별, 도메인에 특화된 커서 기능을 다음과 같이 범용적으로 사용할 수 있다

public KeysetPage<AdminTermsDto, AdminTermsCursor> findTermsByKeysetCondition(AdminTermsKeysetQueryCondition condition) {
    // 커서 조건을 위한 빌더
    BooleanBuilder cursorPredicate = CursorUtils.getCursorPredicate(condition.cursors(), QTerms.class, "terms");

    // 커서 외 조건을 위한 빌더
    BooleanBuilder builder = new BooleanBuilder();
    // 약관 코드로 필터링
    if (condition.code() != null) {
      builder.and(terms.code.eq(condition.code()));
    }

    // 최신 버전만 필터링
    if (!condition.includeAllVersions()) {
      builder.and(terms.isLatest.isTrue());
    }
    // Where 조건 완료
    builder.and(cursorPredicate);
    
    List<OrderSpecifier> orderSpecifiers = CursorUtils.getOrderSpecifiers(condition.cursors(), QTerms.class,
      "terms");

    int size = condition.size();
    // 데이터 조회
    List<AdminTermsDto> results = queryFactory
      .select(new QAdminTermsDto(
        terms.id,
        terms.code,
        terms.title,
        terms.type,
        terms.status,
        terms.version,
        terms.isLatest,
        terms.exposedFrom,
        terms.exposedToOrNull,
        terms.displayOrder,
        terms.createdAt
      ))
      .from(terms)
      .where(builder)
      .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
      .limit(size + 1)
      .fetch();

    boolean hasNext = results.size() > size;
    AdminTermsDto lastRow = hasNext
      ? results.remove(results.size() - 1)
      : null;

    // 다음 커서 기준 값 세팅
    List<AdminTermsCursor> nextCursors = lastRow != null ? condition.cursors().stream()
      .map(cursor -> new AdminTermsCursor(cursor.cursorField(), cursor.direction(),
        cursor.cursorField().resolveNextCursorValue(lastRow))).toList() : null;

    return new KeysetPage<>(
      results,
      hasNext,
      hasNext ? nextCursors : null
    );
  }

 

 

 

 

CursorField 설계기 — 왜 enum이고, 왜 제네릭인가?

📌 왜 enum인가?

CursorField를 enum으로 구현한 이유는 단순하다.
필자가 정의한 커서 설계 요구사항에 가장 적합한 구조였기 때문이다.

 

사용자 편의에 따라 여러 필드를 커서(정렬) 기준으로 사용할 수 있어야 했고,
도메인마다 커서 가능한 필드가 다르며,
같은 도메인이라도 모듈(admin, customer 등)에 따라 노출 필드가 달라질 수 있었다.

 

초기에는 커서 가능한 필드를 DTO의 각 필드로 표현하려 했지만,
그럴 경우 다음과 같은 문제가 발생했다:

  • 커서 필드가 많아질수록 null을 허용해야 할 필드가 늘어남
  • 커서 필드가 변경될 경우, DTO마다 필드 수정이 필요함
  • 레이어/모듈별로 DTO 수가 많을수록 유지보수성 저하

 

또한 문자열 List로 커서 필드를 관리할 경우에도,
다음과 같은 문제들이 존재했다:

  • 커서 가능한 필드명을 문자열로 직접 비교해야 함
  • 쿼리 생성 시 필드명이 잘못되면 런타임 오류 발생
  • 필드 수정 시 일관성 있는 변경이 어려움

 

그래서 필자는 enum 기반 구조를 선택했다.

  • 요청 시점에서 enum으로 허용 가능한 커서 필드를 제한
  • 필드 수정 시 DTO가 아닌 enum만 수정하면 됨
  • 정렬 기준 필드를 한 곳에서 응집력 있게 관리 가능

 

📌 왜 단순한 enum으로 끝나지 않았는가?

CursorField는 단순히 fieldName만 관리하는 것이 아니라,
다음과 같은 정렬 필드 전체에 대한 메타 정보를 함께 관리한다:

  • 필드 이름 (fieldName)
  • 엔티티 필드의 타입 (fieldType)
  • QClass 기준 Path 타입 (CursorPathType)

이를 통해 CursorField는
"정렬 기준" 그 자체를 표현하는 책임을 갖는다.

 

📌 파싱 + 다음 커서 추출 책임까지?

정렬 기준 필드로 사용되기 위해서는
문자열로 넘어온 커서 값을 → 실제 타입으로 파싱 할 수 있어야 하고
또한 다음 페이지 커서 생성을 위해 → 쿼리 결과에서 값을 문자열로 다시 변환해야 한다.

그래서 CursorField는 다음 메서드를 갖게 된다:

  • parseCursor(String) → 문자열 커서 → 실제 필드 타입
  • resolveNextCursorValue(T) → 마지막 row → 커서 문자열

 

📌 왜 제네릭이 필요한가?

resolveNextCursorValue()에서 쿼리 결과를 넘겨받아
해당 필드의 값을 추출하기 위해선,
도메인마다 row 객체의 타입이 명확해야 한다.

이 책임을 enum 클래스에서 명확하게 보장하기 위해
CursorField<T>로 제네릭을 선언했고,
컴파일 타임부터 각 도메인 DTO를 강제할 수 있도록 설계했다.

 


 

 

CursorField는 단순히 정렬 기준 필드를 나열하는 enum이 아니다.
도메인 수준의 안전성과 책임 분리를 설계한 커서 설계의 핵심 축이며,
다음의 책임을 명확히 갖는다:

  • 커서로 사용 가능한 필드 식별
  • 쿼리용 파싱 및 추출 책임
  • 도메인 DTO와의 강결합을 안전하게 분리

 

이젠 이 CursorField가 어떻게 QueryDSL의 쿼리와 연결되었는지를

CursorPathType을 통해 살펴보자.

 

 

 

 

CursorPathType: 왜 따로 분리했나? PathBuilder의 역할

CursorPathType은 QueryDSL과 매우 밀접한 컴포넌트다.
구체적으로는, QueryDSL에서 동적 쿼리를 생성할 때 필드 타입별로 어떤 Path를 선택해야 하는지를 결정하는 책임을 갖는다.

 

📌 PathType이란 무엇인가?

QueryDSL은 엔티티 클래스 기준으로 QClass를 자동 생성하며,
이때 각 필드는 해당 타입에 따라 다음과 같은 Path 타입으로 매핑된다:

  • String → StringPath
  • LocalDateTime → DateTimePath
  • Long → NumberPath

이러한 Path 타입의 분류를 `PathType`이라고 부른다.
그리고 CursorPathType은 커서 페이징에서 사용할 수 있는 필드 타입만 추려낸 Enum이다.

 

📌 "그냥 QClass 쓰면 되는 거 아닌가요?"

QueryDSL에서 동적 쿼리를 만들 때 가장 직관적인 방법은
QClass의 필드에 직접 접근하는 것이다.

하지만 이 구조는 다음과 같은 한계를 갖는다:

  • 각 도메인마다 QClass가 다르기 때문에 코드 재사용이 어려움
  • QClass는 컴파일 타임에 생성되므로 범용 추상화가 불가능
  • 공통 구조를 정의하려면 QClass가 아닌 다른 수단이 필요

 

📌 그래서 등장한 PathBuilder

필자는 PathBuilder를 통해 QClass의 필드를 간접적으로 표현하는 방법을 선택했다.

new PathBuilder<>(Terms.class, "terms") .getDateTime("createdAt", LocalDateTime.class)
 

위와 같은 방식으로
문자열 기반 필드명을 받아 동적으로 Path 객체를 생성할 수 있게 되었고,
QueryDSL 쿼리에서도 안전하게 활용할 수 있었다.

 

📌 "그럼 CursorField가 QClass 필드를 들고 있으면 되는 거 아냐?"

정말 좋은 질문이다.

사실 필자도 처음에는 그렇게 설계하려 했다.

CursorField(enum)가 QClass 필드 정보를 함께 갖고 있으면
직접 쿼리 생성까지 가능했을 것이다.

그러나 멀티 모듈 구조에서 이는 구조적으로 불가능했다.

 

📌 멀티 모듈 구조에서 발생한 제약

  • common 모듈: 엔티티, QClass 소스 존재
  • common-api: 커서 필드 enum 정의
  • customer, admin 등 API 모듈: 기능 별도 정의

이 상황에서 common-api가 QClass를 참조하게 되면,
의존성 순환 문제가 발생한다.

즉, 설계상 CursorField는 QClass를 알 수 없도록 되어 있는 구조다.

 

 

📌 그래서 선택한 구조

  • CursorField는 단지 필드명 + 타입 + PathType + 파싱 책임만 갖고
  • CursorPathType이 PathBuilder를 통해 실제 QueryDSL 쿼리를 생성하도록 분리했다

이렇게 함으로써,
CursorField는 도메인 추상화에 집중하고,
CursorPathType은 QueryDSL 추상화에 집중하는 구조
가 만들어졌다.

 

📌 CursorPathType이 쿼리를 생성하는 이유

QueryDSL의 PathBuilder는 필드명만 가지고는 쿼리를 만들 수 없다.
타입에 맞는 .getDateTime, .getNumber, .getString 등 정확한 메서드를 호출해야 한다.

제네릭으로 메서드 추론이 가능할까 고민도 했지만,
Java의 타입 시스템상 런타임에 타입 분기가 필요한 상황에서는
Enum + switch 분기가 가장 명확하고 안전한 방식
이라는 결론에 도달했다.

 


 

 

CursorPathType은 단순한 타입 Enum이 아니다.
도메인 추상화(CursorField)와 쿼리 추상화(QueryDSL PathBuilder) 사이의 연결 고리이자,
QueryDSL 쿼리 생성을 책임지는 실행 주체
다.

그리고 이 분리를 통해
멀티 모듈 구조와 QueryDSL 기반 쿼리 생성을 동시에 만족시키는 구조가 완성될 수 있었다.

 

 

 

 

Cursor: 단순한 구조지만, 왜 따로 뒀는가? DTO 설계와의 관계

커서 방식으로 조회 기능을 구현하려면
단순히 정렬 기준 필드(CursorField)만 필요한 것이 아니다.

각 커서에는 정렬 기준 값과 정렬 방향도 함께 있어야 한다.

즉, 커서 = 정렬 필드 + 기준 값 + 방향의 구조를 갖는다.


그래서 필자는 이 세 요소를 하나의 객체로 표현하기 위해
Cursor라는 별도 구조를 설계하게 되었다.

 

📌 "CursorField에 값과 방향을 포함하면 되는 거 아닌가요?"

이 질문은 매우 타당하다.

처음에는 CursorField에 값과 방향을 포함시키는 것도 고려했었다.

그러나 그렇게 하지 않은 이유는 다음과 같다:

  1. CursorField는 enum으로 관리되어야 한다
    → 정렬 가능한 필드를 미리 제한하고 컴파일 타임에 검증하기 위함
  2. Cursor 값은 사용자 요청마다 달라지는 동적 데이터다
    → enum처럼 정적 구조 안에서 다룰 수 없음

 

📌 enum에 상태를 두면 어떤 문제가 발생하나?

  • Java의 enum은 싱글톤이다
  • 즉, enum 안에 상태를 넣으면 모든 요청에서 공유됨 (스레드 안전성 깨짐)
  • 게다가 DTO 구조에서 enum 내부 상태를 조작하거나 읽는 건 설계상 매우 부자연스러움

 

📌 결국, 분리는 필연적이었다

정렬 기준 필드의 제한 = CursorField(enum)
사용자 요청 값 + 방향 = Cursor(record)

→ 이 둘을 구분함으로써
정적 검증 가능한 커서 필드와,
요청마다 동적으로 바뀌는 커서 값/방향을 명확히 분리
할 수 있었다.

 

 

📌 그리고 또 하나: 다중 정렬

최종적으로 필자가 정의한 커서 설계는
“단일 커서”가 아닌 “다중 커서”를 전제로 한다.

  • createdAt DESC, id DESC와 같은 정렬 기준은 흔하다
  • 따라서 List<Cursor> 형태로 다수의 커서를 함께 처리할 수 있어야 한다
  • 이를 위해 Cursor 구조는 단순하고, 가볍고, 조합 가능한 구조여야 했다

 

 

Cursor는 단순한 구조지만,
정렬 기준 필드(CursorField)와 커서 값/방향을 분리하기 위해
필연적으로 도입된 구조
였다.

이 분리를 통해 다음과 같은 효과를 얻을 수 있었다:

  • enum의 안전성과 재사용성 유지
  • 요청마다 바뀌는 동적 값 안전하게 처리
  • 다중 정렬 기준 대응 가능

 

 

 

CursorUtils — 쿼리를 만들어내는 손끝의 유틸

엄선된 재료들(CursorField, Cursor, CursorPathType)을 준비했다면
이제 남은 일은, CursorUtils로 그것들을 조립해 쿼리를 완성하는 일뿐이다.

 

커서 설계의 최종 목적은 분명했다:
각기 다른 도메인에서도 동일한 커서 구조로 쿼리를 생성할 수 있도록 만드는 것.

  • 커서 필드는 도메인마다 다르고
  • 필드 타입도, 정렬 방식도 달라질 수 있다
  • 그럼에도 불구하고, 동일한 방식으로 WHERE + ORDER 조건을 생성해야 했다

 

📌 그래서 필요한 건? 추상화 + 타입 안정성

이 구조에서 가장 중요한 건 "형태는 다르되, 사용법은 동일한 구조"였다.

그래서 필자는 처음부터 Cursor / CursorField / CursorPathType을 인터페이스로 추상화했다.

하지만 추상화만으로는 부족했다.
“어떤 타입으로 동작하는지”를 컴파일 타임에 명확히 보장할 수 있어야 했다.

그래서 제네릭을 적극 활용했다.

 

 

📌 메서드 구조

public static <T> BooleanBuilder getCursorPredicate(List<? extends Cursor> cursors, Class<T> domainClass, String alias)
  • 각 커서에 대해 WHERE 조건을 생성
  • 정렬 기준이 2개 이상일 경우, 앞선 커서는 eq, 마지막 커서는 gt/lt 처리
  • QueryDSL의 BooleanBuilder로 조합
 
 
public static <T> List<OrderSpecifier> getOrderSpecifiers(List<? extends Cursor> cursors, Class<T> domainClass, String alias)
 
  • 각 커서 기준 필드에 대해 asc/desc 정렬 조건을 생성
  • CursorPathType 내부에서 PathBuilder를 통해 추출

 

📌 핵심은 반복이 아니라 “구조화된 반복”

이 두 메서드는 본질적으로 반복문을 통해 커서 리스트를 순회한다.
그러나 단순 반복이 아니라,
“CursorField → CursorPathType → PathBuilder”로 이어지는 구조적 흐름 안에서
정렬 조건과 쿼리 조건을 생성하는 구조화된 반복이다.

 


 

 

CursorUtils는 단순 유틸 클래스가 아니다.
정렬 필드(enum), 정렬 값 + 방향(Cursor), QueryDSL 쿼리 구조(CursorPathType)
이 세 가지를 한데 엮어 실제 쿼리로 완성하는 손끝의 조립자다.

  • 구조는 단순하지만,
  • 추상화와 제네릭으로 강하게 묶여 있고,
  • 범용성과 타입 안정성을 동시에 만족시키는 유틸이다.

 

 

 

✍️  회고 — 빠르게 끝낼 생각이었다

이번 작업의 원래 목표는 단순했다.

  1. Offset 기반 조회의 성능 한계 이해하기
  2. Keyset 기반 방식으로 성능 개선 적용하기
  3. 다중 정렬 조건이 있는 Keyset 기능 구현하기

사실 이 세 가지를 빠르게 구현해서
다음 작업으로 넘어가는 게 계획이었다.
회원가입, 인증, 인가, 결제 등 해야 할 기능이 워낙 많았기 때문이다.

 

 

시작은 순조로웠다

Offset 방식의 성능 이슈는
잘 정리된 자료가 많았고,
DB 커버링 인덱스 실습도 어렵지 않았다.

Keyset 방식 역시 WHERE 조건이 조금 복잡했지만,
QueryDSL에 익숙해지면서
구현 자체는 오래 걸리지 않았다.

그래서 이 글을 쓰기 6일 전,
이미 기능 구현은 끝난 상태였다.

 

 

그런데… 뭔가 계속 찜찜했다

약관 도메인에서 CursorField를 enum으로 선언하고 있었지만,
실제 쿼리를 만들 때는 if 문과 반복문이 도메인에 특화되어 흩어져 있었다.

"enum을 썼지만, enum이 가진 힘을 전혀 발휘하지 못하고 있다"는 느낌이었다.
그게 마음에 계속 걸렸다.

 

하지만 작업 우선순위는 따로 있었기에
그 찜찜함을 묻어둔 채
다음 작업들 — 핸드폰 인증, 회원가입, JWT 발급 — 을 먼저 진행했다.

 

 

그런데… 글을 쓰다 보니, 다시 그 찜찜함이 올라왔다

Keyset 기능을 정리하는 글을 쓰기 시작했는데,
“이거, 제대로 설계하면 훨씬 범용적이고 깔끔하게 만들 수 있을 텐데…”
하는 생각이 머릿속을 떠나지 않았다.

그리고 어느새 정신을 차려보니
글을 쓰다 말고, 다시 코드를 열고 리팩토링을 시작하고 있었다.

 

 

그렇게 5~6시간이 지나 있었다

단 하나의 설계를 위해
반복문을 걷어내고
PathBuilder로 쿼리 추상화를 뽑아내고
CursorField에 타입 안정성을 입히고
QueryDSL Expression까지 enum에서 추출해 내는 구조를 설계했다.

중간에 디버깅도 정말 많이 했다.
힘들었다. 그런데…

정말 즐거웠다.

 

 

하루가 다 지나고 나서야 후회했다

“오늘도 너무 한 가지에 몰두해 버렸구나…”
“글은 한 줄도 못 썼고, 리팩토링에 6시간이나 썼구나…”

그런 생각이 들었다.
그리고 팀장님이나 주변 사람들이 말하던
"몰입이 과해서, 전체 흐름을 잊을 때가 있다"는 말도 떠올랐다.

 

 

하지만 그럼에도,
이번 리팩토링은 내 안에 있는
“설계자의 찜찜함”을 해소하는 과정이었다.

 

 

결국 이 회고는 반성이다

  • 시간을 계획대로 쓰지 못한 점
  • 내가 빠르게 넘기려 했던 것을 결국 붙잡게 된 점
  • 몰입이 과해 전체 흐름을 놓치는 경향이 있다는 점

그렇지만 동시에,
이 회고는 내가 설계를 사랑한다는 증거이기도 하다.

설계의 ‘찝찝함’을 그냥 넘기지 못하는 나
그게 아마 내가 개발자로 계속 성장할 수 있는 이유이기도 하다.

 

그래서 이번 글의 마지막은 “설계 코드”가 아니라,
“나를 설계하는 사고 흐름”으로 마무리하고자 한다.