| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 27 | 28 | 29 | 30 | 31 |
- python
- github actions
- 보안
- 온디바이스AI
- playwright
- 아키텍처
- JWT
- contextBridge
- Android
- sqlite
- next.js
- 크롤링
- React
- php
- 메일자동화
- Vite
- REST API
- 마이그레이션
- FastAPI
- 백엔드
- electron
- 상태관리
- typescript
- Kotlin
- 배포
- 소셜로그인
- 프론트엔드
- 도메인설계
- javascript
- 인증
- Today
- Total
HM소프트
시간제 시설 예약 시스템 개발기 (2) — 알림톡 대체 발송과 단말 POS 실시간 연동 본문

알림톡 1차 발송과 SMS 자동 대체를 대행사 failover 파라미터로 위임하고, 발송 실패는 예약과 분리한다. SMS 길이는 글자 수가 아니라 EUC-KR 바이트로 재서 LMS로 분기하고, 슬롯 잠금은 메모리 Map과 타임아웃으로 다룬다. POS 연동을 폴링에서 푸시로 바꿔 Socket.IO와 plain WebSocket을 공존시키고 좀비 연결을 heartbeat로 솎는다.
2편: 예약을 통지하고, 현장과 잇다
1편에서 예약 도메인을 다뤘다. 슬롯·정책·동시성·락커까지. 하지만 예약은 "DB에 레코드가 생겼다"로 끝나는 게 아니다. 사용자에게 닿아야 하고(알림), 현장 결제 단말과 실시간으로 이어져야 한다(POS). 이번 편은 그 두 개의 연결선이다.
두 연결은 성격이 정반대다. 알림은 외부 발송 대행사로 나가는, 실패해도 예약을 막으면 안 되는 단방향 호출이고, POS 연동은 현장 단말이 한 박자도 늦으면 안 되는 실시간 양방향 채널이다. 같은 "예약 발생"이라는 사건을 두고 둘을 어떻게 다르게 다뤘는지가 이 글의 줄거리다.

알림톡 우선, SMS 자동 대체
예약이 확정되면 안내 메시지를 보낸다. 1차로는 알림톡(메신저 채널 기반 정보성 메시지)을 쓰고, 알림톡이 실패하면 SMS로 자동 대체 발송한다. 이걸 직접 if-else로 "알림톡 보내보고 실패하면 SMS 보내고"로 짜면 두 번 호출에 상태 관리가 지저분해진다. 다행히 발송 대행사 API가 failover 파라미터를 지원해서, 한 번의 요청으로 "알림톡 시도 → 실패 시 이 SMS 내용으로 대체"를 위임할 수 있었다.

신경 쓴 지점 두 개. 첫째, env.vendor.enabled 가드. 개발·테스트 환경에서 진짜 메시지가 나가면 사고다(특히 비용·스팸). enabled가 꺼져 있으면 콘솔에 본문만 찍고 즉시 리턴한다. 덕분에 로컬에서도 "어떤 문자가 나갈지"를 눈으로 확인하며 개발할 수 있었다. 둘째, 모든 발송 오류를 try/catch로 삼킨다. 메시지 발송은 외부 의존이라 언제든 실패할 수 있는데, 그 실패가 위로 전파돼 예약 트랜잭션에 영향을 주면 안 된다. 그래서 1편에서 본 것처럼 발송 호출은 커밋 이후에, 그리고 .catch(() => {}) 로 한 번 더 감싼다. "문자가 안 갔다"는 "예약이 안 됐다"가 아니다. 이 경계를 코드 구조로 못 박았다.
바이트로 길이를 재다 — SMS와 LMS의 경계
SMS에는 잔인한 제약이 있다. 한국식 단문(SMS)은 EUC-KR 기준 90바이트까지만 보낼 수 있고, 넘으면 잘리거나 발송이 실패한다. 그런데 한글은 EUC-KR에서 글자당 2바이트다. 처음엔 안내 문자가 슬그머니 잘리는 버그를 겪었다 — JavaScript의 str.length는 한글도 1로 세니, "글자 수는 한도 안인데 실제론 초과"였던 것이다.
해결은 문자 수가 아니라 바이트 수로 재고, 초과하면 자동으로 장문(LMS)으로 분기하는 것이었다.

