카테고리 없음
📅 TIL - 어드민 API 접근 로깅 & 인증 처리 (Spring AOP + Interceptor)-최종편
creator7087
2025. 4. 22. 09:55
✅ 목표
/admin/**로 시작하는 관리자 API에 대해 다음을 수행한다:
- (선처리) 필터에서 JWT 검증 및 사용자 정보 저장
- (로깅) 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 문서화와 협업의 가독성을 높입니다.