1. 개요
The Seoul House 는 서울의 독채 게스트하우스 지점들을 소개하고 예약 문의를 받는 영문 사이트다. 원래 Wix 로 운영되던 것을 자체 스택으로 다시 만들었다. 결제는 없다. 고객이 캘린더에서 날짜를 골라 문의를 남기면 운영자가 확인해 확정한다. Airbnb·Agoda·Vrbo 같은 외부 플랫폼의 예약 일정은 iCal 로 끌어와 한 캘린더에 합친다.
이 프로젝트의 성격은 한 줄로 요약된다. 버그가 나면 고객이 아니라 운영자에게 전화가 오는 시스템. 성능이나 확장성보다 “되돌릴 수 있는가”, “조용히 망가지지 않는가” 가 결정 축이었다. 1편은 그 축에서 나온 가장 큰 결정, Inquiry First 를 다룬다.
2. 핵심 내용
2-1. 스택
| 영역 | 선택 |
|---|---|
| 백엔드 | Kotlin 2.2 / Spring Boot 4.0 / JDK 21, Flyway, Redis, AWS SDK v2 |
| 프론트 | Next.js 16 / React 19, Tailwind 4, shadcn/ui, react-day-picker, TanStack Query |
| 데이터 | PostgreSQL 15(JSONB 다수), Redis 7 |
| 스토리지 | SeaweedFS(S3 호환) |
| 플랫폼 | 홈랩 k3s + ArgoCD + Envoy Gateway + sealed-secrets |
Spring Boot 4 는 의존성 이름부터 다르다. spring-boot-starter-webmvc(web 아님), spring-boot-starter-aspectj(aop 아님), Jackson 3 계열 tools.jackson.module:jackson-module-kotlin. Boot 3 문서를 보고 복사하면 안 잡힌다.
2-2. “Scratch & Scalability” 기획의 요지
기획 문서 제목이 이랬다. 처음부터 다시 만들되, 지점 추가나 설정 변경이 코드 수정 없이 되도록 콘텐츠는 DB(JSONB), 정책은 설정 으로 분리한다는 것. 지점이 생기고 없어질 수 있으니 지점 단위 템플릿 구조.
기획과 구현이 갈린 지점은 문서 자체가 표로 남겨 뒀다. Docker 직접 배포 → k3s + ArgoCD, MinIO → SeaweedFS, iCal 고정 cron → 관리자가 설정하는 동적 주기, 텔레그램 명령 수신 → 아웃바운드 알림만. 기획서를 고치지 않고 “다르게 됐다” 를 표로 붙이는 방식이 나중에 읽기 좋았다.
2-3. Inquiry First
예약 시스템을 만든다고 하면 보통 이걸 떠올린다. 재고 차감, 중복 예약 방지 락, 결제 실패 시 보상 트랜잭션, 외부 채널과의 양방향 동기화. 전부 만들지 않았다.
이유는 데이터의 성격이다. 캘린더의 “예약됨” 표시는 외부 플랫폼 iCal 을 주기적으로 폴링 한 사본이다. 한 시간 전 상태일 수 있다. 이 데이터 위에 “이 날짜는 비어 있으니 지금 결제하세요” 라는 계약을 얹으면, 그 한 시간 사이에 Airbnb 에서 예약이 들어왔을 때 이중 예약이 된다. 락을 아무리 걸어도 우리 DB 안의 락이다. Airbnb 의 재고는 우리가 잠글 수 없다.
그래서 캘린더는 읽기 전용 근사치 로 두고, 계약은 사람이 확정한다. 고객은 문의를 남기고, 운영자가 실제 재고를 확인해 답한다.
flowchart LR OTA[Airbnb · Agoda · Vrbo · WeHome<br/>iCal 피드] -->|주기 폴링| CAL[(calendar_events<br/>읽기 전용 근사치)] CAL --> UI[고객 캘린더<br/>막힌 날 표시] UI --> INQ[문의 제출<br/>날짜 · 인원 · 메시지] INQ --> OP[운영자 확인] OP -->|실제 재고 대조| OK[확정 / 거절] OP -.->|OTA 대시보드에서| OTA
이 결정이 지운 것:
- 재고 테이블, 차감 트랜잭션
- 분산 락, 유니크 제약을 통한 중복 방지
- 결제 모듈과 보상 트랜잭션
- 외부 채널로 우리 예약을 밀어넣는 역방향 동기화
대신 얻은 의존성은 하나다. 운영자의 응답 시간. 이건 기술이 아니라 운영으로 푼다. 문의가 들어오면 텔레그램으로 바로 알린다.
2-4. 서버는 무엇을 검증하나
문의 생성 시 서버가 보는 것은 지점 존재, 인원 상한, 필수 필드다. 날짜 겹침과 최소 숙박일은 서버에서 검사하지 않는다. 캘린더가 근사치인데 서버가 “이 날은 막혔습니다” 라고 거부하면, 사실은 비어 있는 날의 문의를 막는 셈이다. 프론트가 사용자 편의로 막힌 날을 표시하고 클릭을 안내하되, 최종 판단은 운영자다.
중복 제출 방어는 Idempotency-Key 헤더 + Redis SETNX 로 만들어 두었는데, 프론트가 아직 헤더를 보내지 않아 현재는 버튼 비활성 + 레이트리밋이 실질 방어다. 갭을 알고 있다는 것과 문서에 적어 둔 것이 중요하다.
2-5. 데이터 모델은 단순하게
ical_sources: 지점별·플랫폼별 iCal URL. URL 자체가 사실상 자격증명이라 관리자 API 로만 접근.calendar_events: iCal 사본과platform='manual'수동 이벤트가 한 테이블. 종료일은 iCalDTEND관례대로 배타적(체크아웃 당일은 빈 날).inquiries: 체크인·체크아웃을 문자열yyyy-MM-dd로 저장. 시각이 없는 날짜를Date타입으로 저장하면 시간대 문제가 따라온다. 이 얘기는 4편에서.
Room 엔티티가 없다. 독채라 지점 = 숙소 하나다. 나중에 방 단위가 필요해지면 그때 만든다.
3. 마무리
요약
- 실시간성을 보장할 수 없는 데이터 위에는 계약을 얹지 않는다. 확정은 사람이.
- 그 결정 하나가 재고·락·결제·보상·역동기화를 통째로 지웠다.
- 서버는 캘린더 근사치로 문의를 거부하지 않는다. 판단은 운영자.
- 기획과 구현이 갈리면 기획서를 고치지 말고 “다르게 됐다” 표를 붙인다.
다음 편은 네 플랫폼 iCal 을 한 캘린더로다.