개발 일기

[사이드 프로젝트] 정렬 하나에도 설계가 들어간다

Pleasant Pain 2025. 4. 4. 13:54

개요

이번 글은 "단순한 조회"를 넘어,

정렬/페이징/쿼리 조건까지 어떻게 설계했는지를 기록한 글이다.

 

조회 API는 간단해 보이지만, 도메인과 사용자 경험에 가장 밀접한 영역이다. 

 

필자는 이번 사이드 프로젝트에서 조회 API 기능을 구현할 때,

"어떤 기술을 선택하는게 가장 합리적일까?"
"어떻게 해야 도메인 비즈니스에 특화된 조회 API의 정렬 기능을 통합할 수 있을까?"

와 같은 질문을 반복해서 던졌던 것 같다.

이번 글에서는 작은 욕심에서 시작된, 정렬 기능 설계 과정을 정리해보려 한다

 

 

 

정렬 조건은 단순하지 않았다

필자는 사실 처음엔, 조회 API에서의 정렬 값을 단순한 문자열로 받을 계획이었다. 

그 이유는, 도메인마다 정렬할 수 있는 필드가 모두 다를 수밖에 없다고 생각했기 때문이다. 

 

예를 들어 아래와 같이 요청 DTO에서 단순히 문자열로 값을 받는 방식이다:

private String sortField = "code";
private String sortDirection = "DESC";

 

이후에는 이 문자열 값을 기반으로 유효성 검증을 수행하고, 

적절한 정렬 쿼리를 생성할 수 있도록 JPA Repository 메서드를 구성하려 했다. 

 

그러던 중,
Spring Data JPA가 제공하는 Page, Pageable, PageRequest와 같은
강력한 페이징 도구들의 존재를 알게 되었다.

 

 

왜 Page와 Pageable에 감탄했는가

위 도구들이 가장 놀라웠던 점은,
JpaRepository 메서드에서 파라미터를 Pageable 타입으로 받고,
리턴 타입을 Page<T>로 설정하면,
JPA가 알아서 페이징 쿼리를 생성하고, 응답을 처리해준다는 것이었다.


여기서 Pageable은 인터페이스인데,

페이지 번호, 페이지 사이즈, 정렬 필드와 정렬 방향을 담고 있어
페이징 처리 방식 전반을 설계할 수 있다.
게다가 이에 대한 구현체인 PageRequest도 기본으로 제공된다.


리턴 타입인 Page 역시

프론트엔드에서 페이징 UI를 구성할 때 필요한 값들(전체 페이지 수, 총 데이터 개수 등)을
알아서 제공해준다.
(이러한 처리가 JPA 내부에서 어떻게 가능한지는 별도 글로 정리할 예정이다.)

 

 

NestJS와 TypeORM 환경에서 개발하던 필자에게는,
이러한 기능들이 매우 인상 깊었다.


그동안은 요청/응답에 대한 규격부터, 그에 맞는 쿼리까지 모두 직접 구현해야 했기 때문이다.

 

특히 협업 과정에서 페이징 처리 방식이 개발자마다 제각각이었고,
요청 파라미터의 규격에도 일관성이 없었다.

 

그래서 Spring Data JPA의 페이징 기능은
일관된 요청 구조 + 재사용 가능한 쿼리 처리 + 응답 객체 자동 구성이라는
세 가지 측면에서 너무나 매력적인 선택지였다.

 

 

문자열 정렬 값은 안전하지 않았다

Pageable과 PageRequest 덕분에 페이징 처리는 간단히 해결할 수 있을 것 같았다.
그러나 PageRequest 구조를 들여다보는 순간, 불안감이 찾아왔다.

"만약 API 호출자가 정렬 불가능한 필드 값을 요청한다면?"

 

페이지 번호나 페이지 크기 값은 단순한 정수형이기 때문에
DTO 단에서 유효성 검증이 충분히 가능했다.


그러나 문제는 정렬 기준이 되는 정렬 필드 값이었다.

정렬 필드는 도메인마다 다르게 정의될 수밖에 없었기 때문이다.

 

예를 들어, 어떤 도메인에서는 생성일자(createdAt) 기준 정렬이 필요하고,
또 다른 도메인에서는 제목(title) 기준 정렬이 요구될 수 있다.

 

 

이러한 불안감을 떨쳐내기 위해,
필자는 실제로 약관 도메인에서 의도적으로 잘못된 정렬 값을 넣어 요청해보았다.

 

