
백엔드 공부를 하며 각 서버가 state를 가지는가 ? 상태를 가지는가 ? 에 대한 고민을 해본적이 없었다.
아니 사실은 state가 뭔지도 잘 몰랐다. 스프링 공부를 깊게 하게 되면서 서버는 state를 가지는것이 좋은 서버가 아니라는것을 배우게 되면서 그렇다면 여태까지 내가 빌드한 서버들은 모두 state를 가지는가? 에 대한 물음표가 생기게 되었다.
🍃 스프링의 Singleton 디자인패턴과 비슷한 느낌이다. 상태를 가지는 서버가 여러개있다면, 동시성 제어의 어려움부터 시작해 운영이 복잡해질 것이고 무엇보다 Scale-out 수평적 확장에 무리가 있기 때문에 좋은 설계가 아니다.
state는 단어의 뜻 그대로 상태를 말한다.
싱글톤 패턴이 아니라 하나의 클래스에 대하여 여러개의 인스턴스가 존재할때, 각 인스턴스마다 고유한 상태 (변수 , 메타 데이터 등등 ) 을 가질 것이다.
클래스와 인스턴스 범위에서 보게되면 , 인스턴스의 로컬 변수가 state를 말할 수도 있고,
보다 넓은 서버 범위에서 보게되면 세션을 유지하는 경우 state를 가진다 말할 수 있고, Scheduler 정해진 시각에 어떤 기능을 수행하는 경우도 state를 가진다고 말할 수 있을것이다.
State : 과거의 상호작용 결과에 따라 미래의 요청 처리 결과가 달라지게 만드는 정보
보통의 stateless 한 서버들은 상호작용의 결과를 서버에서 가지고 있는것이 아니라, 서버들간의 공유된 DB에 저장해두거나, Redis 와 같은 인메모리와 소통할 수도 있을것이다.
다시 두가지의 차이점을 정의해보자면
Stateful Server : 해당 서버가 이전 요청에 대한 상태를 저장해, 장애가 발생했을때 다른 서버가 처리하기 힘든 서버.
Stateless Server : 해당 서버가 장애가 발생해도 다른 서버가 그 요청을 처리할 수 있는 서버.
그렇다면 상태를 유지해야하는 stateful 서버로 구현해야하는 경우는 어떤 경우가 있을까 ?
클라이언트가 세션을 유지해야하는 WebSocket 서버
-> 실시간 통신이 이루어지기 위해서는 클라이언트의 세션이 계속 유지되어야한다.
대용량 파일 업로드 처리
-> 한번의 요청으로 처리하지 못할정도의 대용량 파일의 경우에도 클라이언트와 서버가 대용량 업로드가 끝날때까지 상태를 유지해야하는 경우다.
Stream 처리 서버
-> Kafka Streams, Flink 같은 시스템에서 지난 5분간 사용자별 요청 수, 누적 합계, window aggregation 등을 로컬 state store에 유지하면 Stateful processing이다.
인프라를 구성할때에 수평적 확장을 항상 고려해야한다.
확장을 고려하지 않은 설계는 언제 트래픽이 몰려 서버를 확장해야할지 모르는데, 서비스의 성장을 전혀 염두에 두고 있지 않은 나쁜 설계라고 생각한다.
그리고 수평적 확장이라함은 서버 인스턴스 갯수를 늘린다는 것인데, 단순히 서버만 늘린다고 TPS Throughput 이 증가하지는 않을 것이다. 당연히 그에 상응하는 DB도 복제가 되야할 것이다.
DB 복제와 샤딩에 대해서는 이번 포스트에서 다루기에는 무거운 주제이니 인스턴스의 수평확장만을 고려해보면 , stateless 서버는 확장에 전혀 어려움이 없어 보인다. 서버들 모두가 상태를 저장하고 있지 않으니, 수평적 확장을 하여서 상태를 전달받거나 전처리를 해야하는 과정이 없다.
stateful 서버는 당연하게도 확장에서 신경써야할 부분이 많을 것이다.
그렇다면 무조건 state, stateless 서버는 분리되어야하나 ?
아니다. 모놀리식 서버에서 state , stateless 기능을 모두 다 가지고 있을 수 있다.
확장의 경우에는 서버 앞단의 API Gateway 에서 state, stateless 요청을 다르게 받아서 각 기능들의 요청에 따라 scale-out 을 결정하게 하는 구조로 두면 된다.
Post Service
│
│ CommentCreated
▼
Event / Message Layer
│
┌───┼───────────────┐
▼ ▼ ▼
WS GW 1 WS GW 2 WS GW 3
│ │ │
├─ User A ├─ User D ├─ User G
├─ User B ├─ User E ├─ User H
└─ User C └─ User F └─ User I
각 Gateway 내부에서 Local Fan-out
Websocket 기능을 가진 gateway를 통한 MSA 구조를 예를 들어 설명해보자.
어떤 Event가 발생하게 되면 그 이벤트의 구독자인 gateway가 이벤트가 발생한것을 'Heard' 듣게 되면,N개의 gateway가 DB Read연산을 수행해 갑작스러운 QPS 의 기하급수적 상승이 있을 수 있다. (Thunder Heard 문제)
그래서 EDA, Redi Stream을 활용한 Local-Fan-Out구조로 위의 문제를 해결하는 설계이다.
Event는 페이지를 새로고침하거나, 사용자에게 줘야하는 최소의 정보를 가진 payload를 담아 발행된다.
그럼 그 Event의 구독자인 WS gateway는 DB를 읽는 것이 아니라, Event Payload를 읽고 WS gateway와 연결된 N개의 클라이언트들에게 정보를 전달한다.
이 구조로 실질적인 DB 연산은 이벤트 발행에 따른 쓰기 연산 한번이 일어난 것이다.
이러한 설계는 DB 부하를 줄이는 것뿐 아니라 Stateful한 WebSocket 서버를 수평 확장할 때도 장점이 있다. 새로운 WebSocket Gateway 인스턴스가 추가되더라도 해당 인스턴스는 새롭게 연결되는 Socket만 관리하면 되고, 이벤트를 전달받은 뒤 자신이 보유한 연결에 대해서만 Fan-out하면 된다. 특정 사용자가 어느 Gateway에 연결되어 있는지를 중앙 DB에서 매번 조회할 필요가 없으며, Gateway 내부의 메모리 접근만으로 실제 전달 대상자를 결정할 수 있다.
Stateful 서버라고 해서 수평적 확장이 불가능한것은 아니다.
새로 생성된 인스턴스에게 세션을 나누어주고, Sticky Session을 유지시키는 오버헤드가 있어서 그렇지, 완전히 불가능한것은 아니다.
보통의 사이드 프로젝트에서 MSA 를 적용하는일은 거의 없기에, stateless, stateful을 잘 구분하여 수평적 확장에 용이한 설계를 해야겠다.
사실 마구잡이로 확장한다고해서 해결되는것은 아니며 사실 DB sharding , DB 복제 및 동기화 등 더 어려운 문제가 나를 기다린다 .. 🥹