개발이군고구마

[HandlerMethodArgumentResolver] 레거시코드, 고쳐도 될까? 본문

SERVER/Spring

[HandlerMethodArgumentResolver] 레거시코드, 고쳐도 될까?

김구황 2025. 12. 31. 07:55
728x90
Static Inner Class 는 필수였을까?

 

신규 도입한 클라이언트 툴의 데이터를 받는 모든 프리젠테이션 클래스는 아래와 같이 작성되어있다.

하나의 요청 모델 클래스 안에 다수의 static inner class가 정의되어 있었고,  요청/응답 모델과 변환 로직이 한 파일에 밀집된 구조였다.

public class DisplayController extends AbstractController {
    
    // ❌ 수십 개의 static inner class들...
    public static class DspTmplatRequest implements ConvertableModel<DspTmplatBoDTO> { ... }
    public static class DspTmplatResponse extends DefaultResponse { ... }
    public static class AnotherRequest { ... }
    public static class AnotherResponse { ... }
    // ... 계속 증가
    
    @RequestMapping("/listTmplat")
    @ResponseModel(DspTmplatResponse.class)
    public DspTmplatResponse getTmplatOpList(...) { ... }
}

이 코드를 유지 보수 하면서 느꼈던 가장 큰 어려움은 가독성과 운영성이었다.

  • DTO 탐색 비용이 과도하게 높았고  (클라이언트에서 받아온 파라미터가 50개가 넘는 것이 있음) 
  • convert 로직의 호출 흐름을 추적하기 어려웠으며  (수 십개의 파라미터들이 convert 메서드에서 데이터 분리 및 조합) 
  • 사소한 수정(예를 들어 클라이언트단 데이터 타입 변경 등) 에도 “이게 어디까지 영향을 미칠까?”를 먼저 고민해야 했다

 

 

1. 문제의 핵심: static inner class 자체가 문제였을까?

 

처음에는 무작정 코드를 바꿔야겠다 생각했는데, 

  • “굳이 static inner class를 쓸 이유가 있나?
  • 패키지로 분리해서 일반 클래스 DTO로 써도 되지 않나?”

고치기 전에 왜 이런 코드가 작성되어있는지 이해하고 넘어갈 필요가 있었다. 

우선 이 구조는 아래와 같은 구조를 실현하기 위함이다. 

  • @RequestModel, @ResponseModel 같은 커스텀 어노테이션
  • 해당 어노테이션이 붙은 inner class를 다시 한 번 받아
  • convertable model을 통해 DTO로 변환
  • 변환된 객체가 컨트롤러 메서드 파라미터로 자동 주입

위에 설명한 방식을 단순화해보면 다음과 같은 형태이다. 

public class ExampleModels {

    @RequestModel
    public static class CreateRequest implements Convertible<CreateDto> {

        private String name;
        private int age;

        @Override
        public CreateDto convert() {
            return new CreateDto(name, age);
        }
    }

    @ResponseModel
    public static class CreateResponse {

        private Long id;
        private String name;
    }
}

컨트롤러에서는 이 inner class를 그대로 사용한다.

@PostMapping("/example")
public ResponseEntity<?> create(@RequestModel ExampleModels.CreateRequest request) {
    // request는 이미 DTO로 변환된 상태
}
  • 요청 모델은 inner class로 강하게 묶여 있고
  • 변환 책임(convert)을 모델 스스로가 가진다
  • 컨트롤러는 변환 과정을 전혀 인지하지 않는다

즉, 이 구조의 핵심에는 Spring MVC의 HandlerMethodArgumentResolver가 있었다.

결국 근본적으로 알아야하는 부분은 Argument Resolver의 작동원리였기에, 다른 질문으로 이어졌다. 

“Argument Resolver 때문에 inner class 구조가 강제된 걸까?”

 

 

 

2. Spring은 언제, 어떻게 메서드 파라미터를 만들어줄까?

HandlerMapping과 ArgumentResolver의 처리 흐름. Resolver는 반환만 한다 (1) (이미지 우측) 그후 Spring은 주입 (2) 한다

 

Spring MVC에서 컨트롤러 메서드가 호출되기 전, Spring은 먼저 이렇게 생각한다.

“이 메서드의 각 파라미터는 어떻게 만들어야 하지?”

 

이때 등장하는 것이 HandlerMethodArgumentResolver다.

Spring은 컨트롤러 메서드의 각 파라미터마다 등록된 Resolver들에게 차례로 물어본다.

“이 파라미터, 네가 처리할 수 있어?”
supportsParameter(MethodParameter parameter)

true를 반환한 Resolver가 있다면, Spring은 그 Resolver의 resolveArgument()를 호출한다.

 

 

 

3. Custom Argument Resolver의 실제 동작 흐름

 