결과는? 역시나 쿼리 에러 발생.

 

정확히 말하자면 이는 DB에서 발생하는 에러가 아니었다.
JPA는 JpaRepository의 메서드들을 런타임 시점에 프록시로 구현하고,
내부 쿼리를 미리 생성
하는 구조를 가지고 있기 때문에,

이 과정에서 잘못된 정렬 필드가 포함되면 쿼리 자체가 로딩되지 못하고 예외가 발생한다.

 

 

이때 필자는 다음과 같은 결론에 도달하게 되었다:

"쿼리 에러로 요청이 실패하게 두는 것보다,
애초에 잘못된 정렬 필드 값 자체를 걸러내고,
정확한 요청 형식이 아님을 사용자에게 알려야 한다."

 

즉, 정렬 필드 값도 명확한 규칙과 검증 구조 안에 있어야 한다는 필요성을 절감했다.

 

 

책임의 무게, 그리고 enum

정확한 정렬 필드를 검증하는 건, 기술적으로 어렵지 않다.

가장 단순한 방법은,

같이 서비스 레이어에서 도메인에 허용된 정렬 필드 목록을 가지고,
하나씩 문자열 값을 검증하는 것이다.

public class TermsService {
    // 코드 생략..
    public boolean termsReadSortValdate(String SortField) {
        if (SortField == "createdAt") {
        	return true;
        }
        if (SortField == "title") {
        	return true;
        }
        return false;
    }
}


그런데 이 방식은, 필자에게 강한 거부감을 주었다.

 

그 이유는,
필자가 이전에 작성했던 글
👉 "레이어 및 패키지 구조 설계"
에서 스스로 다짐한 원칙 때문이다:

"서비스 레이어가 너무 많은 책임을 갖게 만들지 말자."

 

서비스가 도메인의 핵심 로직까지 판단하고 결정하는 순간,
레이어 간의 책임 분리가 무너지고,
유지보수성과 응집력 모두가 훼손될 수 있기 때문이다.

 

그래서 "문자열을 하나하나 검사하는 방식"은 아예 시도조차 하지 않았다.

 

그럼 이런 질문이 떠오른다:

"그렇다면, 정렬 필드 검증은 도메인 객체가 해야 하는가?"

 

하지만 이 방식 역시, 똑같이 거부감이 들었다.

도메인 객체는 자기 자신의 상태를 보장하는 책임을 지는 건 맞다.
예를 들어, 생성자에서 필드 값 유효성을 검증하거나,
상태 전이의 정합성을 확인하는 책임은 명확히 도메인에 속한다.

 

하지만 정렬 필드처럼 요청 조건에 따라 유동적으로 바뀌는 정보
도메인에게 책임지게 하는 건 과한 분산이며, 역할의 경계가 불분명해진다.

 

그렇게 필자는 이 책임을 따로 분리할 수 있는 이상적인 구조를 찾기 시작했다.

 

그리고 정렬 필드라는 것은 정적인 값의 집합이며,
도메인마다 유효한 값들이 제한적이라는 점에서,
enum이 가장 적합하다는 결론에 도달하게 되었다.

 

 

중복 코드에 대한 불편한 감정

앞서 TermsSortField라는 enum을 도입하여,
정렬 필드의 유효성을 보장하는 구조를 설계했다.

@Getter
public enum TermsSortField {
    EXPOSED_FROM("exposedFrom"), // 노출 시작일
    EXPOSED_TO("exposedToOrNull"), // 노출 종료일
    CREATED_AT("createdAt"); // 생성일

    private final String fieldName;

    TermsSortField(String fieldName) {
        this.fieldName = fieldName;
    }
}

 

그리고 이 enum을 API Request DTO에 타입으로 지정함으로써,
잘못된 정렬 필드 값이 들어오는 것 자체를 컴파일 타임에 방지할 수 있었다.

public record TermsSearchCondition(
    // 어노테이션 등 코드 생략..
    TermsSortField sortField
)

 

이제 DTO에서 받은 값으로 바로 PageRequest를 생성하면 된다.

public PageRequest toPageRequest() {
    String field = sortField.getFieldName();        
    Sort sort = Sort.by(new Order(direction, field));
    return PageRequest.of(page, size, sort);         
}

 

이 지점까지는 꽤 만족스러웠다.
그런데, 이 순간조차 불편한 감정이 스쳐 지나갔다.

"조회가 필요한 모든 API에서 PageRequest 생성 로직을
매번 구현해야 하는 걸까?"

 

