1. 시작하면서
이번에도 무려 한달만에 돌아왔습니다! 사실 저번주까지만해도 한달이 안 지났으니 조금만 더 진행되면 하자...라고 생각했었는데 어느새 무려 1달이 넘어가고 있더라구요. 그래서 얼른 하던 것들을 마무리하고 정리 포스팅을 작성하기로 했습니다.
저번부터 제 프로젝트에 V2 를 붙이면서 정말 (생각보다) 많은 기능들이 들어갔습니다. 첫번째로는 electron 을 기반으로 한 데스크톱 앱 개발(mac 과 window 지원) 이고 두번째로는 너무나 감사하게도 퍼블리셔분도 모셔서 기존보다 훨씬 이쁘게 배경화면도 바뀌게 되었습니다. 또 내부 기능적으로도 변화가 있었는데 바로 기본 메모리에 저장하던 채팅방을 redis 로 전부 옮겨서 redis 에서 데이터를 관리한다는 점입니다. 그에따른 전반적인 코드 리펙토링도 포함해서 말이죠!
플젝 외부적으로도 퍼블리셔 분뿐만 아니라 회사 동료 개발자 2명과 함께 하게 되었습니다. 굉장히 열심히 꼬셨어요ㅎㅎ(지금도 도망가시려는거 열심히 붙잡고 있습니다) 사실 마음 같아서는 기획자분도 모시고 싶고, 디자이너분도 모시고 싶고 한데, 지금은 이걸로 만족하려합니다ㅠㅠ 여튼 개발자가 무려 2명이나 붙어서인지 기존에 개발하고 싶었던 부분들이 정말 훨씬 빠르게 진행되고 있습니다. 동시에 배우는 것도 정말 많았는데 기존에는 혼자하던 모든 것들 - 앞으로의 기능 목표, 기능에 따른 세부적인 기획 등등 -을 서로 이야기하고 의견을 맞추고 보완해간다는 점에서도 그렇고, 팀원들에게 전반적인 프로젝트 아키텍쳐를 설명해야했기에 저 역시도 많은 공부를 했습니다.
여튼 이번에 포스팅은 프로젝트 생존 보고와 어떻게, 왜 분산 처리를 하게 되었는지와 이걸 어떻게 처리했고, 어떤 방법을 사용했고 실패했고, 최종적으로 얼마만큼의 성능 개선이 있었는지 전반적으로 정리할까 합니다.
2. 분산 처리 환경 고려하기 : 내 서버가 N 대라면 어떻게 될까?
"만약 내 백엔드 서버가 N 대라면 어떻게 될까?"
첫 시작은 개발자라면 한번쯤 할만한 이런 단순한 질문에서 출발했다. 이러한 물음은 안타깝게도 내 머리속을 떠나지 않았고, k8s 해당 디플로이먼트의 스케일을 2개로 잡고 테스트를 해보았다. 그 결과 k8s 에서 서버를 2대를 켰을 때 문제가 발생했다. 문제 상황을 아래와 같았다.
1. 유저(a-1)가 A 서버에 입장한다.
2. 유저(a-1) 가 A 서버에 test 방을 생성한다.
3. test 방의 메타데이터(방id, 방 이름, 방 비밀번호 등) 은 redis 에 저장하고, 실제 화상채팅 연결에 사용되는 미디어 객체들은 A 서버의 메모리(map) 에 저장된다.
4. 새로운 유저(a-2)가 B 서버에 입장한다.
5. 새로운 유저(a-2)는 B 서버에서 test 방을 확인하고 입장을 시도한다. test 방 확인이 가능한 이유는 서버 메타데이터를 redis 에 저장중이고, 서버는 채팅방의 목록을 redis 에서 가져와서 보여주기 때문이다.
6. 새로운 유저(a-2)는 B 서버에서 test 방에 입장하지만 기존 유저(a-1) 와 통신이 이어지지는 않는다.
7. 새로운 유저(a-2) 는 B 서버에서 test 방에 입장하는 것이고, B 서버는 다시 해당 서버의 메모리에 test 방의 roomId 를 key 로 갖는 미디어 서버 객체를 생성해서 저장한다. 즉, A 서버와 B 서버는 서로 '방'의 존재를 인식할 수 있을지언정 유저 a-1 의 미디어 객체와 유저 a-2 의 미디어 객체를 서로 연결시킬 수 없기 때문이다.
포인트는 동일한 방이라도 A 서버에 존재하는 미디어 객체와 B 서버의 존재하는 미디어 객체는 서로 연결될 수 없기 때문에, 유저 a-1 과 a-2 는 통신을 할 수 없다 라는 것이다. 그럼 여기서 다시 한 번 질문이 나오게 된다.
그럼 kurento 미디어 객체를 redis 에 저장하면 되는거 아니야?
당연하게도 가장 먼저 시도해봤던 방법이다. 그리고 결과는 이미 예상하겠지만 실패였다. 하긴 성공했으면 이렇게 장황하게 이야기를 풀어가지 않았을테니 말이다. 실패한 이유는 생각보다 간단했는데 kurento media 객체를 포함한 MediaObject 는 직렬화/역직렬화가 안된다는 점이었다. 결국 redis 던 db 던 어딘가에 넣으려면 직렬화/역직렬화를 해야하는데 문제의 MediaObject 는 이게 안되니 다른 방법을 생각해야만 했다.
정말 단순하게 생각했던 문제가 이때부터는 생각보다 크게 다가왔다. 물론 여기서 그만두고 기존에 개발하던 걸 이어서 한다는 선택지도 있었지만 그때마다 또 다른 물음이 따라왔다.
만약 동시접속자가 100명, 1000명이되고, 각각 1개의 방을 만든다고 할 때 과연 1대의 서버로 감당 할 수 있을까?
또 k8s 를 쓰는데 메인 백엔드 서버를 1대만 두고 테스트하는건 너무 사치가 아닐까?
이 모든 걸 제쳐두고라도 과연 지금이 아니라 '나중에' 가서 분산 처리를 위한 개선을 하는게 정말 좋을까?
결국 나는 내 목소리에 지고 말았다. 여러가지 기능이 들어간 더 '나중에' 가 아닌 '지금이라도' 이렇한 부분을 고려해야한다고 생각되었기 때문이다. 또 무엇보다 누군가에게 내 프로젝트를 설명할 때 '나는 k8s 를 쓰면서 백엔드 1대, 프론트 1대로 개발하고 테스트하고 운영해봤어' 가 아닌 'k8s 환경에서 스케일 인/아웃과 트래픽 분산을 고려해 백엔드 서버를 다중 인스턴스로 구성하고, 프론트엔드는 단일 인스턴스로 운영하면서 개발·테스트·운영까지 직접 경험했어' 가 훨씬 더 매력있어 보였기 때문이다. 뭐든 보기 좋은 떡이 먹기 좋으니까 말이다!
3. 분산 처리 환경 구성하기 : 분산처리 환경을 위한 아키텍쳐 고민하기
처음 k8s 에 서버를 2대 켰을 때 안되는걸 보고 나서 '어디서 부터 접근해야할지' 막막했다. 단순히 코드를 뜯어고쳐서 리팩토링을 한다고해서 해결될 문제도 아니었고, 심지어 그렇게 뜯어고친다고해도 '확실하게 동작한다' 라는 명제가 없는 이상 바로 개발에 들어가기에는 어려움이 있었다.
그렇게 '어떻게 구성할까?' 라는 생각만으로 약 2주정도가 지나갔던 것 같다. 정말 코드는 하나도 안치고, 오직 아키텍쳐 설계만 계속했다. GPT 한테 물어보기도하고, 내 환경에 맞춰서 검색도 해보고, 심지어는 회사에서 팀장님과 선배 개발자분들께 여쭤보기도 했었다. 결과 약 3가지 정도의 해결책을 생각해낼 수 있었다.
첫번째로는 Kurento 가 아닌 다른 오픈소스로 넘어가는 것이다. 사실 어떻게 생각하면 가장 간단하고, 가장 쉬운 방법임과 동시에 가장 큰 변화를 겪어야하는 해결책이었다. 한번 OpenVidu 라는 예제를 경험했었고, openvidu 를 사용할 때 비슷하게 서버를 2대 놓고 했을때 전혀 이상없이 통화가 잘 되었던 것을 생각하면 '가장 확실한' 해결책이라고 할 수 있었다. 다만 kurento 가 아닌 다른 서비스로 바꾸는 것으로 인해서 기존의 코어 기능들이 바뀔 껄 생각하면, 정말 최후의 방법이라고 할 수 있었다.
두번째 방법으로는 OpenVidu 의 구조를 이용하는 방법이었다. 이전 프로젝트에서 OpenVidu 를 사용하면서 경험한 것 중 가장 재미있고 신기했던 것은 kurento 에서처럼 내가 직접 pipline 를 관리하는게 아니라 OpenVidu 에 rest 요청을 보내면 실제로 미디어 연결 및 기타 등등은 모두 OpenVidu 가 담당한다는 것이었다. 내가 직접 pipline 를 연결하고 관리하고 했던 경험을 생각하면 말도 안될 정도로 편했었다. 이러한 경험은 '나도 미디어를 관리하는 미디어 관리서버 - chatforyou-media-server- 라는 걸로 새로운 서버를 구성한 뒤 실제 미디어의 연결은 해당 미디어 서버에서 관리하면 어떨까?' 하는 생각으로 이어졌다.
chatforyou-media-server 에서 결국 모든 미디어를 관리하는 이상 어느 유저가 어느서버로 진입하던 어느서버에서 방에 접속하던 전혀 상관할 필요가 없는 것이다. 즉, 방의 메타데이터는 redis 에서 관리하고, 분산 및 각종 비즈니스 로직은 각 서버에서 담당하며, 중요한 미디어 관리의 경우 하나의 서버에서 관리하는 구조...!
다만 완벽한 방법은 없다고, 큰 문제가 무려 2가지나 있었다. 하나는 대대적인 코드 리팩토링을 해야한다는 점이었다. 전반적인 webRTC 의 코어 로직이 모두 다른 서버로 빠지게 되고, 이와 연관된 모든 로직이 옮겨져야한다는 점이었다. 이것은 하나의 서버를 다시 설계 개발 해야한다는 점에서 시간적인 비용이 특히 많이 들어갈 것으로 생각되었다. 다음으로는 네트워크 비용 증가의 문제였다. 결국 유저는 chatforyou 서버 -> chatforyou-media-server -> kurento 까지 총 최소 3개의 네트워크 홉을 지나야만 했고, 이에 따른 네트워크 지연을 고려하지 않을 수 없다.
마지막 방법으로는 k8s 의 SessionAffinity 기능을 이용하는 방법이다. k8s 에서는 Session Affinity(Sticky Session) 이라는 기능을 지원한다. 이 기능은 내가 가장 처음 접속했던 서버(파드)에 계속 접속할 수 있도록하는 기능이다. 예를 들어서 Session Affinity 를 cookie 로 활성해놓으면 유저는 서버에 처음 접속할때 해당 서버에 맞는 cookie 를 받게되고, 이후 서버에 요청(접속) 시에 k8s 는 해당 쿠키를 보고 유저의 요청을 특정 파드에만 보내게 된다.
당시에는 굉장히 많은 시간을 들여 고민했었지만 뒤돌아 생각해보면 내가 직면했던 문제는 단순했다. 'A 방에 들어가려는 유저를 A 방이 처음 생성된 서버로 요청을 보내야한다. 어떻게 보내면 될까?' 라는 문제였다. 이 문제의 핵심은 결국 유저를 특정 서버로 보내야한다는 것이었고, 이는 곧 '특정 서버에만 요청을 보내도록 한다' 라는 SessionAffnity 와 정확하게 맞아떨어졌다. 심지어 SessionAffnity 는 내가 코드에서 조작을 많이 하는 것 없이(이때까지만해도 많이 안해도 될 줄 알았다) k8s 에서 제공하는 쿠키를 확인하고 해당 쿠키를 유저에게 세팅해주면, 나머지는 k8s 에서 라우팅을 해줄테니 이후로는 고려할 필요조차 없었다.
이 방법의 가장 큰 장점은 기존 코어 비지니스 로직을 유지한 채 개발이 가능한다는 점이었다. 또한 프로젝트에 참여하는 다른 팀원들에게 영향이 최소화 된다는 점과 라우팅은 k8s 에게 맡기면 된다는 장점도 있었다. 물론 문제점도 있었는데 가장 큰 문제는 k8s 에서 주는 쿠키를 내가 코드에서 인터셉터해서 사용할 수 있을까? 하는 부분이었고, 각 파드(서버) 별로 cookie 를 어떻게 매칭시키고 저장할 것인가? 하는 부분이었다. 그래도 이 부분들은 위에 다른 2가지 방법들에 비해서는 비교적 사소한 문제라고 생각되었다.
| 방법 | 요약 | 장점 | 단점/리스크 | 난이도 |
| Kurento 대신 다른 오픈소스로 교체 | Kurento 기반을 버리고 OpenVidu(또는 mediasoup, Janus 등) 같은 다른 스택으로 전환 | 사용자/개발 입장에서 더 높은 수준의 API(예: OpenVidu), 일부 솔루션은 성능·확장성 개선 가능. 마이그레이션 후 운영·개발 편의성 향상. | 전면 교체로 인한 대규모 리팩토링, 기존 코어 로직 재설계 필요. 특정 기능(녹화/필터 등) 차이로 추가 작업 발생 | 높음 — 코드·테스트·운영 문서 전면 수정 필요 |
| OpenVidu 스타일의 중앙 미디어관리 서버(chatforyou-media-server) 도입 | 미디어 파이프라인 제어(세션 생성/연결/관리)를 별도의 미디어관리서버로 집중시킴; 애플리케이션 서버는 메타데이터/비즈니스 로직만 처리 | 애플리케이션과 미디어 책임 분리, 파드 이동/유저 라우팅 문제 완화, 미디어 스케일링을 독립적으로 설계 가능(장기 확장성 우수). | 큰 설계·개발 비용(단일 서버 재설계), 네트워크 홉 증가(클라이언트→앱→미디어서버→KMS 등): 지연/비용 증가 가능. 장애 시 전역 영향 고려 필요 | 매우 높음 — 아키텍처 재설계 + 대량 작업 |
| k8s SessionAffinity(Sticky session, ingress cookie) 사용 | k8s/Ingress의 세션 어피니티(쿠키 기반)를 이용해 사용자를 최초 방 생성 파드로 항상 라우팅 | 코드 변경 최소(빠른 적용), 기존 비즈니스 로직 거의 유지, 팀 영향 최소. | 쿠키-기반 스티키는 장애/스케일 아웃 시 취약(파드 사망 시 재할당 문제), ingress 구현/애노테이션(경로, secure, path 등) 세심히 설정해야 함. 장기 확장성·유지보수 한계 | 낮음 — 빠르게 적용 가능 |
4. 분산 처리 환경 구성하기 : K8S SessionAffinity 와 함께...
최종적으로 내가 선택한 설계는 k8s 의 sessionAffinity 를 활용해서 유저를 특정 파드로 라우팅해주는 방법을 선택했다. 처음에는 정말 파드만 라우팅하면 되지 않을까? 해서 개발했고, 간단하게 테스팅했을 문제없이 동작하는 것 역시 확인할 수 있었다. 이때 테스트는 웹페이지 접속 후 쿠키를 확인했고, 그 쿠키를 postman 에 세팅 후 방 생성 및 입장 API 를 사용해보았다. 즉, 특정한 쿠키를 세팅했을때 특정한 파드로 요청이 가는지를 로그로 직접 확인해본 것이다. 그리고 여기까지는 문제없이 동작하는 것을 깨달았다.
문제는 이 다음이었다. 파드가 2개로 들어나자 마자 여러 문제들이 생겼다.
1) 서버 - 쿠키 매칭을 어떻게 확인하지? : 수학적 최적화를 통한 쿠키 찾기
처음에는 무작정 50회 시도하는 방식으로...
처음에 쿠키 수집 로직을 구현할 때는 정말 단순하게 생각했다. "그냥 50회 정도 시도하면 되지 않을까?" 해서 고정된 재시도 횟수로 개발했고, 간단하게 테스팅했을 때 문제없이 동작하는 것 역시 확인할 수 있었다. 실제로 대부분의 경우에는 잘 동작했으니까 말이다.
// 초기 버전 - 단순 고정 재시도
private static final int PHASE2_MAX_RETRIES = 50;
private static final int PHASE2_REQUEST_INTERVAL_MS = 200;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// HTTP 요청 시도...
Thread.sleep(PHASE2_REQUEST_INTERVAL_MS);
}
그런데 실제 운영에서 사용해보니 문제가 보이기 시작했다. 다행히 동작은 했지만 네트워크 등 불필요한 리소스 낭비가 너무 컸다. 파드가 1-2개밖에 없는 상황에서도 무조건 50회씩 시도하니까 대부분이 불필요한 요청이었던 것이다. 게다가 단순히 50회 시도한다고 쿠키를 수집할 수 없는 경우도 있었다. 파드 수가 많아지거나 네트워크 상태가 좋지 않을 때는 50회로도 부족한 경우가 발생했다.
보다 효율적인 쿠키 체크 방식을 만들어야했다.
이 문제를 해결하기 위해 방법을 바꿔서 보다 효율적으로 쿠키 매칭을 고려하게 되었다. 단순히 고정된 횟수를 시도하는 것이 아니라, 실제 환경 상황에 맞춰서 능동적으로 시도 횟수를 조절하는 방법이 필요했다. 그래서 도입한 것이 바로 수학적 최적화 접근법이다. 85% 목표 성공률을 기반으로 파드 수에 따라 최적의 시도 횟수를 계산하는 공식을 구현했다. 이때 gpt 를 비롯한 여러 ai 의 도움을 정말 많이받았다. 특히 claude 가 없었다면 아마 시도조차 못했을 것이다.
/**
* 성공률(TARGET_SUCCESS_RATE) 기반 최적 재시도 횟수 계산
* 수학적 공식: k >= ln(1 - TARGET_SUCCESS_RATE) / ln(1 - 1/N)
*/
private int calculateOptimalRetryCount(int activePods) {
if (activePods <= 1) {
return MIN_RETRY_COUNT;
}
double probability = 1.0 / activePods;
double optimalRetries = Math.log(1 - TARGET_SUCCESS_RATE) / Math.log(1 - probability);
int result = (int) Math.ceil(optimalRetries);
result = Math.max(result, MIN_RETRY_COUNT);
result = Math.min(result, MAX_RETRY_COUNT);
return result;
}
그 결과 단순히 50번을 요청하는 것에서 효율과 쿠키 확인 정확성을 모두 고려하여 다방면으로 쿠키를 확인하는 시스템이 완성되었다. 전체적인 스텝은 다음과 같다.
- Phase 1: 파드간 협력 - 다른 파드가 이미 수집한 쿠키를 활용
- Phase 2: 수학적 최적화 - 환경에 맞는 최적 시도 횟수로 효율적 시도
- Phase 3: Fallback 처리 - 다시한번 수학적 최적화 방식을 시도하고, 마지막까지도 쿠키 확인이 안되는 경우도 고려하여 fallback(서버 재시작) 로직도 추가하였다
지수적 백오프까지 적용해서 초기에는 빠르게 시도하다가 점점 간격을 늘려가는 방식으로 네트워크 부하도 고려했다.
실제로 얼마나 개선되었나?
두 방식을 실제로 비교해보니 정말 놀라운 차이가 있었다. 내가 체크했을 때는 전체적으로 10 ~ 15초 정도 시간이 단축되었다. 특히 가장 많이 사용했던 파드 3개의 패턴에서 정말 많은 시간단축이 있었는데 최소는 2.5초 정도, 정말 많이 잡아도 4.5초 정도밖에 걸리지 않았다.
| 파드 갯수 | 50회 요청 방식 | Phase 접근 방식 | 시간 단축 효과 | 네트워크 효율성 |
| 파드 2개 | 50회 (27.3초) | 3회 (1.2초) | 약 26.1초 단축 | 94% 개선 |
| 파드 3개(최다 테스트) | 50회 (27.3초) | 5회 (2.2초 ~ 4.5초) | 약 23.2초 단축 | 87% 개선 |
| 파드 5개 | 50회 (27.3초) | 9회 (5.6초) | 약 21.7초 단축 | 82% 개선 |
| 파드 7개 | 50회 (27.3초) | 13회 (11.0초) | 약 16.3초 단축 | 74% 개선 |
- HTTP 요청 처리 시간: 200ms (실제 요청 처리)
- 네트워크 지연: 50ms (평균 네트워크 latency)
- 서버 처리 시간: 100ms (nginx + 애플리케이션 응답)
- 총 요청당 처리 시간: 350ms
이처럼 내 실제 운영 환경인 파드 2-5개 상황에서 압도적인 성능 개선을 보였다. 현재 상황에서 가장 최대로 늘어난다고 가정했던 5개의 파드에서도 5.6초 많게는 9초 정도가 걸렸다.
2) K8S 대신 라우팅 처리하기 : 방 분산 환경 처리하기
K8S의 sessionAffinity만으로는 부족했다!
처음에 k8s의 sessionAffinity를 도입해서 사용자를 특정 파드로 라우팅하는 것까지는 성공했다. 쿠키 기반으로 사용자가 항상 같은 서버에 접속하도록 하는 부분은 잘 동작했고, 채팅방 입장과 나가기도 문제없었다. 그런데 테스트하면서 예상치 못한 문제가 발생했다.
단순히 k8s에서 지원해주는 라우팅을 사용해서 서버에 입장해서 10개의 방을 생성하게 된다면, 서버가 10대가 있다고해도 동일한 서버에 8개의 방이 생성되고, 나머지 서버에는 1개씩 생성될 가능성도 있었다. 즉, 10대의 서버 중 3대만 운용되고 나머지 서버는 서버 리소스만 사용하고 실제로는 사용되지 않는 서버가 될 수 있다. 실제로 서버를 3대를 켜두고 10개의 방을 만들더라도 서버가 1대에만 10개의 방이 생성되는 경우도 있었다. 사실 이는 sessionAffinity 가 사용자 기준으로 라우팅하기 때문에 발생하는 문제였다. 방을 만드는 사용자가 특정 서버에 집중되면, 그 서버에만 방이 몰리게 되는 것이다. 이렇게 되면 Auto Scaling으로 서버를 늘려봤자 실제로는 몇 개 서버만 과부하를 받고, 나머지는 놀고 있는 비효율적인 상황이 발생할 수 있다는 것이었다.
Consistent Hashing으로 직접 해결해보자
이를 해결하기 위해서 내가 직접 방 분산처리를 위한 라우팅을 개발했다. 이때 사용한 방법은 안정해싱(Consistent Hashing) 방법이다.
왜 Consistent Hashing을 선택했냐면, 서버가 추가되거나 제거될 때 전체 방들을 재배치할 필요 없이 최소한의 영향만으로 부하 분산을 유지할 수 있기 때문이다. 특히 Auto Scaling 환경에서는 이런 특성이 매우 중요하다고 이번에 여러 방법을 찾다가 알게되었다.
public abstract class InstanceProvider {
// 서버당 가상 노드 수
private final int DEFAULT_VIRTUAL_NODES = 150;
// Thread-safe 해시 링
private final ConcurrentSkipListMap<Long, String> hashRing = new ConcurrentSkipListMap<>();
// 고품질 해시 함수
private final HashFunction hashFunction = Hashing.murmur3_128();
/**
* 새 서버를 해시 링에 추가
*/
public synchronized void addServer(String instanceId) {
List<Long> hashes = computeVirtualNodeHashes(instanceId);
for (Long hash : hashes) {
hashRing.put(hash, instanceId);
}
activeServers.add(instanceId);
log.debug("Added server: {} with {} virtual nodes", instanceId, hashes.size());
}
}
핵심은 각 서버마다 150개의 가상 노드(Virtual Node) 를 만드는 것이다. 이렇게 하면 해시링에서 훨씬 균등하게 분산될 수 있다.
Kafka + Redis + Consistent Hashing 조합
Kafka로 서버를 확인하면 각 서버는 안정해싱에 저장된다. 그리고 실제로 방이 생성될 때면 안정해싱과 Redis에 현재 해당 서버에 몇 개의 방이 생성되었는지 확인하고 이를 통해서 가장 최적의 서버를 뽑아서 해당 서버에 방을 만들도록 라우팅한다.
구체적인 동작 과정은 다음과 같다
1단계: 서버 Discovery (Kafka 기반)
@KafkaListener(topics = KafkaTopic.SERVER_LIFECYCLE_EVENTS)
public void handleServerEvent(ConsumerRecord<String, KafkaEvent> record) {
KafkaServerEvent event = (KafkaServerEvent) record.value();
switch (event.getEventType()) {
case SERVER_STARTED:
addServer(event.getInstanceId());
break;
case SERVER_STOPPED:
removeServer(event.getInstanceId());
break;
}
}
새로운 서버가 시작되면 Kafka를 통해 다른 모든 서버에게 알린다. 그러면 모든 서버의 해시링이 동기화된다.
2단계: 방 생성시 최적 서버 선택
@Override
public String getServerForRoom(String roomId, RoomRoutingInfo roomRoutingInfo) {
// 1. Consistent hashing으로 후보 서버들 선택 (상위 3개)
List<String> candidateServers = getTopCandidateServers(roomId, CANDIDATE_SERVER_COUNT);
// 2. 각 후보 서버의 활성 방 개수를 기준으로 최적 선택
String bestServer = selectServerWithLeastRooms(candidateServers);
return bestServer;
}
private String selectServerWithLeastRooms(List<String> candidates) {
String bestServer = null;
long minRoomCount = Integer.MAX_VALUE;
for (String candidate : candidates) {
long roomCount = redisService.getInstanceRoomCount(key);
if (roomCount < minRoomCount) {
minRoomCount = roomCount;
bestServer = candidate;
}
}
return bestServer;
}
여기가 핵심이다. 단순히 해시링만 사용하는 게 아니라, 해시링으로 상위 3개 후보를 뽑은 다음, Redis에서 각 서버의 현재 방 개수를 확인해서 가장 적은 방을 가진 서버를 선택한다.
3단계: 리다이렉트 처리
public ChatRoom createChatRoom(ChatRoomInVo chatRoomInVo, String roomId) {
String selectedInstanceId = instanceProvider.getServerForRoom(roomId, roomRoutingInfo);
// 현재 서버가 선택된 서버가 아니면 리다이렉트
if(!instanceProvider.getInstanceId().equals(selectedInstanceId)) {
return ChatRoom.ofRedirect(chatRoomInVo, roomId, selectedInstanceId);
}
// 실제 방 생성 로직...
}
선택된 서버가 현재 서버와 다르다면, 클라이언트에게 해당 서버로 리다이렉트하라고 응답한다.
오 진짜 (나름대로) 균등한 분산이 가능하네?
이 시스템을 도입한 후 실제로 테스트해보니, 5개의 방을 생성했을 때 5대 서버에 각각 1개씩 고르게 분산되는 것을 확인할 수 있었다. 즉, 더 이상 특정 서버에만 방이 몰리는 문제가 발생하지 않게 된 것이다. 물론 중간중간 이미 방이 생성된 서버에도 요청이가서 어느 서버에 방이 2개나 만들어지는 경우도 있었지만 그런 경우는 많이 않았고, 이런 케이스를 나중에 버그로 제쳐두고 당장 운영할 수준까지는 충분히 올라왔다고 생각해도 될 정도였다.
주요 개선 효과
- 자원 활용률 대폭 개선: 이전에는 10대 중 3대만 사용되던 것이 이제는 모든 서버가 균등하게 사용됨
- Auto Scaling 효과 극대화: 서버를 추가하면 실제로 부하가 분산됨
- 장애 대응력 향상: 하나의 서버가 죽어도 전체 시스템에 미치는 영향 최소화
- 예측 가능한 성능: 방 개수에 비례해서 서버 리소스가 사용됨
// 방 생성 시 서버별 방 개수 증가
instanceProvider.incrementInstanceRoomCount();
// 방 삭제 시 서버별 방 개수 감소
instanceProvider.decrementInstanceRoomCount();
이제 k8s의 단순한 sessionAffinity에서 벗어나서, 실제 비즈니스 로직(방 분산)에 맞는 효율적인 라우팅 시스템을 구축할 수 있었다. Kafka의 이벤트 기반 아키텍처와 Redis의 실시간 상태 관리, 그리고 Consistent Hashing의 균등 분산이 조합되어서 생각했던 것보다 훨씬 효과적인 시스템이 되었다.
3) 서버의 상태를 어떻게 확인하지? : kafka 활용한 서버 상태 체크
다음으로 내가 해결해야하는 문제는 각 서버의 상태를 확인하는 방법이었다. 예를 들어서 3대가 있던 서버 중 1대가 갑자기 꺼지고 2대의 서버만 남은 경우, 혹은 갑작스런 트래픽 증가로 서버가 추가된 경우 어떻게 기존 서버에 서버 1대가 없어졌음을 알릴 것이며, 어떤 서버가 추가되었는지 어떻게 알릴 것인지 개발해야만 했다.
이 문제를 해결하기 위해 내가 선택한 방법은 Kafka 를 활용한 서버 상태를 체크였다. 즉, 모든 서버가 시작, 종료 시, 본인의 쿠키를 확인했을때 다른 사람의 쿠키를 확인했을 때 kafka 로 메시지를 발행하고, 다른 서버들은 그것을 받아서 처리하도록 하는 것이다. 이를 통해 각 서버는 다른 서버들이 존재(시작, 종료) 를 확인할 수 있었다. 추가로 서버 heartbeat 로직도 만들었는데 단순히 시작, 종료뿐만 아니라 특정 시간마다 안정해싱(consistent hashing) 으로 사용되는 서버가 살아있는지 확인하고 만약 서버가 종료되었다면 빠르게 해싱에 반영해서 라우팅에 이상이 없도록 처리한다.
최종적으로 서버 시작부터 종료까지 전체적인 lifecycle 를 모두 관리할 수 있도록 하는 코드를 개발할 수 있었다.
/**
* Kafka에서 서버 이벤트 수신
*/
@KafkaListener(
topics = KafkaTopic.SERVER_LIFECYCLE_EVENTS,
containerFactory = "kafkaServerEventListenerContainerFactory",
groupId = "server-lifecycle-group-#{T(java.util.UUID).randomUUID().toString().split(\"-\")[0]}" // 인스턴스별 고유 groupId
)
public void handleServerEvent(ConsumerRecord<String, KafkaEvent> record) {
try {
KafkaServerEvent event = (KafkaServerEvent) record.value();
if (StringUtil.isNullOrEmpty(event.getInstanceId()) || event.getEventType() == null) {
log.warn("=== Received event from server with null or empty instance ID: {}", event);
return;
}
// 1시간 이전 이벤트는 스킵
if (isEventTooOld(event.getPublishedAt())) {
log.debug("Skipping old event from {}", event.getInstanceId());
return;
}
// 자신이 발행한 이벤트는 무시
if (instanceId.equals(event.getInstanceId())) {
return;
}
switch (event.getEventType()) {
case SERVER_DISCOVERY_REQUEST:
// 새로운 서버가 discovery 요청 → 해당 서버를 등록하고 자신의 존재를 알림
if (!this.isHealthy(event.getInstanceId())) {
addServer(event.getInstanceId());
log.debug("===== New server discovered via discovery request: {}", event.getInstanceId());
}
publishServerEvent(ServerEvent.SERVER_DISCOVERY_RESPONSE, instanceId);
break;
case SERVER_DISCOVERY_RESPONSE:
// 기존 서버의 응답 → 해당 서버를 등록
if (!this.isHealthy(event.getInstanceId())) {
addServer(event.getInstanceId());
log.debug("===== Added existing server via discovery response: {}", event.getInstanceId());
}
break;
case SERVER_STARTED:
if (!this.isHealthy(event.getInstanceId())) {
addServer(event.getInstanceId());
log.debug("===== Added server via SERVER_STARTED event: {}", event.getInstanceId());
}
break;
case SERVER_STOPPED:
removeServer(event.getInstanceId());
log.debug("===== Removed server via SERVER_STOPPED event: {}", event.getInstanceId());
break;
case SERVER_COOKIE_REQUEST:
handleCookieRequest(event.getInstanceId());
break;
case SERVER_COOKIE_RESPONSE:
handleCookieResponse(event);
break;
case SERVER_COOKIE_DISCOVERED:
handleCookieDiscovered(event);
break;
default:
log.warn("Unknown server event type: {}", event.getEventType());
}
} catch (Exception e) {
log.error("Failed to handle server event from Kafka", e);
}
}
5. 전체 시스템 아키텍쳐 및 플로우 차트
1) 전체 시스템 아키텍쳐

