← 홈으로

Dev · 2026/10

동시 접속 6명인데 하루 접속이 1만 번

작당모의 — 친구들이 내 화면 아래에

작당모의라는 데스크톱 앱을 만들었다. 친구들끼리 초대 코드로 방에 모이면, 서로의 캐릭터가 각자 바탕화면 아래쪽을 걸어 다닌다.
일하다가 화면 아래를 보면 친구 캐릭터가 지나가고 있는 식이다.

앱은 Electron으로 맥과 윈도우용을 만들었고, 서버는 네이버 클라우드에 올린 Node.js 웹소켓 릴레이 하나다. 회원가입은 없다.
앱을 처음 켜면 6자리 코드가 생기고, 그 코드가 곧 그 사람이다.

열흘 동안 만들고 배포하면서 이슈가 열다섯 개쯤 나왔다. 대부분은 배포 쪽이었다.

  • 맥 앱이 "확인되지 않은 개발자"로 막혀서 서명과 공증을 붙였다.
  • 방화벽에 막힌 SSH 배포를 서버가 스스로 받아 가는 pull 배포로 바꿨다.
  • 스포트라이트에서 "작당모의"로 검색이 안 돼서 한글 이름을 따로 넣었다.

이런 건 한 번 막히면 문서를 찾아서 풀린다. 이 글에서 쓰려는 건 그게 아니라, 마지막 날 통계 숫자 하나에서 시작된 버그다.


동시 접속 6명, 하루 접속 1만 번

운영자용 통계 페이지를 보다가 이상한 숫자가 눈에 걸렸다.

작당모의 운영자 통계 — 10월 27일 접속 11,094

최대 동시 접속은 6명인데 하루 접속이 1만 번을 넘었다. 여기서 접속은 앱이 웹소켓으로 붙어서 hello로 로그인한 횟수다.
작당모의는 컴퓨터를 켜면 자동으로 실행되고 와이파이가 끊기면 다시 붙으니까 원래도 사람 수보다는 많이 찍힌다. 그래도 여섯 명이 하루에 1만 번은 말이 안 된다.

통계 API를 1분 간격으로 두 번 찍어 봤다.

start      10606  {'online': 3, 'rooms': 4}
after 60s  10660  {'online': 3, 'rooms': 4}

1분에 54번. 거의 1초에 한 번씩 누군가 접속하고 있었다.

서버가 죽었다 살아나는 걸까

처음 의심한 건 서버였다. 서버가 계속 재시작되면 모든 앱이 한꺼번에 다시 붙으니 접속 수가 튄다.
그런데 배포 버전(/version)은 그대로였고, 동시 접속 online: 3도 1분 내내 유지됐다. 서버가 켜질 때마다 오는 디스코드 알림도 반복되지 않았다.

서버는 멀쩡했다. 앱 하나가 1초마다 끊겼다 붙기를 반복하는 모양이었다.

다음으로 내 맥을 의심했다. 개발하면서 테스트용 앱을 여러 개 띄운 적이 있어서다.

pgrep -fl "Jakdang|Electron"
# (아무것도 없음)

하나도 안 켜져 있었다. 범인은 다른 사용자였다.
마침 그날 처음으로 윈도우 다운로드가 10건으로 크게 늘었다.


멀쩡한 규칙 세 개가 만든 루프

코드를 다시 읽어 보니 각자는 멀쩡한 규칙 세 개가 맞물려 있었다.

서버는 같은 코드로 새 접속이 오면 이전 접속을 끊는다

앱을 껐다 켰을 때 예전 연결이 유령처럼 남지 않게 하려고 넣은 규칙이다. 같은 코드로 새 연결이 오면 "앱을 다시 켰구나" 하고 이전 연결을 닫는다.

// server/relay.js (수정 전)
const previous = online.get(msg.code);
if (previous && previous !== ws) previous.close(4000, 'replaced');

앱은 끊기면 다시 붙고, 대기 시간을 연결되는 순간 초기화한다

// src/main/transport.js (수정 전)
socket.on('open', () => {
  retryMs = MIN_RETRY_MS; // ← 1초로 초기화
  socket.send(JSON.stringify({ type: 'hello', ...getHello() }));
});
socket.on('close', () => {
  onStatus({ connected: false });
  scheduleRetry(); // ← 끊긴 이유와 상관없이 무조건 재접속
});

