diff --git a/.gitignore b/.gitignore index aeff3891d..5add0cde6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ src/main/resources/firebase/*.json cockple_dump.sql k6 + +### logs ### +logs/ diff --git a/docker-compose.yml b/docker-compose.yml index 18bb6ed4d..bdbf2b50f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m" SPRING_PROFILES_ACTIVE: prod + LOG_PATH: /logs DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -64,6 +65,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/prod:/logs depends_on: mysql: condition: service_healthy @@ -79,7 +82,7 @@ services: environment: JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m" SPRING_PROFILES_ACTIVE: staging - # [부하테스트용] FCM 인위적 지연(ms). 미설정 시 0=비활성. staging 전용. + LOG_PATH: /logs FCM_FAKE_LATENCY_MS: ${FCM_FAKE_LATENCY_MS:-0} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} @@ -89,6 +92,8 @@ services: KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} + volumes: + - ./logs/staging:/logs depends_on: mysql: condition: service_healthy diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8502513bb..963d1537f 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -31,6 +31,8 @@ EOF echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json +mkdir -p /home/ubuntu/cockple/logs/prod /home/ubuntu/cockple/logs/staging + echo "=== 배포 전 상태 ===" sudo docker ps diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java index aa40bf221..99f218010 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketHandler.java @@ -23,55 +23,63 @@ public class ChatWebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - log.info("웹소켓 연결 성공"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.info("웹소켓 연결 성공"); - try { - Long memberId = (Long) session.getAttributes().get("memberId"); - Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); + try { + Long memberId = (Long) session.getAttributes().get("memberId"); + Boolean authenticated = (Boolean) session.getAttributes().get("authenticated"); - if (memberId != null && Boolean.TRUE.equals(authenticated)) { - MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); - session.getAttributes().put("memberName", memberInfo.memberName()); + if (memberId != null && Boolean.TRUE.equals(authenticated)) { + MemberConnectionInfo memberInfo = memberQueryService.getMemberConnectionInfo(memberId); + session.getAttributes().put("memberName", memberInfo.memberName()); - sessionRegistry.register(memberId, session); - log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); + sessionRegistry.register(memberId, session); + log.info("사용자 연결 완료 - memberId: {}, 세션 ID: {}", memberId, session.getId()); - webSocketResponseSender.sendConnectionSuccessMessage(session, memberInfo); - } else { - log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); + webSocketResponseSender.sendConnectionSuccessMessage(session, memberInfo); + } else { + log.warn("memberId를 찾을 수 없습니다. 세션을 종료합니다."); + session.close(); + } + } catch (Exception e) { + log.error("WebSocket 연결 처리 중 오류 발생", e); session.close(); } - } catch (Exception e) { - log.error("WebSocket 연결 처리 중 오류 발생", e); - session.close(); } } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { - requestDispatcher.dispatch(session, message.getPayload()); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + requestDispatcher.dispatch(session, message.getPayload()); + } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ - Long memberId = (Long) session.getAttributes().get("memberId"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); - log.info("웹소켓 연결 종료"); - log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); + log.info("웹소켓 연결 종료"); + log.info("세션 ID: {}, 사용자 ID: {}, 종료 상태: {}", session.getId(), memberId, status); - if (memberId != null) { - sessionRegistry.remove(memberId, session); - log.info("사용자 세션 정리 완료 - memberId: {}", memberId); + if (memberId != null) { + sessionRegistry.remove(memberId, session); + log.info("사용자 세션 정리 완료 - memberId: {}", memberId); + } } } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { - Long memberId = (Long) session.getAttributes().get("memberId"); - if (isShutdownRelatedError(exception)) { - log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); - } else { - log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + Long memberId = (Long) session.getAttributes().get("memberId"); + if (isShutdownRelatedError(exception)) { + log.debug("서버 종료 관련 WebSocket 전송 오류 (정상) - 세션: {}, 사용자: {}", session.getId(), memberId); + } else { + log.error("WebSocket 전송 오류 발생 - 세션 ID: {}, 사용자 ID: {}", session.getId(), memberId, exception); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java index 6bf9fe6cf..7aa7af119 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcher.java @@ -17,25 +17,27 @@ public class ChatWebSocketRequestDispatcher { private final ChatWebSocketCommandHandler commandHandler; public void dispatch(WebSocketSession session, String payload) { - log.info("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); - - try { - WebSocketMessageDTO.Request request = objectMapper.readValue( - payload, WebSocketMessageDTO.Request.class - ); - - Long memberId = (Long) session.getAttributes().get("memberId"); - if (memberId == null) { - webSocketResponseSender.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); - return; + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + log.debug("메시지 수신 - 세션 ID: {}, payloadSize: {}", session.getId(), payload == null ? 0 : payload.length()); + + try { + WebSocketMessageDTO.Request request = objectMapper.readValue( + payload, WebSocketMessageDTO.Request.class + ); + + Long memberId = (Long) session.getAttributes().get("memberId"); + if (memberId == null) { + webSocketResponseSender.sendErrorMessage(session, "UNAUTHORIZED", "인증되지 않은 사용자입니다."); + return; + } + + log.debug("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); + commandHandler.handle(session, request, memberId); + + } catch (Exception e) { + log.error("메시지 처리 중 에러 발생", e); + webSocketResponseSender.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); } - - log.info("메시지 타입: {}, 채팅방 ID: {}, 사용자 ID: {}", request.type(), session.getId(), memberId); - commandHandler.handle(session, request, memberId); - - } catch (Exception e) { - log.error("메시지 처리 중 에러 발생", e); - webSocketResponseSender.sendErrorMessage(session, "PROCESSING_ERROR", "메시지 처리 중 오류가 발생했습니다."); } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java index 3f3f4b43b..e71a60d3c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptor.java @@ -21,31 +21,37 @@ public class JWTWebSocketAuthInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { - log.debug("JWT 기반 WebSocket 인증 시작"); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + log.debug("JWT 기반 WebSocket 인증 시작"); - try { - String token = extractTokenFromRequest(request); + try { + String token = extractTokenFromRequest(request); - if (isInvalidToken(token)) return false; + if (isInvalidToken(token)) return false; - Long memberId = jwtTokenProvider.getUserId(token); + Long memberId = jwtTokenProvider.getUserId(token); - attributes.put("memberId", memberId); - attributes.put("authenticated", true); + attributes.put("memberId", memberId); + attributes.put("authenticated", true); - log.info("JWT 인증 성공 - memberId: {}", memberId); - return true; - } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생", e); - return false; + try (WebSocketMdcSupport.MdcScope memberScope = WebSocketMdcSupport.open(memberId)) { + log.info("JWT 인증 성공 - memberId: {}", memberId); + } + return true; + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + return false; + } } } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { - if (exception != null) { - log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open((Long) null)) { + if (exception != null) { + log.error("JWT HandshakeInterceptor 실행 중 오류", exception); + } } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java new file mode 100644 index 000000000..61a6952f0 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupport.java @@ -0,0 +1,76 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.Map; + +public final class WebSocketMdcSupport { + + public static final String WS_SESSION_ID = "wsSessionId"; + + private WebSocketMdcSupport() { + } + + public static MdcScope open(WebSocketSession session) { + Map previousContext = MDC.getCopyOfContextMap(); + putSessionMdc(session); + return new MdcScope(previousContext); + } + + public static MdcScope open(Long memberId) { + Map previousContext = MDC.getCopyOfContextMap(); + putMemberId(memberId); + return new MdcScope(previousContext); + } + + private static void putSessionMdc(WebSocketSession session) { + if (session == null) { + MDC.remove(WS_SESSION_ID); + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + return; + } + + String sessionId = session.getId(); + if (sessionId == null || sessionId.isBlank()) { + MDC.remove(WS_SESSION_ID); + } else { + MDC.put(WS_SESSION_ID, sessionId); + } + + Map attributes = session.getAttributes(); + Object memberId = attributes == null ? null : attributes.get(JwtAuthenticationFilter.MEMBER_ID); + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + } + + private static void putMemberId(Long memberId) { + if (memberId == null) { + MDC.remove(JwtAuthenticationFilter.MEMBER_ID); + } else { + MDC.put(JwtAuthenticationFilter.MEMBER_ID, String.valueOf(memberId)); + } + MDC.remove(WS_SESSION_ID); + } + + public static final class MdcScope implements AutoCloseable { + private final Map previousContext; + + private MdcScope(Map previousContext) { + this.previousContext = previousContext; + } + + @Override + public void close() { + if (previousContext == null || previousContext.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(previousContext); + } + } +} diff --git a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java index 50d862fcc..e9e74d1a7 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -1,9 +1,31 @@ package umc.cockple.demo.global.config; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.stereotype.Component; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcTaskDecorator; -@Component +@Configuration @EnableAsync public class AsyncConfig { + + @Bean + public TaskDecorator mdcTaskDecorator() { + return new MdcTaskDecorator(); + } + + @Bean(name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) + public ThreadPoolTaskExecutor applicationTaskExecutor( + ThreadPoolTaskExecutorBuilder builder, + TaskDecorator mdcTaskDecorator + ) { + return builder + .threadNamePrefix("cockple-async-") + .taskDecorator(mdcTaskDecorator) + .build(); + } } diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java new file mode 100644 index 000000000..095208234 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcLoggingFilter.java @@ -0,0 +1,60 @@ +package umc.cockple.demo.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MdcLoggingFilter extends OncePerRequestFilter { + + public static final String REQUEST_ID = "requestId"; + public static final String METHOD = "method"; + public static final String URI = "uri"; + public static final String CLIENT_IP = "clientIp"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + MDC.put(REQUEST_ID, resolveRequestId(request)); + MDC.put(METHOD, request.getMethod()); + MDC.put(URI, request.getRequestURI()); + MDC.put(CLIENT_IP, resolveClientIp(request)); + + filterChain.doFilter(request, response); + } finally { + MDC.remove(REQUEST_ID); + MDC.remove(METHOD); + MDC.remove(URI); + MDC.remove(CLIENT_IP); + } + } + + private String resolveRequestId(HttpServletRequest request) { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + return UUID.randomUUID().toString(); + } + return requestId.trim(); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java new file mode 100644 index 000000000..dffcd8415 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/logging/MdcTaskDecorator.java @@ -0,0 +1,32 @@ +package umc.cockple.demo.global.logging; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map parentContext = MDC.getCopyOfContextMap(); + + return () -> { + Map previousContext = MDC.getCopyOfContextMap(); + try { + setContext(parentContext); + runnable.run(); + } finally { + setContext(previousContext); + } + }; + } + + private void setContext(Map context) { + if (context == null || context.isEmpty()) { + MDC.clear(); + return; + } + MDC.setContextMap(context); + } +} diff --git a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java index 4e69943e0..e7ee42db3 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/domain/KakaoClient.java @@ -1,98 +1,97 @@ -package umc.cockple.demo.global.oauth2.domain; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.BodyInserter; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; - -@Component -@Slf4j -@RequiredArgsConstructor -public class KakaoClient { - - private final WebClient webClient = WebClient.create(); - - @Value("${kakao.client-id}") - private String clientId; - - @Value("${kakao.client-secret:}") - private String clientSecret; - - @Value("${kakao.redirect-uri}") - private String redirectUri; - - @Value("${kakao.token-uri}") - private String tokenUri; - - @Value("${kakao.user-info-uri}") - private String userInfoUri; - - @Value("${kakao.unlink-uri}") - private String unlinkUri; - - @Value("${kakao.admin-key}") - private String adminKey; - - - // 인가코드로 AccessToken 요청하기 - public String getAccessToken(String code) { - BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") - .with("client_id", clientId) - .with("redirect_uri", redirectUri) - .with("code", code); - - if (clientSecret != null && !clientSecret.isBlank()) { - formData.with("client_secret", clientSecret); - } - - log.info("Redirect URI used: {}", redirectUri); - log.info("Auth Code: {}", code); - JsonNode response = webClient.post() - .uri(tokenUri) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(formData) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - return response.get("access_token").asText(); - } - - // access 토큰으로 사용자 정보 요청 - public KakaoClientInfo getClientInfo(String accessToken) { - JsonNode response = webClient.get() - .uri(userInfoUri) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .bodyToMono(JsonNode.class) - .block(); - - Long kakaoId = response.get("id").asLong(); - String nickname = response.path("properties").path("nickname").asText(); - - return new KakaoClientInfo(kakaoId, nickname); - } - - // 회원 탈퇴를 위해 카카오와 연결 끊기 - public void unlinkByAdmin(Long socialId) { - webClient.post() - .uri(unlinkUri) - .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("target_id_type", "user_id") - .with("target_id", String.valueOf(socialId))) - .retrieve() - .toBodilessEntity() - .block() - ; - - log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); - } -} +package umc.cockple.demo.global.oauth2.domain; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; + +@Component +@Slf4j +@RequiredArgsConstructor +public class KakaoClient { + + private final WebClient webClient = WebClient.create(); + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.client-secret:}") + private String clientSecret; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + @Value("${kakao.token-uri}") + private String tokenUri; + + @Value("${kakao.user-info-uri}") + private String userInfoUri; + + @Value("${kakao.unlink-uri}") + private String unlinkUri; + + @Value("${kakao.admin-key}") + private String adminKey; + + + // 인가코드로 AccessToken 요청하기 + public String getAccessToken(String code) { + BodyInserters.FormInserter formData = BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", clientId) + .with("redirect_uri", redirectUri) + .with("code", code); + + if (clientSecret != null && !clientSecret.isBlank()) { + formData.with("client_secret", clientSecret); + } + + log.debug("Redirect URI used for Kakao OAuth token request: {}", redirectUri); + JsonNode response = webClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(formData) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response.get("access_token").asText(); + } + + // access 토큰으로 사용자 정보 요청 + public KakaoClientInfo getClientInfo(String accessToken) { + JsonNode response = webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + Long kakaoId = response.get("id").asLong(); + String nickname = response.path("properties").path("nickname").asText(); + + return new KakaoClientInfo(kakaoId, nickname); + } + + // 회원 탈퇴를 위해 카카오와 연결 끊기 + public void unlinkByAdmin(Long socialId) { + webClient.post() + .uri(unlinkUri) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + adminKey) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("target_id_type", "user_id") + .with("target_id", String.valueOf(socialId))) + .retrieve() + .toBodilessEntity() + .block() + ; + + log.info("[KAKAO] unlink OK. kakaoUserId={}", socialId); + } +} diff --git a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java index 6fb22a186..f58ac28fa 100644 --- a/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilter.java @@ -1,89 +1,92 @@ -package umc.cockple.demo.global.security.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; -import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; - -import java.io.IOException; - -@RequiredArgsConstructor -@Component -@Slf4j -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; - private final RestAuthenticationEntryPoint restEntryPoint; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - String token = resolveToken(request); - - // token이 null -> 로그 찍고 그대로 진행 - if (token == null) { - log.trace("Authorization 헤더에 토큰 없음"); - filterChain.doFilter(request, response); - return; - } - - try { - - if (!jwtTokenProvider.validateToken(token)) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - - - Long memberId = jwtTokenProvider.getUserId(token); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // 탈퇴 회원 검증 - if (member.getIsActive() == MemberStatus.INACTIVE) { - throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); - } - - Authentication auth = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); - - filterChain.doFilter(request, response); - - } catch (MemberException e) { - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - - } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 - SecurityContextHolder.clearContext(); - restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); - } - - } - - - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } - - return null; - } - -} +package umc.cockple.demo.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String MEMBER_ID = "memberId"; + + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final RestAuthenticationEntryPoint restEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + MDC.remove(MEMBER_ID); + try { + String token = resolveToken(request); + // token이 null -> 로그 찍고 그대로 진행 + if (token == null) { + log.trace("Authorization 헤더에 토큰 없음"); + filterChain.doFilter(request, response); + return; + } + + try { + if (!jwtTokenProvider.validateToken(token)) { + throw new MemberException(MemberErrorCode.INVALID_TOKEN); + } + + Long memberId = jwtTokenProvider.getUserId(token); + MDC.put(MEMBER_ID, String.valueOf(memberId)); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 탈퇴 회원 검증 + if (member.getIsActive() == MemberStatus.INACTIVE) { + throw new MemberException(MemberErrorCode.ALREADY_WITHDRAW); + } + + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("인증 정보 SecurityContext에 저장 완료: {}", auth.getName()); + + filterChain.doFilter(request, response); + + } catch (MemberException e) { + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + + } catch (RuntimeException e) { // 혹시 남아있는 경우에도 401로 변환 + SecurityContextHolder.clearContext(); + restEntryPoint.commence(request, response, new BadCredentialsException(e.getMessage() == null ? "UNAUTHORIZED" : e.getMessage())); + } + } finally { + MDC.remove(MEMBER_ID); + } + + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..3d6584bbf --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + + + + + + ${FILE_LOG_PATTERN} + UTF-8 + + + + + + ${APP_LOG_FILE} + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/application.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + ${WEBSOCKET_LOG_FILE} + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/websocket.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 14 + 1GB + + + + + ${ERROR_LOG_FILE} + + ERROR + + + ${FILE_LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 30 + 1GB + + + + + 0 + 1024 + true + false + + + + + 0 + 2048 + true + false + + + + + + ERROR + + 0 + 512 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java index e7464ff40..467334a9a 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/ChatWebSocketRequestDispatcherTest.java @@ -16,6 +16,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.then; diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java new file mode 100644 index 000000000..9b5177354 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/JWTWebSocketAuthInterceptorTest.java @@ -0,0 +1,64 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JWTWebSocketAuthInterceptor") +class JWTWebSocketAuthInterceptorTest { + + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private ServerHttpRequest request; + @Mock private ServerHttpResponse response; + @Mock private WebSocketHandler wsHandler; + + private JWTWebSocketAuthInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new JWTWebSocketAuthInterceptor(jwtTokenProvider); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("handshake 인증 성공 시 세션 attributes에 memberId를 저장하고 MDC를 복구한다") + void beforeHandshakeStoresMemberIdAndRestoresMdc() throws Exception { + Map attributes = new HashMap<>(); + given(request.getURI()).willReturn(URI.create("/ws/chats?token=access-token")); + given(jwtTokenProvider.validateToken("access-token")).willReturn(true); + given(jwtTokenProvider.getUserId("access-token")).willReturn(10L); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + boolean result = interceptor.beforeHandshake(request, response, wsHandler, attributes); + + assertThat(result).isTrue(); + assertThat(attributes).containsEntry(JwtAuthenticationFilter.MEMBER_ID, 10L); + assertThat(attributes).containsEntry("authenticated", true); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java new file mode 100644 index 000000000..7eb3b329e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/presentation/websocket/WebSocketMdcSupportTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.domain.chat.presentation.websocket; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.web.socket.WebSocketSession; +import umc.cockple.demo.global.logging.MdcLoggingFilter; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@DisplayName("WebSocketMdcSupport") +class WebSocketMdcSupportTest { + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("세션 scope 동안 memberId와 wsSessionId를 MDC에 넣고 종료 후 이전 MDC를 복구한다") + void openSessionMdcAndRestorePreviousContext() { + WebSocketSession session = mock(WebSocketSession.class); + Map attributes = new HashMap<>(); + attributes.put(JwtAuthenticationFilter.MEMBER_ID, 10L); + given(session.getId()).willReturn("session-1"); + given(session.getAttributes()).willReturn(attributes); + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(session)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("10"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("session-1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + } + + @Test + @DisplayName("memberId scope는 wsSessionId를 비우고 종료 후 이전 MDC를 복구한다") + void openMemberMdcAndClearWebSocketSessionId() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(WebSocketMdcSupport.WS_SESSION_ID, "stale-session"); + + try (WebSocketMdcSupport.MdcScope ignored = WebSocketMdcSupport.open(20L)) { + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isEqualTo("20"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + } + + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("request-1"); + assertThat(MDC.get(WebSocketMdcSupport.WS_SESSION_ID)).isEqualTo("stale-session"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java new file mode 100644 index 000000000..0a248daee --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java @@ -0,0 +1,54 @@ +package umc.cockple.demo.global.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import umc.cockple.demo.global.logging.MdcLoggingFilter; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class AsyncConfigTest { + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("applicationTaskExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void applicationTaskExecutorPropagatesMdcAndPreventsLeak() throws Exception { + AsyncConfig config = new AsyncConfig(); + TaskDecorator taskDecorator = config.mdcTaskDecorator(); + ThreadPoolTaskExecutor executor = config.applicationTaskExecutor( + new ThreadPoolTaskExecutorBuilder() + .corePoolSize(1) + .maxPoolSize(1) + .queueCapacity(1), + taskDecorator + ); + executor.initialize(); + + try { + MDC.put(MdcLoggingFilter.REQUEST_ID, "async-request"); + CompletableFuture firstTask = new CompletableFuture<>(); + executor.execute(() -> firstTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(firstTask.get(3, TimeUnit.SECONDS)).isEqualTo("async-request"); + + MDC.clear(); + CompletableFuture secondTask = new CompletableFuture<>(); + executor.execute(() -> secondTask.complete(MDC.get(MdcLoggingFilter.REQUEST_ID))); + + assertThat(secondTask.get(3, TimeUnit.SECONDS)).isNull(); + } finally { + executor.shutdown(); + } + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java new file mode 100644 index 000000000..b4e57f840 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/LogbackSpringConfigurationTest.java @@ -0,0 +1,253 @@ +package umc.cockple.demo.global.logging; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.AsyncAppender; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.filter.ThresholdFilter; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.core.spi.FilterAttachable; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.StatusUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.logging.LoggingInitializationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import java.lang.reflect.Constructor; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogbackSpringConfigurationTest { + + private static final String WEBSOCKET_LOGGER = "umc.cockple.demo.domain.chat.presentation.websocket"; + private static final String WEBSOCKET_SERVICE_LOGGER = "umc.cockple.demo.domain.chat.service.websocket"; + private static final String SPRING_WEBSOCKET_LOGGER = "org.springframework.web.socket"; + + @TempDir + Path logPath; + + @Test + @DisplayName("prod 프로필에서는 WebSocket 로그가 파일 appender로만 라우팅된다") + void prodProfileRoutesWebSocketLogsToFileOnly() throws Exception { + LoggerContext context = configure("prod"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(SPRING_WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("local 프로필에서는 WebSocket 로그가 콘솔과 파일 appender에 함께 라우팅된다") + void localProfileRoutesWebSocketLogsToConsoleAndFile() throws Exception { + LoggerContext context = configure("local"); + + try { + assertThat(appenderNames(context.getLogger(WEBSOCKET_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(context.getLogger(WEBSOCKET_LOGGER).isAdditive()).isFalse(); + assertThat(appenderNames(context.getLogger(WEBSOCKET_SERVICE_LOGGER))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_WEBSOCKET_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).contains("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "WEBSOCKET_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "WEBSOCKET_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + assertAsyncAppender(context, "ASYNC_APPLICATION_FILE", "APPLICATION_FILE", 1024, true); + assertAsyncAppender(context, "ASYNC_WEBSOCKET_FILE", "WEBSOCKET_FILE", 2048, true); + assertAsyncAppender(context, "ASYNC_ERROR_FILE", "ERROR_FILE", 512, false); + assertThresholdFilter(context, "ASYNC_ERROR_FILE"); + assertThresholdFilter(context, "ERROR_FILE"); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("test 프로필에서는 XML 테스트 리포트 보호를 위해 콘솔 색상을 사용하지 않는다") + void testProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure("test"); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "APPLICATION_FILE")).doesNotContain("%clr", "%highlight"); + assertThat(appenderPattern(context, "ERROR_FILE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + assertHttpMdcPattern(appenderPattern(context, "APPLICATION_FILE")); + assertHttpMdcPattern(appenderPattern(context, "ERROR_FILE")); + } finally { + context.stop(); + } + } + + @Test + @DisplayName("명시 런타임 프로필이 아니면 기본 콘솔 패턴은 색상을 사용하지 않는다") + void defaultProfileUsesPlainConsolePattern() throws Exception { + LoggerContext context = configure(); + + try { + assertThat(appenderNames(context.getLogger(Logger.ROOT_LOGGER_NAME))) + .containsExactlyInAnyOrder("CONSOLE", "ASYNC_APPLICATION_FILE", "ASYNC_ERROR_FILE"); + assertThat(appenderPattern(context, "CONSOLE")).doesNotContain("%clr", "%highlight"); + assertHttpMdcPattern(appenderPattern(context, "CONSOLE")); + } finally { + context.stop(); + } + } + + private LoggerContext configure() throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + + private LoggerContext configure(String profile) throws Exception { + LoggerContext context = new LoggerContext(); + context.putProperty("LOG_PATH", logPath.toString()); + + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + LoggingInitializationContext initializationContext = new LoggingInitializationContext(environment); + configureContext(context, initializationContext); + return context; + } + + private void configureContext( + LoggerContext context, + LoggingInitializationContext initializationContext + ) throws Exception { + Constructor constructor = Class.forName("org.springframework.boot.logging.logback.SpringBootJoranConfigurator") + .getDeclaredConstructor(LoggingInitializationContext.class); + constructor.setAccessible(true); + JoranConfigurator configurator = (JoranConfigurator) constructor.newInstance(initializationContext); + configurator.setContext(context); + configurator.doConfigure(new ClassPathResource("logback-spring.xml").getFile()); + + StatusUtil statusUtil = new StatusUtil(context); + assertThat(statusUtil.getHighestLevel(0)).isLessThan(Status.ERROR); + } + + private List appenderNames(Logger logger) { + List names = new ArrayList<>(); + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + names.add(appender.getName()); + } + return names; + } + + private String appenderPattern(LoggerContext context, String appenderName) { + Appender appender = findAppender(context, appenderName); + assertThat(appender).isInstanceOf(OutputStreamAppender.class); + OutputStreamAppender outputStreamAppender = (OutputStreamAppender) appender; + assertThat(outputStreamAppender.getEncoder()).isInstanceOf(PatternLayoutEncoder.class); + PatternLayoutEncoder encoder = (PatternLayoutEncoder) outputStreamAppender.getEncoder(); + return encoder.getPattern(); + } + + private void assertHttpMdcPattern(String pattern) { + assertThat(pattern).contains("%X{method:-}", "%X{uri:-}", "%X{clientIp:-}"); + } + + private void assertAsyncAppender( + LoggerContext context, + String asyncAppenderName, + String nestedAppenderName, + int queueSize, + boolean neverBlock + ) { + Appender appender = findAppender(context, asyncAppenderName); + assertThat(appender).isInstanceOf(AsyncAppender.class); + AsyncAppender asyncAppender = (AsyncAppender) appender; + assertThat(asyncAppender.getQueueSize()).isEqualTo(queueSize); + assertThat(asyncAppender.getDiscardingThreshold()).isZero(); + assertThat(asyncAppender.isNeverBlock()).isEqualTo(neverBlock); + assertThat(asyncAppender.isIncludeCallerData()).isFalse(); + assertThat(asyncAppender.getAppender(nestedAppenderName)).isNotNull(); + } + + @SuppressWarnings("unchecked") + private void assertThresholdFilter(LoggerContext context, String appenderName) { + Appender appender = findAppender(context, appenderName); + assertThat(appender).isInstanceOf(FilterAttachable.class); + FilterAttachable filterAttachable = + (FilterAttachable) appender; + assertThat(filterAttachable.getCopyOfAttachedFiltersList()) + .hasSize(1) + .allMatch(ThresholdFilter.class::isInstance); + } + + private Appender findAppender(LoggerContext context, String appenderName) { + List loggers = List.of( + context.getLogger(Logger.ROOT_LOGGER_NAME), + context.getLogger(WEBSOCKET_LOGGER), + context.getLogger(WEBSOCKET_SERVICE_LOGGER), + context.getLogger(SPRING_WEBSOCKET_LOGGER) + ); + + for (Logger logger : loggers) { + Appender directAppender = logger.getAppender(appenderName); + if (directAppender != null) { + return directAppender; + } + } + + for (Logger logger : loggers) { + Iterator> iterator = logger.iteratorForAppenders(); + while (iterator.hasNext()) { + Appender appender = iterator.next(); + if (appender instanceof AsyncAppender asyncAppender) { + Appender nestedAppender = asyncAppender.getAppender(appenderName); + if (nestedAppender != null) { + return nestedAppender; + } + } + } + } + + return null; + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java new file mode 100644 index 000000000..81ea6a1c4 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcLoggingFilterTest.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class MdcLoggingFilterTest { + + private final MdcLoggingFilter filter = new MdcLoggingFilter(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("X-Request-Id가 있으면 요청 MDC에 재사용하고 종료 후 정리한다") + void reuseRequestIdAndClearMdc() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/exercises"); + request.addHeader("X-Request-Id", "request-123"); + request.addHeader("X-Forwarded-For", "203.0.113.10, 10.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference method = new AtomicReference<>(); + AtomicReference uri = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + method.set(MDC.get(MdcLoggingFilter.METHOD)); + uri.set(MDC.get(MdcLoggingFilter.URI)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThat(requestId.get()).isEqualTo("request-123"); + assertThat(method.get()).isEqualTo("POST"); + assertThat(uri.get()).isEqualTo("/api/exercises"); + assertThat(clientIp.get()).isEqualTo("203.0.113.10"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.METHOD)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.URI)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } + + @Test + @DisplayName("X-Request-Id가 없으면 UUID를 생성한다") + void generateRequestIdWhenHeaderIsMissing() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.setRemoteAddr("127.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + AtomicReference requestId = new AtomicReference<>(); + AtomicReference clientIp = new AtomicReference<>(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + requestId.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + clientIp.set(MDC.get(MdcLoggingFilter.CLIENT_IP)); + }); + + assertThatCode(() -> UUID.fromString(requestId.get())).doesNotThrowAnyException(); + assertThat(clientIp.get()).isEqualTo("127.0.0.1"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isNull(); + assertThat(MDC.get(MdcLoggingFilter.CLIENT_IP)).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java new file mode 100644 index 000000000..93172c94a --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,63 @@ +package umc.cockple.demo.global.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import umc.cockple.demo.global.security.filter.JwtAuthenticationFilter; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MdcTaskDecoratorTest { + + private final MdcTaskDecorator decorator = new MdcTaskDecorator(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("decorate 시점의 MDC를 작업 실행 동안 전파하고 이전 MDC를 복구한다") + void copyParentMdcAndRestorePreviousMdc() { + MDC.put(MdcLoggingFilter.REQUEST_ID, "request-1"); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "7"); + + AtomicReference requestIdInTask = new AtomicReference<>(); + AtomicReference memberIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> { + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)); + memberIdInTask.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)); + }); + + MDC.clear(); + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isEqualTo("request-1"); + assertThat(memberIdInTask.get()).isEqualTo("7"); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("decorate 시점에 MDC가 없으면 작업 실행 동안 기존 worker MDC를 비운 뒤 복구한다") + void clearWorkerMdcWhenParentMdcIsEmptyAndRestoreAfterwards() { + MDC.clear(); + + AtomicReference requestIdInTask = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> + requestIdInTask.set(MDC.get(MdcLoggingFilter.REQUEST_ID)) + ); + + MDC.put(MdcLoggingFilter.REQUEST_ID, "worker-before"); + + decorated.run(); + + assertThat(requestIdInTask.get()).isNull(); + assertThat(MDC.get(MdcLoggingFilter.REQUEST_ID)).isEqualTo("worker-before"); + } +} diff --git a/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java new file mode 100644 index 000000000..2c30969c8 --- /dev/null +++ b/src/test/java/umc/cockple/demo/global/security/filter/JwtAuthenticationFilterMdcTest.java @@ -0,0 +1,108 @@ +package umc.cockple.demo.global.security.filter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.exception.RestAuthenticationEntryPoint; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterMdcTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RestAuthenticationEntryPoint restEntryPoint; + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("인증 성공 시 요청 처리 동안 memberId를 MDC에 넣고 종료 후 정리한다") + void putMemberIdDuringAuthenticatedRequestAndClearAfterwards() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/members/me"); + request.addHeader("Authorization", "Bearer token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + Member member = Member.builder() + .id(7L) + .nickname("member7") + .isActive(MemberStatus.ACTIVE) + .socialId(700L) + .build(); + AtomicReference memberIdInChain = new AtomicReference<>(); + + given(jwtTokenProvider.validateToken("token")).willReturn(true); + given(jwtTokenProvider.getUserId("token")).willReturn(7L); + given(memberRepository.findById(7L)).willReturn(Optional.of(member)); + given(jwtTokenProvider.getAuthentication("token")) + .willReturn(new UsernamePasswordAuthenticationToken("member7", "")); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isEqualTo("7"); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + } + + @Test + @DisplayName("토큰이 없는 요청은 이전 memberId MDC를 제거한 상태로 통과시키고 종료 후에도 정리한다") + void clearStaleMemberIdWhenRequestHasNoToken() throws Exception { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference memberIdInChain = new AtomicReference<>(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> + memberIdInChain.set(MDC.get(JwtAuthenticationFilter.MEMBER_ID)) + ); + + assertThat(memberIdInChain.get()).isNull(); + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + } + + @Test + @DisplayName("토큰이 없는 요청의 downstream 예외는 인증 실패로 변환하지 않고 전파한다") + void propagateDownstreamExceptionWhenRequestHasNoToken() { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, memberRepository, restEntryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MDC.put(JwtAuthenticationFilter.MEMBER_ID, "stale-member"); + + assertThatThrownBy(() -> filter.doFilter(request, response, (servletRequest, servletResponse) -> { + throw new IllegalStateException("downstream failure"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessage("downstream failure"); + + assertThat(MDC.get(JwtAuthenticationFilter.MEMBER_ID)).isNull(); + verifyNoInteractions(jwtTokenProvider, memberRepository, restEntryPoint); + } +}