불필요한 공통 ApiResponse 리팩토링하기

Noul·2025년 5월 1일

학습용 프로젝트에서 API 응답을 통일하기 위해 모든 응답을 공통 ApiResponse로 래핑하는 방식을 사용했다.

초기 설계 의도는 성공 및 실패 응답 모두 동일한 형식을 사용해, 화면단에서의 후처리를 일관되게 하는 것이었다.

하지만 실제로 사용해보니 기대한 만큼의 이점이 없었고, 오히려 다음과 같은 불폄함이 발생했다.

  • 성공 응답의 경우, 대부분 필요한 데이터만 추출하여 사용하는데 data로 한번 더 감싸는 구조가 오히려 불필요한 데이터 추출 과정을 유발함
  • 단순 성공 여부만 필요한 경우에도 불필요한 응답 객체가 생성됨
  • Controller 단의 응답 코드가 복잡해짐

그래서 성공 응답에 대해서는 ApiResponse 구조로 감싸는 이점이 크지 않다고 판단되어 다음과 같이 응답 구조를 단순화하기로 했다.

  • 성공 응답
    • 응답 데이터가 필요한 경우 화면에서 요구하는 응답 객체를 반환
    • 성공 응답이 불필요한 경우 204 No Content 반환
  • 실패 응답
    • ErrorResponse(code, message) 형태로 반환


기존의 ApiResponse 구조

기존에는 성공과 실패 응답에 모두 동일한 구조를 사용했다.
success로 성공 여부를, dataerror에 응답 데이터를 담고있다.

data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: ApiError? = null,
) {
    companion object {
        fun <T> success(data: T) : ApiResponse<T> {
            return ApiResponse(success = true, data = data)
        }

        fun <T> success() : ApiResponse<T> {
            return ApiResponse(success = true)
        }

        fun <T> error(errorCode: ErrorCode) : ApiResponse<T> {
            return ApiResponse(success = false, error = ApiError(errorCode.code, errorCode.message))
        }
    }
}

data class ApiError(
    val code: String,
    val message: String,
)

문제는 아래 코드에서 확인할 수 있다.

단순히 성공 여부만 판단하는 경우에도 data 필드를 전달해야 했고, 성공 여부에 따라 화면단에서도 데이터 존재 여부를 추가로 체크해야 하는 번거로움이 있었다.

컨트롤러 예시

@RestController
@RequestMapping("/api/auth")
class AuthApiController(
    private val authService: AuthService
) {
    /**
     * 로그인
     */
    @PostMapping("/login")
    fun login(
        @Valid @RequestBody request: UserLogInRequest,
        httpRequest: HttpServletRequest
    ): ResponseEntity<ApiResponse<UserResponse>> {
        val response = authService.login(request, httpRequest)
        return ResponseEntity.ok(ApiResponse.success(response))
    }

    /**
     * 로그아웃
     */
    @PostMapping("/logout")
    fun logout(request: HttpServletRequest): ResponseEntity<ApiResponse<Nothing>> {
        authService.logout(request)
        return ResponseEntity.ok(ApiResponse.success()) // 응답 필드가 필요 없는 상황
    }
}

테스트 코드 예시

@SpringBootTest
@AutoConfigureMockMvc
@Import(SecurityTestConfig::class)
class GlobalExceptionHandlerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    @DisplayName("BaseException 발생 시, handleBaseException이 핸들링된다")
    fun test100() {
        val errorCode = CommonErrorCode.INVALID_INPUT.code
        val errorMessage = CommonErrorCode.INVALID_INPUT.message

        mockMvc.perform(get("/api/test/base-exception"))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.error.code").value(errorCode))
            .andExpect(jsonPath("$.error.message").value(errorMessage))
    }
 }


리팩토링 후

실패 응답만 별도의 ErrorResponse로 처리하고, 성공 응답은 상황에 따라 처리하도록 변경했다.

컨트롤러 예시

@RestController
@RequestMapping("/api/auth")
class AuthApiController(
    private val authService: AuthService
) {
    /**
     * 로그인
     */
    @PostMapping("/login")
    fun login(
        @Valid @RequestBody request: UserLogInRequest,
        httpRequest: HttpServletRequest
    ): ResponseEntity<UserResponse> {
        val response = authService.login(request, httpRequest)
        return ResponseEntity.ok(response)
    }

    /**
     * 로그아웃
     */
    @PostMapping("/logout")
    fun logout(request: HttpServletRequest): ResponseEntity<Void> {
        authService.logout(request)
        return ResponseEntity.noContent().build()
    }
}
  • ApiResponse로 감싸는 로직이 없어져 코드 복잡성이 줄어들었다.
  • 응답 데이터가 없으면 204 No Content를 반환한다.

테스트 코드 예시

@SpringBootTest
@AutoConfigureMockMvc
@Import(SecurityTestConfig::class)
class GlobalExceptionHandlerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    @DisplayName("BaseException 발생 시, handleBaseException이 핸들링된다")
    fun test100() {
        val errorCode = CommonErrorCode.INVALID_INPUT.code
        val errorMessage = CommonErrorCode.INVALID_INPUT.message

        mockMvc.perform(get("/api/test/base-exception"))
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value(errorCode))
            .andExpect(jsonPath("$.message").value(errorMessage))
    }
 }
  • success 여부를 확인하지 않아도 된다.
  • 불필요한 언래핑(error.code)이 사라졌다.


마무리

이렇게 개선함으로써 Api 응답 구조가 간단하고 목적에 맞게 분리되었다고 생각한다.

이번 작업과 직접적인 연관은 없지만, 리팩토링을 하면서 '과한 추상화는 오히려 독이 될 수 있다'는 생각을 했다.

물론 추상화를 통해 얻는 이점은 명확하지만 현재처럼 로직이 단순하고 도메인 규모도 작을 때는 불필요한 공통화가 오히려 구조를 무겁게 만들고 리팩토링 비용만 키운다는 걸 경험했다.

이번에도 '응답을 통일하자'는 생각에서 시작했지만, 막상 사용해보니 오히려 불편함이 많았고 나중엔 구조를 걷어내는 작업까지 해야 했다.

지금 진행하는 Over Engineering 프로젝트의 이름처럼, 처음부터 크게 설계하는 건 좋지만 '지금 이 단계에서 꼭 필요한 설계인가?'를 생각하는 개발자가 되어야겠다.

profile
고민하고 트레이드오프

0개의 댓글