[SpringBoot] ResonseEntity 와 HTTP 상태코드 with Kotlin

tkppp·2022년 3월 15일
0

문제 의식

스프링부트 공부겸 토이 프로젝트를 진행중에 이전부터 가지고 있던 의문점을 다시 생각해보게 되었다. 시중에서 접할 수 있는 도서를 보면 RestController의 요청에 특정 ResponseDto를 응답으로 반환하는 것을 볼 수 있다.

요청이 성공적으로 이루어지면 이런 방식으로도 문제가 없다. 문제는 요청이 실패할 때이다.

만약 회원가입을 처리하는 컨트롤러에 넘어온 요청 데이터가 DB에 있는 이메일주소가 중복된다면 이 회원가입은 실패한다. 지금까지는 이러한 요청에 대해 ResponseDto에 success 필드를 정의하고 Boolean 값으로 요청에 대한 성공과 실패를 나타냈엇다.

하지만 점점 생각해볼수록 요청은 실패해서 원하는 결과를 받지 못했는데 HTTP 메세지의 상태코드는 200 이다. 뭔가 이상하다. 실패한 요청인데 반환하는 HTTP 메세지의 상태코드는 요청이 성공했음을 의미하는 200 이라는 것은 말이되지 않는다.

이러한 문제점을 해결하기 위해 응답메세지의 상태코드를 조작해야만 했다. 기존의 ResponseDto로는 이 문제를 해결할 수 없어 찾아낸 방법이 ResponseEntity<T> 이다.

ResponseEntity<>()

Spring Framework에서 제공하는 클래스 중 HttpEntity라는 클래스가 존재한다. 이 클래스는 HTTP 메세지의 구성 요소인 헤더와 몸체를 포함하는 클래스다. ResponseEntityHttpEntity 를 상속받은 클래스로 HTTP 메세지 그 자체라고 할 수 있다.

생성자

먼저 ResponseEntity 는 제네릭 클래스로 응답 몸체의 클래스를 지정한다.

ResponseEntity 의 생성자는 3가지지만 주로 두가지가 사용된다.

// 1. http 상태코드만 지정
val response = ResponseEntity<Any?>(HttpStatus)

// 2. 응답 몸체와 http 상태코드를 지정
val responseWithBody = ResponseEntity<Any?>(ResposneBody, HttpStatus)

문제점 해결

어떠한 요청에 대해 요청 몸체를 반환할 때 그 몸체의 클래스 타입은 서로 다를 수 있다. 성공한 요청의 경우 필요한 정보를 반환하고 실패한 요청에 경우 실패한 이유를 몸체에 담아 내려보내기도 하기 때문이다.

가장 간단한 해결책은 제네릭 타입을 Any? 타입으로 지정하면 되는 것이지만 이건 멋있지 않다. 따라서 ResponseDto 인터페이스를 만들고 응답 Dto는 ResponseDto 를 구현하는 방식으로 만들어 제네릭 타입을 ResponseDto? 로 지정하여 사용하였다. 또 에러 Dto를 만들어 요청이 실패한 이유를 클라이언트로 내려보내도록 리팩토링하였다. 아래는 리팩토링 전, 후 코드이다.

// 리팩토링 전
@PostMapping("")
fun completeRegister(@RequestBody localRegisterRequestDto: LocalRegisterRequestDto): LocalRegisterResponseDto {
      return try {
          registerService.localRegister(localRegisterRequestDto)
          LocalRegisterResponseDto(true)
      } catch(e: DuplicatedEmailAddressException){
          LocalRegisterResponseDto(false, "DUPLICATED_EMAIL")
      } catch (ex: DuplicatedNicknameException){
          LocalRegisterResponseDto(false, "DUPLICATED_NICKNAME")
      }
}

// 리팩토링 후
@PostMapping("")
fun completeRegister(@RequestBody localRegisterRequestDto: LocalRegisterRequestDto): ResponseEntity<ResponseDto?> {
        return try {
            registerService.localRegister(localRegisterRequestDto)
            ResponseEntity(HttpStatus.NO_CONTENT)
        } catch (e: DuplicatedEmailAddressException) {
            ResponseEntity(
                ErrorResponseDto(
                    ErrorCode.DUPLICATED_EMAIL_ADDRESS.name, e.message
                ), HttpStatus.CONFLICT
            )
        } catch (e: DuplicatedNicknameException) {
            ResponseEntity(
                ErrorResponseDto(
                    ErrorCode.DUPLICATED_NICKNAME.name, e.message
                ), HttpStatus.CONFLICT
            )
        }
    }
}
    
// ResponseDto
interface ResponseDto {}

// ErrorResponseDto
data class ErrorResponseDto(
    val errorCode: String,
    val message: String?
) : ResponseDto

이로써 클라이언트에서는 response.data.success 를 체크하여 에러처리 핸들링을 try 블록 안에서 했던 것을 catch 블록에서 처리하게 되어 코드가 명확해졌다.

또다른 의문점

자주 사용되는 HTTP 상태코드로 알고 있던건 400번 대에서 로그인 인증 실패를 뜻하는 401(Unauthorized), 자원에 대한 인가 실패를 뜻하는 403(Forbidden), 리소스를 찾는데 실패했다는 404(NotFound) 였다.

그런데 회원가입 실패는 알고 있는 실패 HTTP 상태코드에 부합하지 않아 구글링을 시작했다.

문제 해결

결국 포스트 를 통해 의문을 해소할 수 있었다.

400(BadRequest)도 스펙 상으로는 요청에 대한 실패 시 사용할 수 있다고 정의되어 있긴 하지만 모호하다.

409(Conflict)는 DB에 저장된 회원 정보와 충돌하여 회원가입에 실패한 것이기 때문에 적합하다고 생각했다. 구글링 결과 실무에서 이러한 경우 자주 사용된다고 한다.

참조

0개의 댓글