ChatBird (2) 브라우저 두 개가 진짜로 대화하게 만들기
지난 글에서 소켓이 뭔지 짚고, 브라우저에서 열 채팅 화면까지 만들어뒀다. 연결 버튼을 누르면 빨간 불이 켜지는 상태였다. 서버에 WebSocket 경로가 없으니 당연한 결과였고, 이번 글에서 그걸 만든다.
목표는 하나다. 브라우저 탭 두 개를 띄워서, 한쪽에서 보낸 글이 다른 쪽에 뜨게 하는 것.
패키지 이름과 모듈 이름이 다르다
WebSocket은 Hummingbird 본체에 들어있지 않고 별도 저장소로 나와 있다. Package.swift에 추가한다.
1
2
3
4
5
6
// Package.swift
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0"),
.package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.7.0"),
// 생략
],
여기서 한 번 걸렸다. 저장소 이름은 hummingbird-websocket인데, 코드에서 import 할 때 쓰는 이름은 HummingbirdWebSocket이다. 둘이 달라서 타겟에 적을 때 이렇게 나눠 써야 한다.
1
2
// Package.swift
.product(name: "HummingbirdWebSocket", package: "hummingbird-websocket"),
앞쪽이 코드에서 부를 이름이고, 뒤쪽이 내려받을 저장소 이름이다.
버전도 헷갈렸는데, 본체가 2.26.0인데 WebSocket은 2.7.0이다. 번호가 안 맞아서 잘못 적었나 싶었지만 별개 저장소라 원래 따로 올라간다.
컴파일이 막힌 자리
붙이는 방법을 몰라서 문서를 찾는 대신 받아온 패키지 소스를 직접 열어봤다. .build/checkouts/hummingbird-websocket/Sources 안에 WebSocketRouter.swift가 있었고, 거기서 쓸 함수를 찾았다.
1
2
3
4
public func ws(
_ path: RouterPath = "",
onUpgrade handler: @escaping WebSocketDataHandler<WebSocketRouterContext<Context>>
) -> Self where Context: WebSocketRequestContext
router.ws("/chat") { ... } 형태로 쓰면 되겠다 싶었는데, 마지막 줄에 조건이 붙어 있다.
1
where Context: WebSocketRequestContext
아무 라우터에서나 쓸 수 있는 게 아니라는 뜻이다. hb init이 만들어준 기본값은 이랬다.
1
2
// App+build.swift
typealias AppRequestContext = BasicRequestContext
이 타입은 저 조건을 만족하지 않는다. 그래서 WebSocket을 받을 수 있는 쪽으로 바꿔줬다.
1
2
// App+build.swift
typealias AppRequestContext = BasicWebSocketRequestContext
한 줄이지만 이걸 안 바꾸면 ws를 부르는 순간 컴파일이 멈춘다. 조건을 미리 읽어둔 덕에 에러 메시지로 헤매지는 않았다.
라우터를 하나 더 만든다
WebSocket 경로를 기존 라우터에 얹는 게 아니었다. 라우터를 따로 만들어서 Application에 넘긴다.
1
2
3
4
5
6
7
// App+build.swift
let app = Application(
router: router,
server: .http1WebSocketUpgrade(webSocketRouter: buildWebSocketRouter(chatRoom: chatRoom, logger: logger)),
configuration: ApplicationConfiguration(reader: reader.scoped(to: "http")),
logger: logger
)
왜 따로 두는지는 패키지 주석에 적혀 있었다. 어차피 WebSocket으로 올라갈 일이 없는 주소까지 매번 확인할 필요가 없어서라고 한다. /나 이미지 파일 요청에까지 “이거 WebSocket인가?”를 물을 이유가 없으니 납득이 갔다.
라우터 자체는 짧다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// App+build.swift
func buildWebSocketRouter(chatRoom: ChatRoom, logger: Logger) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.ws("/chat") { inbound, outbound, _ in
let id = await chatRoom.join(outbound)
logger.info("들어옴", metadata: ["id": .stringConvertible(id)])
// 연결이 살아 있는 동안 여기 머물면서 계속 받는다.
for try await message in inbound.messages(maxSize: 1 << 16) {
guard case .text(let text) = message else { continue }
await chatRoom.broadcast(text, logger: logger)
}
await chatRoom.leave(id)
logger.info("나감", metadata: ["id": .stringConvertible(id)])
}
return router
}
REST 핸들러와 모양이 아주 다르다. REST는 요청 하나를 받아서 응답을 돌려주고 함수가 끝난다. 그런데 이 함수는 for try await 안에 머물러 있다. 연결이 끊어질 때까지 이 자리를 떠나지 않는다. 지난 글에서 창구를 열어둔다고 했던 게 코드에서는 이렇게 생겼다.
그래서 마지막 두 줄이 중요하다. 반복문을 빠져나왔다는 건 연결이 끊어졌다는 뜻이라, 그때 명단에서 빼준다.
프레임과 메시지는 다르다
inbound를 그냥 돌리면 될 줄 알았는데, 그렇게 하면 프레임이 하나씩 나온다. 긴 글은 여러 조각으로 쪼개져서 오기 때문에 그대로 쓰면 글이 잘린다.
1
2
// App+build.swift
for try await message in inbound.messages(maxSize: 1 << 16) {
messages(maxSize:)를 거치면 쪼개진 조각을 다시 이어붙여서 온전한 글 단위로 준다. maxSize는 한 개가 이만큼보다 크면 받지 않겠다는 상한선이고, 1 << 16은 65536, 약 64KB다.
짧은 채팅만 주고받는 동안에는 차이가 안 보인다. 그래서 이걸 모르고 넘어갔다면 나중에 긴 글에서 이유 모를 문제가 생겼을 것이다.
명단을 들고 있는 곳
브로드캐스트를 하려면 지금 누가 접속해 있는지 알아야 한다. 그걸 들고 있는 게 ChatRoom이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ChatRoom.swift
actor ChatRoom {
private var outbounds: [Int: WebSocketOutboundWriter] = [:]
private var nextID = 0
func join(_ outbound: WebSocketOutboundWriter) -> Int {
let id = nextID
nextID += 1
outbounds[id] = outbound
return id
}
func leave(_ id: Int) {
outbounds[id] = nil
}
/// 명단에 있는 모두에게 같은 글을 보낸다. 보낸 사람도 포함이다.
func broadcast(_ text: String, logger: Logger) async {
for (id, outbound) in outbounds {
do {
try await outbound.write(.text(text))
} catch {
// 이미 끊어진 연결이면 명단에서 빼준다.
outbounds[id] = nil
}
}
}
}
actor로 만든 이유가 있다. 여러 사람이 동시에 글을 보내면 명단을 건드리는 일이 겹친다. 한쪽이 명단을 훑으며 글을 보내는 도중에 다른 쪽이 명단에서 빠지면 곤란하다. actor로 두면 한 번에 하나씩만 명단을 만지게 된다.
broadcast에서 보낸 사람을 빼지 않은 것도 일부러다. 빼려면 “이 연결이 방금 보낸 그 사람인가”를 매번 가려야 하는데, 그냥 전부에게 보내면 그 판단이 통째로 사라진다. 받는 쪽에서도 내 글이든 남의 글이든 똑같이 처리하면 되니 화면 코드도 단순해진다.
브라우저 주소창으로 /chat 에 들어가면 404
만들고 나서 잘 붙었는지 보려고 /chat을 그냥 열어봤다.
1
2
curl -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/chat
404
경로를 잘못 만들었나 싶었는데 아니었다. WebSocket 라우터는 업그레이드를 요청하는 접속에만 반응한다. 평범한 GET은 일반 라우터로 가고, 거기엔 /chat이 없으니 없는 주소가 된다.
서버 로그를 보면 구분이 된다.
1
hb.request.method=GET hb.request.path=/chat [Hummingbird] Request
요청이 들어온 기록은 있는데, 내가 넣어둔 들어옴 로그는 찍히지 않았다. 요청이 서버까지는 왔지만 WebSocket 핸들러로는 넘어가지 않았다는 뜻이다. 404가 정상이라는 걸 이걸로 확인했다.
탭 두 개로 확인하기
브라우저 탭을 두 개 띄우고 각각 해롤드와 곰돌이로 접속했다. 한쪽에서 글을 보내니 양쪽 화면에 다 떴다.
1
2
3
4
5
6
7
8
9
[해롤드 탭]
[내말] 곰돌이님 들리세요?
[남의말] 곰돌이
네 잘 들려요
[곰돌이 탭]
[남의말] 해롤드
곰돌이님 들리세요?
[내말] 네 잘 들려요
서버 로그도 예상대로였다.
1
2
3
id=0 [ChatBirdServer] 들어옴
id=1 [ChatBirdServer] 들어옴
id=1 [ChatBirdServer] 나감
탭을 닫으니 나감이 찍혔다. 브라우저가 창을 닫을 때 연결을 정리해주고, 서버 쪽에서는 for try await가 끝나면서 명단에서 빠진 것이다.
여기서 서버가 이름에 대해 아무것도 모른다는 점을 짚어둘 만하다. 웹 페이지가 해롤드: 안녕하세요 형태로 보내면 서버는 그 글자를 그대로 다시 뿌리기만 한다. 누가 보냈는지 알아내는 일은 받는 쪽에서 콜론 앞을 잘라 처리한다. 서버 코드에 이름이라는 개념 자체가 없다.
이 구조는 나중에 바꿔야 할 수도 있다. 입장 안내나 접속자 수 같은 걸 붙이려면 서버도 누가 누군지 알아야 하기 때문이다. 지금은 브로드캐스트가 되는지만 보는 게 목적이라 여기까지만 했다.
다음은 앱 차례
서버가 도니까 이제 붙일 곳이 생겼다. 다음 글에서는 지난번에 껍데기만 만들어둔 ChatSocketClient를 채운다.
지난 글에서 만든 대응표를 다시 보면, 브라우저의 onmessage는 한 번 등록하면 알아서 계속 불렸다. 그런데 Swift의 receive는 한 번에 하나만 준다. 서버 쪽 for try await처럼 알아서 돌아주지 않으니, 그 반복을 직접 만들어야 한다. 거기가 이번 프로젝트에서 제일 헷갈릴 자리 같다.