Backend
home
📩

실시간 채팅 세션 관리 시스템 구현

생성 일시
2025/10/15 10:48
태그
SpringBoot
게시일
2025/10/15
최종 편집 일시
2025/10/15 11:23

1. 배경 및 문제 인식

기존 시스템의 한계

DungeonTalk 프로젝트의 실시간 채팅 기능을 리팩토링 하는 과정에서 다음과 같은 문제를 발견했다.
1.
중복 입장 문제: 같은 유저가 여러 세션으로 입장 가능
2.
활동 추적 불가: 마지막 활동 시간을 알 수 없어 idle 상태 판단 불가

2. 설계 방향

1. TTL 기반 자동 만료

Redis/Valkey의 TTL(Time To Live)을 활용한 자동 세션 정리
명시적 종료 없이도 비정상 종료 대응 가능

2. 멱등성(Idempotent) 보장

입장/퇴장 연산을 여러 번 호출해도 안전
중복 요청으로 인한 부작용 방지

3. 활동 기반 세션 연장

메시지 전송, Heartbeat 등 활동 발생 시 자동 연장
자동 로그아웃 방지

4. 상태 일관성 유지

채팅방 멤버 관리와 세션 관리 통합
분산 환경에서도 일관된 상태 보장

3. 핵심 구현 내용

3.1 세션 데이터 구조

