클라우드 Mac의 iOS 시뮬레이터에서는 메시지 목록이 정상으로 보이는데, 실제 네트워크에서는 잠깐 연결이 끊겼다가 복구된 뒤 상태 업데이트 하나가 빠지거나 같은 알림이 두 번 처리될 수 있습니다. SSE(Server-Sent Events, 서버가 클라이언트에 텍스트 이벤트를 지속적으로 보내는 방식)에서는 특히 주의해야 합니다. HTTP 요청이 성공했다고 해서 클라이언트가 마지막 이벤트까지 처리했다는 뜻은 아니기 때문입니다. 여기서는 로컬에서만 수신하는 테스트 서버를 이용해 연결 중단과 이어받기를 반복 검증할 수 있는 절차로 나눕니다.
먼저 이벤트 이어받기 규칙 정하기
SSE 이벤트는 여러 필드 줄로 구성되며 빈 줄로 끝납니다. id는 서버가 이벤트에 부여하는 커서입니다. 클라이언트는 재연결할 때 마지막으로 처리를 마친 이벤트의 ID를 Last-Event-ID 요청 헤더에 넣습니다. 서버는 이를 기준으로 후속 이벤트를 반환합니다.
‘ID를 읽었다’와 ‘이벤트를 처리했다’는 다릅니다. 연결이 id: 2와 그 뒤의 data 사이에서 끊길 수도 있습니다. 이때 ID 2를 저장해 버리면 재연결 시 3부터 시작하므로 이벤트 2는 영영 누락됩니다. 먼저 이벤트를 끝내는 빈 줄까지 받은 뒤 비즈니스 처리를 완료하고, 마지막으로 커서를 확정해야 합니다. 처리 과정에서 데이터베이스에 기록한다면 이벤트 ID를 기준으로 중복 처리도 막아야 합니다. 처리 결과를 확정한 뒤 커서를 저장하기 전에 연결이 끊길 수도 있기 때문입니다.
테스트의 목표는 연결이 절대 끊기지 않음을 증명하는 것이 아닙니다. 연결이 끊기기 전후의 이벤트 집합이 같고, 각 ID가 비즈니스 효과를 최대 한 번만 일으키는지 확인하는 것입니다.
제어할 수 있는 로컬 이벤트 소스 실행하기
클라우드 Mac에 sse_fixture.py를 만듭니다. 이 스크립트는 127.0.0.1에만 바인딩하므로 테스트 포트를 외부에 노출하지 않습니다. cut=1을 지정하면 이벤트 하나를 보낸 뒤 응답을 종료합니다. 다음 요청에서는 서버가 요청 헤더를 읽고 그다음 이벤트부터 보냅니다.
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit
EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlsplit(self.path)
if url.path != "/events":
self.send_error(404)
return
try:
last = int(self.headers.get("Last-Event-ID", "0"))
except ValueError:
self.send_error(400)
return
cut = parse_qs(url.query).get("cut") == ["1"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
for event_id, value in EVENTS:
if event_id <= last:
continue
payload = f"id: {event_id}\ndata: {value}\n\n".encode()
try:
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
if cut:
break
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
python3 sse_fixture.py를 실행하고 다른 터미널을 열어 두 응답을 확인합니다.
curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'
첫 번째 응답에는 ID 1만, 두 번째 응답에는 ID 2와 3이 순서대로 나와야 합니다. 여기서 응답을 정상 종료하는 방식은 ‘스트림이 끝난 뒤 다시 요청하기’를 테스트하기 위한 것입니다. 실제 네트워크의 연결 재설정, 타임아웃, 불완전한 이벤트는 별도로 장애를 주입해 확인해야 합니다. 정상 종료 한 번으로 모든 연결 중단 상황을 검증했다고 볼 수는 없습니다.
iOS에서 완전한 이벤트만 처리 확정하기
테스트 클라이언트에서는 URLSession.bytes(for:)로 한 줄씩 읽을 수 있습니다. 아래 코드는 배포 대상에서 이 API를 지원한다고 가정합니다. 이 함수가 담당하는 범위는 한 번의 연결뿐이며, 재시도 일정은 호출 측에서 관리합니다. 빈 줄이 도착했을 때만 이벤트를 전달하고, 스트림 끝에 남은 불완전한 조각은 버립니다.
import Foundation
struct StreamEvent {
let id: String
let data: String
}
func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
var request = URLRequest(url: url)
if let lastID {
request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
var result: [StreamEvent] = []
var eventID: String?
var data: [String] = []
for try await line in bytes.lines {
if line.isEmpty {
if let eventID, !data.isEmpty {
result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
}
eventID = nil
data = []
} else if line.hasPrefix("id:") {
eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
data.append(String(line.dropFirst(5)).dropFirst(
line.dropFirst(5).first == " " ? 1 : 0
).description)
}
}
return result
}
이 최소 예제는 여러 줄의 data를 보존하고 하트비트 줄과 다른 필드는 무시합니다. 실제 서비스용 파서는 프로토콜에 따라 ID가 없는 이벤트, 빈 ID, 재시도 간격 안내도 처리해야 합니다. 예제는 테스트 결과를 살펴보기 쉽도록 배열을 반환하지만, 무기한 실행되는 운영 스트림에는 적합하지 않습니다. 장시간 연결에서는 이벤트를 받을 때마다 처리하고 메모리에 계속 쌓아 두지 마세요.
시뮬레이터에서 연결 중단을 두 차례 검증하기
먼저 Mac에서 curl로 서비스에 접근할 수 있는지 확인한 다음, iOS 시뮬레이터에서 http://127.0.0.1:8765/events?cut=1을 요청합니다. 시뮬레이터는 보통 자신이 실행되는 Mac의 로컬 서비스에 접근할 수 있습니다. 반면 실기기에서 127.0.0.1은 실기기 자신을 가리키므로 같은 주소를 그대로 쓸 수 없습니다. 앱에서 암호화되지 않은 HTTP를 기본적으로 차단한다면 디버그 빌드에만 범위가 제한된 로컬 네트워크 예외를 설정하고, 광범위한 예외를 출시 설정에 포함하지 마세요.
첫 번째 검사에서는 이벤트 1을 기록하고 처리가 성공한 뒤에만 커서를 1로 설정합니다. 두 번째 검사에서는 cut=1을 제거하고 Last-Event-ID: 1을 보낸 뒤, 받은 ID가 정확히 2와 3인지 확인합니다. 이어서 첫 번째 비즈니스 처리가 실패하도록 일부러 설정합니다. 이때 커서는 이전 값을 유지해야 합니다. 이벤트를 다시 받으면 ID를 기준으로 중복을 막거나 안전하게 재시도해야 합니다. 마지막으로 읽기 작업을 취소하고 재연결 타이머도 함께 취소됐는지 확인합니다. 그렇지 않으면 화면을 나간 뒤에도 백그라운드 요청이 남을 수 있습니다.
| 확인 항목 | 통과 조건 |
|---|---|
| 완전한 이벤트 | 종료 빈 줄을 받은 뒤에만 비즈니스 처리를 시작 |
| 연결 중단 후 이어받기 | 확정한 ID 다음 이벤트가 순서대로 도착 |
| 중복 전달 | 같은 ID가 두 번째 비즈니스 효과를 일으키지 않음 |
| 화면 종료 | 읽기 작업과 대기 중인 재시도가 함께 중지 |
실패 원인을 올바른 계층에서 찾기
두 번째 검사에서 이벤트가 처음부터 다시 온다면 요청 헤더에 확정한 ID가 실제로 들어갔는지 먼저 확인하고, 서버가 커서 기준 필터링을 지원하는지 살펴보세요. 곧바로 시뮬레이터 탓으로 돌리지 마세요. 마지막 이벤트만 누락된다면 파서가 ‘스트림 종료’를 이벤트 경계로 잘못 취급하는지 중점적으로 확인합니다. 재연결 후 알림이 중복된다면 재시도 간격만 늘리지 말고 비즈니스 처리의 중복 방지 로직을 점검하세요.
‘연결 성공’과 ‘이벤트 전달 성공’도 구분해야 합니다. 서버가 바이트를 모두 보낸 뒤 연결을 끊었다고 해서 클라이언트가 로컬 상태를 기록했다는 뜻은 아닙니다. 클라이언트가 커서를 저장했다고 해서 원격 비즈니스 작업을 본래부터 안전하게 재시도할 수 있는 것도 아닙니다. 이벤트 ID, 처리 결과, 확정 후 커서를 하나의 테스트 기록에 남겨 두세요. 그래야 나중에 파서나 재시도 로직을 변경할 때 메시지 목록을 눈으로만 확인하지 않고 항목별로 비교할 수 있습니다.
자주 묻는 질문
Last-Event-ID는 언제 저장해야 하나요?
이벤트 전체를 받은 뒤 업무 처리가 성공했을 때 저장하세요. id 줄만 읽고 저장하면 연결이 끊겼을 때 처리하지 않은 이벤트를 건너뛸 수 있습니다.
iOS 시뮬레이터에서 127.0.0.1 서버에 접속할 수 있나요?
일반적으로 같은 Mac에서 실행 중인 로컬 서비스에 접속할 수 있습니다. 먼저 curl로 포트를 확인하세요. 실제 기기에는 같은 주소를 사용할 수 없습니다.
직접 테스트해 보시겠어요?
Mac mini를 단독 임대해 빌드를 바로 실행하세요
두 가지 M4 물리 머신 중 하나를 선택하고, 필요에 맞는 임대 기간과 리전을 고르세요. 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.