레거시 코드에서 사용하던 구조를 단순화하면 대략 이런 형태다.

1️⃣ 커스텀 어노테이션

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestModel {
}

 

2️⃣ Argument Resolver 구현

public class RequestModelArgumentResolver
        implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(RequestModel.class);
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory
    ) {
        // 요청 데이터 추출
        Object rawRequest = extractRequest(webRequest);

        // convert 로직 수행
        return convert(rawRequest, parameter.getParameterType());
    }

    private Object convert(Object source, Class<?> targetType) {
        // convertable model 구현체 호출
        // DTO로 변환
        return convertedDto;
    }
}

 

3️⃣ 컨트롤러에서는 이렇게 사용된다

@PostMapping("/example")
public ResponseEntity<?> example(@RequestModel ExampleRequest request) {
    // 이미 변환된 DTO가 들어와 있다
}

 

이 구조에서 중요한 점은 이것이다.

resolveArgument가 반환하는 객체가 그대로 컨트롤러 메서드 파라미터가 된다

  • convert는 Spring이 호출하는 메서드가 아니라
  • Resolver 내부 구현의 선택일 뿐이다. 즉, 
  • convert 메서드는 “파라미터로 쓰일 객체를 만들어서 반환”만 하면 되고
  • Spring은 그 반환값을 그대로 컨트롤러 메서드의 파라미터에 주입한다.

 

 

4. static inner class는 Argument Resolver의 요구사항이 아니다

 

Argument Resolver의 동작을 따라가며 확인한 결론은 

  • Resolver는 클래스가 inner class인지
  • 혹은 어느 패키지에 있는지 전혀 신경 쓰지 않는다

 

Resolver가 관심 있는 것은 오직 세 가지다.

  1. 파라미터에 특정 어노테이션이 붙어 있는지
  2. 어떤 타입으로 변환해야 하는지
  3. 변환 로직이 정상적으로 동작하는지
static inner class는 기술적인 필수 조건이 아니었다

 

5. inner class 구조가 필수라고 오해했던 이유

 

레거시 코드에서 static inner class가 사용된 이유는 기술적 제약이라기보다 설계 관성에 가까웠다.

✅ 당시 구조는 다음과 같은 가정을 내포하고 있었다. 아래와 같은 이유로 static class를 키워왔던 것 같다. 

  • 요청 모델은 특정 API에 종속된다
  • 외부에서 재사용될 일이 없다

 

하지만 Argument Resolver의 실제 동작을 보면,

  • Resolver는 클래스 위치를 전혀 고려하지 않고
  • 어노테이션과 타입 정보만으로 동작한다

즉, 아래 구조 역시 동일하게 동작한다.

 

 

 

6. 패키지 분리 후에도 동일하게 동작하는 구조

@RequestModel
public class CreateRequest implements Convertible<CreateDto> {

    private String name;
    private int age;

    @Override
    public CreateDto convert() {
        return new CreateDto(name, age);
    }
}

Resolver를 통해 Request 클래스는 CreateDto로 변환됨 

@PostMapping("/example")
public ResponseEntity<?> create(@RequestModel CreateRequest request) {
}

여기서 CreateRequest 클래스는 import 해옴 

 

Argument Resolver 입장에서는

  • inner class인지
  • 별도 패키지 클래스인지

전혀 중요하지 않다.

중요한 것은 “이 파라미터를 내가 처리할 수 있는가”뿐이다

 

 

 

7. 구조를 분리하여 무엇이 좋아질까? 

 

리펙토링이 가능한 이유는 아래와 같다. 

  • static inner class는 기술적 필수가 아니다
  • 구조 분리는 동작 변경 없이 가능하다
  • 오히려 책임 분리가 명확해진다

만약 inner class를 패키지 하위 클래스로 분리하게 되면 

  • DTO 탐색성이 개선되었고  (← 이 이유가 가장 컸다) 
  • convert 로직의 테스트가 쉬워졌으며 (☝static inner에서도 테스트는 가능하다)
  • 요청/응답 모델의 재사용 가능성이 열리게 된다 (☝ 이럴 가능성은 매우 적다) 

 


 

사실 리펙토링을 하지 않았을 때의 장점에 포커스를 둔다면, 그대로 코드를 작성해도 무리는 없다. 🙄

레거시 구조가 만들어진 이유는 분명 있기 때문이다. 다만 그 이유는 코드 표면이 아니라 프레임워크 내부 동작에 의해서 정해졌을 확률이 높다. 따라서 내부 메커니즘을 이해하면, 구조는 더 이상 고정되지 않는다.

 

요약하자면.... 레거시 코드는 “지켜야 할 규칙”이 아니라 “선택지 중 하나”라는 것을 알고 개발하는 자세가 필요할 듯 하다.