Spring발행일 2024. 12. 24.원본 https://blog.naver.com/jword_/223704561753 ↗

서버는 어떻게 응답해야하는가

서버는 어떻게 응답해야하는가 — #개발자의도구들 #서버응답 #spring 🚨📝 📖 📒✏️💡🔍 AI스쿨 msa기반 java 백엔드 코스 ...

#Spring#Naver Blog

#개발자의도구들 #서버응답 #spring

🚨📝 📖 📒✏️💡🔍

​

AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다

\* 현재 Spring x 프로젝트를 진행하고 있습니다. 전체 목차를 보시려면 여기를 눌러주세요.

\* 해당 프로젝트 이전에 공부한 내용을 보시려면 여기를 눌러주세요.

이미지

삐걱거리는 개발

Spring 프로젝트를 계속해서 진행중인데, 어느 순간부터 개발이 계속 삐걱대는 것을 느끼고 있습니다. 항상 삐걱거릴 때마다 항상 원인은 이전에 배운 개념들을 온전히 흡수하지 못하고 있구나 스스로 느낍니다.

그렇다고 예전으로 돌아가자니, 시간적인 여유가 턱없이 부족해서(사실 게으른 것도 있어서...) 미루지만, 계속 발못을 붙잡고 브레이크를 거네요.

​

앞으로 몇일간 그동안 미루기만 했던 복습을 진행하면서, 개발 과정에 기름칠을 좀 해야겠다고 생각했습니다. 그래서 이번 글을 통해서 다룰것은 항상 저를 고민하게 만들었던 예외처리에 대한 부분입니다.


서버는 어떻게 응답해야하는가?

서버는 어떻게 응답해야 잘 했다고 소문이 날까요... 서버는 클라이언트에게는 적당한 정보를 제공해야하고, 서버간에는 상세한 정보를 제공해야한다고 배웠습니다. 여기서 적당한 정보를 어떻게 줄지, 상세한 정보를 어떻게 제공할지에 대해 항상 고민이되는 것 같습니다.

​

이전글에서 소개한 바로 HTTP 응답코드가 불완전하기 때문에 RFC7807 형식과 함께 AllOK() 전략을 사용하 하였습니다. 해당 글을 작성할 당시만 해도, 클라이언트를 직접적으로 구현하지 않았기 때문에 뭐가 문제인지 크게 깨닫지 못하였는데, 막상 allOK()로 모두 응답을 해버리니, 클라이언트에서 뭐가 문제인지 명확하게 알기 어려운 상황이 생기기도 하였습니다.

클라이언트도 알건 알아야 한다.

기존에는 FrontServer -> Client로 응답을 처리할 때 모두 OK()로 처리를 진행하였습니다. 또한 Exception의 명확한 의미에 대해 이해하지 못하였기 때문에, ExceptionHandler에서 OK로 응답을 처리하였습니다.

​

javascript 코드 예제
                                    // Invalid Id Error
@ExceptionHandler(InvalidIdException::class)
fun handleCustomErrorException(ex: CustomErrorException): ResponseEntity<ErrorResponse> {
    return ResponseEntity.ok().body(
        ErrorResponse(
            code = ex.errorCode,
            message = ex.message
       )
   )
}

// invalid request body
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadableException(ex: HttpMessageNotReadableException): ResponseEntity<ErrorResponse> {
    return ResponseEntity.ok().body(
        ErrorResponse(
            code = FrontServerCode.PARSING_ERROR,
            message = "Invalid Request Body"
        )
     )
}

일단 기본적으로 Exception은 서버가 정상적으로 동작하지 못한다는 상황을 가정하는 것이기 때문에, 모둔 Exception에 대해 OK로 처리를 하는 것은 좋지 못한 설계입니다. 기본적으로 클라이언트도 서버가 정상작동인지 아닌지에 대한 정보는 알아야 디버깅이 가능해지기 때문입니다. 클라이언트를 전혀 만들지 않고 서버를 만들다 보니 이런 치명적인 실수를 하게 되었습니다.

​

비즈니스 로직에러와 서버 작동 에러를 구분하기

우선 비즈니스 로직과 서버 작동 에러를 구분하지 못하였습니다. 아래 코드를 먼저 봅시다.

javascript 코드 예제
                                    // Response DTO
data class NotificationGetResponse(
    // response to client from front
    val notificationGetResult: List<NotificationGetResult>,
    override val responseCode: FrontServerCode
): FrontServerErrorResponse(responseCode) {
    companion object {
        fun of(
            results: List<NotificationGetResult>,
            responseCode: FrontServerCode
        ): NotificationGetResponse {
            return NotificationGetResponse(
                notificationGetResult = results,
                responseCode = responseCode
            )
        }
    }
}

컨트롤러

javascript 코드 예제
                                    @PostMapping("/notification/init")
    fun findInitAll(
        @RequestBody receiverId: Long,
        @RequestHeader(value = "Accept-Language", defaultValue = "en") language: String
    ): ResponseEntity<NotificationGetResponse> {
        return ResponseEntity.ok().body(
            NotificationGetResponse.of(
                notificationService.fetchInitAll(receiverId, language),
                FrontServerCode.SUCCESS
            )
        )
    }

위와 같은 방식으로 설계하면 몇 가지 문제가 발생합니다.

javascript 코드 예제
                                    ⚠️ 현재 NotificationGetResponse에는 results가 not nullable이다.
  - 이는 오류시에도 빈 list를 반환하도록 설계하였다.

현재 Response 객체에 Result를 nullable로 처리하지 않고 있습니다. 이렇게 되면, 단순 데이터를 찾지 못한 것과, 서버 자체에 에러가 있다는 것을 구분하지 못할 수 있습니다.

