본문 바로가기

Spring

BasicErrorController 상속받아 커스텀하기

 

기존에 사용하고 있는 API 응답 형식은 다음과 같다.

{
    "code": ,
    "message": ,
    "timestamp": ,
    "path": ,
    "method": 
}

 

그런데 일정 부분의 예외처리를 직접 응답을 보내지 않고, BasicErrorController를 통하여 응답을 보내야 하는 요구사항이 생겼다.

이에 따랐을 때, 기존에 사용하는 API 응답 형식과는 다른 점이 있었다.

 

BasicErrorController에서 보내주는 응답의 형식은 다음과 같다.

{
    "timestamp": ,
    "status": ,
    "error": ,
    "message": 
}

(method, path를 안 보내주고 있는 것과 순서가 다른 것은 무시하기로 한다.)

 

기존 API 응답에서는 Http status code 숫자값을 “code” 프로퍼티로 보내주고 있었고, BasicErrorController에서는 “status”라는 프로퍼티로 보내주고 있다는 것을 알게 되었다.


 

위의 문제 상황 - 기존에 커스텀하여 사용하던 API 응답형식과 BasicErrorController의 응답형식의 프로퍼티를 통일하기 위해서 두 가지 방안이 있다.

(1) 기존에 API응답으로 보내주고 있던 “code” 프로퍼티까지 모두 “status”로 바꾸기
(2) BasicErrorController를 상속받아 “status”를 “code”로 바꿔서 보내주기

 

(1) 방안 -> API 서버는 커스텀 API 응답을 만드는 유틸리티 클래스만 수정하면 되므로 아주 간단했다.

(2) 방안 -> BasicErrorController를 상속받아 응답을 보내주는 메서드만 override한 후 원하는 프로퍼티 값으로 교체해주면 될 것이다.

 

(2)번 방안을 만드는 과정을 정리해 보았다.

 


1. BasicErrorController 상속받기

BasicErrorController는 자바 코드이지만 프로젝트는 코틀린을 사용하므로 코틀린으로 자바 클래스를 상속해서 다음과 같이 만들었다.

@Controller
class MyBasicErrorController(
    private val errorAttributes: ErrorAttributes,
    private val errorProperties: ErrorProperties,
) : BasicErrorController(errorAttributes, errorProperties) {

}

 

2. 생성자 주입하기

BasicErrorController는 2개의 생성자가 필요한데 ErrorAttributes와 ErrorProperties 타입 객체이다.

 

(1) ErrorAttributes

ErrorAttributes는 ErrorMvcAutoConfiguration이라는 클래스에서 스프링 빈을 하나 만들어 주고 있었다.

// Load before the main WebMvcAutoConfiguration so that the error View is available
@AutoConfiguration(before = WebMvcAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@EnableConfigurationProperties({ ServerProperties.class, WebMvcProperties.class })
public class ErrorMvcAutoConfiguration {

	private final ServerProperties serverProperties;

	public ErrorMvcAutoConfiguration(ServerProperties serverProperties) {
		this.serverProperties = serverProperties;
	}

	@Bean
	@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
	public DefaultErrorAttributes errorAttributes() {
		return new DefaultErrorAttributes();
	}

그래서 디폴트로 만들어진 빈을 주입받아 사용하기로 하였다.

 

(2) ErrorProperties 

 

ErrorProperties 타입 객체는 Could not autowire. No beans of 'ErrorProperties' type found. 예외가 발생했고 디폴트로 만들어지는 빈이 따로 없는 것 같아서 설정 클래스(BasicErrorConfig)를 하나 만들어 디폴트로 생성을 해서 사용하기로 했다.

 

@Configuration
class BasicErrorConfig {

    @Bean
    fun errorProperties(): ErrorProperties {
        return ErrorProperties()
    }
    
}

 

 

3. 메소드 오버라이드

그리고 BasicErrorController를 상속한 MyBasicErrorController 내부에 사용할 메서드를 오버라이드 했다.

 

오버라이드할 자바 메소드

	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
		HttpStatus status = getStatus(request);
		if (status == HttpStatus.NO_CONTENT) {
			return new ResponseEntity<>(status);
		}
		Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
		return new ResponseEntity<>(body, status);
	}

 

오버라이드한 코틀린 메소드(자바 → 코틀린으로만 바꾼 상태)

    @RequestMapping
    override fun error(request: HttpServletRequest): ResponseEntity<Map<String, Any>> {
        val status = getStatus(request)
        return if (status == HttpStatus.NO_CONTENT) {
            ResponseEntity.status(status).build()
        } else {
            val body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL))
            ResponseEntity(body, status)
        }
    }

 

