HTTP
대부분의 채팅 시스템은 초기에 HTTP를 이용함. TCP handshake 등의 비용을 줄이기 위해 keep-alive 옵션을 사용하여 연결을 지속할 수 있음. 클라이언트 → 서버로의 메시지는 보낼 수 있지만 서버 → 클라이언트 방향으로는 메시지를 보낼 수 없음.
Polling
주기적으로 서버에게 메시지가 있는지 물어보는 방법. 자주하면 비용이 커지고 주기가 커지면 실시간성이 떨어지는 문제가 있음.
Long Polling
클라이언트는 새 메시지가 반환되거나 타임아웃 될 때까지 연결을 유지함. 새 메시지를 받으면 연결을 종료하고 서버에 새로운 요청을 보내어 새로운 연결을 맺음.
약점
WebSocket
서버가 클라이언트에게 비동기 메시지를 보낼 때 사용하는 기술.
특징
당연히, 채팅을 제외한 다른 서비스 (인증, 프로필 등)들은 웹소켓이 아니라 일반 HTTP 서비스로 구현해도 된다.
여기서 채팅 서비스가 stateful 서비스이고 로그인, 회원가입, 프로필 등의 서비스들은 stateless(무상태) 서비스들이다.
왜 그럼 채팅 서비스는 stateful이라고 할까?
각 클라이언트와 채팅 서버가 독립적인 네트워크 연결을 유지해야 하기 때문이다. 클라이언트는 보통 서비스를 제공하는 서버를 변경하지 않는데 채팅 서버 같은 경우에는 부하가 몰리지 않도록 여러 서버로 분산될 수 있는 것이다.
어떤 저장소를 사용할 것인지?
데이터의 유형은 어떤 것이 존재할까?
사용자 프로필, 설정, 친구 목록과 같은 일반적인 데이터 → 안정성을 위해 RDB에 저장
채팅 이력 데이터
→ 책에서는 key-value 저장소를 추천하는데 그 이유는 수평적 규모확장
이 쉽다.
Key-Value 저장소의 특징
왼쪽은 1:1 채팅을 지원하는 테이블이고 오른쪽은 그룹 채팅방의 테이블 설계이다.
created_at으로 채팅의 순서를 정하지 못하고 message_id로 채팅의 순서를 정할 수 있다. (동시 생성 가능성)
메시지가 전송된 순서에 따라 메시지 아이디가 부여되어야 한다. RDB는 auto_increment로 처리할 수 있지만 NoSQL에서는 해당 기능을 제공하지 않으므로 다른 방법을 찾아보아야 한다.
LINE 앱 ID Generator (snowflake)
클라이언트에게 가장 적합한 채팅 서버를 추천해주는 것.
사용자의 위치와 서버의 용량 등을 기준으로 추천을 해주게 된다.
메시지 흐름
사용자 A가 채팅 서버 1로 메시지 전송
채팅 서버 1은 ID 생성기를 사용해 해당 메시지의 ID 결정
채팅 서버 1은 해당 메시지를 메시지 동기화 큐(MQ)로 전송
메시지가 키-값 저장소에 보관됨
5-a. 사용자 B가 접속 중인 경우 메시지는 사용자 B가 접속 중인 채팅 서버로 전송됨
5-b. 사용자 B가 접속 중이 아니라면 푸시 알림 메시지를 푸시 서버로 보냄
채팅 서버 2는 유저 B에게 메시지를 전송. 웹소켓이 열려 있어서 그것을 이용.
cur_max_message_id를 관리하여 가장 최신 메시지를 추적한다. 이는 단말마다 별도로 저장되면 된다.
소규모 그룹 채팅에서는 채팅 서버가 각 단말마다의 메시지 큐를 할당하여 각 메시지 큐에 메시지를 보내면 된다. 하지만 이는 굉장히 바람직하지 않은데 메시지를 수신자별로 복사하여 각 메시지 큐에 넣어줘야 함은 물론이고 Scalability가 떨어진다.
사용자 로그인
last_active_at이라는 타임스탬프를 Key-Value 저장소에 보관하며 이 절차 후에 접속 상태로 표시될 것이다.
로그아웃
Key-Value 저장소의 상태를 오프라인으로 변경하여 로그아웃 상태를 표시한다.
접속 장애
사용자의 인터넷이 잠시 끊겼을 때 사용자의 상태가 오프라인으로 표시되는 것이 바람직하지 않은데 이런 상황은 굉장히 빈번하게 발생하기 때문이다. 그렇기에 바로 오프라인으로 변경하는 방법이 아니라 헬스체크 방식을 통해 이 문제를 해결할 수 있을 것이다.
상태 정보의 전송
상태정보 서버는 발행-구독 모델을 사용하여 친구관계마다 채널을 하나씩 두는 것이다. 이렇게 하면 친구 관계에 있는 사용자가 상태정보 변화를 쉽게 감지할 수 있다. 여기서도 웹소켓을 사용한다.
하지만 그룹 크기가 커지게 되면
예를 들어, 10만 사용자가 있는 그룹이라면 1개의 상태 변경에도 10만개의 이벤트 메시지를 생성해야 한다. 이에 대한 대처 방안으로는 갱신을 수동으로 한다던지의 방식이 존재한다.
About end-to-end encryption | WhatsApp Help Center
메시지 큐는 메시지 지향 미들웨어를 구현한 시스템이다.
응용 소프트웨어 간의 데이터(비동기 메시지) 통신을 위한 소프트웨어
MOM은 메시지를 전달하는 과정에서 메시지를 보관하거나 라우팅 및 변환할 수 있다는 장점을 가진다.
단점
메시지 큐 사용의 장점
이런 장점들 덕분에 다양한 곳에서 사용이 된다.
그럼 메시지 큐들의 특징을 알아보자.
build.gradle 의존성 추가
implementation 'org.springframework.kafka:spring-kafka'
application.yml에 kafka 설정
spring:
kafka:
bootstrap-servers:
- 192.168.0.4:9092
consumer:
# consumer bootstrap servers가 따로 존재하면 설정
# bootstrap-servers: 192.168.0.4:9092
# 식별 가능한 Consumer Group Id
group-id: testgroup
# Kafka 서버에 초기 offset이 없거나, 서버에 현재 offset이 더 이상 존재하지 않을 경우 수행할 작업을 설정
# latest: 가장 최근에 생산된 메시지로 offeset reset
# earliest: 가장 오래된 메시지로 offeset reset
# none: offset 정보가 없으면 Exception 발생
auto-offset-reset: earliest
# 데이터를 받아올 때, key/value를 역직렬화
# JSON 데이터를 받아올 것이라면 JsonDeserializer
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
# producer bootstrap servers가 따로 존재하면 설정
# bootstrap-servers: 3.34.97.97:9092
# 데이터를 보낼 때, key/value를 직렬화
# JSON 데이터를 보낼 것이라면 JsonSerializer
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
Kafka Producer class
KafkaTemplate에 Topic명과 메시지를 전달한다. kafkaTemplate.send() 메소드가 실행되면 Kafka 서버로 메시지가 전송된다.
@Service
public class KafkaProducer {
@Value(value = "${message.topic.name}")
private String topicName;
private final KafkaTemplate<String, String> kafkaTemplate;
@Autowired
public KafkaProducer(KafkaTemplate kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void sendMessage(String message) {
System.out.println(String.format("Produce message : %s", message));
this.kafkaTemplate.send(topicName, message);
}
}
Kafka Consumer class
Kafka로부터 메시지를 받으려면 @KafkaListener 어노테이션을 달아주면 된다.
@Service
public class KafkaConsumer {
@KafkaListener(topics = "${message.topic.name}", groupId = ConsumerConfig.GROUP_ID_CONFIG)
public void consume(String message) throws IOException {
System.out.println(String.format("Consumed message : %s", message));
}
}
Controller
@Slf4j
@RestController
@RequestMapping(value = "/kafka/test")
public class SampleController {
private final KafkaProducer producer;
@Autowired
SampleController(KafkaProducer producer) {
this.producer = producer;
}
@PostMapping(value = "/message")
public String sendMessage(@RequestParam("message") String message) {
this.producer.sendMessage(message);
return "success";
}
}
물론 장점이 있다.
하지만 MQ를 동기적 모놀리식 시스템에서 사용할 때 주의할 점은 성능 및 복잡성을 고려해야 한다는 점이다. 비동기 통신은 대규모 분산 시스템에서 효과적으로 사용될 수 있지만 작은 규모의 단일 서비스에서 MQ를 사용하게 되면 오버헤드
를 초래할 수 있다.