codePointAt(0) > 0x7f 한 줄이 EUC-KR의 1바이트/2바이트 경계를 근사한다. 정확한 EUC-KR 인코더를 끌어다 쓸 수도 있었지만, 우리가 보내는 문자에는 ASCII와 한글만 사실상 등장하므로 이 근사로 충분했고 의존성도 늘지 않았다. 그리고 분기를 sendSMS 내부로 숨긴 게 핵심이다. 호출하는 쪽(확정 안내, 결제 완료, 취소 안내, 비밀번호 재설정 코드)은 그냥 본문을 넘기기만 하면 되고, 단문/장문 선택은 신경 쓰지 않는다. 짧은 메시지(예: 인증번호)는 자동으로 저렴한 단문으로, 긴 안내문은 장문으로 나간다. "길이에 따라 전송 방식이 갈린다"는 운영 지식을 한 함수에 가둔 것이다.
안내 문자 본문 자체도 "기능"보다 "운영"의 영역이었다. 예약번호·날짜·시각·라인·인원에 이용료·이용 수칙·모바일 티켓 링크까지 들어가서, 요금이나 정책이 바뀌면(3편의 요금/이벤트) 이 문구도 함께 손봐야 했다. 그래서 발송 함수를 용도별로(...ConfirmedSimple, ...PaymentCompleted, ...Cancelled, ...PasswordResetCode) 나눠, 어디를 고쳐야 하는지 명확하게 했다.
슬롯 잠금 — 실시간 협조 장치
이제 실시간 쪽이다. 1편에서 동시 예약을 막는 "1층 잠금"을 언급했는데, 그 구현이 여기다. 사용자가 슬롯을 선택하면 WebSocket(Socket.IO)으로 "내가 잡는 중"임을 서버에 전하고, 서버는 다른 모든 클라이언트에 그 슬롯이 잠겼다고 브로드캐스트한다. 잠금은 서버 메모리의 Map에 들고, 3분이 지나면 자동 해제한다.

잠금을 DB가 아니라 메모리 Map에 둔 건 의도적이다. 잠금은 "수 분 내에 결제로 이어지거나 사라지는" 휘발성 상태라, 영속화할 가치가 없고 오히려 정리 비용만 생긴다. 대신 두 가지 안전장치를 뒀다. 타임아웃 자동 해제(결제 중 이탈자가 슬롯을 영원히 점유하지 못하게)와 연결 종료 시 일괄 해제다. 사용자가 탭을 닫으면 disconnect 이벤트에서 그 소켓이 잡고 있던 모든 잠금을 풀어, "유령 잠금"이 쌓이지 않게 했다.
socket.on('disconnect', () => {
for (const [slotId, lock] of lockedSlots.entries()) {
if (lock.socketId === socket.id) {
lockedSlots.delete(slotId);
io.emit('slot:unlocked', { slotId });
}
}
});
다시 강조하면, 이 잠금은 협조적 장치다. 최종 보증은 1편의 DB 트랜잭션이 한다. 잠금은 "두 사람이 같은 칸에 들어오는 일을 UX로 줄이는" 역할이고, 그래도 동시에 들어오면 트랜잭션이 막는다. 휘발성 협조 장치와 영속성 보증 장치의 역할을 섞지 않은 게 중요했다.
폴링에서 푸시로 — 단말 POS 연동을 다시 짜다
현장 결제 단말(POS)에 들어가는 플러그인은 예약 정보를 알아야 한다. 손님이 오면 그 예약을 띄워 결제를 받기 때문이다. 처음엔 플러그인이 30초마다 폴링으로 예약을 가져왔다. 단순하지만 두 가지가 나빴다.
- 지연: 방금 들어온 예약이 단말에 뜨기까지 최대 30초가 걸린다. 워크인 손님 앞에서 30초는 길다.
- 낭비: 예약 변화가 없어도 30초마다 계속 요청한다.
그래서 서버 → 단말 푸시로 전환했다. 예약이 생기거나 상태가 바뀌면 서버가 단말로 즉시 신호를 쏜다. 여기서 기술 선택이 갈렸다. 일반 브라우저에는 이미 Socket.IO를 쓰고 있었지만, 단말 플러그인 SDK는 plain WebSocket을 요구했다. 그래서 같은 HTTP 서버에 두 개의 WebSocket 채널을 path로 분기해 얹었다.

noServer: true + upgrade 이벤트 가로채기가 열쇠다. Socket.IO와 plain WebSocket이 같은 포트를 공유하되, /ws/pos로 들어온 업그레이드만 plain WS가 가져가고 나머지는 Socket.IO가 처리한다. 두 개의 서버 프로세스나 포트를 띄울 필요가 없었다. 그리고 단말 채널에는 access key 헤더 인증을 걸었다 — 일반 사용자 소켓과 달리 단말은 매장 권한을 가지므로, 아무나 붙으면 안 되기 때문이다.
푸시로 바꾼 뒤의 흐름을 시퀀스로 그리면 이렇다. 예약이 커밋되는 순간 서버가 두 채널로 동시에 신호를 쏘고, 단말은 신호를 받자마자 최신 목록을 직접 당겨간다.