재접속 대기 시간은 1초에서 시작해서 실패할 때마다 두 배로 늘어나야 한다. 지수 백오프다.
그런데 이 코드는 TCP 연결만 열려도 대기 시간을 1초로 되돌렸다. 연결은 매번 성공하고 곧바로 쫓겨나니, 대기 시간은 한 번도 늘어나지 못했다.

윈도우에서는 앱이 두 개 뜰 수 있다

맥은 같은 앱을 두 번 실행하면 운영체제가 기존 앱을 앞으로 가져올 뿐 새로 띄우지 않는다. 윈도우는 다르다.
바로가기를 두 번 누르거나, 설치 직후 자동 실행과 로그인 자동 실행이 겹치면 같은 앱이 두 개 뜬다. Electron에서는 app.requestSingleInstanceLock()을 직접 불러야 이걸 막을 수 있는데, 작당모의에는 이 코드가 없었다.

셋이 만나면

두 앱은 같은 프로필 폴더를 읽으니 같은 코드를 쓴다.

같은 코드를 쓰는 앱 두 개가 서버에 번갈아 붙으며 서로를 쫓아낸다

A가 붙고, B가 붙으면서 A를 끊는다. A는 1초 뒤에 다시 붙으면서 B를 끊는다.
B도 1초 뒤에 다시 붙는다. 대기 시간이 늘어나지 않으니 이게 1초 간격으로 하루 종일 반복된다.
하루 86,400초 중 반나절이면 1만 번이 나온다.

규칙 하나하나는 다 말이 됐다. 이전 연결을 끊는 것도, 끊기면 다시 붙는 것도 각자는 합리적이다.
문제는 상대가 나와 같은 규칙을 가진 복사본일 때를 아무도 생각하지 않았다는 거였다.


서버 먼저, 그다음 앱

데스크톱 앱은 고쳐도 사용자가 업데이트하기 전까지는 예전 코드가 계속 돈다. 그래서 루프는 서버에서 먼저 끊고, 근본 수정은 앱 업데이트로 나눴다.

서버: 끊지 말고 붙잡아 둔다

예전 앱은 연결이 끊겼을 때만 재접속한다. 거꾸로 말하면, 끊지 않고 대답만 안 하면 그 앱은 그냥 기다린다.

// server/relay.js
const SWAP_WINDOW_MS = 30_000;
const SWAPS_BEFORE_HOLD = 3;
const HOLD_MS = 5 * 60_000;
 
const previous = online.get(msg.code);
if (previous && previous !== ws) {
  const recent = (swaps.get(msg.code) || []).filter((at) => now() - at < SWAP_WINDOW_MS);
  if (recent.length >= SWAPS_BEFORE_HOLD) {
    swaps.set(msg.code, recent);
    // 끊지도, 환영하지도 않는다. 5분 뒤에 조용히 닫는다.
    ws.held = setTimeout(() => ws.close(4002, 'duplicate'), HOLD_MS);
    return;
  }
  swaps.set(msg.code, [...recent, now()]);
  previous.close(4000, 'replaced');
}

30초 안에 같은 코드끼리 쫓아내기가 세 번을 넘으면 새로 온 쪽을 붙잡아 둔다. 붙잡힌 소켓이 보내는 메시지에는 아무 대답도 하지 않는다.
에러를 보내면 앱 화면에 이상한 안내가 뜰 수 있어서다. 먼저 접속해 있던 쪽은 그대로 정상 이용한다.
5분이 지나면 닫아서 서버 자원을 정리하고, 그래도 두 개가 켜져 있으면 다시 붙잡힌다. 1초에 한 번이던 접속이 5분에 한 번으로 줄어든다.

서버만 바꾸면 되니까 이미 깔린 예전 버전 앱에도 바로 효과가 있었다.

앱: 애초에 두 개가 안 뜨게

// src/main/main.js
const isFirstCopy = app.requestSingleInstanceLock();
if (!isFirstCopy) app.quit();
 
