포스트

MyKVM (8) 끊겨도 혼자 다시 붙는 연결과 맥 찾기

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

Synergy를 그만 쓴 이유가 “연결이 불안정한 느낌”이었다. 그래서 MyKVM은 연결이 끊기는 것 자체를 막기보다, 끊겨도 사람이 손대지 않고 다시 붙는 걸 목표로 했다. 맥이 잠들었다 깨거나, 윈도우가 재부팅하거나, 공유기가 맥에 새 주소를 줘도 알아서 돌아와야 한다. 이번 글은 맥의 서버, 윈도우의 다시 붙기, 그리고 그걸 맥 안에서 시험한 방법이다.


맥은 기다리고 윈도우가 찾아오는 구조

맥은 TCP 52810번 문을 열어 두고 기다린다(서버). 윈도우가 그 문으로 찾아온다(클라이언트). 끊기면 다시 찾아오는 쪽도 늘 윈도우다. 역할을 이렇게 나눈 건 1편의 원칙과 같다. 판단은 맥이 하고, 윈도우는 맥이 어디 있든 계속 찾아와 붙어 있기만 하면 된다.

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
// KVMServer.swift (KVMCore)
private func startListener() throws {
    let tcp = NWProtocolTCP.Options()
    tcp.noDelay = true
    let parameters = NWParameters(tls: nil, tcp: tcp)
    parameters.allowLocalEndpointReuse = true
    let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!)
    listener.newConnectionHandler = { [weak self] nw in self?.accept(nw) }
    listener.stateUpdateHandler = { [weak self] state in
        guard let self else { return }
        switch state {
        case .failed, .cancelled:
            // 맥이 잠들었다 깨는 등으로 리스너가 죽으면 1초 뒤 다시 연다
            guard !self.stopped else { return }
            self.listener = nil
            self.queue.asyncAfter(deadline: .now() + 1) { [weak self] in
                guard let self, !self.stopped, self.listener == nil else { return }
                try? self.startListener()
            }
        default: break
        }
    }
    listener.start(queue: queue)
    self.listener = listener
}

NWListener로 접속 기다리기

이름하는 일
NWListenerNetwork 프레임워크에서 정해진 포트로 들어오는 접속을 받는 객체
NWProtocolTCP.OptionsTCP 연결의 세부 설정 묶음
noDelay작은 메시지를 모으지 않고 바로 보낸다(아래 설명)
allowLocalEndpointReuse방금 닫은 포트를 바로 다시 열 수 있게 한다. 다시 열기를 1초 뒤에 할 때 필요하다
newConnectionHandler윈도우가 접속할 때마다 불린다. 접속 하나를 NWConnection으로 받는다
stateUpdateHandler리스너 상태가 바뀔 때 불린다. 실패나 취소면 1초 뒤 다시 연다
start(queue:)리스너를 켜고, 위의 처리들을 이 큐에서 차례로 부른다

서버의 모든 상태(연결 목록, 지금 쓰는 연결, 타이머)는 queue 하나에서만 만진다. 접속 처리, 메시지 받기, 0.25초마다 도는 하트비트 확인이 모두 같은 줄에 서서 차례로 실행되니, 둘이 동시에 연결 목록을 고치는 일이 없다.


네이글 알고리즘이란?

TCP가 작은 데이터를 바로 보내지 않고 잠깐 모았다가 한 번에 보내는 기본 동작이다. 택배를 한 개씩 보내지 않고 상자가 찰 때까지 기다렸다 보내는 것과 같다. 채팅이나 파일 전송에서는 네트워크를 아껴 주지만, 커서 위치는 몇십 바이트짜리 메시지가 1초에 수십 번 나간다. 모아 보내면 커서가 뚝뚝 끊겨 움직인다. 그래서 맥은 noDelay, 윈도우는 TcpClient.NoDelay로 양쪽 다 이 동작을 껐다.


