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.
세션 분석: 사용자 행동 패턴 분석 및 인사이트 도출