현실적으로, 거의 모든 비즈니스는
정렬과 페이징이 포함된 조회 API를 가지고 있다.
그런데 각 도메인마다 PageRequest 생성 로직이 반복된다면?

  • 코드의 일관성이 무너지고,
  • default 값이나 null 처리도 매번 중복되고,
  • 설계는 점점 산만해지게 된다.

 

이러한 고민 끝에 필자는

PageRequest 생성 책임을 DTO에 부여하되,
이를 강제할 수 있는 추상 구조가 필요하다

고 판단했다.

 

즉, 각 도메인 DTO가 PageRequest 생성 로직을 스스로 갖되,
통일된 인터페이스 구조를 통해 일관성을 보장하고 싶었다.

 

그 결과가 바로 —
다음 챕터에서 다룰 PageableRequest<T extends SortField> 인터페이스 설계로 이어지게 된다.

 

 

 

PageableRequest<T extends SortField> 설계

SortField 인터페이스 구현

PageRequest 생성 로직을 일관되게 책임지는 구조를 만들기 위해,
필자가 가장 먼저 고민한 지점은 바로
정렬 기준 필드(SortField)의 추상화였다.

 

그 이유는 단순하다.
PageRequest를 생성하는 데 필요한 인자 중,
정렬 기준 필드(sortField)를 제외한 나머지 값들은
page, size, direction처럼 모두 타입이 동일한 공통 요소였기 때문이다.

 

그러나 문제는,
정렬 기준 필드는 도메인마다 다를 수밖에 없다는 점이다.

  • 약관 도메인에서는 exposedFrom, createdAt 등이 필요하지만,
  • 회원 도메인에서는 그런 필드가 존재하지 않을 수 있다.

그래서 필자는 각 도메인마다
"자신만의 정렬 enum 객체"를 갖도록 설계했고,
그 enum들을 하나의 추상 타입으로 묶기 위한 방법을 고민하게 된다.

 

여기서 중요한 제약이 하나 있다:

enum은 클래스를 상속할 수 없다.
→ 대신 인터페이스는 구현할 수 있다.

그래서 아래와 같은 인터페이스를 먼저 정의했다:

public interface SortField {
    String getFieldName();
}

 

이 인터페이스를 도메인별 정렬 enum이 구현하도록 설계했다:

@Getter
public enum TermsSortField implements SortField {
    EXPOSED_FROM("exposedFrom"), 
    EXPOSED_TO("exposedToOrNull"), 
    CREATED_AT("createdAt");

    private final String fieldName;

    TermsSortField(String fieldName) {
        this.fieldName = fieldName;
    }
}

 

 

이렇게 함으로써,
각 도메인마다 정렬 기준이 다른 enum이더라도
공통된 방식으로 fieldName을 추출하고,
PageRequest 생성 로직에서 추상 타입으로 활용할 수 있는 기반이 마련되었다.

 

 

인터페이스 default 메서드 구현

먼저, PageRequest 생성에 필요한 값들을 다시 확인해보자

  • int page -> 페이지 번호
  • int size -> 페이징 처리 사이즈
  • Sort.Direction direction -> 정렬 방향
  • String sortField -> 정렬할 필드명

위 값들을 기준으로
PageRequest을 생성 로직을 책임질 인터페이스를 다음과 같이 정의할 수 있다

public interface PageableRequest {
    Integer page();
    Integer size();
    SortField sortField();
    Sort.Direction sortDirection();
    
    default PageRequest toPageRequest() {
        int page = this.page() != null ? this.page() : 0;
        int size = this.size() != null ? this.size() : 10;
        
        if (this.sortDirection() == null || this.sortField() == null) {
            return PageRequest.of(page, size);
        }
                
        Order order = new Order(this.sortDirection(), sortField().getFieldName());
        
        return PageRequest.of(page, size, Sort.by(order));
    }
}

 

위와 같이 인터페이스 메서드에 default를 붙여,

상속 받은 클래스가 직접 구현하지 않아도 메서드를 사용할 수 있도록 한다

public record TermsSearchCondition(
    Integer page,
    Integer size,
    TermsSortField sortField,
    Sort.Direction sortDirection
) implements PageableRequest {
}

 

이렇게 인터페이스로 공통 로직을 설계한 이유는 다음과 같다

 

  • DTO가 책임지는 구조를 유지하고 싶었기 때문이다
  • 유틸리티 함수로 뺐을 경우, 책임이 애매해진다
  • 도메인마다 default 값이 달라질 수 있으니 DTO에서 갖고 있어야 한다

 

 

