필수 필드 하나를 추가했더니, 이미 열려 있던 탭들의 요청이 사라졌다
랜챗 신고 기능을 고치면서 신고 메시지에 필수 필드 하나를 추가했다. 신고 대상을 명시하는 partnerId였다. 지금까지는 서버가 “현재 매칭된 상대”를 암묵적으로 신고 대상으로 삼았는데, 그걸 클라이언트가 명시적으로 지정하도록 바꾼 것이다. 서버는 그 필드가 문자열이 아니면 조기 리턴하도록 검증을 걸었다. 변경 자체는 필드 하나 추가와 서버 쪽 검증 한 줄이라 코드만 보면 사소했다. 리뷰를 요청할 때도 “신고 대상을 명시적으로 지정하도록 바꿨습니다” 정도로만 설명했지, 배포 절차까지 언급할 생각은 하지 못했다.
리뷰에서 배포 창을 짚었다. 이 프로젝트는 웹과 게임서버를 한 명령으로 함께 재기동한다. 그래서 두 프로세스가 서로 다른 버전으로 잠깐씩 엇갈리는 시차는 초 단위밖에 안 된다. 처음엔 그 초 단위 시차를 걱정했는데, 실제 위험은 거기가 아니었다. 프로세스끼리는 재기동 순서를 코드로 통제할 수 있지만, 사용자의 브라우저 탭은 그렇지 않다.
이미 열려 있던 탭들
랜챗은 특성상 탭을 오래 열어두는 서비스다. 상대를 기다리거나 대화를 이어가는 동안 사용자는 새로고침을 할 이유가 없다. 그런데 배포는 서버 쪽 코드만 바꾸는 게 아니라 웹 번들도 새로 나간다. 배포가 끝나도 이미 열려 있던 그 탭들은 새로고침하기 전까지 계속 구버전 번들을 실행한다. 구버전 번들은 애초에 partnerId라는 필드 자체를 모르니 당연히 보내지 않는다.
그러면 새로 배포된 서버는 그 메시지를 받아 검증에서 걸러내고 조기 리턴한다. 문제는 그 지점에 로그가 없었다는 것이다. 거부 로그는 검증을 통과한 다음, 그러니까 더 아래쪽 로직에 있었다. 필드 타입이 안 맞아 조기 리턴되는 경로는 그 로그보다도 앞에 있어서, 신고가 서버에 도달했다는 흔적조차 남기지 못하고 사라졌다.
사용자 입장에서는 신고 버튼을 누르면 모달이 닫히는 게 전부다. 접수됐는지 실패했는지 알려주는 확인 UI가 따로 없으니, 모달이 닫혔다는 사실만으로 신고가 접수됐다고 믿게 된다. 실제로는 서버가 그 요청을 조용히 버린 뒤였다.
정리하면, 신고가 소리 없이 사라지는 기존 문제를 고치려던 작업이 신고가 소리 없이 사라지는 새로운 창을 만들고 있었던 셈이다. 게다가 하필 신고라는, 유실되면 안 되는 기능에서 그 일이 벌어질 뻔했다. 버그를 신고하는 기능이 아니라 사람을 신고하는 기능이라, 접수가 안 됐는데 사용자만 접수됐다고 믿는 상황은 다른 기능보다 훨씬 무겁게 다가왔다.
타입 검사가 못 잡은 이유
필수 필드를 안 보내면 TypeScript가 컴파일 단계에서 잡아주지 않겠냐고 생각했다. 그런데 이 프로젝트의 웹 빌드 설정에는 typescript: { ignoreBuildErrors: true }가 들어 있었다. 타입 에러가 있어도 빌드 자체는 그냥 통과하는 설정이다.
다행히 최종 상태의 웹 코드는 항상 그 필드를 보내도록 되어 있었다. 신고를 보내는 호출부가 딱 하나뿐이라 직접 확인했고, 그 호출부는 partnerId를 빠짐없이 채워 넘기고 있었다. 하지만 그 사실과는 별개로, “타입이 필수로 선언돼 있으니 실수로 빠뜨리면 빌드가 막아줄 것”이라는 기대 자체가 이 프로젝트에서는 성립하지 않는다는 게 중요했다. 리뷰에서 이 설정을 짚어주지 않았다면 그 안전망을 실제로 믿고 넘어갔을 것이다. 타입이 필수라고 선언해두는 것과, 그 선언을 빌드가 실제로 강제하는 것은 서로 다른 층위의 이야기라는 걸 이번에 다시 확인한 셈이다.
수정
서버 쪽에 하위호환 폴백을 넣기로 했다. partnerId가 없으면 예전 방식대로 서버가 기억하는 현재 페어를 신고 대상으로 삼는다.
이 폴백이 안전하다고 판단한 이유는 대상 범위가 좁기 때문이다. 폴백이 참조하는 건 클라이언트가 주장하는 임의의 값이 아니라 서버가 실제로 관리하는 현재 페어 상태다. 그러니 조작된 클라이언트가 partnerId를 일부러 빼더라도 얻을 수 있는 결과는 “지금 매칭된 상대를 신고”뿐이고, 이건 필드를 정상적으로 채워 보냈어도 어차피 가능했던 결과와 동일하다. 임의의 다른 사용자나 과거에 스쳐 지나간 상대를 지목할 방법은 이 폴백으로는 열리지 않는다.
그리고 이 폴백을 영구히 남겨둘 생각은 없어서, 제거해야 할 조건을 코드에 TODO로 남겼다. 옛 번들을 물고 있는 탭들이 충분히 정리되고 나면 걷어내야 할 임시 코드라는 뜻이다. 이걸 적어두지 않으면 “이 필드는 없어도 통과한다”는 느슨한 계약이 아무도 의식하지 못한 채 영구적인 사양으로 굳어버린다.
폴백을 서버에서 처리하기로 한 것도 같은 맥락이다. 클라이언트 쪽에서 옛 번들 사용자를 걸러내거나 강제로 새로고침을 유도하는 방법도 있었겠지만, 그러려면 옛 번들 자체를 수정해야 하는데 이미 배포된 번들은 손댈 수 없다. 통제 가능한 지점은 서버뿐이었다.
문서에도 남긴 것
이 프로젝트의 운영 문서에는 이미 배포와 관련된 경고가 하나 있었다. 등록되지 않은 메시지 타입을 보내면 Colyseus가 프로덕션 모드에서 그 연결을 강제로 끊어버린다는 내용이다. 이번 일은 그것과는 다른 실패 모드였다. 연결은 끊기지 않는다. 등록된 메시지 타입으로, 형식도 맞게 도착하지만, 그 안의 특정 필드 하나가 없다는 이유로 메시지 자체가 조용히 폐기될 뿐이다. 증상이 정반대다 보니 기존 경고를 읽었어도 이번 케이스는 예방되지 않았을 것이다.
그래서 항목을 하나 더 추가했다. 기존에 이미 쓰이고 있는 이벤트에 필수 필드를 새로 추가할 때는, 필드가 없는 옛 클라이언트를 위한 폴백을 서버에 두거나, 혹은 그 유실을 감수하겠다는 판단을 코드나 문서에 명시적으로 남기라는 내용이다. 아무 언급 없이 넘어가는 게 가장 나쁜 선택지라고 봤다.
두 경고를 나란히 놓고 보니 공통점이 하나 보였다. 두 경우 모두 겉으로 보이는 증상만으로는 원인을 짐작하기 어렵다는 점이다. 연결이 끊기면 적어도 사용자가 이상을 느끼기라도 하는데, 메시지가 조용히 폐기되는 쪽은 그마저도 없다. 문서에 두 항목을 나란히 적어둔 이유도, 다음에 필드를 바꾸는 사람이 두 실패 모드를 한 번에 떠올릴 수 있게 하기 위해서였다.
교훈
- 배포 창은 서버 프로세스들 사이의 짧은 틈에만 있는 게 아니다. 이미 사용자 브라우저에 로드된 클라이언트 번들이 훨씬 더 오래 그 창을 붙잡고 있다. SPA거나 탭을 오래 열어두는 서비스라면 그 창이 몇 시간, 심하면 그 이상으로 벌어질 수 있다.
- 기존 이벤트에 필수 필드를 추가하는 건 그 자체로 하위호환을 깨는 변경이다. 새 엔드포인트를 새로 만드는 변경보다 눈에 덜 띄기 때문에 오히려 더 위험하다.
- 조용히 실패하는 경로가 가장 나쁘다. 로그도 없고 사용자에게 보이는 실패 표시도 없으면, 그 사이에 몇 건이 유실됐는지조차 나중에 셀 방법이 없다.
- 임시로 넣은 하위호환 코드에는 반드시 제거 조건을 함께 적어야 한다. 적어두지 않으면 그 코드는 영원히 남고, 그 순간 그건 임시가 아니라 사실상의 계약이 된다.
- 이미 배포되어 사용자 손에 들어간 클라이언트는 다시 손댈 수 없다. 그래서 하위호환을 지켜야 할 책임은 결국 서버처럼 아직 통제 가능한 지점에 남는다.