
시작에 앞서: 실행 환경과 전제
이 글은 Query 중심의 CRUD 서비스를 동작 파라미터화 관점에서 다시 설계해보는 기록이다. 복잡한 도메인 규칙보다 “요청 → 조건 → 쿼리” 흐름이 핵심인 서비스에서, 어떻게 하면 Service 계층을 최소화하면서도 구조를 무너지지 않게 유지할 수 있을까에 초점을 맞춘다.
예제 코드는 아래 환경에서 동작한다.
- Java: 21
- Launcher JVM: 21.0.5 (Amazon.com Inc. 21.0.5+11-LTS)
- Spring Boot: starter-web, starter-data-jpa
- QueryDSL, MySQL, Lombok
$ java -version
openjdk version "21.0.5" 2024-10-15 LTS
OpenJDK Runtime Environment Corretto-21.0.5.11.1
OpenJDK 64-Bit Server VM Corretto-21.0.5.11.1
모든 코드는 위 환경에서 컴파일 및 런타임에 문제없이 동작한다. 실제 프로젝트에 적용할 때는 JDK 버전, Spring Boot 버전, QueryDSL 버전만 맞춰주면 된다.
동작 파라미터화로 보는 MVC와 Controller-Repository 패턴
가장 익숙한 웹 아키텍처는 MVC 패턴이다. 실무에서는 이를 조금 변형해 Controller - Service - Repository 구조로 사용하는 경우가 많다.
Controller → Service → Repository
하지만 모든 서비스가 풍부한 도메인을 가지는 것은 아니다. 특히 다음과 같은 경우를 떠올려보자.
- 주요 역할이 조회용 Query 위주인 API
- 도메인 규칙보다는 조건 필터링, 정렬, 페이징이 핵심인 화면
- 단순 CRUD이지만 화면 종류만 많은 관리자 페이지
이런 경우, Service 계층이 하는 일은 종종 “Repository 호출만 대신하는 얇은 껍질”이 되기도 한다. 트랜잭션 처리나 도메인 규칙이 거의 없다면, 과감하게 계층을 단순화하는 것도 선택지다.
그래서 구조를 아래처럼 줄여볼 수 있다.
Controller → Repository (혹은 Port)
여기서 중요한 포인트는 “Service를 없앴다”가 아니라, “Query 중심 요구사항을 다른 방식으로 추상화했다”는 점이다. 이 글에서는 이 추상화의 핵심으로 동작 파라미터화를 사용한다.
동작 파라미터화와 Port & Adapter 구조
구조를 너무 단순하게 줄여버리면 금방 다시 스파게티 코드가 된다. 그래서 Port & Adapter 패턴을 얇게 도입해 레이어를 정리한다.
- adapter.input:
REST API, gRPC, GraphQL 등 “요청이 들어오는 입구”에 해당하는 계층이다. 이 글에서는@RestController가 여기에 속한다. - adapter.output:
QueryDSL, JPA, MySQL 등 실제 쿼리를 실행하는 계층이다. 여기에는 Port 인터페이스 구현체들이 위치한다. - application.usecase:
“무엇을 하고 싶은지”를 기술하는 곳이다. 이 글에서는 Query를 사용하는 유스케이스 중심으로 정의한다. - application.model / value:
요청 파라미터, 응답 DTO, 값 객체들이 모여 있는 계층이다. - infrastructure:
JPA, QueryDSL 설정 등 기술적인 설정을 모아둔다.
겉으로 보면 계층이 많아 보이지만, 실제로는 Controller → UseCase → Port 정도의 흐름으로 단순하게 유지할 수 있다. 그리고 여기서 동작 파라미터화를 활용해 Query 자체를 파라미터로 전달한다.
Query 중심 요구사항을 동작 파라미터화로 추상화하기
기본 요구사항은 다음과 같이 요약된다.
- 동적으로 정렬 기준을 바꾸고 싶다 (ORDER BY)
- 페이지네이션을 적용하고 싶다 (OFFSET, LIMIT, Pageable)
- 필터 조건을 QueryString으로 자유롭게 조합하고 싶다
보통은 Controller에서 @RequestParam, @PageableDefault를 사용해 정렬과 페이징을 받는다. 여기까지는 일반적인 코드다.
문제는 이 이후이다. Repository마다 “필터 조건 + 정렬 + 페이징”을 반복해서 구현하기 시작하면 코드 중복과 조건 분기가 빠르게 늘어난다.
여기서 동작 파라미터화를 도입하면 설계가 달라진다. 핵심 아이디어는 다음과 같다.
- QueryBase:
모든 쿼리가 공통으로 가져야 하는 요소를 추상화한 상위 타입.
예: JPAQueryFactory, 기본 필터, 기본 정렬, 기본 페이징 등. - QueryUseCase:
Controller에서 호출하는 “DSL 같은 인터페이스”.
여러 결과 타입과 여러 Port를 한 번에 받도록 설계할 수 있다. - Port 인터페이스:
각 도메인(예: Banana, Fruit)에 대한 실제 Query 구현체.
QueryDSL을 사용해 쿼리를 작성한다. - QueryParamConditions:
클라이언트의 요청 파라미터를 캡슐화한 값 객체.
정렬 기준, 필터 조건, 페이지 정보 등.
이 구조에서는 “어떤 쿼리를 날릴지”를 동작 파라미터로 포트에 넘기는 셈이 된다.
Query 동작 파라미터화 설계 예시
1. QueryBase: 공통 쿼리 골격 만들기
QueryBase는 QueryDSL과 Spring Data JPA가 공통으로 사용하는 자원과 규칙을 모아두는 상위 클래스다.
- 공통 JPAQueryFactory 주입
- 기본 정렬 또는 공통 필터 정의
- 공변 반환 타입을 활용해
<T> List<T>형태로 조회
하위 Port들은 이 QueryBase를 상속받아 “어떤 엔티티를 대상으로 어떤 조건을 걸지”만 구현한다.
2. QueryUseCase: Controller가 부를 DSL
QueryUseCase는 Controller에서 직접 사용하는 인터페이스다.
public interface QueryUseCase {
<T> QueryResult<T> query(QueryParamConditions conditions,
QueryPortList ports);
}
여기서 포인트는 QueryPortList다. 여러 Port를 한 번에 넘겨서 여러 엔티티의 결과를 모아 하나의 응답으로 구성할 수도 있다.
3. Port: 실제 쿼리를 수행하는 구현체
각 도메인별로 Port를 만든다. 이 Port는 QueryBase를 상속받으며, “엔티티별 검색 조건”을 구현한다.
- 예: BananaPort, FruitPort, ValidAuthorityPort 등
- 각 Port는
createEntityPath()같은 메서드를 통해 QueryDSL에서 사용할 EntityPath를 생성한다.
이때도 동작 파라미터화를 쓸 수 있다. 정렬 기준이나 필터 조건을 함수형 인터페이스로 받아서 Port 내부에서 조합하는 식이다.
4. QueryParamConditions: 클라이언트 요구 캡슐화
Controller는 다양한 QueryString을 받게 된다.
- 정렬 기준:
sort=createdAt,desc - 필터:
status=ACTIVE&color=YELLOW - 페이지 정보:
page=0&size=20
이 값들을 모두 DTO나 값 객체로 모아둔 것이 QueryParamConditions이다.
이렇게 만들어두면 Controller는 필터와 정렬을 일일이 해석하는 대신, “조건 묶음”을 그대로 UseCase에 넘겨줄 수 있다.
ORDER BY와 QueryDSL, 그리고 보안 이슈
정렬 기준을 동적으로 받다 보면 ORDER BY 항목을 문자열로 처리하는 경우가 많다.
하지만 QueryDSL의 경우 잘못 쓰면 SQL/HQL 인젝션 문제를 야기할 수 있다. 단순히 문자열을 그대로 OrderSpecifier에 넘기는 방식은 위험하다.
그래서 QueryDslUtil 같은 유틸 클래스를 두고,
- 정렬 가능한 필드 목록 화이트리스트
- 정렬 방향(asc/desc) 검증
- 안전하게 OrderSpecifier를 생성하는 함수형 인터페이스
를 한 번에 처리하는 것이 좋다.
Port에서는 이 유틸을 활용해 createEntityPath()와 함께 “정렬 기준을 동작 파라미터로 받는 구조”를 만들 수 있다.
예시: QueryBananaApi와 QueryFruitApi 흐름
실제 흐름을 예시로 정리하면 다음과 같다.
- 클라이언트가
/api/bananas엔드포인트로 정렬, 필터, 페이지 정보를 담은 요청을 보낸다. - QueryBananaApi (adapter.input의 @RestController)가 이를 QueryParamConditions로 묶는다.
- Controller는 이 조건을 그대로 QueryUseCase에 전달한다.
- UseCase는 내부에서 BananaPort를 호출해 조건에 맞는 목록 + 단일 조회 등을 구성한다.
- 필요하다면 FruitPort 등을 추가로 호출해 “전체 과일 목록”과 같은 복합 응답도 쉽게 만들 수 있다.
여기서 중요한 점은, 새로운 화면이 생겨도 대부분의 로직이 재사용된다는 점이다.
- 정렬 로직: QueryDslUtil 재사용
- 조건 파싱: QueryParamConditions 재사용
- 쿼리 실행: Port 재사용
- 결과 구조: QueryResult, 일급 컬렉션 재사용
새로운 요구 사항이 들어오면, 대부분은 조건 조합을 위한 동작(람다, 함수형 인터페이스)만 추가하면 된다. 이는 동작 파라미터화가 가진 장점을 Query 레벨에서 활용한 사례라고 볼 수 있다.
정리: 동작 파라미터화를 Query에 적용한다는 것
동작 파라미터화를 Query 중심 서비스에 적용하면 다음과 같은 장점을 얻는다.
- Service 계층이 얇거나 불필요한 경우, 구조를 단순하게 유지할 수 있다.
- 정렬, 필터, 페이징 로직을 재사용 가능한 동작으로 추상화할 수 있다.
- Port & Adapter 구조와 함께 쓰면 Query가 퍼져 나가는 것을 막고, 응집도 높은 구현체로 묶을 수 있다.
- 새로운 요구 사항이 들어와도 기존 코드를 크게 건드리지 않고 함수형 인터페이스나 람다만 추가해 대응할 수 있다.
즉, 이 글에서 말하는 동작 파라미터화는 단순히 “람다를 쓴다”가 아니라, “Query 자체를 역할과 동작으로 분리해서 설계한다”에 가깝다.
CRUD 위주의 관리 화면, 검색 조건이 자주 바뀌는 리스트 페이지라면 한 번쯤 이런 구조를 떠올려보는 것도 충분히 가치가 있다.