타입 안정성을 위한 제네릭 활용

PageableRequest 인터페이스를 구현으로 인해,
PageRequest 생성에 대한 코드 중복을 줄일 수 있는 적절한 방안이 마련됐다.

 

사실 여기까지만 해도 목적이 달성됐기에 만족스러운 결과라고 할 수 있지만,
뭔가 2% 아쉬운 느낌이 들었다.

 

그 이유는 아래와 같은 생각이 들었기 때문이다

 

"각 도메인마다 고유의 정렬 enum이 존재하는데,
PageableRequest 인터페이스 내부에서는 추상화된 SortField는 모두 허용하네?"

"정렬 enum 타입을 컴파일 시점부터 강제할 순 없을까?"

 

예를 들어, 아래와 같이 컴파일 시점부터 명확하게 타입을 선언하고 싶었던 것이다

List<SortField> sortFields = new ArrayList<>();
List<TermsSortField> termsSortFields = new ArrayList<>();

 

위 예시 코드에서 sortFields List 변수에는
추상화된 여러 도메인의 enum 타입들의 값이 추가될 수 있다.

그러나 더 명확한 TermsSortField라고 제네릭을 명시한 termsSortFields 변수는

TermsSortField 타입에 대한 값만 추가될 수 있다.

 

필자는 위와 같이 컴파일 시점부터 명확하게 타입을 지정함으로써,
혹시라도 발생될 실수를 컴파일 시점부터 줄이게 하고 싶었다.

 

따라서, 아래와 같이 PageableRequest 인터페이스에도 제네릭을 활용해보고자 한다

public interface PageableRequest<T extends SortField> {
    Integer page();
    Integer size();
    T sortField();
    Sort.Direction sortDirection();
    
    default PageRequest toPageRequest() {
        int page = this.page() != null ? this.page() : 0;
        int size = this.size() != null ? this.size() : 10;
        
        if (this.sortDirection() == null || this.sortField() == null) {
            return PageRequest.of(page, size);
        }
                
        Order order = new Order(this.sortDirection(), sortField().getFieldName());
        
        return PageRequest.of(page, size, Sort.by(order));
    }
}
 
위와 같이 PageableRequest 선언 뒤에 <T extends SortField> 를 명시하여,
제네릭 타입이 추상화된 SortField를 상속한 타입임을 알린다.
 
그리고 이를 상속할 도메인별 DTO에서 아래와 같이 타입을 지정한다.
public record TermsSearchCondition(
    Integer page,
    Integer size,
    TermsSortField sortField,
    Sort.Direction sortDirection
) implements PageableRequest<TermsSortField> {
}

 

 
이렇게 타입을 명확하게 지정함으로써,

PageableRequest 인터페이스의 내부 구현이,
혹시라도 다른 정렬 enum 타입으로 구현될 가능성을 사전에 차단한다.

 

PageableRequest 인터페이스 최종 형태

public interface PageableRequest<T extends SortField> {
    Integer page();
    Integer size();
    List<T> sortFields();
    List<Direction> sortDirections();
    
    default PageRequest toPageRequest() {
        int page = this.page() != null ? this.page() : 0;
        int size = this.size() != null ? this.size() : this.defaultPageSize();
        Sort sort = this.toSort();
        return PageRequest.of(page, size, sort);
    }
    
    default Sort toSort() {
        List<T> sortFields = sortFields();
        List<Direction> sortDirections = sortDirections();

        if (sortFields == null || sortFields.isEmpty() || sortDirections == null || sortDirections.isEmpty()
            || sortFields.size() != sortDirections.size()) {
            return Sort.by(getDefaultSortOrder());
        }

        List<Order> orders = new ArrayList<>();
        for (int i = 0; i < sortFields.size(); i++) {
            if (sortFields.get(i) == null || sortDirections.get(i) == null) {
                continue;
            }
            Order order = new Order(sortDirections.get(i), sortFields.get(i).getFieldName());
            orders.add(order);
        }
        return Sort.by(orders);
    }
    Order getDefaultSortOrder();
    int defaultPageSize();
}
 
정렬 조건이 1개 이상일 수 있어,
List 타입으로 정렬에 대한 값을 받는다.
 
또한, 각 도메인마다 디폴트 페이징, 정렬 조건이 다를 수 있기에,
디폴트 값을 별도로 지정할 수 있도록 메서드를 추가한다.
 
 

