본문 바로가기
항해 플러스 백엔드/서버 구축

[항해] 5주차, 서버 구축 리팩토링

by 코딩맛 2025. 2. 11.

 

항해 5주차에는 콘서트 예약 서비스 코드를 리팩토링 하는 것을 진행하였다!

시스템 성격에 적합하게 Filter와 Interceptor 를 활용해 기능의 관점을 분리(AOP)하여 개선하는 것이 목표이다.

지난 6주차에서도 토큰 검증은 Interceptor에서 처리하도록 방향을 정하였는데 그 내용을 다뤄보려고 한다~! +_+

클린 코드로 재탄생!

목차
1. Filter와 Interceptor의 차이점
2. Filter를 활용하여 request, response에 대한 로깅
3. Interceptor를 통해 대기열 토큰 검증 처리 
4. 회고

 

1. Filter와 Interceptor의 차이점

 

Filter (서블릿 규격에 기반)

- 서블릿 규격에 따라 작동

- 스프링과는 독립적으로 동작

- 주로 인증, 권한 처리, 보안 관련 작업에 사용

- 클라이언트 요청이 디스패처 서블릿에 도달하기 전에 실행

 

Interceptor (스프링 규격에 기반)

- 스프링 컨테이너 내에서 동작

- 스프링의 빈과 오토와이어 같은 기능을 사용 가능

- 컨트롤러 전후의 흐름을 제어하는 데 주로 사용

 

 

Filter 위치
Interceptor 위치

 

앞서 두 기능의 차이점을 비교했을 때, Filter는 HTTP 요청과 응답을 직접 처리하며 서블릿 컨테이너의 초기 단계에서 실행되기 때문에 빠른 처리가 가능하다. 따라서 요청 및 응답에 대한 로깅과 같은 작업에 적합하다.

 

반면, Interceptor의 preHandle 메서드는 컨트롤러가 실행되기 직전에 호출되므로, 모든 API 요청에 대해 대기열 토큰의 유효성을 검증하는 로직을 수행하는데 적용할 것이다.

 

적용 계획

  1. Filter 사용 (전역적 요청/응답 처리)
    • 모든 요청에 대해 공통적으로 로깅(Log)과 예외(Exception) 처리를 수행.
    • 요청 전/후 데이터를 기록하고, 응답을 표준화된 형태(ApiResponse)로 반환하도록 설정.
  2. Interceptor 사용 (특정 기능 검증)
    • 대기열(Queue) API에 접근할 때 Queue-Token의 유효성을 사전에 검증.
    • 인증(Authentication) 및 권한(Authorization) 체크를 Interceptor에서 처리.
    • 유효하지 않은 토큰일 경우 401 Unauthorized 응답을 반환하도록 함.

2. Filter를 활용하여 request, response에 대한 로깅

resources 하위에 logback-spring.xml을 생성한다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 콘솔에 로그 출력 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 파일에 로그 저장 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/application-%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 루트 로거 설정 -->
    <root level="DEBUG">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

 

LogFilter 클래스를 생성하고 각 HTTP 요청 당 한 번만 실행되게끔 하기 위해 OncePerRequestFilter를 상속받아서 로깅 환경을 구축한다. 그리고 요청값, 응닶값 및 API 수행 속도 등을 로그에 남기도록 해주었다.

@Slf4j
@Component
public class LogFilter extends OncePerRequestFilter {
    private static final String TRACE_ID = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);

        // Trace ID 설정 (UUID 사용, 분산 환경용)
        String traceId = UUID.randomUUID().toString();
        MDC.put(TRACE_ID, traceId);

        // 요청 시작 시간 계산
        long startTime = System.currentTimeMillis();

        try {
            filterChain.doFilter(requestWrapper, responseWrapper); // 요청을 필터에 전달
            log.info("[{}] request : {}", traceId, new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8)); // request body값 출력
        } finally {
            // 요청 처리 시간 계산
            long duration = System.currentTimeMillis() - startTime;
            // 요청 URI 및 처리 시간 로그 기록
            String method = request.getMethod();
            String uri = request.getRequestURI();
            log.info("[{}] - Request [{} {}] completed in {} ms", traceId, method, uri, duration);
            log.info("[{}] response : {} ", traceId, new String(responseWrapper.getContentAsByteArray(), StandardCharsets.UTF_8)); // response body값 출력
            responseWrapper.copyBodyToResponse(); // 요청을 전달
            MDC.clear();
        }
    }

}

 