app.on('second-instance', () => {
  if (state.profile) openControl(); // 두 번째 실행은 첫 번째 앱의 설정 창을 열어 준다
});

두 번째로 실행된 앱은 바로 꺼지고, 사용자는 첫 번째 앱의 창이 열리는 걸 본다. 아이콘을 눌렀더니 창이 떴다, 정도로 자연스럽다.

앱: 밀려났으면 다시 덤비지 않는다

// src/main/transport.js
const REPLACED_CODE = 4000;
 
socket.on('message', (raw) => {
  const msg = parseServerMessage(String(raw));
  if (msg.type === 'welcome') {
    retryMs = MIN_RETRY_MS; // 진짜 로그인에 성공했을 때만 초기화
    onStatus({ connected: true });
  }
});
socket.on('close', (code) => {
  if (code === REPLACED_CODE) {
    onStatus({ connected: false, replaced: true }); // 재접속하지 않고 안내만
    return;
  }
  onStatus({ connected: false });
  scheduleRetry();
});

서버가 "다른 곳에서 같은 코드로 들어왔다"는 뜻의 4000으로 끊으면, 앱은 다시 붙지 않고 안내만 띄운다.

다른 곳에서 같은 작당모의가 켜져서 여기는 연결을 멈췄어요. 이 기기에서 쓰려면 앱을 다시 켜 주세요.

대기 시간도 TCP 연결이 아니라 서버가 로그인을 받아 줬을 때, 그러니까 welcome을 받았을 때만 초기화하게 바꿨다. 이제는 연결은 되는데 바로 끊기는 상황이 와도 대기 시간이 1초, 2초, 4초로 늘어난다.


고쳐졌는지 확인하기

새 테스트 두 개는 고치기 전 코드에서 먼저 실패하는 걸 봤다. 서버 쪽은 같은 코드로 세 번 쫓아낸 뒤 네 번째는 붙잡히고, 먼저 있던 쪽은 끊기지 않는지를 본다.
앱 쪽은 다른 복사본에 밀려난 앱이 재시도 간격보다 오래 기다려도 다시 붙지 않는지를 본다. 고친 뒤 전체 테스트 87개가 통과했다.

단위 테스트만으로는 불안해서 같은 프로필로 실제 앱을 두 번 띄워 봤다.

second copy: exited (code 0) after 226ms
first copy: still running
PASS

두 번째 앱은 0.2초 만에 꺼지고 첫 번째는 그대로 남았다.

마지막은 운영 서버 숫자다. 서버 수정을 배포하고 2분 동안 접속 수를 다시 쟀다.

1분당 접속 증가
수정 전54번
서버 수정 후0번 (2분 동안 11,020 그대로)

앱 수정은 v0.1.6으로 내보냈다. v0.1.3 이후 버전을 쓰는 사람은 자동 업데이트로 받는다.


남은 것

맥에서만 개발하니 이 버그는 한 번도 재현되지 않았다. 맥은 운영체제가 중복 실행을 막아 주니까.
Electron 앱이라면 requestSingleInstanceLock()은 처음부터 넣어야 했다.

지수 백오프에서 중요한 건 "성공"을 어디서 세느냐였다. TCP 연결 성공을 성공으로 치면, 연결은 되는데 바로 끊기는 상황에서 백오프는 아무 일도 하지 않는다.
로그인 완료처럼 애플리케이션이 정말로 받아들인 순간을 기준으로 삼아야 했다.

끊을 때 종료 코드에 의미를 실어 보내는 것도 처음엔 대수롭지 않게 생각했다. 4000 replaced처럼 다시 시도해도 소용없는 끊김을 따로 구분해 두니, 클라이언트가 그 끊김에만 다르게 반응할 수 있었다.

그리고 이 버그는 숫자를 남겨 두지 않았으면 못 찾았다. 이름도 메시지도 없이 날짜별 숫자만 세는 통계였는데, 최대 동시 접속과 접속 횟수를 나란히 놓고 보지 않았다면 "사용자가 좀 늘었네" 하고 넘어갔을 것이다.


작당모의는 jakdang.naegoworks.com에서 맥·윈도우용으로 받을 수 있다. 친구 한 명만 있어도 바탕화면이 조금 덜 심심해진다.