Filter & Interceptor

java

Filter

Filter란?

Filter는 클라이언트의 요청이 서블릿에 도달하기 전(pre-processing)과 응답이 클라이언트에게 돌아가기 전(post-processing)에 작업을 수행합니다.

  • 사용자 인증, 요청 정보 로깅, 데이터 암/복호화, 헤더 검사(XSS 방어) 등과 같은 전처리 또는 후처리 작업에 사용됩니다.
  • Filter는 서블릿 컨테이너(Tomcat 등)에 의해 관리됩니다. 이는 스프링의 빈(Bean)과는 다른 관리 영역이라는 의미입니다. 따라서 Filter에서 스프링의 의존성 주입(Dependency Injection)과 같은 특정 스프링 기능을 직접 사용하기 어렵습니다. 하지만 스프링에서는 Filter를 빈으로 등록하여 스프링의 일부 기능을 활용할 수 있습니다.

Body 수정에 대한 주의:

  • Request body를 수정할 때에는 주의가 필요합니다. 그러나 Request body의 데이터는 InputStream에 있기 때문에, 한 번 읽고 나면 다시 읽을 수 없습니다. 이로 인해 실제로는 Request body를 수정하기 어려울 수 있습니다. 이 문제를 해결하기 위해서는 ServletRequestWrapperHttpServletRequestWrapper를 사용하여 요청을 래핑하고, 래핑된 객체에서 새로운 InputStream을 제공하는 방식을 사용해야 합니다.

예외 처리:

  • Filter에서의 예외 처리는 직접 구현해야 합니다. 서블릿 컨테이너는 일반적으로 Filter에서 발생한 예외를 적절히 처리하지 않으므로, 예외 처리는 개발자에게 달려 있습니다.

대표적인 사용 예시

  • 로그인 인증 필터
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Value("${jwt.secret}")
    private String jwtSecret; // application.properties 또는 application.yml에서 JWT 시크릿 키 설정

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // HTTP 헤더에서 토큰을 가져오기
        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            // "Bearer " 이후의 토큰 부분만 추출
            String token = header.substring(7);

            // 토큰을 검증하고 클레임(사용자 정보)을 추출
            Claims claims = Jwts.parser()
                    .setSigningKey(jwtSecret)
                    .parseClaimsJws(token)
                    .getBody();

            // 클레임에서 사용자명을 추출
            String username = claims.getSubject();

            if (username != null) {
                // 추출한 사용자명을 기반으로 인증 객체 생성
                Authentication auth = new UsernamePasswordAuthenticationToken(username, null, null);
                // SecurityContextHolder에 인증 객체 저장
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }

        // 다음 필터로 전달
        filterChain.doFilter(request, response);
    }
}
  • 인코딩 필터
public class EncodingFilter implements Filter {

    private String encoding;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 필터 초기화 시 호출되는 메소드
        encoding = filterConfig.getInitParameter("encoding");
        if (encoding == null) {
            encoding = "UTF-8"; // 기본 인코딩은 UTF-8로 설정
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 필터가 실제로 수행하는 로직
        request.setCharacterEncoding(encoding);
        response.setCharacterEncoding(encoding);
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        // 필터 종료 시 호출되는 메소드
    }
}

Interceptor

Interceptor란?

  • 인터셉터는 Dispatcher Servlet이 컨트롤러에 매핑되기 전, 후에 요청과 응답을 가로채서 전처리 및 후처리 작업을 수행하는 Spring Framework의 기능입니다.
  • 공통 작업 수행, 권한 확인, 로깅 등의 비즈니스 로직을 처리하여 컨트롤러에 도달하기 전에 필요한 작업을 수행합니다.

관리 영역 및 장점:

  • 인터셉터는 Spring Container의 관리를 받아 스프링의 모든 bean 객체에 접근 가능하며, Spring 기능을 사용할 수 있다는 것을 의미합니다.
  • 스프링 컨테이너의 관리를 받아서 예외 처리에 관련된 다양한 기능들을 활용할 수 있습니다.

대표적인 사용 예시

  • 로그인 인터럽트
public class AuthenticationInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 사용자의 로그인 상태를 체크하는 로직
        boolean userLoggedIn = checkUserLoggedIn(request);

        if (userLoggedIn) {
            return true; // 계속 진행
        } else {
            // 로그인이 필요한 페이지에 접근하려고 할 때 로그인 페이지로 리다이렉트
            response.sendRedirect("/login");
            return false; // 요청 중단
        }
    }

    private boolean checkUserLoggedIn(HttpServletRequest request) {
        // 실제로는 세션 또는 인증 토큰을 확인하여 사용자의 로그인 상태를 체크하는 로직
        // 여기에서는 간단히 true를 반환하도록 가정
        return true;
    }
}
  • 로깅 인터럽트
public class LoggingInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 컨트롤러 메소드 호출 전에 실행되는 로직
        // 요청 시작 시간 로깅
        log.info("Request started at: " + System.currentTimeMillis());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 컨트롤러 메소드 호출 후에 실행되는 로직
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 뷰 렌더링까지 완료된 후에 실행되는 로직
        // 요청 종료 시간 로깅
        log.info("Request completed at: " + System.currentTimeMillis());
    }
}

Junit5 로그 테스트하는 방법

junit5

소개

  • 테스트 코드를 작성할 때 로그가 정상적으로 출력 됐는지 검증하고자 할 때가 있다.
  • 테스트 코드에서 로그를 검증하고자 하는 2가지 방법에 대해서 알아보자.

첫번재 방법

Junit5 환경에서 OutputCaptureExtension.class 사용해서 콘솔에 출력되는 로그를 캡쳐 후 원하는 로그가 출력되었는지 확인하는 방법이다. 샘플 코드는 @Slf4j를 사용해서 로그를 출력한다.