GlobalExceptionHandler를 통해 전 예외 처리가 중앙에서 관리되게 하였다. 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFoundException(UserNotFoundException ex){
        ErrorResponse errorResponse = new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage());
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }
}
public class UserNotFoundException extends RuntimeException{
    public UserNotFoundException(String message){super(message);}
}

3. Interceptor를 통해 대기열 토큰 검증 처리

preHandle 메서드를 implements해서 "Queue-Token"이라는 헤더에 포함된 대기열 토큰을 검증한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class QueueInterceptor implements HandlerInterceptor {
    private final QueueFacade queueFacade;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
    {
        log.info("==================== QueueInterceptor START ====================");
        log.info(" Request URI \t: " + request.getRequestURI());

        String token = request.getHeader("Queue-Token");

        //헤더에 토큰이 비어있거나 검증에 통과하지 못하면 대기열 진입 불가
        if (token == null || token.isEmpty() || !queueFacade.isQueueValidToken(token)) {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("text/plain; charset=UTF-8");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 반환
            // ApiResponse 객체 생성
            ApiResponse<Object> apiResponse = ApiResponse.failure("사용자 대기열 토큰 검증에 실패하였습니다.", HttpServletResponse.SC_UNAUTHORIZED);

            // JSON 변환 및 응답 쓰기
            ObjectMapper objectMapper = new ObjectMapper();
            String jsonResponse = objectMapper.writeValueAsString(apiResponse);
            response.getWriter().write(jsonResponse);
            return false; // 요청 중단

        }

        return true;
    }
}

 

토큰 테이블에 대기열 토큰 정보가 있는지 확인하고 boolean 타입을 리턴한다.

@Component
@RequiredArgsConstructor
public class QueueFacade {
    public QueueTokenResult createQueueToken(QueueTokenParam queueTokenParam) {
        User user = userService.findById(queueTokenParam.userId());
        Queue queue = queueService.createQueueToken(queueTokenParam.userId());
        return QueueTokenResult.from(queue);
    }

    // 사용자 대기열 토큰 발급 시 토큰 테이블에 유저가 있는지만 체크
    public boolean isQueueValidToken(String tokenUserId) {
        Queue queue = queueService.findByUserId(Long.valueOf(tokenUserId));
        boolean result = true;
        if (queue == null) {
            result = false;
        }
        return result;
    }
}

 

4. 회고

이번 주차에 리팩토링을 진행하면서 코드의 관심사 분리를 하였다. 공통으로 쓰이는 코드를 어떻게 분리할 수 있을지 관리할 수 있을지 고민하고 개선할 수 있었다. 또한 filter와 interceptor의 차이점을 명확히 파악하면서, 두 요소가 각기 어떤 시점에서 작동하는지, 그리고 어떤 책임을 가져야 하는지에 대해 구체적으로 알게 되었다.

예외 처리를 각 도메인 서비스나 파사드 내에서 흩어져서 처리하게 되면 관리하기가 어려워지고 복잡해지는데 비슷한 기능을 하는 것끼리 묶어서 관리해주니 수정하려고 할 때 한 클래스만 수정하면 돼서 매우 효율적이게 코드가 변경되고 가독성 또한 크게 높아졌다.

코드의 확장성과 유지보수하기 편한 코드를 작성하는 것의 중요성을 느낀 주차였다. 그리고 앞으로도 redis의 캐싱 등을 사용하여 성능을 높일 예정이다.