학습용 프로젝트에서 API 응답을 통일하기 위해 모든 응답을 공통 ApiResponse로 래핑하는 방식을 사용했다.
초기 설계 의도는 성공 및 실패 응답 모두 동일한 형식을 사용해, 화면단에서의 후처리를 일관되게 하는 것이었다.
하지만 실제로 사용해보니 기대한 만큼의 이점이 없었고, 오히려 다음과 같은 불폄함이 발생했다.
data로 한번 더 감싸는 구조가 오히려 불필요한 데이터 추출 과정을 유발함그래서 성공 응답에 대해서는 ApiResponse 구조로 감싸는 이점이 크지 않다고 판단되어 다음과 같이 응답 구조를 단순화하기로 했다.
204 No Content 반환ErrorResponse(code, message) 형태로 반환기존에는 성공과 실패 응답에 모두 동일한 구조를 사용했다.
success로 성공 여부를, data와 error에 응답 데이터를 담고있다.
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 프로젝트의 이름처럼, 처음부터 크게 설계하는 건 좋지만 '지금 이 단계에서 꼭 필요한 설계인가?'를 생각하는 개발자가 되어야겠다.