곧 출시할 서버에서 임시로 쓸 채팅 서버를 구상하였습니다.
네트워크 엔진(프라우드넷 같은)이 도입되면 거기 것을 쓸 것이고, 채팅이 중요한 부분을 차지한다고 생각도 들지 않아서 일단 땜빵 비스무리하게 구현했네요.
요구 조건은 다음과 같습니다.
- 사용자가 주기적으로 새로운 메세지가 있는지 확인하는 방식은 안됨. (주기적 확인)
- 많은 사용자가 접속할 수 있으므로 메세지 전송은 최대한 가벼워야 함. (가벼움)
- 채팅 내용이 많이 전송되지 않을 수 있지만, 사용자가 매우 많이 접속할 수 있으므로 수평적 확장(Scale Out)이 가능해야 함. (수평적 확장)
- 사용자 인증이 된 접속에게만 메세지 내용을 전파해야 함. (인증)
- 메세지 내용을 필터링 할 수 있어야 함. (필터링)
채팅방을 따로 만들어서 채팅하고 이런 기능은 일단 고려 대상에서 제외하고, 서버마다 개별적으로 내용이 전달되는 형태보다는 모든 서버가 논리적으로 하나의 서버인 것처럼 보이고 싶었던 것이 나의 고민이었습니다.
혼자서 이것저것 고민해봤으나 가장 괜찮은 방법은 클러스터링 된 Queue 을 이용해서 사용자들이 L4 을 통해 어떤 서버에 접속을 해 있든 자신이 받을 메세지를 Queue 에서 뽑아오는 형태가 좋을 것 같았습니다. 물론 상대가 메세지를 보내는 순간 접속이 되어 있지 않다면 그 메세지는 받을 필요가 없기 때문에 반드시 사용자별로 Queue 를 구현할 필요는 없다는 생각도 있었습니다.
그래서 구상한 물리적 구성은 다음과 같습니다.
L4 - 채팅서버 애플리케이션 - Cluster 된 RabbitMQ 서버
서버는 아주 단순합니다. 다만, RabbitMQ 서버는 Cluster 된 상태여야 합니다. 1 개의 RabbitMQ 만 있다가 서버가 중지되면 곤란하니까요. 그리고 채팅서버 애플리케이션이 동작하는 서버와 RabbitMQ 서버는 물리적으로 분리되어 있어도 상관없으나, 퍼블리셔에게 신청한 채팅서버는 분리된 형태가 아니다보니, 물리적으로 하나의 서버에 설치하는 것으로 할 계획입니다. 어짜피 채팅서버 애플리케이션은 매우 가벼울테니...
실제 채팅서버 애플리케이션은 다음과 같은 흐름을 가질 것입니다.
- 애플리케이션이 실행되면 RabbitMQ 에 애플리케이션 독자적인 Queue 을 선언합니다. 채팅서버 애플리케이션마다 한 개씩 가지게 됩니다. Topic 형태에 맞게 이름을 부여하겠죠.
- RabbitMQ 에 TopicExchange 을 생성합니다. 위에서 만든 Queue 에 모두 전파할 수 있도록 말이죠.
- 위에서 만든 Queue 와 Exchanger 을 이용해 RabbitMQ 에 접속합니다.
- 사용자 접속 요청을 받을 수 있도록 Netty 로 decoder 와 encoder 을 생성합니다.
- 사용자가 접속을 하면 접속자 풀(Singleton 에 있는 Map)에 등록을 합니다.
- 사용자의 첫 메세지가 인증에 관련된 것이라면 인증을 처리하고 사용자 풀(Singleton 에 있는 Map)에 사용자 정보(Netty 의 Channel 이 있어야겠죠)를 입력합니다. 인증 실패 혹은 인증에 관련된 것이 아니라면 접속을 강제로 끊어야겠죠.
- 사용자에게 채팅 메세지가 전송되어 오면, 메세지 내용과 상황에 맞는 변조를 거쳐 RabbitMQ 에 데이터를 전송합니다. 물론 위에서 선언한 Exchanger 을 통해 전송해야하고, 이 작업을 통해 채팅 메세지는 모든 Queue 에 하나씩 전송됩니다.
- 각 서버는 자신이 바라보고 있는 하나의 Queue 에서 채팅 내용을 순서대로 하나씩 꺼내옵니다. 이 때 Spring Boot AMQP 같은 것을 이용하면 구현이 매우 쉽게 되겠죠.
- 꺼내온 내용을 처리하여 사용자 풀에 있는 모든 사용자에게 채팅 내용을 전송합니다. 사용자 풀에 Channel 이 있을 것이므로 Map 의 values 을 Collection 으로 받아와 pararellStream() 으로 처리하면 병렬 처리도 될 것입니다.
이렇게 프로그래밍 된 채팅서버를 가지고 테스트를 진행해볼 것입니다. 물론 채팅 클라이언트는 제가 만드는게 아니라서...언제 테스트를 진행할지는 의문이지만요
실제 코드는 그리 길지 않습니다. 자잘한거 다 빼면 30 라인 조금 넘으려나...
일단, 조건에 대한 만족 여부를 판단해보겠습니다.
- 주기적 확인 : 사용자가 메세지를 보내면 서버에서 모든 사용자에게 Channel 을 이용해서 메세지를 전송하기 때문에 연결만 유지한 상태면 됩니다. 그러므로 클라이언트에서 주기적으로 새로운 메세지가 있는지 확인할 필요가 없습니다.
- 가벼움 : HTTP 전송 등을 사용하지 않기 때문에 가볍습니다. Netty 에서 StringDecoder/StringEncoder 을 제공하기 때문에 평문으로 그냥 보내도 되고, ProtoBuffer 을 이용해서 보낼 수도 있겠죠. 다만, 클라이언트가 Unity 이다 보니, ProtoBuffer 는 못쓸 것 같네요. FlatBuffer 가 제공되면 좋겠는데...일단은 장기적으로 쓸 서버가 아니라서 String 으로 해보고 판단하겠습니다.
- 수평적 확장 : L4 만 버텨준다면 채팅서버 애플리케이션은 몇 대가 있더라도 상관 없습니다. 자신에게 접속된 사용자를 Singleton Map 으로 관리하고, 메세지가 있을 경우 그 사용자들에게 메세지만 보내주면 되니까요. RabbitMQ 역시 한 대든 여러 대든 상관 없습니다. Queue 가 어마어마하게 생길 리도 없고(채팅서버 애플리케이션이 그렇게 많이 실행될 리가 없으니까요) 서버가 늘어나도 Cluster 연결만 하면 문제 없습니다. 다만 전파속도가 얼마나 빠를지는 테스트를 안해봤네요. RabbitMQ 에 대한 분석이 조금 부족한게 문제겠네요.
- 인증 : Netty 에서 TCP 나 UDP(UDT) 등이 연결될 때 channelActive() 가 호출됩니다. 여기서 접속된 사용자를 저장하는게 어떤 의미가 있을지...사실 모르겠네요. 그래도 메세지를 보내면 channelRead() 가 호출되는데, 인증된 사용자 풀에 저장되어 있지 않을 경우 로그인 처리를 시도하고, 실패하면 접속을 강제로 끊어버리기 때문에(Channel.close() 을 호출) 연결만 하고 멍하니 있는 시도 등은 한 번 걸러낼 수 있겠지요. 인증된 사용자 풀이 따로 있기 때문에, 여기서 메세지 보낼 사용자 목록을 담아둘 수 있겠습니다. 좀 더 응용하면, 방에 해당하는 Map 도 만들어서 방 사용자에게만 메세지를 보내는 형태도 구현가능하겠죠. 물론, 채팅방 위주의 채팅서버라면 이런 구성보다는 매칭서버와 채팅서버를 분리하는게 좋겠죠.
- 필터링 : 인증된 사용자가 메세지를 보내면(channelRead() 호출) 메세지 내용을 필터링 해버리면 되지 않을까 싶네요. 보낸 메세지에 대한 로그도 남길 수 있을 것이구요.
이렇게 되겠네요.
아직 확인을 못한 부분은, 채팅서버 애플리케이션에서 RabbitMQ 서버로 접속할 때 Cluster 된 서버 목록을 지정해서 살아있는 서버에 계속 연결이 될 수 있느냐겠네요. RabbitMQ 분석을 다른 사람에게 맡겨놔서 저는 잘 몰라요.