@Getter @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) public class ChatSessionDto { private String roomId; // 채팅방 ID private String memberId; // 회원 ID private String nickname; // 회원 닉네임 private Status status; // 세션 상태 (ONLINE/OFFLINE) private Instant joinedAt; // 세션 생성 시간 (최초 입장) private Instant lastActivity; // 마지막 활동 시간 private String websocketSessionId; // WebSocket 세션 ID (디버깅용) }
Java
복사
toBuilder = true: 불변 객체 패턴으로 세션 갱신 시 새 객체 생성
Instant 타입: UTC 기준 시간으로 타임존 문제 방지
websocketSessionId: 디버깅 및 추적 용이성

3.2 세션 관리 서비스

세션 시작 (입장)
@Transactional public ChatSessionDto startSession(String roomId, String memberId, String nickname, String websocketSessionId) { log.info("채팅 세션 시작 요청: roomId={}, memberId={}, nickname={}", roomId, memberId, nickname); // 채팅방 존재 확인 ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new ChatException(ErrorCode.CHAT_ROOM_NOT_FOUND, "roomId=" + roomId)); Instant now = Instant.now(); // 세션 데이터 생성 ChatSessionDto sessionDto = ChatSessionDto.builder() .roomId(roomId) .memberId(memberId) .nickname(nickname) .status(Status.ONLINE) .joinedAt(now) .lastActivity(now) .websocketSessionId(websocketSessionId) .build(); // Valkey에 세션 저장 (30분 TTL) String sessionKey = buildSessionKey(roomId, memberId); String sessionData = serializeSessionData(sessionDto); valkeyService.setWithExpiration(sessionKey, sessionData, DEFAULT_SESSION_TIMEOUT_SECONDS); return sessionDto; } private String buildSessionKey(String roomId, String memberId) { return CHAT_SESSION_PREFIX + roomId + ":" + memberId; // 예: "chat:session:room123:user456" }
Java
복사
구현 내용
채팅방 존재 여부 먼저 검증
Redis 키 패턴: chat:session:{roomId}:{memberId}
TTL 30분으로 자동 만료 설정
트랜잭션으로 데이터 일관성 보장
세션 연장(활동 갱신)
public void extendSession(String roomId, String memberId) { String sessionKey = buildSessionKey(roomId, memberId); if (!valkeyService.exists(sessionKey)) { log.warn("세션 연장 실패 - 세션이 존재하지 않음: roomId={}, memberId={}", roomId, memberId); return; } try { // 기존 세션 데이터 조회 String sessionData = valkeyService.get(sessionKey); ChatSessionDto sessionDto = objectMapper.readValue(sessionData, ChatSessionDto.class); // 마지막 활동 시간 갱신 ChatSessionDto updatedSession = sessionDto.toBuilder() .lastActivity(Instant.now()) .build(); // Valkey에 갱신된 세션 저장 및 TTL 연장 valkeyService.setWithExpiration(sessionKey, serializeSessionData(updatedSession), DEFAULT_SESSION_TIMEOUT_SECONDS); log.debug("채팅 세션 연장: roomId={}, memberId={}", roomId, memberId); } catch (JsonProcessingException e) { log.error("세션 데이터 역직렬화 실패: roomId={}, memberId={}", roomId, memberId, e); } }
Java
복사
구현 내용
세션 존재 여부 먼저 확인 (방어적 프로그래밍)
불변 객체 패턴으로 안전한 업데이트
TTL 자동 갱신으로 활성 사용자 세션 유지
Heartbeat (주기적 연결 유지)
public boolean heartbeat(String roomId, String memberId) { String sessionKey = buildSessionKey(roomId, memberId); if (!valkeyService.exists(sessionKey)) { log.warn("Heartbeat 실패 - 세션이 존재하지 않음: roomId={}, memberId={}", roomId, memberId); return false; } // TTL만 연장 (데이터 업데이트 없음) valkeyService.expire(sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS); log.debug("Heartbeat 처리: roomId={}, memberId={}", roomId, memberId); return true; }
Java
복사
구현 내용
TTL만 연장하여 불필요한 데이터 읽기/쓰기 방지
경량 연산으로 성능 최적화
반환값으로 세션 유효성 알림
세션 종료 (퇴장)
@Transactional public void endSession(String roomId, String memberId) { String sessionKey = buildSessionKey(roomId, memberId); if (!valkeyService.exists(sessionKey)) { log.debug("세션 종료 - 세션이 이미 없음: roomId={}, memberId={}", roomId, memberId); return; } // Valkey 세션 삭제 valkeyService.delete(sessionKey); log.info("채팅 세션 종료: roomId={}, memberId={}", roomId, memberId); }
Java
복사
구현 내용:
멱등성 보장: 여러 번 호출해도 안전
명시적 삭제로 즉시 자원 정리
트랜잭션으로 데이터 일관성

3.3 채팅방 활성 세션 조회

public List<ChatSessionDto> getActiveSessionsInRoom(String roomId) { String pattern = buildSessionKeyPattern(roomId); Set<String> keys = valkeyService.keys(pattern); List<ChatSessionDto> sessions = new ArrayList<>(); if (keys == null || keys.isEmpty()) { return sessions; } for (String key : keys) { try { String sessionData = valkeyService.get(key); if (sessionData != null) { ChatSessionDto session = objectMapper.readValue(sessionData, ChatSessionDto.class); sessions.add(session); } } catch (JsonProcessingException e) { log.error("세션 데이터 역직렬화 실패: key={}", key, e); } } return sessions; } private String buildSessionKeyPattern(String roomId) { return CHAT_SESSION_PREFIX + roomId + ":*"; // 예: "chat:session:room123:*" }
Java
복사
구현 내용:
Redis KEYS 패턴 매칭으로 채팅방 단위 조회
역직렬화 실패 시에도 다른 세션은 정상 반환
실시간 접속자 목록 제공

3.4 비활성 세션 정리

@Transactional public void cleanupInactiveSessions(String roomId) { log.info("비활성 세션 정리 시작: roomId={}", roomId); List<ChatSessionDto> sessions = getActiveSessionsInRoom(roomId); Instant now = Instant.now(); int cleanedCount = 0; for (ChatSessionDto session : sessions) { if (session.getLastActivity() != null) { long hoursSinceActivity = Duration.between(session.getLastActivity(), now).toHours(); if (hoursSinceActivity >= INACTIVE_SESSION_CLEANUP_HOURS) { endSession(session.getRoomId(), session.getMemberId()); cleanedCount++; log.info("비활성 세션 정리: roomId={}, memberId={}, hoursSinceActivity={}", session.getRoomId(), session.getMemberId(), hoursSinceActivity); } } } log.info("비활성 세션 정리 완료: roomId={}, cleanedCount={}", roomId, cleanedCount); }
Java
복사
구현 내용:
24시간 이상 활동이 없는 세션을 명시적 정리
TTL 자동 만료 + 명시적 정리의 이중 안전장치
스케줄러와 결합하여 주기적 실행 가능

4. 기술적 세부사항

4.1 세션 상수 정의

public final class ChatConstants { // 세션 키 접두사 public static final String CHAT_SESSION_PREFIX = "chat:session:"; // 기본 타임아웃 설정 public static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 1800; // 30분 public static final int DEFAULT_SESSION_HEARTBEAT_SECONDS = 300; // 5분 // 정리 작업 설정 public static final int INACTIVE_SESSION_CLEANUP_HOURS = 24; // 24시간 }
Java
복사
30분 TTL: 일반적인 웹 세션 타임아웃 기준
5분 Heartbeat: 30분 TTL의 1/6, 충분한 갱신 여유
24시간 정리: 하루에 한 번 장기 idle 세션 청소

4.2 JSON 직렬화/역직렬화

문제 상황
Instant 타입을 KST 포맷(yyyy-MM-dd HH:mm:ss)으로 직렬화하는데, 역직렬화가 실패하는 문제 발생:
Cannot deserialize value of type `java.time.Instant` from String "2025-10-13 13:15:20": Failed to deserialize java.time.Instant
Java
복사
원인 분석
InstantToKstSerializer만 등록되어 있음
기본 Jackson 파서는 ISO-8601 형식만 지원
커스텀 포맷으로 저장된 데이터를 읽을 수 없음
해결 방법
Deserializer 구현:
public class InstantToKstDeserializer extends StdDeserializer<Instant> { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") .withZone(ZoneId.of("Asia/Seoul")); public InstantToKstDeserializer() { super(Instant.class); } @Override public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { String text = p.getText(); // KST 포맷 파싱 LocalDateTime localDateTime = LocalDateTime.parse(text, FORMATTER); return localDateTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); } }
Java
복사
JacksonConfig 등록
@Configuration public class JacksonConfig { private final ObjectMapper objectMapper; @PostConstruct public void setup() { objectMapper.registerModule(new JavaTimeModule()); SimpleModule module = new SimpleModule(); module.addSerializer(Instant.class, new InstantToKstSerializer()); module.addDeserializer(Instant.class, new InstantToKstDeserializer()); // 추가 objectMapper.registerModule(module); objectMapper.setTimeZone(TimeZone.getTimeZone(ZoneId.of("Asia/Seoul"))); } }
Java
복사
결과
직렬화: Instant → "2025-10-13 13:15:20"
역직렬화: "2025-10-13 13:15:20" → Instant
양방향 변환 정상 작동

4.3 채팅방 멤버 관리 통합

기존 Set/Hash 기반 관리에서 세션 기반으로 전환
@Component @RequiredArgsConstructor public class ChatRoomMemberManager { private final StringRedisTemplate redisTemplate; private final ChatSessionService chatSessionService; // 현재 유저 수 (세션 기반) public long getUserCount(String roomId) { List<ChatSessionDto> sessions = chatSessionService.getActiveSessionsInRoom(roomId); return sessions.size(); } // 현재 유저 목록 (memberId -> nickname) public Map<String, String> getOnlineNickMap(String roomId) { List<ChatSessionDto> sessions = chatSessionService.getActiveSessionsInRoom(roomId); if (sessions.isEmpty()) { return Collections.emptyMap(); } Map<String, String> map = new LinkedHashMap<>(); for (ChatSessionDto session : sessions) { map.put(session.getMemberId(), session.getNickname() != null ? session.getNickname() : ""); } return map; } // 채팅방 전체 정리 (세션 포함) public void clearRoom(String roomId) { redisTemplate.delete(setKey(roomId)); redisTemplate.delete(hashKey(roomId)); // 세션 정리 chatSessionService.endAllSessionsInRoom(roomId); log.info("채팅방 전체 정리 완료: roomId={}", roomId); } }
Java
복사
구현 내용:
기존 Set/Hash 관리는 유지 (하위 호환성)
유저 수, 닉네임 조회는 세션 기반으로 전환
SSOF(Single Source of Truth): 세션 데이터가 실제 접속 상태 반영

5. 통합

5.1 메시지 전송 시 세션 연장

@Service public class ChatMessageService { private final ChatSessionService chatSessionService; public ChatMessageDto handleTalkMessage(ChatMessageSendRequestDto dto) { // 세션 연장 (메시지 전송 = 활동) chatSessionService.extendSession(dto.getRoomId(), dto.getSenderId()); // ... 메시지 처리 로직 } }
Java
복사
통합 효과:
메시지를 보낼 때마다 자동으로 세션 연장
사용자는 세션 만료를 신경 쓸 필요 없음

5.2 WebSocket 연결 종료 시 세션 종료

@Component @RequiredArgsConstructor public class WebSocketDisconnectHandler implements ApplicationListener<SessionDisconnectEvent> { private final ChatRoomService chatRoomService; private final ChatSessionService chatSessionService; @Override public void onApplicationEvent(SessionDisconnectEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); Map<String, Object> attrs = accessor.getSessionAttributes(); String memberId = (String) attrs.get("memberId"); String roomId = (String) attrs.get("roomId"); if (memberId == null || roomId == null) return; try { // 세션 종료 (명시적) chatSessionService.endSession(roomId, memberId); // 퇴장 처리 (멱등) chatRoomService.leaveRoom(roomId, memberId); log.info("WS disconnect handled: memberId={}, roomId={}", memberId, roomId); } catch (ChatException e) { log.error("Leave failed: memberId={}, roomId={}, reason={}", memberId, roomId, e.getMessage(), e); } } }
Java
복사
통합 효과:
브라우저 종료, 네트워크 끊김 시 자동 세션 종료
즉시 퇴장 처리로 다른 사용자에게 알림
TTL 만료 전 명시적 정리로 리소스 절약

6. 트러블슈팅

6.1 Kafka 연결 실패

문제:
Connection to node -1 (localhost/127.0.0.1:9092) could not be established. Node may not be available.
Markdown
복사
원인
Kafka 브로커가 실행되지 않음
해결:
docker-compose -f docker-compose-kafka.yml up -d docker ps | grep kafka # 실행 확인
Markdown
복사

6.2 세션 데이터 역직렬화 실패

문제:
Cannot deserialize value of type `java.time.Instant` from String "2025-10-13 13:15:20"
Markdown
복사
원인
Deserializer 미등록
해결:
InstantToKstDeserializer 구현
JacksonConfig에 Deserializer 등록
4.2 JSON 직렬화/역직렬화 참고

7. 아키텍처 다이어그램

세션 생명주기

메시지 흐름과 세션 통합

8. 성과 및 개선 효과

구분
구현 전
구현 후
세션 종료 처리
비정상 종료 시 유령 유저 발생
TTL 기반 자동 정리로 유령 유저 방지
접속자 수 관리
접속자 수 부정확
실시간 정확한 접속자 수 관리
입장 처리
중복 입장 가능
멱등성 보장으로 중복 입장 방지
활동 추적
활동 시간 추적 불가
마지막 활동 시간 기반 idle 판단 가능
일관성 수준
AI 게임 채팅 대비 낮은 세션 관리 품질
AI 게임 채팅과 동등한 수준의 세션 관리

9. 향후 개선 방향

단기 개선

1.
Heartbeat 자동화: 클라이언트에서 5분마다 자동 Heartbeat 전송
2.
세션 모니터링: 활성 세션 수, 평균 세션 시간 등 메트릭 수집
3.
재연결 처리: 네트워크 불안정 시 세션 복구 로직

장기 개선

1.
분산 락: Redis 분산 락으로 동시성 제어 강화
2.
세션 클러스터링: 다중 서버 환경에서 세션 동기화
3.
실시간 알림: 사용자 입장/퇴장 실시간 푸시 알림
4.
세션 분석: 사용자 행동 패턴 분석 및 인사이트 도출