2) 쿠키 획득 플로우 차트

3) 유저 방 입장 플로우 차트

5. 분산 처리 환경 구성하기 : 테스트하기
분산 처리 환경 테스트는 사실 생각보다 간단하다.
1. 서로 다른 사용자가 각각 서버 A 서버 B 에 접속한다. 이때 사용자 둘이 서로 다른 서버에 접속한 것은 각 유저의 브라우저에서 쿠키 값을 확인하면 된다.
2. 서버 A 에서 방을 만들고 사용자가 입장한다.
3. 서버 B 에 있는 사용자는 A 에서 생성된 방을 확인한다. 이후 방에 접속한다.
case 1. 제대로 라우팅이 안된다면 둘은 서로 연결이 안될 것이고, 쿠키를 확인 시 서로 쿠키값이 다를 것이다.
case 2. 정상 동작한다면 사용자간의 화상 채팅이 가능할 것이고, 쿠키를 확인 시 서로 동일한 쿠키값을 확인 할 수 있을 것이다.
6. 마치면서
이번 개발은 올해들어 회사와 개인 공부를 통틀어서 최고로 재미있는 개발 중 하나였다. 도저히 해결이 안될 것 같던 문제에 대해서 '어떻게 해결할까' 를 넘어서 고민하고 공부하고 수정해가는 과정이 너무나도 재미있었던 것 같다.
배운것들도 정말 많았는데 특히 k8s 에 대해서 더욱 공부하는 계기가 되었다. 단순히 k8s 의 몇몇 기능들을 사용하는 것을 넘어서 k8s 배포환경이라는 컨셉을 어떻게 효율적으로 활용할 수 있을지에 대해서 공부할 수 있었다. 역시나 매번 느끼는 '네트워크 공부에 대한 필요성은' 는 덤이었고, 추가로 '시스템 아키텍쳐 구조'를 어떻게 가져가야할까에 대한 고민이 정말 필요하다는 것도 느꼈다. 결국 이렇게 고생한 것은 지금까지 '분산 환경 처리' 라는 고민이 없었기에 이런 문제가 발생했기 때문이었다.
개발적으로는 단순한 접근에서 알고리즘적 접근으로의 전환이 얼마나 중요한지 다시 한 번 경험할 수 있었다. 쿠키 확인을 할 때 처음에는 "그냥 충분히 많이 시도하면 되지 않을까?"라는 생각으로 50회라는 임의의 숫자를 정했지만, 이후 85% 목표 성공률이라는 명확한 기준을 세우고, 수학적으로 접근했을때야말로 비로소 리소스 효율성과 안정성을 모두 달성할 수 있었기 때문이다. 또한 안정 해싱을 사용하면서 방생성 시 서버 안정성과 효율 모두를 고려할 수 있었다. 결과적으로 내가 맨 처음 생각했던 구조보다도 훨씬 더 단단하고, 안정적이며 효율적인 구조가 될 수 있었다. 사실 두가지를 개발 할 때 쯤 맨날 알고리즘 공부하다던 옛 동료가 생각났는데, '만약 친구가 있었으면 지금의 나보다 시간은 더 줄이면서 이런저런 알고리즘을 이용해서 훨씬 더 좋은 구조, 효율적인 코드를 만들 수 있지 않았을까?' 하는 생각이 들어 새삼 부끄럽기도하고 후회스럽기도 했던 것 같다.
다만 아쉬운 점과 아직 보완해야하는 점도 여전히 남아있다. 먼저 서버가 종료되었을 때 해당 서버에 있던 채팅방에 대한 처리이다. 기존에 방에 사람이 없다면 다행이지만 방에 사람이 있었고, 그 방과 매칭되었던 서버가 알 수 없는 이유로 죽거나 혹은 무중단 배포 시
즉, 기존 파드가 새로운 파드로 대체되었을때 기존 파드에 남아있던 방을, 또 방에 남아있던 사람들을 어떻게 처리할 것인가?
에대해서는 꼭 대처가 필요했다.
두번째로는 현재 구조가 너무 복잡하고, 너무 결합도가 강하다는 점이었다. 물론 현재 개발된 구조가 결코 나쁘다라고는 생각하지 않는다. 내가 지금 할 수 있는 한 가장 머리를 많이 쓴 방법이고, 내가 할 수 있는 한 최대한의 방법으로 개발하였기 때문에 만족하는 부분도 있다. 다만 redis + kafka + k8s + cookie 이 모두를 결합해야 서비스가 동작할 것이고, 특히 redis 와 kafka 의존성은 이루 말할 수 없이 높았다. 결국 이 모든 걸 해결하는 가장 좋은 방법은 OpenVidu 스타일의 중앙 미디어관리 서버(chatforyou-media-server) 도입 하는 것이라고 생각한다. 이렇게하면 webrtc 의 코어 기능과 나머지 비즈니스 로직의 분리 뿐만 아니라 다른 서비에대한 의존도 역시 많이 줄일 수 있다고 생각하기 때문이다. 현재는 이 정도에 만족하겠지만 chatforyou-media-server 의 설계 역시 멈추지는 않을 것이다.
Reference
https://cloud.google.com/run/docs/configuring/session-affinity?hl=ko
서비스의 세션 어피니티 설정 | Cloud Run Documentation | Google Cloud
의견 보내기 서비스의 세션 어피니티 설정 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 Cloud Run 서비스 버전에 대한 세션 어피니티를 사
cloud.google.com
Consistent hashing (Hash ring)
Consistent hashing은 Hashing을 일관되게 유지하는 방법이다. 이게 뭐다 라고 설명하기 보단, 먼저 상황을 예시로 들어보자. 노드 3개에 데이터를 분산 저장하는 상황이 있다. 제일 쉬우면서 분산저장
binux.tistory.com
https://velog.io/@lclgood/Kafka-on-k8s
Kafka on k8s
Strimzi with KRaft mode
velog.io
https://steady-coding.tistory.com/611
[Spring] @Async 사용 방법
spring-study에서 스터디를 진행하고 있습니다. Java 비동기 처리 Spring @Async를 살펴 보기 전에 동기, 비동기, 멀티 스레드의 개념은 필수적이다. 해당 개념을 알고 있다고 가정하고, 순수 Java 비동기
steady-coding.tistory.com
https://jiwondev.tistory.com/299
안정 해시(Consistent Hashing)
🍀 해시를 사용해 요청을 분산하는 방법 N개의 캐시 서버가 있을 때 부하를 나누는 보편적인 방법으로는 해시 함수를 많이 사용한다. MD5 (2^128): 속도가 보안보다 중요한 어플리케이션. SHA-256 에
jiwondev.tistory.com
[Kubernetes] 카나리아 배포와 Session Affinity(스티키 세션, 세션 선호도)
아래의 지난 글에서도 언급했듯이, 쿠버네티스에서 카나리아 배포를 사용할 경우 로드 밸런싱 때문에 문제가 생길 수 있다. 신 버전에서 구 버전 서버로 로드 밸런싱이 되어 UI/UX가 변경되는 등
cn-c.tistory.com
https://stackoverflow.com/questions/1040025/difference-between-session-affinity-and-sticky-session
Difference between session affinity and sticky session?
What is the difference between session affinity and sticky session in context of load balancing servers?
stackoverflow.com
'토이 프로젝트 > ChatForYou V2 프로젝트' 카테고리의 다른 글
| Spring Boot Web Chatting v2: 스프링 부트로 실시간 화상 채팅 만들기(3) 소셜로그인 기능 개발(feat. QR 로그인) (0) | 2025.11.21 |
|---|---|
| Spring Boot Web Chatting v2: 스프링 부트로 실시간 화상 채팅 만들기(1) ChatForYou v2 (3) | 2025.07.27 |
댓글