이제 오버라이드한 코틀린 메소드에 내용을 추가해 주었다.

이 클래스를 만든 목적이 응답에서 “status”를 “code”로 바꾸는 것이었다. 완성된 MyBasicErrorController는 다음과 같다.

 

import jakarta.servlet.http.HttpServletRequest
import org.springframework.boot.autoconfigure.web.ErrorProperties
import org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController
import org.springframework.boot.web.servlet.error.ErrorAttributes
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping

@Controller
class MyBasicErrorController(
    private val errorAttributes: ErrorAttributes,
    private val errorProperties: ErrorProperties,
) : BasicErrorController(errorAttributes, errorProperties) {

    @RequestMapping
    override fun error(request: HttpServletRequest): ResponseEntity<MutableMap<String, Any>> {
        val status = getStatus(request)
        return if (status == HttpStatus.NO_CONTENT) {
            ResponseEntity.status(status).build()
        } else {
            val body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL))
            
            // "code"라는 property에 "status" 프로퍼티 값 담기
            body["code"] = body["status"]

            // "status"라는 property는 제거
            body.remove("status")
            
            ResponseEntity(body, status)
        }
    }
}

 

이렇게 하면 잘 동작할 줄 알았는데, 테스트를 해보니 “message” 프로퍼티가 유실되어 들어왔다.

 

4. 문제 발생(message 유실) 및 해결

왜 message가 유실될까? (참고로 MyBasicErrorController를 만들기 전, 그러니까 BasicErrorController가 예외 처리 응답을 보내줄 때는 message가 유실되지 않았었다. )

 

(1) 디버깅으로 원인 찾기

디버깅을 하면서 원인을 발견했다. 원인은 ErrorProperties가 디폴트값으로 객체를 만들 때 '메시지를 프로퍼티에 포함할지 여부를 NEVER 로 설정해서'였다.

 

public class ErrorProperties {

	/**
	 * Path of the error controller.
	 */
	@Value("${error.path:/error}")
	private String path = "/error";

	/**
	 * Include the "exception" attribute.
	 */
	private boolean includeException;

	/**
	 * When to include the "trace" attribute.
	 */
	private IncludeAttribute includeStacktrace = IncludeAttribute.NEVER;

	/**
	 * When to include "message" attribute.
	 */
	private IncludeAttribute includeMessage = IncludeAttribute.NEVER;

당연히 새로 만든 Config 클래스에서 디폴트 값 그대로 ErrorProperties 객체를 만들어 사용하므로 includeMessage의 값이 NEVER로 고정되어 메시지를 응답에서 제외하고 있는 것이었다.

 

(2) 문제 해결하기 

결론적으로 Config 클래스를 다음과 같이 수정하였다.

@Configuration
class BasicErrorConfig {