Sample Service Code
아래 코드는 userId를 통해 UserInfo를 조회할 때 User가 없다면 예외 처리를 하는 로직이다. throw를 이용해 예외 처리를 넘기지 않고 단순히 log로만 예외 처리를 하는 방식이다.

@Slf4j
@Service
@RequiredArgsConstructor
public class userService {

                private final UserRepository userRepository;

		public UserInfo getUserInfo(Long userId) {
				try {
						UserInfo userInfo = userRepository.findUserInfo(userId);
						log.debug("success get userInfo")
						return userInfo;
				} catch(EntityNotFoundException e) {
						log.debug("failed get userInfo")
						log.warn("no entity By {}", userId);
						코드가 실패했을 때 로그가 정상적으로 찍혔는지 확인하는 방법이다.
			  }
		}
}

Sample Test Code

package com.test.junit;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;

@ExtendWith(OutputCaptureExtension.class)
public class userServiceTest {

    @Autowired
    UserService userService;

    @Test
    void given_userId_when_getUserInfo_then_generate_error_log   (CapturedOutput capturedOutput) {
	       
	        Long userId = 1L;
                userService.getUserInfo(userId);
                assertThat(capturedOutput.toString(), containsString("failed get userInfo"));
                assertThat(capturedOutput.toString(), containsString("no entity By 1"));
        
    }
}

결론

간단하게 로그에 대한 검증 테스트를 할 수 있다는 장점이 있고 application.yml 파일에서 로그 Level 설정을 통해서 원하는 Level의 로그만 따로 검증 테스트를 할 수 있다.


두번째 방법

LoggerFactory는 로그 메시지를 기록하는 Logger를 생성할 때 사용된다. Logger에 기록된 로그들을 Slf4j의 구현체인 ch.qos.logback를 통해서
실제로 출력된 로그들을 저장하고 검증할 수 있다.

Gradle Dependencies

dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation 'ch.qos.logback:logback-classic:1.2.11' // Slf4j의 구현체
}

Sample Service Code

아래는 각각의 로그 level 별로 동일한 메시지를 출력하는 메소드이다.

package com.test.junit.log;


import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CustomLog {

    public void generateLogs(String msg) {
        log.trace(msg);
        log.debug(msg);
        log.info(msg);
        log.warn(msg);
        log.error(msg);
    }



}

Slf4j의 구현체인 logback를 통해서 실제로 출력된 로그들을 관리할 수 있는 클래스이다.
countEventsForLogger 메소드는 몇 개의 로그 출력 이벤트가 발생되었는지, search 메소드는 출력된 로그 msg와 level을 찾아서 List 형태로 반환해주며, contains는 단순히 msg와 level에 맞는 로그가 있는지 검증만 해준다.

package com.test.junit.log;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class MemoryAppender extends ListAppender<ILoggingEvent> {

    public void reset() {
        this.list.clear();
    }

    public boolean contains(String string, Level level) {
        return this.list.stream()
                .anyMatch(event -> event.toString().contains(string)
                        && event.getLevel().equals(level));
    }

    public int countEventsForLogger(String loggerName) {
        return (int) this.list.stream()
                .filter(event -> event.getLoggerName().contains(loggerName))
                .count();
    }

    public List<ILoggingEvent> search(String string) {
        return this.list.stream()
                .filter(event -> event.toString().contains(string))
                .collect(Collectors.toList());
    }

    public List<ILoggingEvent> search(String string, Level level) {
        return this.list.stream()
                .filter(event -> event.toString().contains(string)
                        && event.getLevel().equals(level))
                .collect(Collectors.toList());
    }

    public int getSize() {
        return this.list.size();
    }

    public List<ILoggingEvent> getLoggedEvents() {
        return Collections.unmodifiableList(this.list);
    }
}

Sample Test Code
LoggerFactory를 통해 원하는 클래스의 LogWatch를 시작하고 로그의 level 까지도 커스텀 할 수 있다는 장점이 있다.
테스트를 시작하기 전에 start() 메소드를 통해 LogWatch를 시작하고, 테스트가 끝나면 stop()으로 LogWatch를 종료한다.

package com.test.junit;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import com.test.junit.log.CustomLog;
import com.test.junit.log.MemoryAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

import static org.assertj.core.api.Assertions.assertThat;

public class CustomLogTest {

    MemoryAppender memoryAppender;

    @BeforeEach
    public void setup() {

        Logger logger = (Logger) LoggerFactory.getLogger(CustomLog.class);
        memoryAppender = new MemoryAppender();
        memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
        logger.setLevel(Level.DEBUG);
        logger.addAppender(memoryAppender);
        memoryAppender.start();

    }

    @AfterEach
    public void teardown() {
        memoryAppender.stop();
    }

    @Test
    public void test() {

        LogTest001 logTest001 = new LogTest001();
        logTest001.generateLogs("MSG");
        assertThat(memoryAppender.countEventsForLogger(CustomLog.class.getName())).isEqualTo(4);
        assertThat(memoryAppender.search("MSG", Level.INFO).size()).isEqualTo(1);
        assertThat(memoryAppender.contains("MSG", Level.TRACE)).isFalse();
    }

}

결론

Application 코드에서 테스트할 클래스의 로그 출력이 정상적으로 되었는지 검증할 수 있고 application.yml 같은 설정 파일이 아닌
코드에서 watch할 Log의 Level을 설정할 수 있다.

느낀점

간편하게 로그 검증을 하기 위해서는 OutputCaptureExtension.class를 사용해서 검증하는 것이 좋고, 세세히 검증을 해야 할 때는 직접 @Slf4j의 구현체를 사용해 Custom한 LogWatch를 구현해서 사용하는게 좋아 보인다.