좀비 연결을 솎아내는 heartbeat
푸시로 바꾸니 새 문제가 생겼다. 연결이 끊겼는데 서버는 아직 "살아 있다"고 믿는 좀비 연결이다. 네트워크가 끊기거나 단말이 죽으면 TCP는 한참 뒤에야 안다. 그동안 서버는 죽은 소켓에 계속 푸시를 쏜다. 그래서 ping/pong heartbeat로 죽은 연결을 능동적으로 솎아냈다.

패턴이 깔끔하다. 매 주기마다 모든 연결을 "일단 죽음"으로 표시하고 ping을 쏜다. 다음 주기 전에 pong이 오면 isAlive가 다시 true가 되어 살아남고, 안 오면 terminate()로 정리된다. 폴링을 없앤 대가로 "연결의 생사 관리"라는 책임이 새로 생겼는데, 이 짧은 루프가 그걸 감당한다.
두 채널을 한 함수로 — 브로드캐스트 허브
마지막으로, 예약이 발생했을 때 일반 브라우저(Socket.IO)와 단말(plain WS) 양쪽에 동시에 알려야 한다. 이걸 호출하는 쪽(예약 서비스)이 두 채널을 다 알 필요는 없다. 그래서 socket.ts가 브로드캐스트 허브 역할을 하고, 서비스는 함수 하나만 부른다.

설계 의도가 두 가지 보인다. 첫째, 슬롯이 확정/취소되면 그 슬롯의 잠금도 함께 푼다. 예약이 끝났으니 잠금이 남아 있을 이유가 없다. 둘째, 단말에는 데이터가 아니라 "변경됨" 신호만 보낸다. 단말은 신호를 받으면 자기가 API로 최신 목록을 다시 당겨간다. 푸시로 데이터까지 실어 보내면 단말의 화면 상태와 서버 데이터가 어긋날 수 있는데, "변경됐다"만 통지하고 데이터는 단말이 직접 fetch하게 해서 단일 진실 원천을 API로 유지했다. 1편에서 본 예약 생성 함수가 커밋 후 broadcastSlotUpdate와 broadcastNewReservation을 부르는 게 바로 이 허브로 들어오는 것이다.
정리하며
- 알림톡 1차 + SMS 자동 대체를 대행사의 failover 파라미터로 한 번에 위임했다. 발송 실패는 try/catch +
.catch()로 삼켜 예약과 분리했다. - SMS 길이는 문자 수가 아니라 EUC-KR 바이트 수로 재고, 90바이트 초과 시 자동 LMS로 분기. 분기 지식을
sendSMS한 함수에 가뒀다. - 슬롯 잠금은 메모리 Map + 3분 타임아웃 + 연결 종료 시 일괄 해제의 휘발성 협조 장치. 최종 보증은 1편의 DB 트랜잭션.
- POS 연동을 30초 폴링 → 서버 푸시로 전환. 같은 HTTP 서버에 Socket.IO와 plain WS를 path로 분기해 공존시키고, 단말 채널엔 access key 인증.
- 푸시의 대가인 좀비 연결은 ping/pong heartbeat로 솎아냈다.
- 두 채널 알림을 브로드캐스트 허브로 모으고, 단말에는 데이터 대신 "변경됨" 신호만 보내 API를 단일 진실 원천으로 유지.
다음 마지막 3편에서는 운영자가 매일 쓰는 관리자 도구, 요금/이벤트 처리, 시간표 무중단 전환, 검색 노출(SEO), 그리고 단위·E2E 테스트로 시리즈를 마친다.
'외주 개발일지' 카테고리의 다른 글
| 급여 계산 자동화 도구 개발기 (1) — 4대보험·세금 계산 로직과 엣지케이스 (1) | 2026.06.23 |
|---|---|
| 시간제 시설 예약 시스템 개발기 (3) — 시간표 무중단 전환·일괄 차단·SEO·테스트 (0) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (1) — 슬롯 도메인과 동시성, 그리고 자원 배정 (0) | 2026.06.23 |
| 과제 채점 플랫폼 개발기 (4) — 랜딩 첫인상 다듬기: 스크롤 리빌, 날짜 피커, 가입 페이지 (0) | 2026.06.23 |
| 교육 플랫폼 개발기 (4) — 멘토링·알림 폴백·서버사이드 소셜 로그인·배포의 함정 (0) | 2026.06.23 |