간단한 트러블 슈팅이다.
문제 상황
새 프로젝트를 만들어 항상 해왔던 것처럼 컨트롤러에 API 엔드포인트를 만들어 실행했더니 전에는 발생하지 않던 문제가 발생했다.
예외 발생
java.lang.IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.
스택은 자주 쓰던 것과는 다른 점이 없었는데 이러한 예외가 발생했다.
원인
파악된 원인을 간략하게 나타내자면, `@PathVariable`이나 `@RequestParam`등의 애너테이션을 사용할 때 `value` 파라미터는 기존엔 굳이 명시할 필요가 없었다. 예를 들어, id를 path variable로 받는 API인 `/users/{id}`의 메서드를 다음과 같이 나타낼 수 있었다.
@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> retrieveUser(@PathVariable Long id) {
...
}
이런 상황에서 애터네이션의 파라미터인 `value`가 필요 없던 이유는 Spring이 자체적으로 유추했기 때문이다. 즉, 위와 같은 표현은 `@PathVariable(value = "id")`와 같이 작동했다.
하지만 직접 유추하는 방식이 아니라 리플렉션을 통해 값을 주입하는 방식으로 진행되었고, 결국 long 타입의 적절한 argument를 찾지 못했기 때문에 리플렉션을 실패한 것이다.
Parameter Name Discoverer
스프링은 메서드나 생성자의 파라미터의 이름을 찾는 인터페이스로 `ParameterNameDiscoverer`를 사용한다.
/**
* Interface to discover parameter names for methods and constructors.
*
* <p>Parameter name discovery is not always possible, but various strategies exist
* — for example, using the JDK's reflection facilities for introspecting
* parameter names (based on the "-parameters" compiler flag), looking for
* {@code argNames} annotation attributes optionally configured for AspectJ
* annotated methods, etc.
*
* @author Rod Johnson
* @author Adrian Colyer
* @since 2.0
*/
public interface ParameterNameDiscoverer {
/**
* Return parameter names for a method, or {@code null} if they cannot be determined.
* <p>Individual entries in the array may be {@code null} if parameter names are only
* available for some parameters of the given method but not for others. However,
* it is recommended to use stub parameter names instead wherever feasible.
* @param method the method to find parameter names for
* @return an array of parameter names if the names can be resolved,
* or {@code null} if they cannot
*/
@Nullable
String[] getParameterNames(Method method);
/**
* Return parameter names for a constructor, or {@code null} if they cannot be determined.
* <p>Individual entries in the array may be {@code null} if parameter names are only
* available for some parameters of the given constructor but not for others. However,
* it is recommended to use stub parameter names instead wherever feasible.
* @param ctor the constructor to find parameter names for
* @return an array of parameter names if the names can be resolved,
* or {@code null} if they cannot
*/
@Nullable
String[] getParameterNames(Constructor<?> ctor);
}
스프링 6.0 버전까진 이 인터페이스의 기본 인스턴스는 아래와 같다.
public class DefaultParameterNameDiscoverer extends PrioritizedParameterNameDiscoverer {
@SuppressWarnings("removal")
public DefaultParameterNameDiscoverer() {
if (KotlinDetector.isKotlinReflectPresent()) {
addDiscoverer(new KotlinReflectionParameterNameDiscoverer());
}
// Recommended approach on Java 8+: compilation with -parameters.
addDiscoverer(new StandardReflectionParameterNameDiscoverer());
// Deprecated fallback to class file parsing for -debug symbols.
// Does not work on native images without class file resources.
if (!NativeDetector.inNativeImage()) {
addDiscoverer(new LocalVariableTableParameterNameDiscoverer());
}
}
}
기본적으로 `StandardReflectionParameterNameDiscoverer`를 사용하는 듯 하지만, GraalVM의 네이티브 이미지를 사용하는 것이 아니라면 `LocalVariableTableParameterNameDiscoverer`가 사용되었다.
`StandardReflectionParameterNameDiscoverer`의 구현부는 매우 간단하다.
public class StandardReflectionParameterNameDiscoverer implements ParameterNameDiscoverer {
@Override
public @Nullable String @Nullable [] getParameterNames(Method method) {
return getParameterNames(method.getParameters());
}
@Override
public @Nullable String @Nullable [] getParameterNames(Constructor<?> ctor) {
return getParameterNames(ctor.getParameters());
}
private String @Nullable [] getParameterNames(Parameter[] parameters) {
String[] parameterNames = new String[parameters.length];
for (int i = 0; i < parameters.length; i++) {
Parameter param = parameters[i];
if (!param.isNamePresent()) {
return null;
}
parameterNames[i] = param.getName();
}
return parameterNames;
}
}
메서드나 생성자의 파라미터를 리플렉션을 통해 파라미터를 얻는다.
반면 `LocalVariableTableParameterNameDiscoverer`는 구현부가 복잡하다. 바이트파일로 전환된 `.class`클래스 파일에서 직접 파싱해서 변수명 테이블을 직접 만든다.
스프링 6.1 버전 이후의 Default Parameter Name Discoverer
스프링 6.1 버전부터는 `DefaultParameterNameDiscoverer`의 구현은 다음과 같이 수정됐다.
public class DefaultParameterNameDiscoverer extends PrioritizedParameterNameDiscoverer {
public DefaultParameterNameDiscoverer() {
if (KotlinDetector.isKotlinReflectPresent()) {
addDiscoverer(new KotlinReflectionParameterNameDiscoverer());
}
// Recommended approach on Java 8+: compilation with -parameters.
addDiscoverer(new StandardReflectionParameterNameDiscoverer());
}
}
`LocalVariableTableParameterNameDiscoverer`가 아예 삭제되었다.
Blocking class file로 성능에 영향을 주지만 GraalVM의 네이티브 이미지에선 아예 사용되지 않으니, 더 이상 쓰임이 없다고 판단해 아예 삭제되었다. 컴파일된 바이트코드에서 파라미터명을 파싱하는 것이 기능성으론 유익해보이지만, 사실 성능으로는 오히려 별로였던 것이다.
JDK의 파라미터명 리플렉션
`StandardReflectionParameterNameDiscoverer`를 사용하게되면 기본적으로 파라미터명이 리플렉션이 되어야만 정상적으로 동작할 것이다. 만약 리플렉션이 정상적으로 되지 않았더라면 아래의 코드에 따라 예외가 발생한다.
public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver {
...
private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
String name = info.name;
if (info.name.isEmpty()) {
name = parameter.getParameterName();
if (name == null) {
throw new IllegalArgumentException("""
Name for argument of type [%s] not specified, and parameter name information not \
available via reflection. Ensure that the compiler uses the '-parameters' flag."""
.formatted(parameter.getNestedParameterType().getName()));
}
}
String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
return new NamedValueInfo(name, info.required, defaultValue);
}
...
}
리플렉션된 파라미터명이 없으니 맨 위에 나타났던 예외 메세지와 같은 메세지의 `IllegalArgumentException`이 발생하는 것을 확인 할 수 있다.
`StandardReflectionParameterNameDiscoverer`를 사용하게 되면 JDK의 기본적인 리플렉션 전략을 따라가는 것과 같다. Java 8 버전 이후에선 기본적으로 `.class` 바이트코드 파일에 파라미터명을 저장하지 않는다. 그렇기 때문에 스프링에서도 별도의 조치가 없어 예외를 반환한 것이다. 예외 반환이 조치라면 조치긴 하지만.
해결 방안
해결은 간단하다.
사용하는 빌드 툴에 따라 다음과 같이 `-parameters`라는 컴파일러 옵션을 넣어주면 된다.
이는 Java의 공식적인 방안이다.
Java의 Reflection API를 활성화시키기 위해 `-parameters`옵션을 컴파일러에 넣어주면 컴파일하면서 변수를 `.class`파일에 저장하니 리플렉션이 가능해진다.
IDE에 따라 옵션을 추가할 수도 있다. Eclipse나 STS에선 해당 옵션을 아무리 빌드툴에서 추가해도 안되는 경우가 있는데, 참고해도 괜찮을 것 같다.
좀 더 극단적으로 말하면, 스프링 버전을 낮추는 것도 방법이다. 스프링 6.1 버전 이후에선 아예 없어졌지만, 6.0 이하의 버전에선 deprecated 상태로 남아있긴 해서 스프링 6.0 이하에 상응하는 스프링부트 3.1 이하 버전을 사용하면 된다. 스프링부트 3.2 이상의 버전에선 이와 같은 문제가 똑같이 발생할 수 있다.
저는 스프링부트 3.2.X인데 되는데요? (`spring-boot-gradle-plugin`)
누구는 버전에 상관없이 된다고 한다. 사실 그건 빌드툴인 Gradle의 java 플러그인과 spring boot 플러그인이 합쳐졌을 때 가능하게 된다.
Gradle을 빌드툴로 쓴 스프링부트 프로젝트에서 항상 고정적으로 쓰는 익숙한 플러그인이 있다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.4'
}
java 플러그인은 Java 프로젝트의 컴파일, 테스팅, 번들링 등 빌드를 가능하게 해주는 Gradle의 기본적인 플러그인이다. spring boot의 플러그인이 java 플러그인과 함께 쓰이면, spring boot에선 이에 맞춰 몇가지 대응이 추가된다.
마지막 12번째 줄을 보면 `-parameters`를 컴파일러 argument로 추가해주는 것이다.
스프링부트 3.2 이상의 버전을 사용하면서 리플렉션 문제가 발생하지 않았다면, Gradle을 빌드툴로 사용했을 것이다. 만약 IntelliJ를 IDE로 사용하고 있다면, 빌드가 Gradle로 사용되고 있을 것이다.
빌드나 실행 시 Gradle을 사용하면 자연스럽게 해결이 되어서 리플렉션 예외를 인지하지 못 할 수도 있다. 개인적으로, 인텔리제이에서 제공해주는 편안함이 많기 때문에 Gradle로 무조건 바꾸는 건 아쉬운 점이 많다고 생각한다.
근본적인 해결책
사실 이 문제는 누군가에겐 일어나서 고통을 받을 수도 있고, 누군가에겐 일어나지 않아서 자연스럽게 프로젝트를 수행할 수도 있다. 이 문제는 Gradle이나 IDE 등 개발환경에 따라서 자연스럽게 해결될 수도 있는 문제이기 때문이다. 하지만 스프링 팀의 `LocalVariableTableParameterDiscoverer`의 삭제에 대한 이유는 성능 때문이다. 무작정 어떠한 기능을 삭제하는 팀이 아니다. Gradle을 사용한 해결책은 근본적인 해결책이 아니라고 생각하고, 환경을 온전히 통일하는 것이 아니라면 누군가에겐 발생해서 골머리 썩힐 수 있는 문제라고 생각한다.
내가 개인적으로 생각하기에 이 문제에 대한 근본적인 해결책은 더욱 간단하다고 생각한다. 애너테이션의 `value`값을 채워 넣는 것이다.
@PathVariable("id")
@RequestParam("id")
프로젝트 구성원의 개별적인 환경과 관련되지도 않고, 스프링 팀의 의도에 따라갈 수 있는 근본적인 해결책이라고 생각한다.
'Spring > Core(Boot)' 카테고리의 다른 글
객체지향스러운 Properties 관리 (1) | 2024.01.24 |
---|---|
Spring Boot의 다양한 Properties 설정 방법 (.properties, .yaml) (1) | 2024.01.22 |