옛 연결보다 새 연결 우선

윈도우가 갑자기 재부팅하면 맥 쪽에는 옛 연결이 한동안 살아 있는 것처럼 남는다. 그 사이 다시 켜진 윈도우가 새로 붙으면, 맥은 연결 두 개를 갖게 된다.

1
2
3
4
5
6
7
8
9
10
// KVMServer.swift (KVMCore)
case let .ready(_, name):
    // 윈도우가 재부팅해서 옛 연결이 남아 있어도 새 연결이 이긴다
    if let old = active, old !== connection {
        active = nil
        old.nw.cancel()
        connections[ObjectIdentifier(old)] = nil
    }
    connection.isReady = true
    active = connection

인증까지 마친 새 연결이 오면 옛 연결을 바로 끊고 새 연결로 바꾼다. 키와 마우스는 언제나 가장 최근에 인증한 연결 하나로만 간다.


조용히 끊긴 연결 알아채기

가장 성가신 끊김은 끊겼다는 소식조차 오지 않는 경우다. 이걸 알아채려고 양쪽이 짧은 신호를 계속 주고받는다.


하트비트란?

연결이 살아 있는지 확인하려고 주기적으로 주고받는 짧은 신호다. 전화 통화 중에 상대가 한참 말이 없으면 “여보세요?” 하고 묻는 것과 같다. TCP는 선을 뽑거나 상대 PC가 멈추면 그걸 바로 알려 주지 않는다. 몇 분 동안 “아직 연결됨”으로 보이기도 한다. 그동안 커서는 윈도우로 넘어가 있는데 아무것도 안 움직이는 상태가 된다.

규칙값
맥이 ping을 보내는 간격1초
윈도우가 ping에 답하는 것같은 번호로 pong
이 시간 동안 아무것도 못 받으면 끊음3초 (양쪽 모두)

맥은 pong이든 다른 메시지든 무언가를 받을 때마다 “마지막으로 받은 시각”을 고친다. 3초 넘게 조용하면 연결을 끊고, 커서가 윈도우에 있었다면 맥으로 되돌린다. 윈도우도 맥의 ping이 3초 넘게 안 오면 끊고 다시 붙기를 시작한다. 한쪽이 멈춰도 다른 쪽이 3초 안에 알아챈다.


맥 주소를 모를 때 찾기

공유기는 맥에 주는 주소를 가끔 바꾼다. 윈도우는 마지막으로 붙었던 맥 주소를 기억해 두고 거기로 먼저 가지만, 처음 설치했을 때나 주소가 바뀌었을 때는 맥을 찾아야 한다.


브로드캐스트란?

같은 공유기 안의 모든 기기에게 한꺼번에 보내는 방식이다. 교실에서 “MyKVM 맥 있어?” 하고 외치면, 다른 기기는 무시하고 맥만 손을 드는 것과 같다.

윈도우가 프린터, TV, 맥 모두에게 "MYKVM?1"을 보내면 MyKVM이 떠 있는 맥만 "MYKVM!1 52810"으로 답한다

윈도우가 UDP 52811번으로 MYKVM?1을 모두에게 보내면, 맥은 보낸 주소로 MYKVM!1 52810을 답한다. 뒤의 숫자가 TCP 포트이고, 윈도우는 답이 온 주소와 그 포트로 접속한다. 끝의 1은 형식 번호다. 나중에 형식을 바꾸면 이 숫자를 올려서 옛 버전과 섞이지 않게 하려는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Discovery.cs (MyKVM.Core)
/// 255.255.255.255만으로는 랜카드가 여럿인 윈도우에서 한 곳으로만 나간다. 각 랜카드의 브로드캐스트 주소에도 보낸다.
static IEnumerable<IPAddress> BroadcastAddresses()
{
    yield return IPAddress.Broadcast;
    foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
    {
        if (nic.OperationalStatus != OperationalStatus.Up) continue;
        foreach (var unicast in nic.GetIPProperties().UnicastAddresses)
        {
            if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
            var address = unicast.Address.GetAddressBytes();
            var mask = unicast.IPv4Mask.GetAddressBytes();
            yield return new IPAddress(address.Select((b, i) => (byte)(b | ~mask[i])).ToArray());
        }
    }
}

