Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c8cb5e2
feat: MDC 필터 추가 및 JWT 필터에서 MEMBER_ID 추가 및 삭제 로직 추가
Dimo-2562 Jul 3, 2026
60ec4ee
feat: 비동기 스레드의 MDC 전파를 위해 설정 및 TaskDecorator 추가
Dimo-2562 Jul 3, 2026
8b37245
chore: Logback 기반 콘솔 및 파일 로그 개선
Dimo-2562 Jul 3, 2026
0eb42ce
chore: 콘솔 로그에는 색상을 추가하여 가시성 개선
Dimo-2562 Jul 3, 2026
bafd0e6
chore: 로그 I/O를 비동기로 하도록 변경
Dimo-2562 Jul 3, 2026
84ba1e8
chore: 에러 로그는 별도로 중복 저장하여 운영 편리성 향상
Dimo-2562 Jul 3, 2026
946ab99
feat: 웹소켓에도 MDC를 별도로 적용
Dimo-2562 Jul 3, 2026
bccb818
chore: 도커 컴포즈 volume 설정으로 log 추가
Dimo-2562 Jul 3, 2026
1c51c5b
chore: local, staging, prod를 제외하고는 색상을 없애 xml 생성 오류 해결
Dimo-2562 Jul 3, 2026
1aada5d
docs: 카카오 Authorization code를 로그에서 제거하여 보안 이슈 해결
Dimo-2562 Jul 3, 2026
5fdecb0
chore: 에러 로그에서는 ThresholdFilter를 통해 Error 로그만 받도록 변경
Dimo-2562 Jul 3, 2026
83aede0
chore: Error 제외 Appender는 block 되지 않도록 하고, 로그 레벨을 debug로 낮춤
Dimo-2562 Jul 3, 2026
fdd6f3e
chore: 누락된 HTTP 메소드, uri, clientIp도 로깅하도록 변경
Dimo-2562 Jul 3, 2026
4563cad
test: MDC 로그 테스트 검증을 중복해서 하지 않도록 변경
Dimo-2562 Jul 3, 2026
39ab997
chore: 불필요한 줄바꿈 제거
Dimo-2562 Jul 3, 2026
7488839
chore: 토큰 없는 요청의 경우 서버 에러가 인증 에러로 잘못 변환될 수 있으므로 별도 try-catch로 분리
Dimo-2562 Jul 3, 2026
1539e9f
chore: Ai로 인해 줄바꿈이 잘못 변경된 걸 해결
Dimo-2562 Jul 3, 2026
18bf01f
Merge branch 'develop' into feat/#647
Dimo-2562 Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ src/main/resources/firebase/*.json
cockple_dump.sql

k6

### logs ###
logs/
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand All @@ -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}
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "메시지 처리 중 오류가 발생했습니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,37 @@ public class JWTWebSocketAuthInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> 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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> previousContext = MDC.getCopyOfContextMap();
putSessionMdc(session);
return new MdcScope(previousContext);
}

public static MdcScope open(Long memberId) {
Map<String, String> 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<String, Object> 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<String, String> previousContext;

private MdcScope(Map<String, String> previousContext) {
this.previousContext = previousContext;
}

@Override
public void close() {
if (previousContext == null || previousContext.isEmpty()) {
MDC.clear();
return;
}
MDC.setContextMap(previousContext);
}
}
}
26 changes: 24 additions & 2 deletions src/main/java/umc/cockple/demo/global/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading