Kanal을 만들며: JVM 서버에서 실시간 기능을 조금 더 편하게 다루기
WebSocket은 연결을 열어주지만, 채팅방과 접속자 목록까지 대신 만들어주지는 않는다.
요즘 서버 애플리케이션에서 실시간 기능은 꽤 흔하다.
채팅, 알림, 실시간 대시보드, 협업 문서, 접속자 목록, 타이핑 표시 같은 기능이 대표적이다. Spring 서버에서도 WebSocket을 열 수 있고, STOMP를 사용할 수도 있다. 그런데 실제로 기능을 만들다 보면 WebSocket 연결을 여는 것만으로는 부족하다는 걸 금방 느끼게 된다.
예를 들어 채팅방을 만든다고 해보자.
서버는 이런 것들을 직접 관리해야 한다.
- 어떤 사용자가 어떤 방에 들어와 있는가?
- 같은 사용자가 브라우저 탭을 여러 개 열면 어떻게 볼 것인가?
- 사용자가 방에 들어올 때와 나갈 때 어떤 처리를 할 것인가?
- 방에 메시지를 보내면 누구에게 전달할 것인가?
- 느린 클라이언트 때문에 메시지가 쌓이면 어떻게 할 것인가?
- 접속자 목록은 어디에 저장하고 언제 지울 것인가?
- 운영 중에 문제가 생기면 어떤 지표를 보고 판단할 것인가?
Kanal은 이 반복되는 작업을 조금 더 높은 수준의 모델로 다루기 위해 시작한 프로젝트다.
간단히 말하면, Kanal은 JVM 서버 개발자가 실시간 기능을 더 애플리케이션 코드답게 작성할 수 있게 만들려는 시도다.
WebSocket만으로는 무엇이 부족할까
WebSocket은 클라이언트와 서버 사이에 양방향 연결을 열어준다. 이것은 매우 중요한 기능이지만, 제품 기능을 만들기에는 낮은 수준의 도구다.
WebSocket handler 안에서 직접 이런 코드를 만들 수는 있다.
roomId별 session 목록- user id와 session id 매핑
- join, leave 처리
- broadcast loop
- disconnect cleanup
- 접속자 목록 저장
- 느린 client 처리
처음에는 빠르게 만들 수 있다. 하지만 기능이 커지면 코드가 여러 handler와 service에 흩어지기 쉽다. 특히 접속자 목록, 방 참여 상태, 메시지 전달 대상이 서로 섞이면 나중에 고치기 어려워진다.
Kanal은 이 부분을 channel이라는 단위로 묶으려고 한다.
예를 들면 이런 식이다.
import io.github.kimseungjin.kanal.core.dsl.channel
import io.github.kimseungjin.kanal.core.dsl.realtime
val app =
realtime {
channel<ChatMessage>("chat/{roomId}") {
description("Realtime chat channel")
onJoin {
presence.track(
key = session.userId ?: session.id,
metadata = mapOf("roomId" to address.parameters.getValue("roomId")),
)
}
onMessage { message ->
broadcast(message)
}
}
}
여기서 개발자는 socket 자체보다 애플리케이션의 의미에 집중한다.
chat/{roomId}라는 채널이 있다.- 사용자가 들어오면
onJoin이 실행된다. - 접속자 목록은
presence로 관리한다. - 메시지가 오면
onMessage가 실행된다. - 같은 방에 있는 사람들에게
broadcast한다.
이런 코드가 Kanal이 원하는 방향이다.
현재 프로젝트 상태
아직 Kanal은 완성된 실시간 서버 프레임워크가 아니다.
중요한 점을 먼저 말하면, 지금은 WebSocket 연결을 실제로 받고 메시지를 주고받는 runtime이 없다. 그러니까 지금 당장 /realtime 같은 endpoint에 브라우저를 연결해서 채팅할 수 있는 상태는 아니다.
대신 그 runtime을 만들기 위한 기초 구조를 먼저 만들었다.
현재 모듈은 이렇게 나뉜다.
kanal-core
실시간 기능을 표현하는 핵심 모델을 담는다. channel, session, presence, channel pattern, resolver가 여기에 있다.kanal-runtime
실제 runtime에서 필요할 성능 관련 구조를 담는다. 방 참여 목록, bounded queue, metrics 같은 것들이다.kanal-spring-boot-starter
Spring Boot에서 자동 설정으로 붙일 수 있는 시작점이다.kanal-samples:chat-presence
채팅과 접속자 목록을 어떻게 모델링할지 보여주는 예제다.
이 글에서는 지금까지 만든 것 중 서버 개발자 입장에서 중요한 부분을 쉽게 정리해보려고 한다.
1. 채널 주소를 해석하는 기능
채팅방 주소를 보통 이렇게 표현할 수 있다.
chat/general
chat/random
chat/spring
그런데 서버 코드에서는 매번 방 이름을 직접 쓰기보다 이런 패턴으로 다루고 싶다.
chat/{roomId}
chat/general이 들어오면 roomId = general로 해석되는 식이다.
Kanal에서는 이것을 ChannelPattern으로 표현한다.
ChannelPattern("chat/{roomId}")
초기에는 이걸 단순 문자열로만 들고 있었다. 하지만 runtime이 생기면 이 작업은 매우 자주 일어난다. 클라이언트가 메시지를 보낼 때마다 서버는 이 메시지가 어떤 channel에 해당하는지 찾아야 한다.
그래서 지금은 ChannelPattern을 만들 때 미리 검증하고 쪼개둔다.
chat/{roomId}
이 패턴은 내부적으로 대략 이렇게 나뉜다.
고정된 부분: chat
변수 부분: roomId
이렇게 해두면 나중에 매번 문자열을 처음부터 분석하지 않아도 된다.
또한 잘못된 패턴은 시작할 때 바로 실패한다.
예를 들면 이런 패턴은 거부된다.
/chat/{roomId}
chat/{roomId}/
chat//{roomId}
chat/{room-id}
chat/{roomId}/{roomId}
서버가 뜬 뒤에 이상한 동작을 하는 것보다, 애플리케이션 시작 시점에 빨리 실패하는 편이 낫다.
2. ChannelResolver
ChannelResolver는 실제 주소를 channel 정의와 연결해주는 역할을 한다.
예를 들어 서버에 이런 channel이 등록되어 있다고 하자.
realtime {
channel<Message>("chat/{roomId}") {}
}
클라이언트가 chat/general로 들어오면 resolver는 이렇게 해석한다.
pattern: chat/{roomId}
parameters: roomId = general
Kanal에서는 RealtimeApplication.resolve(path)로 이 기능을 사용할 수 있다.
val resolution = app.resolve("chat/general")
또 하나 신경 쓴 부분은 고정 경로를 우선하는 것이다.
예를 들어 다음 두 channel이 함께 있다고 하자.
realtime {
channel<Message>("chat/{roomId}") {}
channel<Message>("chat/system") {}
}
chat/system은 chat/{roomId}에도 들어맞을 수 있다. 이때 Kanal은 chat/system이라는 더 구체적인 channel을 먼저 선택한다.
이건 라우팅을 다뤄본 서버 개발자라면 자연스럽게 기대할 동작이다.
또한 이런 등록은 막는다.
channel<Message>("chat/{roomId}") {}
channel<Message>("chat/{id}") {}
두 패턴은 이름만 다를 뿐 실제 요청 경로 기준으로는 구분할 수 없다. 이런 애매한 등록은 시작할 때 실패하게 했다.
3. Membership과 Presence를 나누기
실시간 기능을 만들 때 자주 헷갈리는 개념이 있다.
바로 membership과 presence다.
둘 다 "누가 방에 있나?"와 관련 있어 보인다. 하지만 서버 내부에서는 역할이 다르다.
Membership은 runtime이 메시지를 보내기 위해 필요한 정보다.
예를 들면 이런 질문에 답한다.
- 이 채팅방에 연결된 session id들은 무엇인가?
- 이 session은 어떤 방들에 들어가 있는가?
- connection이 끊기면 어떤 방에서 제거해야 하는가?
Presence는 사용자에게 보여줄 상태다.
예를 들면 이런 정보다.
- 현재 온라인으로 표시할 사용자는 누구인가?
- display name은 무엇인가?
- device 정보는 무엇인가?
- 같은 사용자가 여러 탭으로 접속하면 어떻게 보여줄 것인가?
Kanal은 이 둘을 섞지 않으려고 한다.
그래서 kanal-runtime에 LocalMembershipIndex를 만들었다.
이 index는 두 방향으로 정보를 들고 있다.
ChannelAddress -> SessionId 목록
SessionId -> ChannelAddress 목록
첫 번째는 broadcast할 때 필요하다.
chat/general 방에 있는 session들을 찾아서 메시지를 보낸다.
두 번째는 연결이 끊겼을 때 필요하다.
s1 session이 들어가 있던 모든 방을 찾아서 정리한다.
이 구조를 미리 잡아두면 나중에 WebSocket runtime을 만들 때 메시지 전달과 cleanup이 훨씬 명확해진다.
4. 느린 클라이언트를 어떻게 다룰 것인가
실시간 서버에서 어려운 문제 중 하나는 느린 클라이언트다.
서버는 빠르게 메시지를 만들고 있는데, 어떤 클라이언트가 네트워크 문제나 브라우저 상태 때문에 천천히 받는다고 해보자. 그러면 그 클라이언트에게 보낼 메시지가 queue에 쌓인다.
이 queue가 무한히 커지면 언젠가 메모리 문제가 된다.
그래서 Kanal은 outbound queue를 기본적으로 크기가 정해진 queue로 보려고 한다. 이것을 BoundedOutboundQueue로 만들었다.
queue가 가득 찼을 때는 정책이 필요하다.
현재는 네 가지 정책을 둔다.
enum class BackpressurePolicy {
SUSPEND,
DROP_OLDEST,
DROP_LATEST,
DISCONNECT,
}
각각 의미는 이렇다.
SUSPEND
잠시 멈추는 방식이다. 다만 느린 클라이언트 때문에 서버 처리 전체가 영향을 받을 수 있으니 조심해야 한다.
DROP_OLDEST
가장 오래된 메시지를 버리고 새 메시지를 넣는다. 최신 상태가 중요한 기능에 어울린다. 예를 들어 실시간 대시보드 업데이트나 presence 상태처럼 "최신 값"이 더 중요한 경우다.
DROP_LATEST
새로 들어온 메시지를 버린다. 이미 queue에 있는 메시지를 보존하는 쪽이다. typing indicator처럼 중요도가 낮고 자주 발생하는 이벤트에 쓸 수 있다.
DISCONNECT
너무 느린 클라이언트는 연결을 끊는 방식이다. 조금 거칠지만, 어떤 서비스에서는 이게 가장 정직한 선택일 수 있다.
예제에서는 chat message와 typing signal에 서로 다른 정책을 적용했다.
channel<ChatMessage>("chat/{roomId}") {
backpressure(BackpressurePolicy.DROP_OLDEST)
}
channel<TypingSignal>("chat/{roomId}/typing") {
backpressure(BackpressurePolicy.DROP_LATEST)
}
chat message와 typing signal은 둘 다 실시간 이벤트지만 중요도가 다르다. Kanal은 이런 차이를 코드에 드러내고 싶다.
5. Metrics를 처음부터 생각하기
운영 중인 실시간 서버에서 문제가 생기면 이런 질문을 하게 된다.
- 현재 연결된 session은 몇 개인가?
- 방 참여 수는 얼마나 되는가?
- 메시지가 얼마나 들어오고 나가는가?
- queue가 계속 쌓이는가?
- 메시지가 drop되고 있는가?
- 어떤 정책 때문에 disconnect가 발생했는가?
- channel resolve가 느려지고 있는가?
- handler 실행 시간이 튀고 있는가?
이런 지표가 없으면 문제를 추측으로 봐야 한다.
그래서 kanal-runtime에는 RuntimeMetrics를 먼저 넣었다.
현재는 Micrometer나 Spring Actuator에 바로 연결된 상태는 아니다. 하지만 runtime 내부에서 어떤 값을 측정해야 하는지 먼저 정해두었다.
현재 담는 값은 이런 것들이다.
- active sessions
- active memberships
- inbound frames
- outbound frames
- dropped outbound messages
- disconnects by policy
- heartbeat timeouts
- channel resolution latency
- handler latency
- max outbound queue depth
- max broadcast fan-out
나중에 Spring Boot integration을 더 만들면 이 값들을 Micrometer metric으로 노출할 수 있다.
6. Chat Presence 예제
추상적인 설명만 있으면 감이 잘 오지 않는다. 그래서 kanal-samples:chat-presence 예제를 만들었다.
이 예제는 아직 실제 WebSocket 서버를 열지는 않는다. 대신 Spring Boot application 안에서 Kanal DSL로 channel을 정의한다.
핵심은 이런 흐름이다.
- 사용자가
chat/{roomId}channel에 들어온다. onJoin에서 presence를 기록한다.- 들어온 사용자에게 welcome message를 보낸다.
- 방 전체에 presence 변경 이벤트를 broadcast한다.
- message가 오면 같은 방에 broadcast한다.
- typing signal은 별도 channel에서 더 가벼운 이벤트로 다룬다.
예제 일부는 이렇다.
channel<ChatMessage>("chat/{roomId}") {
description("Room chat messages with presence-aware join and leave hooks")
backpressure(BackpressurePolicy.DROP_OLDEST)
onJoin {
val roomId = address.parameters.getValue("roomId")
val memberKey = session.userId ?: session.id
presence.track(
key = memberKey,
metadata = session.presenceMetadata(roomId),
)
send(
ChatSystemNotice(
roomId = roomId,
message = "Welcome to $roomId.",
),
)
}
onMessage { message ->
val roomId = address.parameters.getValue("roomId")
broadcast(
message.copy(
roomId = roomId,
authorId = message.authorId.ifBlank { session.userId ?: session.id },
),
)
}
}
아직 runtime이 없으니 이 코드는 실제 client와 통신하지는 않는다. 하지만 앞으로 runtime이 실행해야 할 application code의 기준점이 된다.
지금 하지 않기로 한 것
Kanal이 실시간 프레임워크라고 해서 처음부터 모든 것을 하려는 것은 아니다.
첫 단계에서는 하지 않기로 한 것들이 있다.
- 메시지 영구 저장 보장
- 전체 클러스터에서 강한 일관성 보장
- 연결 자동 이동
- actor runtime 만들기
- Kafka나 Redis 같은 message broker 대체하기
이런 것들은 언젠가 필요할 수 있다. 하지만 첫 번째 목표는 아니다.
지금 가장 중요한 목표는 작지만 제대로 동작하는 single-node runtime이다.
먼저 한 서버 안에서 다음이 잘 되어야 한다.
- WebSocket 연결 받기
- channel path 해석하기
- join, leave, message 처리하기
- presence 관리하기
- bounded queue로 느린 client 다루기
- metrics로 현재 상태 설명하기
이게 잘 되어야 그 다음에 cluster 이야기를 할 수 있다.
다음 단계
이제 기초 모델은 어느 정도 생겼다.
다음으로는 실제 WebSocket runtime을 만들어야 한다.
필요한 일은 대략 이런 순서다.
- WebSocket endpoint 만들기
- client와 주고받을 JSON frame 형식 정하기
- join, leave, message frame 처리하기
RealtimeApplication.resolve(path)로 channel 찾기ChannelContext를 만들어 handler 실행하기- session별 outbound queue 붙이기
- heartbeat 처리하기
- graceful shutdown 처리하기
- metrics를 Spring Boot에서 볼 수 있게 연결하기
이 단계가 끝나면 README에 있는 DSL 예제가 실제 client와 통신할 수 있게 된다.
그때부터 Kanal은 "아이디어가 담긴 skeleton"에서 "실제로 써볼 수 있는 local realtime framework"로 넘어간다.
마무리
Kanal은 아직 초기 프로젝트다.
하지만 만들고 싶은 방향은 분명하다.
JVM 서버 개발자가 실시간 기능을 만들 때 매번 WebSocket handler 주변에 session map, room map, presence map, broadcast loop를 직접 조립하지 않아도 되는 것. 실시간 기능을 socket 코드가 아니라 application model로 다룰 수 있게 하는 것.
그 시작점으로 channel, presence, membership, backpressure, metrics를 먼저 잡았다.
좋은 프레임워크는 보통 멋진 transport 코드보다 좋은 모델에서 시작한다고 생각한다. Kanal은 지금 그 모델을 다지는 중이다.
https://github.com/not-a-platform-bug/kanal
GitHub - not-a-platform-bug/kanal: Kanal is a Jvm realtime framework (alike Phoenix Channels's JVM/Kotlin Version)
Kanal is a Jvm realtime framework (alike Phoenix Channels's JVM/Kotlin Version) - not-a-platform-bug/kanal
github.com