외부 파라미터 이름은 바꿀 수 없었습니다
기존 다운로드·결과 조회 기능은 의미를 파악하기 어려운 축약형 요청 파라미터를 사용하고 있었습니다. 이 이름들은 현재 화면뿐 아니라 기존 시스템이 만든 URL과 메일 링크, 다른 업무 시스템의 요청 규격에도 그대로 쓰이고 있었습니다.
파라미터명을 일괄 변경하면 기존 링크와 호출 시스템을 함께 수정해야 했습니다. 반대로 레거시 이름을 신규 DTO 필드명으로 그대로 사용하면 업무 의미가 드러나지 않고 과거 명명 규칙이 새 코드까지 전파됩니다.
변환 책임을 Spring MVC 확장 지점으로 옮겼습니다
외부 파라미터 규격은 유지하되 애플리케이션 내부에서는 업무 의미가 명확한 이름을 사용하도록 요청과 DTO 사이에 매핑 계층을 뒀습니다. DTO 필드에는 과거 요청 키를 선언하고, Spring MVC의 HandlerMethodArgumentResolver가 요청 값 탐색·우선순위 결정·자료형 변환·DTO 생성을 맡도록 했습니다.
컨트롤러에서 @RequestParam으로 과거 키를 하나씩 받은 뒤 DTO에 옮길 수도 있습니다. 하지만 같은 별칭 탐색과 변환 코드가 엔드포인트마다 반복되고, 레거시 이름이 컨트롤러와 서비스까지 퍼집니다. 여러 API가 같은 변환을 사용하므로 공통 요청 바인딩 규칙을 MVC 확장 지점으로 분리했습니다.
아래 코드는 실제 구현의 책임과 동작 순서를 유지하되, DTO 이름과 요청 키 같은 업무 식별자를 일반화한 축약 예시입니다.
1. 반복 가능한 매핑 애너테이션
한 업무 값이 호출 화면에 따라 서로 다른 키로 전달되는 경우가 있어, 하나의 필드에 별칭을 여러 개 선언할 수 있어야 했습니다. @Repeatable을 사용하면 별도의 중앙 매핑표 없이 DTO 가까이에 호환 규칙을 둘 수 있습니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(ArgumentMappings.class)
public @interface ArgumentMapping {
String value();
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ArgumentMappings {
ArgumentMapping[] value();
}리졸버는 getAnnotationsByType(ArgumentMapping.class)으로 단일 선언과 반복 선언을 같은 방식으로 읽습니다. 애너테이션 선언 순서가 곧 별칭 우선순위가 되므로, 두 키가 동시에 들어오면 먼저 선언한 키의 값을 사용합니다.
2. DTO가 외부 별칭을 선언합니다
DTO 필드명은 내부 업무 용어를 따르고, 호환해야 할 요청 키만 애너테이션으로 선언합니다. 애너테이션이 없는 필드는 필드명과 같은 요청 키를 사용하는 대체 규칙을 남겼습니다.
@Getter
public class DownloadRequest {
@ArgumentMapping("legacyUserKey")
@ArgumentMapping("alternateUserKey")
private String userKey;
@ArgumentMapping("legacyOrderKey")
private String orderKey;
@ArgumentMapping("legacyItemKey")
private Integer itemSequence;
// 요청의 fileType을 그대로 바인딩한다.
private String fileType;
}이 구조에서 레거시 키는 DTO의 애너테이션에만 남습니다. 컨트롤러와 서비스는 userKey, orderKey, itemSequence처럼 의미가 드러나는 필드만 사용합니다.
3. Resolver가 처리할 DTO를 선별합니다
모든 Controller 인자를 가로채면 Spring MVC의 기본 바인딩까지 영향을 받을 수 있습니다. 그래서 대상 타입의 필드에 @ArgumentMapping이 있을 때만 이 Resolver가 동작하도록 범위를 제한했습니다.
@Component
@Slf4j
public class ArgumentMappingResolver
implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return Arrays.stream(
parameter.getParameterType().getDeclaredFields()
)
.anyMatch(field ->
field.getAnnotationsByType(ArgumentMapping.class).length > 0
);
}
// resolveArgument 구현은 다음 절에서 설명한다.
}특정 Controller나 URL을 조건으로 삼지 않았습니다. DTO가 매핑 규칙을 선언하면 Resolver가 처리하고, 그렇지 않은 인자는 기존 Spring MVC 처리 방식을 따릅니다.
4. 별칭 탐색과 자료형 변환
resolveArgument는 DTO를 생성한 뒤 각 필드에 대해 다음 순서로 값을 찾습니다.
- 필드에 선언된 별칭을 앞에서부터 조회합니다.
- 값이 있으면 첫 번째로 일치한 별칭을 선택합니다.
- 일치한 별칭이 없으면 DTO 필드명으로 다시 조회합니다.
- 요청 값이 하나면 단일 문자열을, 여러 개면
String[]을 변환 대상으로 사용합니다. ConversionService로 대상 자료형에 맞게 변환한 뒤 필드에 주입합니다.
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
Class<?> dtoType = parameter.getParameterType();
Object target = dtoType.newInstance();
Map<String, String[]> parameterMap = webRequest.getParameterMap();
ConversionService conversionService =
DefaultConversionService.getSharedInstance();
for (Field field : dtoType.getDeclaredFields()) {
String[] parameterValues = null;
for (ArgumentMapping mapping
: field.getAnnotationsByType(ArgumentMapping.class)) {
parameterValues = parameterMap.get(mapping.value());
if (parameterValues != null) {
break;
}
}
if (parameterValues == null) {
parameterValues = parameterMap.get(field.getName());
}
if (parameterValues == null) {
continue;
}
Object value = parameterValues.length == 1
? conversionService.convert(
parameterValues[0], field.getType())
: conversionService.convert(
parameterValues, field.getType());
field.setAccessible(true);
field.set(target, value);
}
return target;
}문자열을 직접 Integer.parseInt 같은 방식으로 분기하지 않고 Spring의 변환 체계를 사용했습니다. 요청 값이 하나면 단일 문자열을, 여러 개면 String[]을 대상 필드형으로 변환합니다. DTO는 Reflection으로 생성하므로 기본 생성자가 필요하고, 지원하지 않는 값은 변환 실패로 명확히 드러납니다.
5. Spring MVC에 한 번만 등록합니다
Resolver는 WebMvcConfigurer#addArgumentResolvers에서 등록합니다. 등록 이후에는 Controller마다 별도의 변환 코드를 호출할 필요가 없습니다.
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final ArgumentMappingResolver argumentMappingResolver;
@Override
public void addArgumentResolvers(
final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(argumentMappingResolver);
}
}6. Controller에서는 내부 모델만 사용합니다
기존 요청 형식은 변경하지 않습니다. 매핑 계층이 레거시 별칭을 의미가 명확한 DTO 필드로 변환하므로 Controller와 Service는 과거 명명 규칙을 알 필요가 없습니다.
// 공개용 예시 요청
// /download?legacyUserKey=user&legacyOrderKey=order&legacyItemKey=1
@GetMapping("/download")
public FileResult download(DownloadRequest request) {
return downloadService.findFile(
request.getUserKey(),
request.getOrderKey(),
request.getItemSequence()
);
}요청이 들어오면 Spring MVC가 Controller 인자를 확인하고, supportsParameter가 이 DTO를 처리 대상으로 선택합니다. Resolver가 객체를 생성해 값을 채운 뒤 Controller에 전달하므로, Controller에는 request.getParameter(...), 별칭 분기, 문자열 변환 코드가 남지 않습니다.
리졸버가 맡는 일과 맡지 않는 일
이 리졸버는 요청 파라미터를 DTO 필드로 옮기는 일까지만 맡습니다. 필수값 검증, 파일 경로 보안, 주문과 사용자 관계 같은 업무 정합성은 여기에 넣지 않았습니다. 매핑과 검증을 섞으면 리졸버가 업무 규칙까지 알아야 해 다른 API에서 재사용하기 어려워지기 때문입니다.
Reflection을 사용하는 만큼 일반 바인딩보다 동작이 명시적이지 않을 수 있고, 복수 별칭의 우선순위도 테스트로 고정해야 합니다. 또한 커스텀 Resolver가 객체 생성을 맡으므로 Spring의 기본 @ModelAttribute 바인딩과 동일하게 동작한다고 가정하지 않고, 자료형 변환·빈 값·배열·변환 실패를 별도로 검증했습니다.
검증
- 기존 요청 URL과 메일 링크가 그대로 동작하는지 확인했습니다.
- 첫 번째 별칭, 대체 별칭, DTO 필드명 대체 규칙을 각각 확인했습니다.
- 복수 별칭이 동시에 들어왔을 때 선언 순서의 값이 적용되는지 확인했습니다.
- 문자열·숫자·배열 변환과 변환 실패가 의도한 방식으로 처리되는지 확인했습니다.
- 매핑 후 Controller와 Service에 레거시 이름이 전파되지 않는지 확인했습니다.
- 기존 파일명·압축 결과·오류 응답 형식이 유지되는지 전체 회귀 범위에서 확인했습니다.
적용 결과
- 기존 URL과 연계 시스템의 요청 규격을 바꾸지 않고 신규 코드의 가독성을 개선했습니다.
- 레거시 파라미터명이 Service와 업무 코드까지 전파되는 문제를 줄였습니다.
- Controller마다 반복되던 요청 조회·변환 로직을 공통 계층으로 모았습니다.
- 여러 레거시 DTO가 같은 별칭 탐색·변환 규칙을 재사용하도록 했습니다.
- 변경하기 어려운 외부 요청 규격과 개선 가능한 내부 객체 모델을 분리했습니다.
정리
파라미터명을 새 이름으로 치환하는 것만으로는 기존 링크와 연계 호출을 지킬 수 없었습니다. 외부 요청 이름은 그대로 두고 @Repeatable 애너테이션에 호환 규칙을 선언했으며, 탐색·변환·객체 생성은 HandlerMethodArgumentResolver로 모았습니다. 레거시를 바꿀 때는 먼저 밖에서 유지해야 할 이름과 안에서 개선할 수 있는 모델을 구분해야 합니다.