​

여기서 전자의 경우는 앞서말한 비즈니스 로직 에러이고, 후자의 경우에는 서버 동작 에러입니다. 이는 클라이언트 입장을 전혀 고려하지 않은 설계입니다.

​

실제로 사용하고자 한다면, result를 nullable형태로 변경하고 에러가 발생할 경우 null을 제공하는 것이 좋습니다.

​

비즈니스 로직과 서버 동작 에러에 대한 명확성이 없어서 이런 문제들이 발생하였습니다. 이번 기회에 정리해봤습니다.

javascript 코드 예제
                                    👩‍💼 비즈니스 로직:
- 비밀번호 규칙 안 맞음, 중복 데이터 존재, 없는 데이터 조회, 권한 없는 리소스 접근
- HTTP 응답 코드 : 4xx 계열로 return함

🚧 서버 동작에러:
- DB가 죽었음, 네트워크가 끊어짐, 서버 메모리 부족, 서버가 닫혓음
- HTTP 응답 코드 : 5xx 계열

CircuitBreaker

에러 처리에 대해 헷갈렷던 이유가 아마도 MSA 구조를 배우고 나서인 것 같은데요. CurcuitBreaker에서는 비즈니스로직이나 서버 동작에러가 발생해도 Client에는 정상처리를 내보내야 합니다. (하나의 서비스가 죽었다고 전체 서비스가 Shut Down 되는 것은 적절하지 않은 처리이기 떄문)

​

그리고 이전글에서 다룬 RFC 7807도 이 CircuitBreaker를 사용한 서버 통신을 위해서 사용한다고 해도 과언이 아닙니다. 이유는 서버의 에러는 수없이 많기 때문에, HTTP 코드로 모두 표현하기 어렵고, 또 해당 코드가 구체적인 정보를 제공하지도 않기 때문에, 디버깅이 쉽지도 않기 때문입니다.

​

하지만 클라이언트에게 보낼 코드는 다릅니다. 클라이언트에게는 최소한의 정보만을 노출해야 하고, 실제로 클라이언트 개발자들은 해당 서버의 오류가 구체적으로 뭔지 관심이 없기 때문입니다.

​

CircuitBreaker는 Exception이 발생할 때 동작한다.

circuitBreaker는 기본적으로 MSA 서버의 ServerExeception이 발생할 때 fall 카운트가 세어집니다. 즉 5xx 계열 코드를 계속해서 받게 되면 open상태로 변경되는 것이지요.

이미지

그럼 비즈니스 로직에러인 4xx은 어떤가요? fall로 카운트 되지 않도록 설계할 수 있습니다. 그렇지만, 400번대 코드로 표현할 수 있는 비즈니스 로직은 한정적이기 때문에, 비즈니스 로직 에러에 한해서만 모두 OK()로 리턴하고 body값으로 RFC7807 에러 형식을 전송하여 서버간의 디버깅을 돕도록 할 수 있습니다.

이미지

또한 open상태일때 서버자체가 닫혀지면 안되기 때문에, fallback method를 등록해 클라이언트 한테는 정상처리 되도록 합니다. 그래서 실제로 MSA서버가 닫히더라도 빈 list객체나 4xx 에러 코드를 제공하여 서버 자체에는 문제가 없다는 것을 알릴 수 있습니다.


가이드

이제는 실질적인 가이드가 필요합니다. 이는 작성이후 피드백을 거쳐서 최종적으로 정리를 마치고, 오류 처리에 대한 고민 시간을 최소로 만드는데 기여할 것 입니다.

​

javascript 코드 예제
                                    1. Client에는 일반적인 HTTP CODE를 보내자.

클라이언트는 구체적인 오류 정보를 알 필요가 없습니다. HTTP 코드로도 충분합니다. 모든 오류나 예외에 대해서 기본적인 HTTP CODE를 사용합시다.

javascript 코드 예제
                                    2. MSA 서버의 비즈니스 에러에는 ALL OK, SERVER ERROR CODE + RFC 7807 형식을 따르자.

data class RFC780(
    val url: String?,
    val status: HttpStatus,// original http code ...
    val title: String?,
    val detail: String
)

MSA 서버에서 발생하는 비즈니스 에러는 모두 OK()로 처리하고 에러가 생긴다면 사전에 협의한 에러코드와 함께, RFC 7807형식으로 에러에 대한 구체적인 정보를 명시합시다. 서버간의 오류는 최대한 자세하게 서술하여 디버깅을 최소화하는 것에 목적을 둡시다.

​

javascript 코드 예제
                                    3. MSA 서버의 동작 에러는 그대로 내보내자.

MSA서버의 동작에러(DB에러나, 네트워크 에러) 등은 서버가 닫힌 상태를 의미하기 때문에 fall count를 발생시켜 Circut breaker가 open 상태가 되도록 해야합니다.

번외

💡 RFC형식 지정 (피드백 필요함)

RFC 형식은 우선 기본적인 아래의 요소만 넣습니다.

javascript 코드 예제
                                    data class ServerErrorDetails( // common used
    val type: String?,     // error url
    val status: ServerResponseCode,      // Server Error Code --> 서버별로 따로 만들기가 어려워
    val title: String?,    // summary of Error
    val detail: String?,   //  (Optional but need)
)
💡에러 코드를 보낼때 - 만약 Client에게 FrontServer와 협의된 코드를 보내고자 한다면, 반드시 숫자로 보낼 것 - String으로 처리하면 관리하기 어려움 (대소문자와 같은 문제)
💡클라이언트 서버 코드- save: 201 created - body가 필요하다고 판단되면 보내기(필요없을 듯?)- delete: 204 no Content

​