255.255.255.255는 “모두에게”라는 주소인데, 윈도우는 랜카드(유선, 무선, 가상 랜카드 등)가 여럿이면 그중 하나로만 내보낸다. 그래서 켜져 있는 랜카드마다 그 랜카드가 속한 동네의 브로드캐스트 주소도 계산해서 보낸다. 내 주소가 192.168.0.23이고 마스크가 255.255.255.0이면, 마스크가 0인 마지막 자리를 전부 1로 채운 192.168.0.255가 그 동네 전체 주소다. 답은 1초까지만 기다린다. UdpClient로 보내고 받는다.

맥 쪽 답하는 부분은 Network 프레임워크 대신 socket, bind, recvfrom 같은 오래된 BSD 소켓 함수로 짰다. 브로드캐스트를 받는 일만 놓고 보면 포트에 소켓 하나 묶어 두는 이 방식이 동작을 예측하기 쉬웠다. 소켓에 데이터가 오면 DispatchSource.makeReadSource가 서버와 같은 queue에서 답하기를 부른다.


윈도우의 다시 붙기

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
// KvmClient.cs (MyKVM.Core)
public async Task RunAsync(CancellationToken ct)
{
    var failures = 0;
    while (!ct.IsCancellationRequested)
    {
        // 생략: 거절당한 상태면 "페어링 다시 하기"를 누를 때까지 기다린다
        var reachedReady = false;
        try
        {
            reachedReady = await ConnectOnceAsync(failures, ct);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            break;
        }
        catch (Exception e)
        {
            log.Info($"connection ended: {e.Message}");
        }
        failures = reachedReady ? 0 : failures + 1;
        SetStatus(ClientStatus.Disconnected);
        try { await Task.Delay(TimeSpan.FromSeconds(1), ct); }
        catch (OperationCanceledException) { break; }
    }
}

앱이 켜져 있는 동안 이 반복이 계속 돈다. 한 번 붙어서 끊길 때까지가 ConnectOnceAsync 한 번이고, 끊기면 1초 쉬고 다시 붙는다. 어떤 오류가 나도 반복은 멈추지 않고 로그만 남긴다. 앱을 끌 때만 ct(취소 신호)로 빠져나온다.

ConnectOnceAsync는 기억한 맥 주소로 먼저 가고, 기억한 주소가 없거나 연속 세 번 인증까지 못 갔으면 브로드캐스트로 다시 찾는다. 공유기가 맥 주소를 바꿨으면 옛 주소로는 계속 실패하니, 세 번째부터 새 주소를 찾는다. 접속은 2초 안에 안 되면 포기하고 다음 회차로 넘어간다.


async와 await란?

기다려야 하는 일을 시켜 두고, 그동안 다른 일을 하다가 끝나면 이어서 하는 방식이다. 식당에서 주문하고 진동벨을 받아 자리에 앉아 있는 것과 같다. 카운터 앞에 서서 기다리지 않는다.

await tcp.ConnectAsync(...)는 “접속이 끝나면 다음 줄부터 이어서 해 줘”라는 뜻이다. 접속을 기다리는 동안 그 스레드는 다른 일(트레이 메뉴 그리기 등)을 할 수 있다. async가 붙은 함수 안에서만 await를 쓸 수 있고, 그런 함수는 Task(진동벨)를 돌려준다. 자세한 내용은 C# 비동기 프로그래밍 Docs에 있다.


여러 곳에서 동시에 보내지 않게