인터페이스를 구현한 DTO 예시

public record TermsSearchCondition(
    // .. 코드 생략
    Integer page,
    Integer size,
    List<TermsSortField> sortFields,
    List<Direction> sortDirections
) implements PageableRequest<TermsSortField> {
    private static final int DEFAULT_PAGE_SIZE = 10;

    @Override
    public Order getDefaultSortOrder() {
        return new Order(Direction.DESC, TermsSortField.CREATED_AT.getFieldName());
    }

    @Override
    public int defaultPageSize() {
        List<Object> strs = new ArrayList<>();
        return DEFAULT_PAGE_SIZE;
    }
}

 

 

 

결론

이번 사이드 프로젝트에서 조회 API를 개발하면서
페이징, 정렬 조건 등을 함께 구현해야 했다.

 

이 과정에서 Spring Data JPA가 제공하는
Pageable, PageRequest, Page<T> 등의 기능은
일관된 요청 구조와 응답 포맷을 자동으로 제공해주는 매우 강력한 도구였다.

하지만 한 가지 문제가 있었다.

 

"정렬 필드 값에 대한 유효성 검증은,
JPA 쿼리 에러가 발생된 이후에야 알 수 있었다"

 

즉, 잘못된 정렬 필드를 사용한 경우
요청 자체는 통과되지만, 쿼리 로딩 시점에서 런타임 에러가 발생하는 구조였다.
이러한 흐름은, 필자에게 "검증 책임의 위치"에 대한 깊은 고민을 던져주었다.


  • 서비스 레이어가 이를 검증하는 건 책임이 과하다고 느꼈고,
  • 도메인 객체가 담당하기엔 역시 부적절하다고 판단했다.

따라서 정렬 필드를 도메인 별로 enum으로 분리하고,
해당 enum이 자신만의 정렬 기준을 갖도록 설계했다.


그 다음 고민은 PageRequest 생성 위치였다.
"각 DTO가 이를 담당하되, 중복 로직을 어떻게 통합할 수 있을까?"

  • 정렬 필드를 추상화한 SortField 인터페이스를 도입했고,
  • DTO가 이를 구현하는 PageableRequest 인터페이스를 구현하도록 설계했다.
  • 공통 로직은 default 메서드로 처리하여 재사용성과 응집도를 높였다.

마지막으로,
여전히 남아있던 타입 안정성에 대한 불편한 감정을 해소하기 위해,
PageableRequest<T extends SortField>로 제네릭을 도입했다.

 

이로써 컴파일 시점에서부터 정렬 enum의 타입을 명확히 제한하고,
불필요한 유연함을 방지할 수 있었다.

 

조회 API의 단순한 구현을 넘어, 

정렬 기능 하나에도 철저한 책임 분리와 타입 안정성을 녹이고자 했다.

 
 
 

생각 정리 

이번 사이드 프로젝트는 필자에게 매우 중요한 의미를 지닌다.

 

그동안 필자는 빠른 기능 구현에만 집중하면서,
설계에 대한 고민은 미뤄두고 살아왔다.

그 결과, 자신이 담당한 프로젝트의 코드가 점점 복잡해지는 것을
직접 경험했다.


하지만 이제는 더 이상 같은 실수를 반복할 수 없다.

이제는 단순히 기능을 만드는 것이 아니라,
제품의 퀄리티를 조금이라도 높일 수 있는 "설계"를 고민할 때다.

 

그래서 이번 사이드 프로젝트는
다음과 같은 질문을 나 자신에게 던지고 있다:

"당신은 어떤 태도로 코드를 작성하고 있는가?"

 

기술적인 성숙함도 중요하다.
그러나 좋은 코드를 설계한다는 것은,
그 코드를 작성할 때 어떤 태도를 지녔는가에 달려 있다.


스스로에게 끊임없이 질문하고,

조금이라도 더 나은 구조를 찾아내려는 과정은
분명 괴롭고 번거로운 길이다.


하지만 지금껏 안일한 태도로 살아온 시간을 생각하면,

이 정도의 고통은 충분히 감수할 수 있어야 한다.


앞으로는 끊임없는 고민을 통해

  • 설계를 통해 책임을 명확히 나누고,
  • 불필요한 혼란을 줄이며,
  • 코드의 일관성을 높이겠다는 태도를 키워야 한다.

그리고 그 모든 태도는,
조회 API 하나를 설계할 때부터 이미 드러나야 한다고 믿는다.