PC에서 문자를 주고받게 만들기 — Device Pairing과 Exactly-Once Delivery
전달 보장이 없는 푸시 채널 위에서 문자를 정확히 한 번만 보내야 합니다. 링커가 이 기능을 어떻게 설계했는지, 그 설계가 왜 안전한지를 코드와 함께 공개합니다.
링커 기능 하나를 골라 안이 어떻게 움직이는지 적는 시리즈입니다.
링커는 PC 화면에서 문자를 씁니다. 보내기를 누르면 내 폰이 그 문자를 보냅니다. 받는 사람에게는 늘 쓰던 번호로 도착합니다.
기능 설명은 두 줄이면 끝나는데 만드는 데는 꽤 걸렸습니다. 전달 보장이 없는 채널 위에서 문자를 정확히 한 번만 보내야 하고, 그 과정에서 남의 폰이 끼어들 틈이 한 칸도 없어야 하기 때문입니다.
발송 주체를 누구로 할 것인가
길이 셋 있었습니다.
서버가 직접 보낸다. 가장 쉽습니다. 그런데 발신번호가 우리 번호가 됩니다. 고객은 처음 보는 번호로 문자를 받고 답장은 우리 서버로 옵니다. 영업하는 분들에게는 그 자체로 못 쓰는 기능입니다.
사용자 번호를 통신사에 등록한다. 발신번호 사전등록을 사용자마다 시켜야 합니다. 가입 절차가 며칠짜리가 됩니다.
폰이 보낸다. 이미 그 번호를 가진 기기가 손에 있습니다. 등록할 것도 없고 번호도 그대로입니다.
셋째를 골랐습니다. 그러면 서버의 역할이 바뀝니다. 서버는 문자를 보내지 않습니다. 요청을 기록하고 폰에 알리기만 합니다.
이 선택이 보안 설계를 통째로 단순하게 만듭니다. 서버에 문자 발송 능력이 없으면 그 능력을 지킬 일도 없습니다. 서버가 털려도 나갈 문자가 없습니다.
sequenceDiagram
autonumber
participant W as 웹 (브라우저)
participant S as 서버
participant F as FCM
participant P as 폰 (앱)
participant T as 통신망
W->>S: 문자 요청 (본문, 받는 번호)
Note over S: 페어링·소유권 검증<br/>요청을 기록만 한다
S->>F: data push { message_id }
F->>P: 전달 (보장 없음)
P->>S: 본문 가져가기
Note over S: 만료 판정 + 잠금
S-->>P: 본문
P->>T: SmsManager.send
T-->>P: 발송 결과
P->>S: 결과 보고
W->>S: 5초 폴링
S-->>W: 보내는 중 → 보냄푸시에는 식별자만 싣습니다
FCM data message에 본문을 넣으면 왕복이 한 번 줄어듭니다. 그렇게 하지 않았습니다.
pushSender.sendData(
token,
mapOf(
"type" to "paired_sms",
"message_id" to messageId.toString(),
),
)
이유가 셋입니다.
FCM 페이로드는 4KB입니다. 장문 문자에 첨부 메타까지 실으면 언젠가 넘칩니다.
푸시를 놓쳐도 id로 다시 조회하면 됩니다. 푸시를 신뢰할 수 없는 채널로 다루면 복구 경로가 저절로 생깁니다.
그리고 사용자의 문자 본문이 구글 인프라를 거치지 않습니다. 개인 대화가 지나가는 구간이 하나 줄어듭니다.
짝을 맺는 방법
폰과 PC를 잇는 건 6자리 숫자입니다. PC에 뜬 코드를 사용자가 폰에 옮겨 적습니다.
이 절차가 없으면 안 되는 이유가 둘입니다.
기술적으로는 서버가 어느 폰에 푸시를 보낼지 알아야 합니다. 정책적으로는 구글 플레이가 「이 브라우저가 이 폰의 주인 것」이라는 증명을 요구합니다.
Remote control of user phone — 금지 Cross-device Synchronization — 허용 (SEND_SMS 포함)
동작은 같고 증명 여부만 다릅니다. 마이그레이션 파일에 그대로 적어 뒀습니다.
-- 웹에서 쓴 문자를 폰이 보내려면
-- 「이 브라우저가 이 폰의 주인 것」임이 증명돼야 한다.
-- 그 증명이 없으면 «Remote control of user phone»(금지)과
-- 구분되지 않는다.
sequenceDiagram
autonumber
participant W as 웹 (로그인됨)
participant S as 서버
participant R as Redis
participant P as 폰 (로그인됨)
W->>S: 페어링 시작
S->>R: SETNX code = "userId:webDeviceId" (TTL 3분)
S-->>W: 코드 6자리
Note over W,P: 사용자가 눈으로 옮겨 적는다
P->>S: 코드 입력
S->>R: Lua GET + DEL (원자적)
R-->>S: "userId:webDeviceId"
Note over S: 발급자 == 로그인한 사용자 ?
alt 다르면
S-->>P: 400 코드가 만료됐거나 이미 사용됐습니다
else 같으면
S->>S: 짝 성립
S-->>P: 연결 완료
end코드에 발급자를 함께 저장합니다
fun pair(userId: Long, code: String, deviceId: Long): Long {
val ticket = pairingCodeStore.consume(code)
?: throw InvalidDataException("코드가 만료됐거나 이미 사용됐습니다")
if (ticket.userId != userId) {
throw InvalidDataException("코드가 만료됐거나 이미 사용됐습니다")
}
...
}
Redis에 코드만 넣는 게 아니라 userId:webDeviceId를 값으로 넣습니다. 폰이 코드를 넣을 때 로그인한 사용자와 발급자를 대조합니다.
대조가 없으면 남의 코드를 주워 넣어 그 사람 PC 화면에 내 폰을 붙일 수 있습니다. 그 사람이 PC에서 쓰는 문자를 내가 보게 됩니다.
대조가 있으면 6자리를 무작위로 찍어도 자기 계정이 발급한 코드에만 걸립니다. 코드는 권한을 넘겨주는 열쇠가 아니라 같은 계정의 두 기기가 만나는 약속 장소입니다.
에러 문구를 둘 다 같게 둔 것도 여기서 옵니다. 「그런 코드 없음」과 「남의 코드임」을 구분해 주면 코드가 존재하는지 알려주는 창구가 됩니다.
한 번 쓰면 사라져야 합니다
읽기와 지우기를 두 명령으로 나누면 그 사이에 같은 코드가 두 번 소비됩니다.
/**
* 🔑 GETDEL 을 쓰지 않는다.
* 우리 Redis 는 5.0.7 이고 GETDEL 은 6.2 부터라
* ERR unknown command 로 떨어진다.
* Lua 는 2.6 부터 있고 한 덩어리가 원자적으로 돈다.
*/
private val CONSUME_SCRIPT = DefaultRedisScript(
"local v = redis.call('get', KEYS[1]) " +
"if v then redis.call('del', KEYS[1]) end return v",
String::class.java,
)
발급은 SETNX입니다. SET으로 쓰면 앞사람 코드를 덮어씁니다. 앞사람은 이유도 모른 채 연결에 실패합니다.
난수는 SecureRandom, 수명은 3분입니다. 만료를 Redis에 맡기면 「만료된 코드를 누가 치우느냐」는 문제가 생기지 않습니다.
짝은 세션이 아니라 브라우저에 답니다
-- 🔑 페어링 주체는 브라우저의 영속 ID 다.
-- 로그인 세션(jti)이 아니다 —
-- 토큰 갱신마다 jti 가 새로 발급되므로
-- 거기 매달면 수시로 끊긴다.
세션에 매달면 토큰이 갱신될 때마다 페어링이 끊깁니다. 사용자는 하루에 몇 번씩 코드를 다시 넣습니다. 그러면 누군가 「연결 유지」 같은 우회로를 만들고 그게 진짜 구멍이 됩니다.
브라우저 ID는 그 자체로 권한이 없습니다. 서버는 「로그인한 사용자 + 이 브라우저」를 함께 봅니다. 브라우저 ID만 훔쳐도 로그인 쿠키가 없으면 못 씁니다. 그 쿠키는 httpOnly라 JS가 읽지 못합니다.
전달 보장이 없는 채널 위에서 정확히 한 번
여기가 이 기능에서 제일 어려운 부분이었습니다.
FCM은 전달을 보장하지 않습니다. 늦게 오기도 하고, 안 오기도 하고, 한꺼번에 몰려 오기도 합니다. 그 위에서 문자는 정확히 한 번만 나가야 합니다. 한 번 나간 문자는 회수가 안 되니까요.
stateDiagram-v2
[*] --> REQUESTED : 웹이 요청
REQUESTED --> PUSHED : FCM 전달 성공
REQUESTED --> FAILED : 토큰 없음 / 푸시 실패
PUSHED --> DISPATCHING : 폰이 본문을 가져감 (행 락 + 잠금)
REQUESTED --> DISPATCHING : 푸시를 놓쳤다가 복구 조회
DISPATCHING --> SENT : 발송 성공 보고
DISPATCHING --> FAILED : 발송 실패 보고
REQUESTED --> EXPIRED : 5분 초과
PUSHED --> EXPIRED : 5분 초과
DISPATCHING --> EXPIRED : 결과 보고 없이 굳음
SENT --> [*]상태를 다섯으로 나눈 건 「왜 안 갔는지」에 답하기 위해서입니다.
REQUESTED에 머물면 푸시조차 못 보낸 것이고, PUSHED에 머물면 폰이 받고도 안 가져간 것이고, DISPATCHING에 머물면 가져가 놓고 결과를 안 알린 것입니다. 셋을 구분할 수 있어야 사용자에게 다른 말을 해 줄 수 있습니다.
가져가는 순간 잠급니다
「다시 보내기」를 연타하면 푸시가 쌓입니다. 폰이 꺼져 있다 켜지는 순간 한꺼번에 배달됩니다. 실측으로 3통이 나갔고 폰은 쌓인 푸시를 80ms 안에 처리했습니다.
폰에도 게이트가 있었습니다. 「이미 보냈으면 보내지 마라」. 그건 서버가 상태를 바꾼 뒤에야 듣습니다. 그 전에 들어온 건 전부 통과합니다.
// 🔑 락을 걸고 읽는다. 그냥 읽으면 동시에 들어온 요청들이
// 모두 잠그기 «전» 값을 읽고 다 같이 통과한다.
val message = repository.findByIdForUpdate(messageId)
?.takeIf { it.userId == userId }
?: throw InvalidDataException("요청을 찾지 못했습니다")
val claimed = message.status in DISPATCHABLE
val statusBeforeClaim = message.status
if (claimed) message.markDispatching()
// 응답에는 «잠그기 전» 상태를 담는다.
// 잠근 뒤 값을 주면 첫 번째 폰조차 보내지 않는다.
두 가지가 같이 있어야 동작합니다. 행 락이 없으면 동시 요청이 다 같이 통과하고, 응답에 잠근 뒤 값을 담으면 첫 번째 폰조차 보내지 않습니다.
이 함수에 takeIf { it.userId == userId }가 붙은 것도 같은 자리에서 하는 일입니다. message id는 연속된 정수라 없으면 아무 폰이나 남이 PC에서 쓴 문자를 읽어 갈 수 있습니다.
만료는 받는 쪽에서 판정합니다
「30분 지난 요청은 보내지 마라」를 FCM TTL로 하려다 실패했습니다.
TTL은 FCM 서버가 메시지를 들고 있는 시간이지 기기가 들고 있는 시간이 아닙니다. 앱이 강제 종료돼 있으면 메시지는 이미 기기까지 내려와 있고 앱이 살아나는 순간 전달됩니다. 실측으로 만료 90초 뒤에 배달됐습니다.
// 기준은 createdAt 이 아니라 updatedAt
// (= 마지막으로 폰에 알린 시각)이다.
// 생성 시각으로 재면 「다시 보내기」가 눌리는 즉시 또 만료된다.
val stale = message.updatedAt
.isBefore(LocalDateTime.now().minusMinutes(5))
조회하는 그 시점에 판정하므로 만료 배치가 따로 필요 없습니다.
결과는 한 방향으로만 흐릅니다
긴 문자는 SmsManager가 여러 조각으로 쪼개 보내고 조각 수만큼 콜백이 옵니다. 첫 조각이 성공한 뒤 마지막 조각이 실패하면 성공이 실패로 덮입니다. 문자는 갔는데 화면은 실패라고 말하고, 사용자는 다시 보내고, 상대는 같은 문자를 두 번 받습니다.
if (message.status == OutboundMessageStatus.SENT) return
콜백이 여러 번 오는 상태 기계는 한 방향으로만 흘러야 합니다.
실패를 어떻게 말할 것인가
폰이 본문을 가져가 놓고 결과를 안 알리면 상태가 DISPATCHING에 굳습니다. 그대로 내려주면 웹이 스피너를 계속 돌립니다.
status = if (isDispatchStuck)
OutboundMessageStatus.FAILED.name
else
status.name
DB에 저장된 상태와 사용자에게 보여줄 상태를 분리했습니다. 굳었는지는 시각으로 판정하므로 배치가 안 돌아도 화면은 정확합니다. 진행 중으로 남겨 두는 것보다 실패라고 말해 주는 편이 낫습니다. 그래야 사용자가 다시 보낼 수 있습니다.
폰이 꺼져 있는 동안의 화면도 문제였습니다. 웹은 타임라인을 5초마다 폴링하는데, 아직 안 나간 문자는 정본이 없습니다. 웹이 그 자리에서 만든 줄은 다음 폴링에 쓸려 나갑니다.
/**
* 아직 폰이 보내지 못한 것들.
* 이걸 빼면 폰이 꺼져 있는 동안 새로고침했을 때
* 보낸 문자가 사라진 것처럼 보인다.
*/
private fun loadPendingOutbound(...) = ...
폴링으로 갱신되는 화면에서 낙관적 업데이트를 하려면 그 낙관적 줄도 서버가 알아야 합니다.
사진은 전부 아니면 전무
첨부가 붙으면 실패 지점이 늘어납니다. 세 장 중 한 장을 못 받으면 어떻게 할까요.
두 장이라도 보내면 안 됩니다. MMS는 회수가 안 되고 사용자는 다 보낸 줄 압니다.
val files = downloadAttachments(
messageId, request.attachments)
if (files == null) { // 한 장이라도 실패하면 null
reportFailure(messageId, "사진을 받지 못했습니다")
return@launch
}
웹 버튼 잠금, 서버 검증, 폰 다운로드까지 네 겹인데 그중 서버 검증이 진짜 방어입니다.
// 🔴 못 다루는 폰이면 여기서 막는다 —
// 웹의 버튼 잠금만으로는 부족하다.
// 앱을 되돌려 깔았거나, 버전 보고가 늦었거나,
// 웹이 요청을 만든 뒤 폰이 바뀌는 경합이 있다.
if (command.attachments.isNotEmpty()
&& !device.supportsAttachment()) {
throw InvalidDataException("연결된 폰의 앱을 업데이트해야 합니다")
}
파일 id에는 소유권 검사를 붙였습니다. 웹이 S3에 직접 올리고 서버에는 id만 넘어오기 때문입니다.
/**
* 🔑 남의 fileId 를 붙이지 못하게 발급자를 확인한다.
* PENDING 인 파일 id 는 연속된 정수라 찍어 맞히기 쉽다.
*/
attachments.forEach {
if (!fileManager.isOwnedBy(it.fileId, userId)) {
throw InvalidDataException("첨부를 찾지 못했습니다")
}
}
없으면 남의 명함이나 사업자등록증을 내 문자에 붙여 아무 번호로나 보낼 수 있습니다.
폰에서는 원래 쓰던 경로를 그대로 씁니다
sendSms(
context, db, phone, body, threadKey, scope,
outboundMessageId = messageId, // 이것만 다르다
)
폰에서 직접 쓴 문자와 같은 함수를 부릅니다. 그래서 로컬 Room 기록, 시스템 문자함 미러, 서버 동기화가 전부 그대로 따라옵니다.
경로를 따로 만들었다면 그 셋을 각각 다시 붙여야 했고, 「PC에서 보낸 문자만 기록이 없는」 구멍이 나기 쉬웠습니다.
발송하면 폰에 알림도 남깁니다. 사용자가 자기 폰이 뭘 했는지 알아야 합니다. 문자함에 남는 것만으로는 부족하다고 봤습니다.
받는 쪽
수신은 안드로이드가 대부분 해 줍니다. 다만 어떤 브로드캐스트를 받느냐가 앱의 성격을 결정합니다.
// 기본 문자앱(ROLE_SMS)일 때만 SMS 를 처리한다.
// Play SMS 정책: default handler 아닌 앱의 SMS 사용 금지 —
// 비기본앱 SMS 수집은 스파이웨어 시그널.
if (intent.action
!= Telephony.Sms.Intents.SMS_DELIVER_ACTION) return
if (!SystemSmsWriter.isDefaultSmsApp(context)) return
SMS_RECEIVED는 기본 문자앱이 아니어도 받습니다. 매니페스트에서 그 인텐트 필터를 지우고 SMS_DELIVER만 남겼습니다. 코드에 if로 막으면 다음 사람이 지웁니다. 선언에서 지우면 되살리는 데 결정이 필요합니다.
기본앱 판정 기준도 한 번 갈렸습니다. getDefaultSmsPackage()로 인라인 판정했더니 One UI 7에서 role을 갖고도 레거시 설정이 안 바뀌어 수신 문자를 통째로 드롭했습니다. RoleManager 기준으로 통일했습니다.
받은 문자는 Room에 먼저 쌓고 SyncWorker가 15분 주기와 이벤트 직후에 배치로 올립니다. 실시간이 아니어도 되는 걸 실시간으로 만들지 않는 편이 배터리에 낫습니다.
어느 PC에서 나갔는지 남깁니다
CREATE TABLE outbound_message (
user_id BIGINT NOT NULL,
web_device_id BIGINT NOT NULL, -- 어느 PC 에서
device_id BIGINT NOT NULL, -- 어느 폰이
...
);
-- 🔑 web_device_id 를 남기는 이유는 감사다.
-- 폰만 기록하면 어느 PC 에서 나간 문자인지 알 수 없다.
-- 페어링이 이 기능의 정당성이므로,
-- 그 출처를 남기지 않으면 정당성도 증명되지 않는다.
취소는 soft delete입니다. 화면에서는 사라지고 누가 무엇을 보내려 했는지는 남습니다.
정리
이 기능 하나에 들어간 판단을 모으면 이렇습니다.
- 발송 주체를 폰으로 두면 발신번호 문제가 풀리고 서버가 가질 능력도 줄어듭니다.
- 푸시에는 식별자만 싣습니다. 크기 제한을 피하고, 놓쳐도 복구되고, 본문이 경유하지 않습니다.
- 랑데부 코드에는 발급자를 함께 묶습니다. 코드가 열쇠가 아니라 약속 장소가 됩니다.
- 1회 소비는 원자적으로 합니다.
- 권한 수명은 세션이 아니라 기기에 답니다. 자주 끊기는 인증은 대충 하게 됩니다.
- 중복 실행은 가져가는 순간 잠가서 막습니다. 락 위에서 잠그고, 응답에는 잠그기 전 값을 담습니다.
- 만료는 보내는 쪽이 아니라 받는 쪽에서, 조회 시점에 판정합니다.
- 콜백이 여러 번 오는 상태는 한 방향으로만 흐르게 만듭니다.
- 저장된 상태와 보여줄 상태는 다를 수 있습니다.
- 되돌릴 수 없는 동작에서는 부분 성공을 인정하지 않습니다.
- 연속 정수 id는 소유권 검사와 한 몸입니다.
다음 글은 흩어진 문자와 통화를 한 고객 아래로 모으는 데이터 모델입니다.