dotgimon 제작기 10. 통신 세션 복구와 폰트 통일
dotgimon의 근거리 통신 모듈로 문자열을 주고받을 수 있게 됐다. 그 위에 대전과 합체 결과를 저장하는 규칙을 얹었다. 양쪽 결과를 맞추는 일만큼, 끊겼다가 다시 연결해도 각 기기에 한 번씩만 반영하는 일이 중요했다.
날짜는 작업 순서에 맞춰 배치했다. 통신 프로토콜과 폰트 변경은 실제로 9월 30일에 저장소에 반영했다.
한쪽만 저장한 순간에 끊길 수 있었다
두 기기의 저장소는 서로 독립된 SQLite였다. 한 기기 안에서는 결과와 기록을 트랜잭션으로 묶을 수 있지만, 두 기기가 동시에 저장하는 기능은 없었다. 이긴 쪽만 승리를 저장하고 끊기거나, 같은 메시지를 두 번 받아 결과를 중복 반영할 수 있었다.
합체는 몬스터 자체가 바뀌는 동작이라 더 조심스러웠다. 한쪽만 합체한 상태에서 상대가 돌아오지 않으면 서버 없이 되돌리거나 중재할 방법이 없었다. 결과를 한 번에 맞추기보다, 각 기기에 동의와 반영 상태를 남겨 재연결했을 때 맞추는 쪽으로 설계했다.
상대가 보고한 몬스터 상태의 진위까지 확인할 서버는 없었다. 대신 양쪽에 같은 입력을 주면 같은 승패가 나오는 결정론적 전투 엔진이 있었다. 결과를 그대로 믿는 대신 각자 재계산할 수 있도록 입력을 합의했다.
호스트는 연결을 먼저 시작한 쪽으로 정하지 않았다. 코드에서는 프로필 UUID를 문자열로 비교해 작은 쪽을 호스트로 삼았다. UUID는 프로필을 구분하는 식별자다. 양쪽이 같은 두 값을 비교하므로 별도 역할 협의가 필요 없었다.
입력 순서가 달라도 같은 제안서를 만들었다
연결 직후 프로필 ID인 identity와 몬스터 정보인 hello를 교환했다. Hello에는 프로필, 펫 개체 키, 모드, 몬스터 정보와 난수 기여분 nonce를 넣었다.
// src/nearby/protocol.ts
export interface Hello { profileId: string; petKey: string; mode: LinkMode; mon: LinkMon; nonce: number }
export interface Proposal { v: 1; id: string; host: Hello; guest: Hello }petKey는 세대:출생시각, mode는 대전 B 또는 합체 F였다. nonce는 0부터 4,294,967,295까지의 정수로 제한했다. 상대가 보낸 값은 parseHello에서 확인했다.
export function parseHello(value: unknown): Hello {
const h = value as Hello
if (!h || !identity.test(h.profileId) || typeof h.petKey !== 'string' || !/^\d+:\d+$/.test(h.petKey) || !['B', 'F'].includes(h.mode) || !Number.isInteger(h.nonce) || h.nonce < 0 || h.nonce > 0xffffffff) throw new Error('올바르지 않은 상대 정보예요.')
const payload: LinkPayload = { v: 1, k: 'PXPT', m: h.mode, s: '00000000', r: 'H', d: h.mon, n: h.nonce }
// Reuse the engine protocol's species, eligibility and integer range validation.
const decoded = decodePayload(encodePayload(payload), { myRole: 'G', usedSessions: [] })
if (!decoded.ok) throw new Error('상대 몬스터가 통신 조건을 만족하지 않아요.')
return { profileId: h.profileId, petKey: h.petKey, mode: h.mode, mon: decoded.payload.d, nonce: h.nonce }
}형식을 검사한 뒤 기존 QR 프로토콜의 decodePayload를 재사용했다. 종, 정수 범위, 통신 참가 자격을 두 군데에서 따로 관리하지 않으려는 선택이었다.
두 입력이 모이면 호스트와 게스트를 정렬하고 거래 ID를 만들었다. 합체 모드라면 실제 조합인지도 여기서 확인했다.
export function propose(a: Hello, b: Hello): Proposal {
a = parseHello(a)
b = parseHello(b)
if (a.profileId === b.profileId || a.mode !== b.mode) throw new Error('서로 같은 통신 모드를 선택해 주세요.')
const [host, guest] = a.profileId < b.profileId ? [a, b] : [b, a]
if (host.mode === 'F' && !resolveFusion(host.mon.i, guest.mon.i)) throw new Error('두 몬스터는 합체 조합이 아니에요.')
return { v: 1, id: `${host.profileId}:${host.nonce}:${guest.profileId}:${guest.nonce}`, host, guest }
}거래 ID는 호스트 ID·nonce와 게스트 ID·nonce를 이어 붙인 값이었다. 같은 프로필끼리 연결하거나 서로 다른 모드를 선택하면 거절했다. propose(a, b)와 propose(b, a)는 같은 제안서를 만들었다.
거래 ID를 FNV-1a 해시로 바꾼 값을 전투 세션 입력에 넣었다. FNV-1a는 문자열을 정수 해시로 계산하는 방식이다. 양쪽은 같은 제안서를 재계산하고 자기 역할에 맞춰 승패를 읽었다.
export function outcome(p: Proposal, profileId: string) {
// ...
const s = fnv1a(p.id).toString(36).padStart(8, '0')
const fusion = p.host.mode === 'F' ? resolveFusion(mine.mon.i, peer.mon.i) : null
const result = fusion ? null : simulateBattle(payload(p.host, 'H'), payload(p.guest, 'G'))
return { mine, peer, fusion, win: result ? (host ? result.hostWins : !result.hostWins) : false }
}같은 제안서에서 호스트의 승리와 게스트의 패배가 짝을 이루는지 테스트했다. 합체는 resolveFusion으로 각자 자기 버전에 해당하는 결과를 골랐다.
동의는 보내기 전에 저장했다
사용자가 결과 미리보기에서 확인을 누르면 로컬에 동의를 기록한 뒤 메시지를 보냈다. 메시지를 먼저 보내면 상대는 동의를 받았는데 내가 재시작 후 그 동의를 기억하지 못할 수 있었다.
// src/nearby/session.ts
confirm() {
const p = this.proposal
if (!p || !this.peerId) throw new Error('상대 정보를 기다리고 있어요.')
this.options.prepare(p) // Persist consent before telling the peer.
if (this.remoteConsents.has(p.id)) this.options.commit(p)
this.pulse()
this.notify()
}prepare는 SQLite의 link_journal에 prepared 상태를 저장했다. 거래 ID 하나당 저널 한 행을 두고, 이미 적용한 ID는 used_sessions에 따로 남겼다.
CREATE TABLE IF NOT EXISTS link_journal (id TEXT PRIMARY KEY, peerId TEXT NOT NULL, status TEXT NOT NULL, proposal TEXT NOT NULL, acknowledged INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS used_sessions (id TEXT PRIMARY KEY);상대 동의도 받아 둔 상태라면 commit으로 결과, 생애 이벤트, 거래 ID를 한 트랜잭션에 저장했다. 결과만 저장되고 중복 차단 ID는 빠지는 중간 상태를 막으려는 구성이었다.
양쪽 동의와 반영 확인은 다음 순서로 오갔다. 저널이 남아 있으므로 중간에 끊겨도 재연결할 때 이어갈 수 있었다.
상대의 committed를 받으면 acknowledge로 수신 확인을 남기고 완료 화면을 열었다. 같은 거래를 다시 받아도 used_sessions로 중복 적용을 막았다.
모르는 거래의 완료 메시지는 동의가 아니었다
상대가 보낸 committed라는 이름만으로 결과를 적용하지 않았다. 내 저널에 없는 제안서는 현재 화면의 제안서와 정확히 같을 때만 상대 동의로 인정했다.
if (!entry) {
if (!this.proposal || JSON.stringify(this.proposal) !== serialized) return // Unsolicited/replayed proposals never grant consent.
this.remoteConsents.add(p.id)
return
}이미 저장한 거래라면 메시지의 내용이 저널과 같은지도 확인했다. 다르면 저장된 통신 내용과 달라요. 오류를 냈다. JS 세션에서는 문자열 길이 16,384를 넘는 메시지를 거절했고, 연결 중 상대 프로필 ID가 바뀌어도 거절했다.
본체 RESET으로 이전 펫을 끝낸 거래는 abandoned로 남기고 used_sessions에도 넣었다. 늦게 온 메시지가 새 펫에게 적용되는 것을 막았다. 상대 기기에 이미 저장한 결과까지 되돌릴 수는 없어 리셋 확인창에 그 차이를 안내했다.
재전송은 같은 결과를 한 번만 적용할 때 쓸 수 있었다
연결이 끊기거나 앱이 다시 시작되면 상대가 결과를 저장했는지 확인해야 했다. 미완료 저널을 2초마다 보내고, 다시 연결했을 때도 같은 상대의 저널을 교환했다.
pulse() {
this.start()
if (!this.peerId) return
if (this.options.hello && !this.complete) this.send({ type: 'hello', hello: this.options.hello })
this.sendJournal()
}acknowledged = 0인 저널만 재전송했다. 한쪽은 committed, 다른 쪽은 prepared 상태에서 끊겼다면, 다시 연결해 완료 기록을 전달하고 아직 저장하지 않은 쪽이 commit하게 했다. 같은 거래를 여러 번 받아도 한 번만 반영되는 성질을 멱등성이라고 한다. 재전송을 쓸 수 있었던 전제였다.
테스트에서는 실제 무선 대신 메모리 큐 wire로 두 세션을 연결했다. SQLite 테스트 어댑터도 사용해 프로토콜과 저널을 함께 검사했다.
- 양쪽 제안서가 같고 대전 결과는 반대로 나오는지
- 첫 identity가 사라지거나 hello가 먼저 와도 복구되는지
- 한쪽만 확인하면 반영하지 않고 양쪽 확인 후 한 번씩만 반영하는지
- 한쪽 반영 직후 모든 패킷을 버리고 앱을 재시작해도 재연결하면 맞춰지는지
- 확인 메시지 누락을 저널 재전송으로 복구하는지
- 요청하지 않은 committed를 무시하는지
- prepared로 저장한 제안서를 바꿔 보내면 거절하는지
- 리셋한 펫의 늦은 확인을 새 펫에 반영하지 않는지
- 모드·종·정수 범위를 검사하는지
버전이 다른 합체 조합은 호스트 순서를 뒤집어도 각자 자기 버전 결과를 받는지 별도로 검사했다. 실제 무선 연결을 하지 않고도 끊김과 재시작 조건을 만들 수 있었다.
상대가 영영 돌아오지 않는 경우까지 해결하지는 못했다. 미완료 거래가 있으면 펫 조작을 제한하되 STATUS와 LINK는 열어 뒀다. 기록을 보거나 재연결할 수 있고, 본체 RESET으로 해당 생애와 미완료 거래를 끝낼 수 있었다. 프로필 ID는 인증 토큰이 아니므로 상대가 규칙 안의 거짓 몬스터 값을 보내는 문제도 남았다.
폰트 굵기도 한 컴포넌트에서 골랐다
별도 작업으로 앱 글꼴을 Pretendard로 바꿨다. iOS와 Android의 시스템 글꼴 차이를 줄이려고 Regular, Medium, SemiBold, Bold, ExtraBold, Black 여섯 파일을 넣었다. 원본 OTF 합계는 9,446,848바이트였고 OFL 라이선스 파일도 함께 보관했다.
루트 레이아웃의 useFonts로 파일을 불러왔다. 로딩 중에는 대기 화면을 보여주고, 실패하면 오류를 던졌다.
// app/_layout.tsx
const [loaded, error] = useFonts({
'Pretendard-Regular': require('../assets/fonts/Pretendard-Regular.otf'),
'Pretendard-Medium': require('../assets/fonts/Pretendard-Medium.otf'),
// ... SemiBold, Bold, ExtraBold, Black
})
if (error) throw error
if (!loaded) return <View style={{ flex: 1, backgroundColor: '#131417', alignItems: 'center', justifyContent: 'center' }}><ActivityIndicator color="#aebd88" /></View>굵기마다 별도 이름으로 등록했으므로 AppText에서 fontWeight에 해당하는 fontFamily를 골랐다. 최종 fontWeight는 normal로 바꿔 선택한 파일의 굵기를 사용했다.
// src/components/AppText.tsx
const FAMILIES: Record<string, string> = {
normal: 'Pretendard-Regular', '400': 'Pretendard-Regular',
'500': 'Pretendard-Medium', '600': 'Pretendard-SemiBold',
bold: 'Pretendard-Bold', '700': 'Pretendard-Bold',
'800': 'Pretendard-ExtraBold', '900': 'Pretendard-Black',
}
function AppText({ style, ref, ...props }: TextProps & { ref?: Ref<NativeText> }) {
const weight = StyleSheet.flatten(style)?.fontWeight ?? '400'
// Each weight has its own registered font so Android and iOS use the same face.
return <NativeText ref={ref} {...props} style={[style, {
fontFamily: FAMILIES[String(weight)] ?? 'Pretendard-Regular',
fontWeight: 'normal',
}]} />
}
export default cssInterop(AppText, { className: 'style' })StyleSheet.flatten으로 스타일을 펼쳐 굵기를 읽었다. React 19의 ref prop을 받아 forwardRef 없이 내부 Text로 넘겼고, NativeWind의 cssInterop으로 className을 style에 연결했다. font-bold 클래스도 이 경로에서 Bold 파일로 바뀌었다.
이 방식은 이번에 폰트를 등록한 구조에 맞춘 선택이었다. 번들 폰트의 굵기를 언제나 별도 family로 골라야 하는 것은 아니다. Expo 폰트 설정에는 family와 weight를 함께 구성하는 방식도 있다.
통신 결과는 저장된 동의와 저널 재전송으로 복구하고, 중복 적용은 같은 SQLite 트랜잭션에 기록한 거래 ID로 막았다. 테스트에서 확인한 범위는 이 세션 로직과 저장이었다. 실기기 두 대 사이의 연결 결과는 아직 없었다.