윈도우 클라이언트에서는 세 군데가 같은 연결을 만진다. 맥 메시지를 받는 반복, 0.25초마다 하트비트를 확인하는 반복, 그리고 화면 목록이나 페어링 숫자를 보내는 트레이 앱이다. 둘이 동시에 연결에 쓰면 프레임 두 장의 바이트가 섞여 맥이 읽지 못한다. 순번도 꼬인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// KvmClient.cs (MyKVM.Core)
readonly SemaphoreSlim _gate = new(1, 1);

/// 세션 호출과 쓰기를 한 번에 한 스레드만 하게 묶는다. 연결을 닫아야 하면 true.
async Task<bool> Run(Func<ClientSession, IReadOnlyList<ClientOutput>> action, Action<ClientOutput>? track = null)
{
    await _gate.WaitAsync();
    try
    {
        if (_session is null) return false;
        var outputs = action(_session);
        if (track is not null) foreach (var o in outputs) track(o);
        return await ApplyAsync(outputs);
    }
    // 생략: 오류가 나면 로그를 남기고 연결을 닫는다
    finally { _gate.Release(); }
}

SemaphoreSlim이란?

동시에 들어갈 수 있는 사람 수를 정해 둔 문이다. new(1, 1)은 “한 번에 한 명”이라는 뜻이라, 화장실 열쇠가 하나뿐인 것과 같다. WaitAsync로 열쇠를 받고, 일이 끝나면 Release로 돌려준다. 열쇠가 없으면 돌려받을 때까지 기다린다.

C#에는 lock이라는 더 간단한 문도 있지만, lock 안에서는 await를 쓸 수 없다. 연결에 쓰는 WriteAsync는 await가 필요해서, await로 기다릴 수 있는 SemaphoreSlim을 썼다. finally에 Release를 두면 중간에 오류가 나도 열쇠가 꼭 돌아온다.

맥은 앞에서 본 것처럼 서버의 모든 일을 큐 하나에서 차례로 처리하니, 같은 문제를 큐로 해결한 셈이다.


맥 안에서 양쪽을 붙여 보기

1편에서 말한 세 번째 시험이다. 윈도우 클라이언트의 연결 코드는 net10.0 대상이라 맥에서도 돈다. 그래서 맥 서버를 띄우는 시험용 실행 파일(mykvm-harness)과 윈도우 클라이언트를 띄우는 콘솔(MyKVM.Cli)을 만들고, scripts/e2e.sh가 둘을 이 맥 안에서 실제 소켓으로 붙인다. 두 프로그램은 받은 일을 로그로 찍고, 스크립트는 로그에 기다리는 줄이 나오는지 본다.

장면하는 일통과 조건
1. 페어링서버 로그의 6자리 숫자를 콘솔에 입력양쪽이 연결됨
2. 맥 → 윈도우서버에서 키 메시지를 보냄콘솔이 같은 JSON을 받음
3. 윈도우 → 맥콘솔이 화면 목록을 보냄서버가 displays를 받음
4. 클라이언트 재시작콘솔을 강제 종료하고 다시 띄움숫자를 다시 묻지 않고 연결됨
5. 서버 재시작서버를 강제 종료하고 다시 띄움콘솔이 알아채고 혼자 다시 붙음
6. 멈춘 클라이언트콘솔 프로세스를 일시 정지서버가 하트비트로 끊음
7. 주소 모르는 클라이언트설정에서 맥 주소를 지우고 띄움브로드캐스트로 찾아 연결됨
8. 거절한 페어링새 콘솔이 페어링 요청, 서버가 거절3초 동안 다시 조르지 않음

6번에서 콘솔을 kill -STOP으로 멈추면, 프로세스는 살아 있어 TCP 연결은 끊기지 않는데 pong만 안 온다. 하트비트가 없으면 영영 알아채지 못하는 바로 그 상황이다. 이 시험은 진짜 MyKVM 앱과 겹치지 않게 52890, 52891번 포트를 따로 쓴다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.