카테고리 없음

📅 TIL - 어드민 API 접근 로깅 & 인증 처리 (Spring AOP + Interceptor)-최종편

creator7087 2025. 4. 22. 09:55

✅ 목표

/admin/**로 시작하는 관리자 API에 대해 다음을 수행한다:

  1. (선처리) 필터에서 JWT 검증 및 사용자 정보 저장
  2. (로깅) AOP로 @Admin 어노테이션이 붙은 메서드의 요청/응답 로그 출력

0. application.properties

jwt.secret.key=TXlTdXBlclNlY3JldEtleUZvckpXVFNpZ25pbmcxMjM0NTY=

1. JwtUtil 클래스

JWT 토큰 생성, 파싱 및 검증 역할

  • createToken: 사용자 ID, 이메일, 역할 기반 JWT 생성
  • substringToken: "Bearer " prefix 제거
  • extractClaims: JWT의 payload(claims) 추출

팁: 토큰 유효 시간은 60분 설정되어 있으며, 사용자 권한을 claim에 함께 포함합니다.

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        throw new ServerException("Not Found Token");
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

2. JwtFilter

모든 요청에 대해 JWT 유효성 검사 수행하는 필터

처리 흐름:

  • /auth 경로는 필터 통과
  • Authorization 헤더 확인 후, JWT 파싱
  • 사용자 정보(request attribute 저장: userId, email, userRole)
  • /admin/** 경로의 경우 ADMIN 권한이 아니면 403 반환

팁: 이 필터는 보안의 첫 관문으로 동작합니다. 이후 로직(AOP, Interceptor)은 이 필터가 정상 통과해야 실행됩니다.

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        if (url.startsWith("/auth")) {
            chain.doFilter(request, response);
            return;
        }

        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }

            UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));

            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email"));
            httpRequest.setAttribute("userRole", claims.get("userRole"));

            if (url.startsWith("/admin")) {
                // 관리자 권한이 없는 경우 403을 반환합니다.
                if (!UserRole.ADMIN.equals(userRole)) {
                    httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
                    return;
                }
                chain.doFilter(request, response);
                return;
            }

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("Invalid JWT token, 유효하지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "유효하지 않는 JWT 토큰입니다.");
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

3. AdminApiInterceptor

요청이 /admin/** 인 경우, 사용자 정보 로깅

주요 역할:

  • userId, userRole, 요청 시간, URI 로그 출력

팁: 이 인터셉터는 필터에서 검증된 사용자 정보를 기반으로 사전 로깅만 수행합니다. 실제 비즈니스 로직은 여기서 실행되지 않습니다

@Component
@Slf4j
@RequiredArgsConstructor
public class AdminApiInterceptor implements HandlerInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(AdminApiInterceptor.class);
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
        Long userId = (Long) request.getAttribute("userId");
        String requestURI = request.getRequestURI();

        // ✅ 사전 인증 통과 후, 로깅
        logger.info("[어드민 접근] 사용자 ID: {}, 시간: {}, URL: {}, 사용자 권한: {}" ,
                userId, LocalDateTime.now(), requestURI,userRole);

        log.info("Interceptor - Admin API Access: Timestamp={}, URL={}", System.currentTimeMillis(), request.getRequestURI());
        return true;
    }

}

4. WebConfig

필터나 인터셉터를 스프링 MVC에 등록

package org.example.expert.common.config;

import lombok.RequiredArgsConstructor;
import org.example.expert.common.auth.AuthUserArgumentResolver;
import org.example.expert.common.interceptor.AdminApiInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());

    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AdminApiInterceptor())
                .addPathPatterns("/admin/**");
    }

}

5. AdminAccessLoggingAspect

AOP 방식으로 @Admin 어노테이션이 붙은 메서드의 요청/응답 로그 출력

핵심 포인트:

  • @Around 어노테이션으로 메서드 실행 전후 로그 출력
  • joinPoint.getArgs()로 파라미터 확인 → JSON 변환
  • result를 JSON 변환해 응답 로그 출력

팁: ObjectMapper를 이용해 직렬화된 JSON 형태로 로그 출력하므로, 객체에 Jackson 어노테이션이 붙어있거나 Getter가 있어야 JSON 직렬화가 올바르게 작동합니다.

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AdminAccessLoggingAspect {

    private final HttpServletRequest request;
    private final ObjectMapper objectMapper;

    @Around("@annotation(org.example.expert.config.aop.annotation.Admin)")
    public Object logAdminApiAccess(ProceedingJoinPoint joinPoint) throws Throwable {
        Long userId = (Long) request.getAttribute("userId");
        String url = request.getRequestURI();
        long requestTimestamp = System.currentTimeMillis();

        String requestBody = objectMapper.writeValueAsString(joinPoint.getArgs());
        log.info("AOP - Admin API Request: userId={}, Timestamp={}, URL={}, RequestBody={}",
                userId, requestTimestamp, url, requestBody);

        Object result = joinPoint.proceed();

        String responseBody = objectMapper.writeValueAsString(result);
        log.info("AOP - Admin API Response: userId={}, Timestamp={}, URL={}, ResponseBody={}",
                userId, System.currentTimeMillis(), url, responseBody);

        return result;
    }
}

6. @Admin 어노테이션

AOP 대상 식별용 사용자 정의 어노테이션

package org.example.expert.config.aop.annotation;

import java.lang.annotation.*;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Admin {
}

✅ JSON 직렬화 정상 작동 여부 확인 포인트

  • objectMapper.writeValueAsString(...)을 통해 request/response를 JSON으로 직렬화하고 로그 출력함
  • 문제가 없다면, 콘솔에 예를 들어 다음과 같은 로그가 출력됩니다:

팁: 직렬화가 실패할 수 있는 대표 케이스

  • 순환 참조가 있을 때 (@JsonIgnore 또는 @JsonManagedReference/@JsonBackReference 필요)
  • 직렬화 대상에 getter가 없거나 private만 있을 때

🔍 추가 설명: logger와 @Documented

1. logger의 역할

  • Logger는 로그를 남기기 위한 도구입니다.
  • @Slf4j를 통해 log.info, log.error 등으로 로그 출력 가능
  • LoggerFactory.getLogger()를 명시적으로 사용하면 클래스 이름 기반 로거 생성

팁: 운영 환경에서는 로깅 수준을 INFO, ERROR 위주로 조절하고, 디버깅 시에만 DEBUG 로그를 활성화하는 것이 좋습니다.


2. @Documented 어노테이션의 역할

  • 사용자 정의 어노테이션을 사용할 때, Javadoc으로 문서화할 수 있도록 지원합니다.
  • 예를 들어 @Admin 어노테이션이 어떤 역할을 하는지 Javadoc 생성 시 해당 설명에 포함되도록 합니다.

팁: 실무에서는 커스텀 어노테이션에 @Documented를 붙여서 API 문서화와 협업의 가독성을 높입니다.