    @Bean
    fun errorProperties(): ErrorProperties {
        val errorProperties = ErrorProperties()
        errorProperties.includeMessage = ErrorProperties.IncludeAttribute.ALWAYS
        return errorProperties
    }
}

그리고 “message”프로퍼티가 유실되지 않고 body에 잘 구성이 되는 것을 확인할 수 있었다.

 

그러면 이전에 MyBasicErrorController를 만들기 전, BasicErrorController가 예외 처리 응답을 보내줄 때는 왜 message가 유실되지 않았던 걸까?

 

생각해 보니 yml 파일에 다음과 같은 옵션을 추가했었다.

# 서버의 오류 응답에 항상 메시지를 포함할지를 설정
server:
  error:
    include-message: always

yml 파일에 위와 같은 설정을 추가함으로써 ErrorProperties 객체가 스프링의 자동 설정에 의해 실제로 만들어질 때 includeMessage가 always로 설정되었던 것이다.

 

스프링의 ErrorProperties의 설정값 관련 내용은 아래 링크로 확인할 수 있다.

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#appendix.application-properties.server

 

Common Application Properties

 

docs.spring.io

 


5. 개선하기

 

여기까지 만들고 구현이 완료되었다고 생각했지만 여전히 찝찝함이 남았다.

1. yml 파일에 server.error.include-message: always 설정 값은 삭제해야 하나?
2. 다음에 또 디폴트와 다른 server.error 관련된 설정을 추가해야 할 때는 어떻게 하지?
3. BasicErrorConfig에 계속 추가해야 하나? 이 모든 걸 모르는 다른 개발자가 설정파일에 server.error 관련 설정을 추가하고 적용이 안 되어서 왜 적용이 안 되는지 디버깅하느라 애먹으면 어떡하지?
4. Readme에 적어놔야 하나?
5. 나도 몇 주만 지나면 까먹을 것 같은데 어떡하지? 

등등..

 

결국에 내가 만든 ErrorProperties 타입의 객체를 사용하는 건 너무 안 좋은 선택이라는 생각이 들어 또 해결방법을 찾아보았다.

목표는 스프링의 자동설정에 의해 만들어진 ErrorProperties 타입의 객체를 MyBasicErrorController에서 주입받아 사용하는 것이다.

기존의 BasicErrorController는 ErrorProperties 타입 객체를 필드로 가지고 있다. 그러면 어딘가에서 yml, properties 설정값을 반영한 ErrorProperties 객체를 만드는 곳이 있을 것이다. 그곳을 찾아보니 ServerProperties라는 클래스였다.

 

@ConfigurationProperties(prefix = "server", ignoreUnknownFields = true)
public class ServerProperties {

	/**
	 * Server HTTP port.
	 */
	private Integer port;

	/**
	 * Network address to which the server should bind.
	 */
	private InetAddress address;

	@NestedConfigurationProperty
	private final ErrorProperties error = new ErrorProperties();

위 클래스를 발견하고, MyBasicErrorController 생성자 부분을 이렇게 고쳤다.

 

@Controller
class MyBasicErrorController(
    private val errorAttributes: ErrorAttributes,
    private val serverProperties: ServerProperties
) : BasicErrorController(errorAttributes, serverProperties.error) {

이렇게 함으로써 BasicErrorConfig 클래스를 삭제할 수 있었고, yml에 설정한 설정대로 ErrorProperties타입 객체가 잘 생성되어 원하는 대로 동작하는 것을 확인할 수 있었다.

 


6. 추가 정보

 

ServerProperties는 @ConfigurationProperties(prefix="server", ignoreUnknownFields=true) 어노테이션이 선언되어 있다. 이는 스프링 부트에서 외부 설정을 자동으로 바인딩하기 위해 사용되는 어노테이션으로, 프로퍼티 파일이나 환경 변수와 같은 외부 설정과 매핑할 수 있게 해준다.

 

그런데 @ConfigurationProperties만으로는 스프링 빈으로 등록되지 않는다.

@ConfigurationProperties가 붙은 클래스를 다른 설정 클래스에서 @EnableConfigurationProperties로 활성화해 주어야 한다.

 

이것을 해주는 곳은 도대체 어디인지 궁금해서 찾아보았다.

@AutoConfiguration
@ConditionalOnNotWarDeployment
@ConditionalOnWebApplication
@EnableConfigurationProperties(ServerProperties.class)
public class EmbeddedWebServerFactoryCustomizerAutoConfiguration {

 

여기에서 해주고 있었다.