Spring Boot와 Spring REST Docs를 사용하여 RESTful API를 개발하고 테스트하는 것은 현대적인 백엔드 개발의 표준적인 워크플로우 중 하나입니다. 특히 Spring REST Docs는 테스트 코드를 기반으로 항상 최신 상태를 유지하는 정확한 API 문서를 생성해주기 때문에, Swagger와 같은 도구와는 다른 결의 강력한 장점을 제공합니다. 개발자는 테스트 케이스를 작성함으로써 자연스럽게 문서까지 완성하게 되어, 문서와 실제 코드 간의 불일치를 원천적으로 방지할 수 있습니다. 그러나 이 강력한 도구를 사용하는 과정에서 때때로 개발자를 당황하게 만드는 예외 상황을 마주치기도 합니다. 그 중에서도 특히 @WebMvcTest
환경에서 List 형태의 파라미터를 처리할 때 발생하는 java.lang.NoSuchMethodException: java.util.List.<init>()
오류는 많은 개발자들이 한 번쯤 겪어봤을 법한 까다로운 문제입니다. 이 글에서는 해당 오류가 왜 발생하는지 근본적인 원인을 심층적으로 분석하고, 다양한 관점에서 제시할 수 있는 해결책들을 상세한 코드 예제와 함께 제시하여 이 문제를 완벽하게 정복할 수 있도록 돕겠습니다.
문제 상황 재현: 에러는 언제, 어떻게 나타나는가?
이론적인 설명에 앞서, 어떤 상황에서 NoSuchMethodException
이 발생하는지 구체적인 코드를 통해 재현해 보겠습니다. 문제 상황을 명확히 이해하는 것이 해결의 첫걸음입니다.
1. 문제의 Controller 작성
먼저, 여러 개의 검색 키워드를 Query Parameter를 통해 List/api/search?keywords=spring&keywords=jpa
와 같은 요청을 처리하는 엔드포인트입니다.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
public class SearchController {
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(List<String> keywords) {
if (keywords == null || keywords.isEmpty()) {
return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
}
String result = "검색된 키워드: " + keywords.stream().collect(Collectors.joining(", "));
return ResponseEntity.ok(result);
}
}
위 코드는 특별할 것 없는 평범한 컨트롤러입니다. Spring MVC는 동일한 이름으로 여러 개의 Query Parameter가 들어오면 이를 자동으로 List 또는 배열로 매핑해주는 편리한 기능을 제공합니다. 따라서 위 코드 자체에는 아무런 문제가 없습니다. 실제로 애플리케이션을 실행하고 curl "localhost:8080/api/search?keywords=java&keywords=test"
와 같이 요청을 보내면 정상적으로 "검색된 키워드: java, test" 라는 응답을 받을 수 있습니다.
2. 문제를 유발하는 @WebMvcTest 작성
문제는 Spring REST Docs와 함께 @WebMvcTest
를 사용하여 이 컨트롤러를 테스트할 때 발생합니다. @WebMvcTest
는 웹 레이어에 대한 슬라이스 테스트(Slice Test)를 지원하며, 전체 애플리케이션 컨텍스트를 로드하지 않고 MVC 관련 빈들만 로드하여 빠르고 가볍게 테스트를 진행할 수 있게 해줍니다.
아래와 같이 REST Docs 문서 생성을 포함한 테스트 코드를 작성해 보겠습니다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(SearchController.class)
@AutoConfigureRestDocs // REST Docs 자동 설정 활성화
@ExtendWith(RestDocumentationExtension.class) // JUnit 5에서 REST Docs 사용을 위한 확장
public class SearchControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void 키워드_리스트로_검색_테스트() throws Exception {
mockMvc.perform(get("/api/search")
.param("keywords", "spring", "jpa", "restdocs"))
.andExpect(status().isOk())
.andDo(document("search-by-keywords",
queryParameters(
parameterWithName("keywords").description("검색할 키워드 목록 (다중값 가능)")
)
));
}
}
이 테스트 코드를 실행하면, 우리는 성공을 기대하지만 안타깝게도 다음과 같은 끔찍한 예외를 마주하게 됩니다.
org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.NoSuchMethodException: java.util.List.<init>()
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
...
Caused by: java.lang.NoSuchMethodException: java.util.List.<init>()
at java.base/java.lang.Class.getConstructor0(Class.java:3585)
at java.base/java.lang.Class.getConstructor(Class.java:2271)
at org.springframework.beans.BeanUtils.getResolvableConstructor(BeanUtils.java:274)
... 40 more
로그의 핵심은 Caused by: java.lang.NoSuchMethodException: java.util.List.<init>()
입니다. 직역하면 'java.util.List의 생성자(<init>)를 찾을 수 없다'는 의미입니다. 이 에러 메시지는 왜 발생하는 것일까요? 당연히 List는 인터페이스이므로 생성자가 없는 것이 당연한데, 왜 Spring은 List를 직접 생성하려고 시도하는 것일까요?
에러의 근본 원인: MockMvc 환경과 데이터 바인딩의 미스터리
오류의 원인을 이해하기 위해서는 Spring MVC의 데이터 바인딩(Data Binding) 메커니즘과, 전체 애플리케이션 환경과 @WebMvcTest
슬라이스 테스트 환경의 미묘한 차이를 알아야 합니다.
1. Spring MVC의 데이터 바인딩 과정
클라이언트로부터 HTTP 요청이 들어오면, DispatcherServlet은 HandlerMapping을 통해 요청을 처리할 컨트롤러와 메서드를 찾습니다. 그 후, HandlerAdapter는 해당 메서드를 호출해야 하는데, 이때 요청에 포함된 파라미터들(Query Parameter, Path Variable, Request Body 등)을 메서드의 인자(Argument) 타입에 맞게 변환하고 주입해주는 과정이 필요합니다. 이 과정을 '데이터 바인딩'이라 하고, HandlerMethodArgumentResolver
인터페이스의 구현체들이 이 역할을 담당합니다.
우리가 겪는 문제의 상황, 즉 Query Parameter를 컨트롤러 메서드의 인자로 바인딩하는 것은 RequestParamMethodArgumentResolver
와 관련이 깊습니다. 이 리졸버는 다음과 같은 순서로 동작합니다.
- HTTP 요청에서 파라미터 이름('keywords')에 해당하는 값들을 추출합니다. 여러 개일 경우 문자열 배열(
String[]
) 형태로 가져옵니다. (e.g.,["spring", "jpa", "restdocs"]
) - 이 문자열 배열을 컨트롤러 메서드의 목표 타입(
List<String>
)으로 변환해야 합니다. - 이 변환 과정에서 Spring은 내부적으로
ConversionService
를 사용합니다.ConversionService
는 다양한 타입 변환기(Converter
)를 등록하고 관리하는 역할을 합니다. 일반적으로String[]
->List<String>
변환을 처리할 수 있는 컨버터가 등록되어 있습니다.
2. @WebMvcTest 환경의 함정
문제는 @WebMvcTest
가 전체 애플리케이션 컨텍스트를 로드하지 않는 '슬라이스 테스트'라는 점에서 발생합니다. 이 환경에서는 MVC 동작에 필수적인 최소한의 빈들만 등록됩니다. 완전한 WebMvcConfigurationSupport
나 사용자가 정의한 커스텀 WebMvcConfigurer
설정이 모두 적용되지 않을 수 있습니다.
이로 인해, @WebMvcTest
의 기본 설정에서는 String[]
을 List<String>
으로 유연하게 변환해주는 ConversionService
의 일부 기능이 활성화되지 않거나 다른 방식으로 동작할 수 있습니다.
따라서 데이터 바인더는 목표 타입인 List<String>
을 보고, 이 컬렉션을 직접 생성하여 값을 채우려고 시도하게 됩니다. 이를 위해 자바의 리플렉션(Reflection) API를 사용하여 `List.class.getConstructor()`와 같은 코드를 내부적으로 호출합니다. 하지만 java.util.List
는 인터페이스(Interface)이므로 인스턴스화할 수 있는 생성자(<init>
)가 존재하지 않습니다. 이것이 바로 NoSuchMethodException
이 발생하는 근본적인 이유입니다. Spring의 테스트 환경 데이터 바인더가 구체적인 List 구현체(e.g., ArrayList
)를 선택하지 못하고, 인터페이스 자체를 생성하려고 시도하다가 실패하는 것입니다.
반면, 전체 애플리케이션을 실행했을 때 정상 동작했던 이유는, 전체 컨텍스트에서는 모든 자동 설정이 적용되어 ConversionService
가 String[]
을 `ArrayList<String>`과 같은 구체적인 컬렉션으로 변환하는 로직을 정상적으로 수행했기 때문입니다.
이제 원인을 명확히 알았으니, 해결책을 찾아봅시다. 해결책은 한 가지만 있는 것이 아닙니다. 상황의 복잡도와 프로젝트의 설계 원칙에 따라 여러 가지 접근법을 취할 수 있습니다.
해결 방안 1: 배열(Array) 사용 (가장 간단한 해결책)
가장 즉각적이고 간단한 해결책은 컨트롤러의 파라미터 타입을 List<String>
에서 String[]
(문자열 배열)으로 변경하는 것입니다.
// 수정 전
// public ResponseEntity<String> searchByKeywords(List<String> keywords) { ... }
// 수정 후
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(String[] keywords) {
if (keywords == null || keywords.length == 0) {
return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
}
String result = "검색된 키워드: " + String.join(", ", keywords);
// 필요하다면 내부에서 List로 변환하여 사용 가능
// List<String> keywordList = Arrays.asList(keywords);
return ResponseEntity.ok(result);
}
왜 이것이 동작할까요?
데이터 바인더는 요청 파라미터를 기본적으로 String[]
형태로 수집합니다. 컨트롤러 메서드의 파라미터 타입이 이와 동일한 String[]
이므로, 별도의 타입 '변환' 과정이 필요 없게 됩니다. 인터페이스를 생성하려는 시도 자체가 일어나지 않기 때문에 NoSuchMethodException
이 발생할 여지가 사라집니다. 만약 내부 로직에서 List의 메서드(stream, forEach 등)를 사용해야 한다면, Arrays.asList(keywords)
를 통해 간단하게 List로 변환하여 사용할 수 있습니다.
이 방법은 매우 간단하고 직관적이지만, 자바 컬렉션 프레임워크의 사용을 선호하는 최신 자바 스타일에서는 배열을 직접 사용하는 것이 다소 어색하게 느껴질 수 있다는 단점이 있습니다.
해결 방안 2: @RequestParam 어노테이션 명시
많은 경우, 컨트롤러 파라미터에 @RequestParam
어노테이션을 명시적으로 붙여주는 것만으로도 문제가 해결됩니다.
import org.springframework.web.bind.annotation.RequestParam;
...
// 수정 전
// public ResponseEntity<String> searchByKeywords(List<String> keywords) { ... }
// 수정 후
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(@RequestParam List<String> keywords) {
if (keywords.isEmpty()) {
return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
}
String result = "검색된 키워드: " + String.join(", ", keywords);
return ResponseEntity.ok(result);
}
왜 이것이 동작할까요?
컨트롤러 메서드의 파라미터에 어떠한 어노테이션도 붙이지 않으면 Spring MVC는 여러 HandlerMethodArgumentResolver
중 어떤 것을 사용해야 할지 추론 과정을 거칩니다. 이 과정에서 @WebMvcTest
의 미니멀한 환경에서는 앞서 설명한 문제가 발생할 수 있습니다.
하지만 @RequestParam
을 명시적으로 선언하면, Spring MVC에게 "이 파라미터는 HTTP 요청의 쿼리 파라미터(또는 form data)로부터 값을 가져와야 한다"는 강력한 힌트를 주게 됩니다. 이는 RequestParamMethodArgumentResolver
가 활성화되도록 강제하는 효과가 있습니다. 이 리졸버는 내부적으로 컬렉션 타입 바인딩을 처리하기 위한 더 정교한 로직과 `ConversionService`와의 연계를 포함하고 있어, `List` 인터페이스를 만나면 `ArrayList`와 같은 적절한 구체 클래스를 사용하여 값을 채워 넣을 수 있게 됩니다.
사실, 파라미터 이름이 메서드 변수명과 같더라도 @RequestParam
을 붙이는 것은 코드의 명시성을 높이고 의도를 명확하게 하는 좋은 습관이므로, 많은 경우에 이 방법을 기본으로 사용하는 것을 권장합니다.
해결 방안 3: DTO(Data Transfer Object) 사용 (가장 권장되는 방식)
요청 파라미터가 2개를 넘어가거나, 연관된 데이터들을 함께 받아야 하거나, 유효성 검사(Validation)가 필요한 경우, 파라미터를 개별적으로 나열하는 것보다 DTO(또는 Command 객체)로 묶어서 처리하는 것이 소프트웨어 공학적으로 훨씬 우수합니다. 놀랍게도 이 설계 패턴은 NoSuchMethodException
문제에 대한 가장 확실한 해결책이기도 합니다.
1. 요청 파라미터를 담을 DTO 클래스 작성
import java.util.List;
public class SearchRequest {
private List<String> keywords;
// Lombok을 사용한다면 @Getter, @Setter, @NoArgsConstructor 등으로 대체 가능
public List<String> getKeywords() {
return keywords;
}
public void setKeywords(List<String> keywords) {
this.keywords = keywords;
}
}
2. DTO를 사용하도록 컨트롤러 수정 (@ModelAttribute)
import org.springframework.web.bind.annotation.ModelAttribute;
@RestController
public class SearchController {
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(@ModelAttribute SearchRequest request) {
List<String> keywords = request.getKeywords();
if (keywords == null || keywords.isEmpty()) {
return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
}
String result = "검색된 키워드: " + String.join(", ", keywords);
return ResponseEntity.ok(result);
}
}
GET 요청에서 객체로 파라미터를 바인딩할 때는 @ModelAttribute
를 사용합니다. (참고: @RequestBody
는 요청의 본문(body)을 객체로 변환할 때 사용하며, 주로 POST, PUT 요청의 JSON 데이터에 사용됩니다. @ModelAttribute
는 쿼리 파라미터나 form 데이터를 객체의 필드에 바인딩할 때 사용됩니다.)
3. 테스트 코드 (변경 없음)
놀랍게도, 컨트롤러가 DTO를 사용하도록 변경되어도 테스트 코드는 전혀 변경할 필요가 없습니다. MockMvc
의 .param()
메서드는 여전히 동일하게 동작합니다.
@Test
void 키워드_리스트로_검색_테스트_DTO사용() throws Exception {
// 컨트롤러의 파라미터가 DTO로 바뀌었지만 테스트 코드는 동일하다.
mockMvc.perform(get("/api/search")
.param("keywords", "spring", "jpa", "restdocs"))
.andExpect(status().isOk())
.andDo(document("search-by-keywords-dto",
queryParameters(
// 문서화 대상이 DTO의 필드명이 된다.
parameterWithName("keywords").description("검색할 키워드 목록 (다중값 가능)")
)
));
}
왜 DTO 방식은 완벽하게 동작할까요?
@ModelAttribute
를 사용한 객체 바인딩은 @RequestParam
을 사용한 개별 파라미터 바인딩과 다른 메커니즘을 사용합니다. Spring MVC는 다음과 같은 절차를 따릅니다.
- 먼저 DTO 객체(
SearchRequest
)의 인스턴스를 생성합니다. 이때는 DTO 클래스의 기본 생성자를 사용하므로 아무런 문제가 없습니다. - 그 후, 요청 파라미터들을 DTO 객체의 필드에 하나씩 채워 넣습니다. 'keywords' 라는 요청 파라미터를 발견하면 `SearchRequest` 객체의 `setKeywords()` 메서드를 호출하려고 합니다.
- 이때 `setKeywords()` 메서드의 파라미터 타입이
List<String>
인 것을 확인하고,String[]
형태의 요청 파라미터 값들을 `List<String>`으로 변환합니다. 객체의 필드에 값을 설정하는 이 과정에서는ConversionService
가 올바르게 동작하여, 구체적인 List 구현체(ArrayList
)를 생성하고 값을 채워 넣어 `setKeywords` 메서드를 성공적으로 호출합니다.
즉, 인터페이스인 `List`를 직접 생성하려는 위험한 시도 대신, DTO 객체를 먼저 생성하고 그 내부 필드를 채우는 안전한 방식으로 동작하기 때문에 문제가 발생하지 않습니다. 또한, DTO를 사용하면 향후 확장성(새로운 검색 조건 추가), 유효성 검사(@Valid
, @Size
등), 코드의 가독성 및 유지보수성 측면에서 압도적인 이점을 가집니다.
종합: 어떤 해결책을 선택해야 할까?
지금까지 세 가지 해결책을 살펴보았습니다. 각 방법의 장단점을 정리하고 어떤 상황에 어떤 방법을 선택해야 할지 가이드를 제시합니다.
해결 방안 | 장점 | 단점 | 추천 상황 |
---|---|---|---|
1. 배열 (String[] ) |
가장 간단하고 즉각적인 해결책. 데이터 바인딩 원리를 몰라도 적용 가능. | 현대 자바 스타일에 다소 어울리지 않음. List의 풍부한 API를 사용하려면 변환 필요. | 급하게 버그를 수정해야 할 때, 또는 프로젝트 전반적으로 배열 사용에 거부감이 없는 경우. |
2. @RequestParam List<...> |
코드의 의도가 명확해짐. List를 그대로 사용할 수 있어 Java 컬렉션 친화적. | 특정 Spring 버전이나 복잡한 설정 조합에서는 여전히 문제가 발생할 가능성을 배제할 수 없음. | 간단한 파라미터 한두 개를 받을 때. 가장 먼저 시도해볼 만한 균형 잡힌 방법. |
3. DTO와 @ModelAttribute |
가장 안정적이고 근본적인 해결책. 확장성, 유효성 검사, 가독성 등 장점이 많음. | 단일 파라미터를 위해 DTO 클래스를 하나 더 만들어야 하는 약간의 번거로움. | 대부분의 실무 애플리케이션에 권장. 요청 파라미터가 2개 이상이거나, 앞으로 확장될 가능성이 있거나, 유효성 검사가 필요한 모든 경우. |
결론
Spring REST Docs 테스트 중 발생하는 java.lang.NoSuchMethodException: java.util.List.<init>()
오류는 @WebMvcTest
의 슬라이스 테스트 환경에서 Spring MVC 데이터 바인더가 List
인터페이스를 직접 인스턴스화하려고 시도하기 때문에 발생합니다. 이는 테스트 환경의 `ConversionService` 설정이 전체 애플리케이션 환경과 미묘하게 다르기 때문입니다.
이 문제에 대한 해결책으로 우리는 파라미터 타입을 String[]
으로 변경하는 가장 간단한 방법, @RequestParam
어노테이션을 명시하여 바인딩 힌트를 주는 방법, 그리고 DTO를 사용하여 구조적으로 문제를 해결하는 가장 강력하고 권장되는 방법을 알아보았습니다.
단순히 에러를 해결하는 것을 넘어 그 원인을 깊이 있게 파고드는 과정은 Spring 프레임워크의 내부 동작에 대한 이해를 높이는 좋은 기회가 됩니다. 당장의 문제를 해결하기 위해 배열을 사용하는 것도 방법일 수 있지만, 장기적인 관점에서는 코드의 의도를 명확히 하는 @RequestParam
을 사용하거나, 더 나아가 DTO를 도입하여 애플리케이션의 설계 품질을 높이는 방향을 지향하는 것이 바람직합니다. 이제 이 성가신 예외를 마주치더라도 당황하지 않고, 원인을 자신있게 설명하며 최적의 해결책을 적용할 수 있는 개발자로 거듭나시기를 바랍니다.
0 개의 댓글:
Post a Comment