1. 문제
5주차의 과제는 다음과 같다.
* 비즈니스 별 발생할 수 있는 에러 코드 정의 및 관리 체계 구축
* 시스템 성격에 적합하게 Filter, Interceptor 를 활용하여 기능의 관점을 분리하여 개선
* 시나리오 별 동시성 통합 테스트 작성
이번 시나리오를 구현하면서 발생할 수 있는 에러는 커스텀 예외를 만들었기 때문에 에러 코드 정의하고 관리 체계를 잡는 건 얼추 되었다고 할 수 있는 수준이었다. 또한 동시성 통합 테스트도 이전 과제하면서 이미 작성해서 이번 주차에서 할 게 없었다.
우선 정의한 커스텀 예외는 다음과 같았다.
data class BadRequestException(val errorStatus: ErrorStatus) : RuntimeException() {}
data class NotFoundException(val errorStatus: ErrorStatus) : RuntimeException() {}
enum class ErrorStatus(val message: String) {
CHARGED_AMOUNT_ERROR("충전 금액 에러"),
NOT_FOUND_USER("사용자 없음"),
NOT_FOUND_USER_BALANCE("사용자에 대한 비용 정보 없음"),
NOT_FOUND_PRODUCT("상품 없음"),
NOT_FOUND_ORDER("주문 정보 없음"),
NOT_FOUND_CART("장바구니 없음"),
NOT_ENOUGH_BALANCE("잔액 부족"),
NOT_ENOUGH_QUANTITY("재고 부족")
}
BadRequestException
은 이름 그대로 400 BAD_REQUEST 예외일 경우 발생시키는 예외이며, 400 에러는 비즈니스에서 올바른 요청이 들어오지 않은 경우 발생한다. 이러한 케이스는 다음과 같다.
- 잔액 충전 시 충전 금액이 음수가 들어올 경우
- 재고 차감 시 차감하려는 수보다 재고량이 부족할 경우
- 잔액 사용 시 사용하려는 금액보다 잔액이 부족할 경우
NotFoundException
은 404 NOT_FOUND 예외일 경우 발생시키는 예외이며, 404 에러는 요청한 자원이 존재하지 않을 경우 발생한다. 이러한 케이스는 다음과 같다.
- 사용자에 대한 잔액 정보를 찾지 못하는 경우
- 상품 정보를 찾지 못하는 경우
- 주문 정보를 찾지 못하는 경우
- 장바구니 정보를 찾지 못하는 경우
그리고 ErrorStatus
라는 Enum 을 만들어 발생할 수 있는 예외 상황에 대해 정리하여 커스텀 에러에서 필드로 가지고 있도록 했다.
그리고 @RestControllerAdvice
를 활용하여 GlobalExceptionHandler
를 다음과 같이 구현했다.
@RestControllerAdvice
class GlobalExceptionHandler {
private val logger = KotlinLogging.logger {}
/**
* BadRequest 예외 처리
* code: 400
* status: BAD_REQUEST
*/
@ExceptionHandler(BadRequestException::class)
fun handleBadRequestException(e: BadRequestException): ResponseEntity<ErrorBody> {
logger.error(e.errorStatus.message, e)
return ResponseEntity(ErrorBody(e.errorStatus.message, 400), HttpStatus.BAD_REQUEST)
}
/**
* NotFound 예외 처리
* code: 404
* status: NOT_FOUND
*/
@ExceptionHandler(NotFoundException::class)
fun handleNotFoundException(e: NotFoundException): ResponseEntity<ErrorBody> {
logger.error(e.errorStatus.message, e)
return ResponseEntity(ErrorBody(e.errorStatus.message, 404), HttpStatus.NOT_FOUND)
}
}
이렇게 구현하면 @RestController
를 통해 호출된 서비스 로직에서 @ExceptionHandler
로 설정된 예외가 발생하면 이에 맞는 메서드가 실행된다. 나는 error 로그를 찍고 ResponseEntity
를 반환하도록 구현했다.
그리고 작성한 동시성 통합 테스트는 다음과 같다. 예시로 상품 재고를 차감하는 기능에 대한 동시성 통합 테스트를 가져와봤다.
@Test
@DisplayName("상품 재고 차감 - 동시에 재고 차감 시 동시성 제어 테스트")
fun quantityDecreaseConcurrency() {
// 상품 재고 3개에 대해 5번 동시 차감 요청 시
// 예상 성공 카운트 3, 실패 카운트 2, 남은 재고양 0
val productId = productRepository.save(Product("상품 A", "A 상품")).id
val detailId = productDetailRepository.save(ProductDetail(productId, 1000, 3, ProductCategory.CLOTHES)).id
val executor = Executors.newFixedThreadPool(5)
val countDownLatch = CountDownLatch(5)
val successCount = AtomicInteger(0) // 성공 카운트
val failCount = AtomicInteger(0) // 실패 카운트
try {
repeat(5) {
executor.submit {
try {
productService.updateProductQuantityDecrease(detailId, 1)
successCount.incrementAndGet()
} catch (e: BadRequestException) {
failCount.incrementAndGet()
} finally {
countDownLatch.countDown()
}
}
}
countDownLatch.await()
val actual = productService.getProductInfoById(productId)
assertThat(actual.stockQuantity).isEqualTo(0)
assertThat(successCount.get()).isEqualTo(3)
assertThat(failCount.get()).isEqualTo(2)
} finally {
executor.shutdown()
}
}
테스트 시나리오는 재고가 3개가 있는 상품에 대해 동시에 5번 상품 재고를 1개씩 차감을 하는 요청을 했을 때, 예상 성공 횟수는 3번, 실패 횟수는 2번, 그리고 해당 상품의 재고는 0개가 될 것이라는 시나리오이다.
위와 같이 에러 코드 정의와 관리 체계를 구축했고, 동시성 통합 테스트도 작성했다. 이제 비즈니스에 Filter 또는 Interceptor를 활용해 어떤 기능을 개선할 수 있을지 고려해봐야 했다.
2. 시도
우선 제대로 Filter와 Interceptor를 이해할 필요가 있었다. 그래서 Filter와 Interceptor에 대해 찾아보고 이를 정리해봤다.
필터
필터(Filter)는 디스패처 서블릿(Dispatcher Servlet)에 요청이 전달되기 전과 후에 url 패턴에 맞는 모든 요청에 대해 부가작업을 처리할 수 있는 기능을 제공한다. 디스패처 서블릿은 스프링의 가장 앞 단에 존재하는 프론트 컨트롤러로, 필터는 스프링 범위 밖에서 처리가 된다.
즉 필터는 스프링 컨테이너가 아닌 톰캣과 같이 웹 컨테이너에 의해 관리가 되며, 디스패처 서블릿 전과 후에 처리하는 것이다.
필터를 사용하기 위해서는 java.servlet
의 Filter
인터페이스를 구현해야 하며, 해당 인터페이스는 다음과 같은 메서드를 가진다.
init
웹 컨테이너가 init()
메서드를 호출하여 필터 객체를 초기화하고 서비스에 추가하기 위한 메서드이다. 초기화가 이루어지면 doFilter()
를 통해 처리가 이루어진다.
doFilter
url-pattern에 맞는 모든 HTTP 요청이 디스패처 서블릿으로 전달되기 전에 웹 컨테이너에 의해 실행되는 메서드이다.
doFilter()
의 파라미터로 FilterChain
이 있는데, 이를 통해 다음 대상으로 요청을 전달할 수 있게 된다. chain.doFilter()
로 전/후에 우리가 필요한 처리 과정을 부여해줌으로써 원하는 처리를 진행할 수 있다.
destroy
필터 객체를 제거하고 사용하는 자원을 반환하기 위한 메서드이다. 웹 컨테이너가 1회 destroy()
를 호출하여 필터 객체를 종료하면 이후에는 doFilter()
에 의해 처리되지 않는다.
인터셉터
인터셉터(Interceptor)는 필터와 달리 스프링이 제공하는 기술로, 디스패처 서블릿이 컨트롤러를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있는 기능을 제공한다. 쉽게 말하면 요청에 대한 작업 전/후로 가로챈다고 생각하면 된다.
디스패처 서블릿이 핸들러 매핑을 통해 컨트롤러를 찾도록 요청하는데, 그 결과로 실행 체인 (HandlerExecutionChain
)을 돌려준다. 여기에서 1개 이상의 인터셉터가 등록되어 있다면 순차적으로 인터셉터들을 거쳐 컨트롤러가 실행되도록 하고, 인터셉터가 없다면 바로 컨트롤러를 실행한다.
인터셉터를 사용하기 위해서는 org.springframework.web.servlet
의 HandlerInterceptor
인터페이스를 구현해야 하며, 해당 인터페이스는 다음과 같으 메서드를 가진다.
preHandle
컨트롤러가 호출되기 전에 실행되는 메서드로, 컨트롤러 이전에 처리해야 하는 전처리 작업이나 요청 정보를 가공하거나 추가하는 경우에 사용할 수 있다.
postHandle
컨트롤러가 호출된 후에 실행되는 메서드로, 컨트롤러 이후에 처리해야 하는 후처리 작업이 있을 때 사용할 수 있다.
여담으로 이 메서드는 컨트롤러가 반환하는 ModelAndView
타입의 정보가 제공되는데, 최근에는 json 형태로 데이터를 제공하는 RestAPI 기반의 @RestController
를 만들면서 자주 사용되지 않는다.
afterCompletion
모든 뷰에서 최종 결과를 생성하는 일을 포함해 모든 작업이 완료된 후에 실행된다. (View 렌더링 후)
요청 처리 중에 사용한 리소스를 반환할 때 사용할 수 있다.
필터와 인터셉터의 차이 비교
1. 관리하는 컨테이너
필터는 웹 컨테이너가 관리하며, 인터셉터는 스프링 컨테이너가 관리한다.
2. Request, Response 객체 조작 가능 여부
필터는 Request와 Response를 조작할 수 있지만 인터셉터는 조작할 수 없다.
3. 각 사용 사례
필터의 사용 사례
필터는 기본적으로 스프링과 무관하게 전역적으로 처리해야 하는 작업들을 처리할 수 있다.
- 보안 및 인증/인가 관련 작업
- 모든 요청에 대한 로깅 또는 검사
- 이미지/데이터 압축 및 문자열 인코딩
- 스프링과 분리되어야 하는 기능
인터셉터의 사용 사례
인터셉터에서는 클라이언트의 요청과 관련되어 전역적으로 처리해야 하는 작업들을 처리할 수 있다.
- 세부적인 보안 및 인증/인가 공통 작업
- API 호출에 대한 로깅 또는 검사
- 컨트롤러로 넘겨주는 데이터의 가공
3. 해결
최종적으로 Filter는 해당 서비스에 FE를 연동한다는 가정 하에 CORS 에러를 해결하고자 CorsFilter
를 도입했다. 그리고 Interceptor는 모든 API로 들어오는 요청에 대해 디버깅용 로깅을 남길 수 있도록 구현해봤다.
CorsFilter는 다음과 같이 정의했다.
@Component
class CorsFilter : Filter {
override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
val httpRequest = request as HttpServletRequest
val httpResponse = response as HttpServletResponse
httpResponse.setHeader("Access-Control-Allow-Origin", "http://localhost:3000")
httpResponse.setHeader("Access-Control-Allow-Credentials", "true")
httpResponse.setHeader("Access-Control-Allow-Methods","*")
httpResponse.setHeader("Access-Control-Max-Age", "3600")
httpResponse.setHeader("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, Authorization")
if ("OPTIONS" == httpRequest.method) {
httpResponse.status = HttpServletResponse.SC_OK
} else {
chain?.doFilter(request, response)
}
}
}
특정 url (여기에선 localhost:3000
)에 대해 접근을 허용하도록 해 CORS 에러를 방지할 수 있게끔 설정했다.
그리고 Interceptor는 다음과 같이 구현했다.
@Component
class ApiLoggingInterceptor : HandlerInterceptor {
private val logger = KotlinLogging.logger {}
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
when {
Objects.equals(request.method, "POST") -> {
val inputMap = ObjectMapper().readValue(request.inputStream, Map::class.java)
logger.info("요청 URL: {}", request.requestURL)
logger.info("요청 정보: {}", inputMap)
}
else -> {
logger.info("요청 URL: {}", request.requestURL)
logger.info("요청 정보: {}", request.queryString)
}
}
return true
}
}
POST 요청이 들어올 때는 RequestBody 값을 Map으로 받아 요청 정보로 로깅을 찍을 수 있도록 했고, POST 요청이 아닌 경우에는 queryString을 요청 정보로 로깅을 찍을 수 있도록 했다.
4. 알게된 것
이번 주차 과제에서는 Filter와 Interceptor에 대해 더 이해하게 되었고, 각 기능들을 어떨 때 사용해야 하는지 알게 되었다는 점이 가장 많이 배워가는 부분이었다.
Keep : 현재 만족하고 계속 유지할 부분
이번 과제에서 요구 사항에 대해 구현하는 것 뿐만 아니라 배운 점에 대해 정리하고 문서화했는데, 이렇게 문서로 남기는 점에 대해 좋은 피드백을 받았다. 다음에도 문서를 작성할 시간이 있다면 최대한 작성할 수 있게끔 해야겠다.
Problem : 개선이 필요하다고 생각하는 문제점
처음 발제를 받았을 때 Filter와 Interceptor에 대해 잘 알지 못했어서 스스로 아직 아는 점이 많지 않구나 하고 생각했다.
Try : 문제점을 해결하기 위해 시도해야 할 것
앞으로의 과제에서도 분명 잘 알지 못하는 것들이 나올 것이다. 이러한 부분들이 나올 때 알게 된 점들을 최대한 이해하고 이해한 내용을 바탕으로 정리할 수 있도록 해야겠다.
🤩 다음 수료생 추천 할인 혜택!
혹시라도 항해 플러스에 합류하고 싶은데 비싼 수강료 때문에 망설여진다면…? 🤔
수료생 추천 할인 혜택으로 20만 원을 할인받으실 수 있다는 사실! 💡
결제페이지 → 할인 코드 → 수료생 할인 코드에 tJQjYK 입력하면 추가로 20만 원을 할인받는 혜택 꼭 챙겨가시길 바란다🚀🚀🌟
#추천인: tJQjYK #항해플러스 #항해99