분산 환경에서 Redis와 Kafka로 상태 공유하기
분산 환경에서 Redis와 Kafka로 상태 공유하기
배경
sprint mission 12의 마지막 주제는 분산 환경이었다.
처음에는 서버가 하나라고 생각하고 기능을 만들었다.
서버가 하나라면 메모리에 저장해도 문제가 잘 드러나지 않는다.
하지만 백엔드 인스턴스가 3개로 늘어나면 상황이 달라진다.
backend-1
backend-2
backend-3
각 서버는 자기만의 메모리를 가진다.
즉, backend-1 메모리에 저장된 값은 backend-2가 볼 수 없다.
이번 요구사항은 이런 한계를 찾고, Redis와 Kafka를 이용해 해결하는 것이었다.
InMemoryJwtRegistry의 한계
기존에는 JWT 로그인 상태를 InMemoryJwtRegistry로 관리했다.
이름 그대로 애플리케이션 메모리에 저장한다.
서버가 하나라면 흐름은 단순하다.
로그인
-> 서버 메모리에 JWT 상태 저장
다음 요청
-> 같은 서버 메모리에서 JWT 상태 확인
하지만 서버가 여러 개면 문제가 생긴다.
예를 들어 로그인을 backend-1이 처리했다고 하자.
backend-1 메모리: 이 사용자 로그인됨
backend-2 메모리: 모름
backend-3 메모리: 모름
이후 요청이 Nginx 로드밸런싱 때문에 backend-2로 가면 backend-2는 이 토큰을 모른다.
그러면 사용자는 방금 로그인했는데도 인증 실패를 겪을 수 있다.
RedisJwtRegistry로 바꾼 이유
이 문제는 InMemoryJwtRegistry 코드를 조금 고친다고 해결되지 않는다.
저장 위치가 각 서버의 메모리인 것 자체가 문제다.
그래서 JWT 상태를 Redis에 저장하도록 바꿨다.
backend-1
backend-2 -> Redis
backend-3
이제 어떤 백엔드가 요청을 처리해도 같은 Redis를 조회한다.
로그인을 어느 서버에서 했는지 중요하지 않다.
Redis에 저장한 구조
마지막 요구사항에서는 사용자별 JWT 목록과 토큰 인덱스를 Redis에 저장했다.
사용자별 JWT 목록:
jwt:users:{userId}
Access Token 인덱스:
jwt:access_tokens
Refresh Token 인덱스:
jwt:refresh_tokens
사용자별 목록은 Redis List로 관리했다.
토큰 인덱스는 Redis Set으로 관리했다.
이렇게 하면 사용자 기준으로도 찾을 수 있고, 토큰 기준으로도 확인할 수 있다.
분산락이 필요한 이유
동시에 같은 계정으로 로그인하거나 토큰 재발급이 일어날 수 있다.
서버가 하나라면 synchronized 같은 방식으로 어느 정도 막을 수 있다.
하지만 서버가 여러 개면 Java 메모리 락은 소용이 없다.
backend-1의 synchronized
backend-2의 synchronized
backend-3의 synchronized
각 서버 안에서만 잠기기 때문이다.
그래서 Redis 기반 분산락을 사용했다.
RedisLockProvider는 Redis의 SETNX 방식으로 락을 잡는다.
락 키가 없으면 생성 -> 락 획득 성공
락 키가 이미 있으면 -> 다른 서버가 작업 중
락에는 TTL도 둔다.
만약 서버가 중간에 죽어도 락이 영원히 남지 않게 하기 위해서다.
동시 로그인 수 제한
이번 구조에서는 한 사용자당 활성 JWT 개수를 제한한다.
현재는 최대 1개만 유지한다.
새 로그인이 들어오면 기존 토큰을 제거하고 새 토큰을 등록한다.
기존 토큰 있음
-> 오래된 토큰 제거
-> 새 토큰 저장
이렇게 하면 같은 계정으로 여러 곳에서 동시에 로그인하는 상황을 제어할 수 있다.
WebSocket과 SSE의 분산 환경 한계
WebSocket과 SSE도 비슷한 문제가 있다.
클라이언트 연결은 각 서버의 메모리에 붙어 있다.
예를 들어 사용자 A의 브라우저가 backend-2에 SSE로 연결되어 있다고 하자.
그런데 새 메시지 이벤트를 backend-1만 받으면 backend-2에 연결된 사용자 A에게 이벤트를 보낼 수 없다.
backend-1: 이벤트 받음, 하지만 사용자 연결 없음
backend-2: 사용자 연결 있음, 하지만 이벤트 못 받음
이게 분산 환경에서 WebSocket/SSE가 어려운 이유다.
Kafka consumer group의 함정
Kafka는 consumer group을 사용한다.
같은 group에 여러 consumer가 있으면 Kafka는 이벤트를 나눠서 전달한다.
하나의 이벤트
-> group 안의 consumer 중 한 명만 처리
이 방식은 DB에 알림을 저장할 때는 좋다.
알림을 여러 서버가 동시에 저장하면 중복 알림이 생길 수 있기 때문이다.
하지만 WebSocket/SSE 전송은 다르다.
각 서버가 자기 서버에 연결된 클라이언트에게 이벤트를 보내야 하므로, 모든 서버가 이벤트를 받아야 한다.
그래서 group을 분리했다
이번에는 Kafka 소비 목적을 두 가지로 나눴다.
첫 번째는 알림 DB 저장이다.
discodeit-group
이 group은 이벤트를 한 번만 처리한다.
두 번째는 실시간 전송이다.
discodeit-realtime-{hostname}
이 group은 백엔드 인스턴스마다 다르게 만든다.
Kafka에서 group이 다르면 같은 이벤트를 각각 받을 수 있다.
MessageCreatedEvent
-> discodeit-realtime-backend-1
-> discodeit-realtime-backend-2
-> discodeit-realtime-backend-3
결과적으로 각 백엔드는 같은 이벤트를 받고, 자기에게 연결된 WebSocket/SSE 클라이언트에게만 전송한다.
최종 이벤트 흐름
메시지 생성 흐름은 다음과 같다.
MessageService
-> MessageCreatedEvent 발행
-> KafkaProduceRequiredEventListener
-> Kafka topic 전송
-> RealtimeTopicListener
-> 각 백엔드에서 WebSocket 전송
알림 생성 흐름은 다음과 같다.
MessageCreatedEvent
-> Kafka
-> NotificationRequiredTopicListener
-> 알림 DB 저장
-> SseSendRequiredEvent 발행
-> Kafka
-> RealtimeTopicListener
-> 각 백엔드에서 SSE 전송
여기서 중요한 점은 DB 저장과 실시간 전송의 책임을 분리했다는 것이다.
- 알림 DB 저장: 한 번만 처리
- WebSocket/SSE 전송: 모든 백엔드 인스턴스가 처리
실제 확인한 내용
Docker Compose로 백엔드 3개를 띄웠다.
로그인 후 같은 JWT로 /api/users를 여러 번 호출했다.
응답 헤더 X-Upstream-Server를 확인하니 서로 다른 백엔드가 번갈아 응답했다.
login status=200 upstream=172.21.0.6:80
/api/users -> 172.21.0.7:80
/api/users -> 172.21.0.5:80
/api/users -> 172.21.0.6:80
전부 200이 나왔기 때문에 Redis 기반 JWT 상태 공유가 동작한다고 볼 수 있다.
Kafka consumer group도 확인했다.
discodeit-group
discodeit-realtime-...
discodeit-realtime-...
discodeit-realtime-...
discodeit-group은 알림 저장용이고, discodeit-realtime-*은 각 백엔드 인스턴스의 실시간 전송용이다.
Redis에도 새 JWT 키가 생성된 것을 확인했다.
jwt:users:{userId}
jwt:access_tokens
jwt:refresh_tokens
이번에 겪은 문제
중간에 Redis 키 타입 충돌이 있었다.
이전 구현에서는 jwt:user:{userId} 키를 단일 값으로 저장했다.
마지막 요구사항에서는 사용자별 JWT 목록을 List로 바꾸었다.
같은 키 이름을 그대로 쓰면 Redis 입장에서는 이런 문제가 생긴다.
기존 키: String 또는 JSON Value
새 코드: List로 사용하려고 함
그래서 Redis에서 WRONGTYPE 오류가 발생했다.
해결 방법은 새 구조에 맞게 prefix를 분리하는 것이었다.
기존: jwt:user:{userId}
변경: jwt:users:{userId}
이 경험으로 Redis는 키 이름 설계도 중요하다는 것을 배웠다.
배운 점
서버가 하나일 때 잘 되던 코드가 서버를 여러 개로 늘리면 바로 문제가 될 수 있다.
특히 메모리에 저장하는 정보는 조심해야 한다.
서버 메모리
-> 빠르고 단순함
-> 서버 여러 대에서는 공유되지 않음
분산 환경에서는 상태를 어디에 저장할지 먼저 생각해야 한다.
이번 미션에서는 다음처럼 정리할 수 있다.
JWT 로그인 상태
-> Redis에 저장
중복되면 안 되는 DB 알림 생성
-> Kafka 같은 consumer group 사용
모든 서버가 받아야 하는 실시간 전송 이벤트
-> 인스턴스별 Kafka consumer group 사용
이번 미션의 핵심은 Redis와 Kafka를 쓰는 문법보다, 왜 서버 메모리만으로는 부족한지 이해하는 것이었다.