Spring Boot에서 JWT + Redis로 게스트 로그인 시스템 구현하기
이번에 웹게임 프로젝트를 진행하면서 비회원도 게스트 계정으로 게임을 플레이할 수 있게 하는 기능이 필요했다.
따라서 평소엔 항상 회원만 관리했지만 이번에는 비회원을 관리하는 로직이 필요해졌다.
하지만 구글링을 해도 내가 원하는 방식의 게스트 로그인은 보이지 않았다.
그래서 나는 직접 게스트 로그인 시스템을 설계하고, JWT + Redis를 활용해서 구현해보았다.
목표
- 비회원도 로그인 없이 게임 이용 가능
- 별도 회원가입 없이 “게스트 계정”으로 임시 토큰 발급
- 일정 시간 동안 유효한 로그인 상태 유지
- 게스트가 임시 회원이므로 DB가 아닌 Redis에 게스트 정보 저장
- 토큰 기반 인증 시스템 그대로 활용
전반적인 흐름
1. 게스트 로그인 요청 → /auth/guest/sign-in
게스트 로그인 API를 호출하면 아래와 같은 흐름으로 처리된다.
- UUID 기반 무작위 게스트 ID와 닉네임 생성
- 해당 정보를 Redis에 저장하고 TTL 설정
- JWT 토큰 발급 (Access + Refresh)
- 클라이언트는 일반 회원처럼 토큰을 받아 게임을 이용할 수 있음
public TokenDto guestSignIn() {
Long guestId;
do {
guestId = UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE;
} while(redisUtil.existed(GUEST_KEY_PREFIX + guestId));
String nickname = RandomNicknameGenerator.generateNickname();
User guestUser = User.builder()
.id(guestId)
.nickname(nickname)
.role(Role.GUEST)
.build();
redisUtil.setData(GUEST_KEY_PREFIX + guestId, guestUser);
redisUtil.setDataExpire(GUEST_KEY_PREFIX + guestId, GUEST_TTL_SECONDS);
return jwtHelper.createGuestToken(guestUser);
}RandomNicknameGenerator는 "귀여운고양이", "졸린펭귄"처럼 무작위로 형용사와 명사가 조합된 닉네임을 생성해준다.
게스트 유저는 DB에 저장하지 않고 Redis에서만 관리하며 일정 시간 후에는 자동 만료된다.
2. 인증 요청 시 → JwtAuthenticationFilter + GuestDetailsService
게스트 유저도 JWT 토큰 기반으로 인증을 받는다.
Spring Security의 커스텀 UserDetailsService를 통해 Redis에서 정보를 불러온다.
UserDetails userDetails = guestDetailsService.loadUserByUsername(guestId.toString());User guest = Optional.ofNullable((User) redisUtil.getData(GUEST_KEY_PREFIX + guestId))
.orElseThrow(() -> new UsernameNotFoundException("Guest not found : " + guestId));
redisUtil.setDataExpire(GUEST_KEY_PREFIX + guestId, GUEST_TTL_SECONDS); // TTL 연장
return new SecurityUserDetails(guest);- Redis에 정보가 있으면 TTL을 연장하면서 인증
- 정보가 없으면 만료된 계정이므로 재로그인을 유도
3. 게스트 회원 전환 → /auth/guest/sign-up
게스트가 마음에 들어 게임을 더 즐기고 싶을 때, 회원 전환을 할 수 있다.
- 게스트 토큰을 가진 상태에서 회원가입 요청
- Redis에서 해당 게스트 정보를 불러와 새로운 유저로 전환
- DB에 저장하고 Redis에서는 해당 정보 삭제
public TokenDto guestSignUp(SignUpReq signUpReq, SecurityUserDetails userDetails){
if (!userDetails.getRole().equals(Role.GUEST)) {
throw new CustomException(AuthExceptionCode.INVALID_GUEST);
}
Long guestId = userDetails.getId();
User guest = Optional.ofNullable((User) redisUtil.getData(GUEST_KEY_PREFIX + guestId))
.orElseThrow(() -> new CustomException(AuthExceptionCode.INVALID_USERID));
String encodedPassword = passwordEncoder.encode(signUpReq.password());
User newUser = userRepository.save(signUpReq.toEntityFromGuest(encodedPassword, guest));
redisUtil.deleteData(GUEST_KEY_PREFIX + guestId);
return jwtHelper.createToken(newUser, false);
}- 이 과정에서 기존 게스트 경험치/스코어를 그대로 이전할 수 있도록 ID를 유지한다.
- 회원 전환 후, 일반 유저로서 Access/Refresh 토큰이 재발급된다.
인증 필터는 어떻게 구성했는가?
JwtAuthenticationFilter에서 토큰을 읽고, GUEST인지 USER인지 구분하여 적절한 인증 서비스를 호출한다.
String role = tokenProvider.getRole(accessToken);
UserDetails userDetails;
if (role.equals(String.valueOf(Role.GUEST))) {
Long guestId = tokenProvider.getUserId(accessToken);
userDetails = guestDetailsService.loadUserByUsername(guestId.toString());
} else {
String username = tokenProvider.getUsername(accessToken);
userDetails = userDetailsService.loadUserByUsername(username);
}Redis TTL은 왜 필요한가?
게스트 계정은 임시로 생성되는 계정이기 때문에, 굳이 DB에 영구적으로 저장할 필요성을 느끼지 못했다.
오히려 DB에 저장하는 순간부터 불필요한 데이터가 쌓일 수 있고 짧은 시간 사용하고 사라지는 계정 특성상 가벼운 저장소인 Redis가 더 적합하다고 판단했다.
그래서 게스트 계정은 로그인 시점부터 일정 시간 동안만 Redis에 존재하며 그 이후엔 TTL 설정을 통해 자동으로 만료된다.
redisUtil.setDataExpire(GUEST_KEY_PREFIX + guestId, GUEST_TTL_SECONDS);또한, 사용자가 계속해서 활동 중이라면 TTL을 갱신하여 접속 상태를 유지시킬 수 있다. 일정 시간 이상 아무 활동이 없다면 자동으로 로그아웃되며 Redis에서도 정보가 삭제된다.
다만, Redis 역시 메모리 기반 저장소이기 때문에 게스트 사용자가 많아질 경우 부담이 될 수 있다는 점도 문제가 될 수 있다.
앞으로 캐시 용량이나 구조를 조정하거나 게스트 상태를 더 가볍게 유지할 수 있는 다른 대안들도 고려해볼 예정이다.
정리하며
비회원 사용자의 흐름을 "게스트"라는 개념으로 통합하고,
회원과 동일한 JWT 인증 시스템을 재사용하면서 DB가 아닌 Redis로 부담을 줄인 구조를 만들 수 있었다.
- 빠른 체험 진입
- 적당한 보안 수준 유지
- 전환율 증가 유도
이번에 구현한 게스트 로그인 시스템이 완벽하거나 정답이라고 생각하지는 않는다.
아직 부족한 부분도 많고, 더 나은 구조로 개선할 여지도 충분히 있다.
앞으로도 계속 리팩토링하고 발전시켜 나가면서 더 나은 방식으로 다듬어갈 계획이다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 Pessimistic Lock으로 동시성 문제를 해결했다. 이번에는 한 단계 앞으로 가서, "애초에 서버에 동시에 들어오는 요청 수를 제어하면 어떨까?" 라는 질문에서 출발한다. 콘서트 티켓 오픈 시간에 2000명이 동시에 좌석 조회를 요청한다고 가정하자.
대규모 트래픽 경험기 - 동시성 제어
현재 코드는 성능적인 문제를 어느정도 해결했지만 동시성 문제를 해결하지 못한다. 예를들어, 10명의 사람이 한 좌석에 대해 동시에 예약을 한다면 아래와 같이 10명 모두 같은 좌석을 예매할 수 있게 된다.
대규모 트래픽 경험기 - SSE
3편에서 API를 seat-map(정적 메타)과 seat-status(동적 상태)로 분리하고, DTO 프로젝션으로 조회 비용을 줄였다. 2000명 동시 접속 기준 TPS 51 → 136, p95 36초 → 13초. 처리량 2.7배, Tail Latency 2.6배 개선이었다.
대규모 트래픽 경험기 - api 분리
Redis 캐시를 적용했지만 결과는 기대 이하였다. TPS 48 → 57, 평균 응답 19.2초 → 18.2초. DB 접근은 2초에 1회까지 줄였는데도 체감 성능은 거의 그대로였다. 분석 결과 병목은 이미 DB가 아니었다.