문제
고객사는 대학 입시 컨설팅 회사예요. 목표는 결제부터 컨설팅 완료, 후속 관리까지 모든 과정을 디지털로 옮기는 것이었어요. 상담은 쇼핑몰(Cafe24)에서 결제하면서 시작되는데, 예약 위젯에서 고른 시간이 결제 뒤 내부 상담 데이터에 그대로 이어지지 않을 수 있었어요.
성수기에는 여러 신청자를 동시에 매칭해야 해서, 같은 시간에 컨설턴트가 두 번 잡힐 위험이 컸어요.
“결제부터 컨설팅 완료 및 후속 관리까지 모든 프로세스를 디지털화”
목표
- 쇼핑몰 주문을 자동으로 가져와 학생과 연결한다.
- 상품마다 다른 설문을 받고, 컨설턴트 매칭과 일정을 관리한다.
- 상태가 바뀌면 알림톡과 문자를 자동으로 보낸다.
- 학생, 학부모, 컨설턴트, 관리자 역할마다 볼 수 있는 것을 나눈다.
풀어 간 과정
처음 시도한 방법, 막힌 지점, 방향을 바꾼 이유를 시간 순서로 적었어요.
2026년 2~5월
주문과 예약을 잇는 방법 찾기
처음에는 '미매칭 주문' 탭에서 사람이 직접 맞췄어요. 학생이 결제하자마자 로그인할 수 있게 주문 동기화를 10분에서 3분, 다시 1분으로 줄였어요. 한 상품 번호를 옵션 여러 개가 같이 써서 엉뚱한 상품으로 연결되는 일이 생겨 상품 코드와 옵션 이름을 함께 보게 했고, 5월에는 학생 정보로 맞추고 가장 최근 예약을 쓰게 바꿨어요.
2026년 2~8월
슬롯 가드를 여러 번 고쳐 쓰기
처음에는 시작 시각이 정확히 같을 때만 막아서, 10시에 시작하는 60분 상담과 10시 15분 상담이 겹쳐도 통과했어요. 2월 말에 상담 시간이 겹치는지 보고 행 잠금을 거는 방식으로 바꿨고, 7월에는 확정된 충돌은 강제로도 넘길 수 없게 했어요. 8월에는 DB가 최종 판단을 맡는 방안을 대안 5개와 비교해 설계 문서로 남겼어요.
2026년 4월
서버 이전은 검증 스크립트부터
옮기는 스크립트보다 검증 스크립트를 먼저 썼어요. 그다음 설정, 런북, 전환 스크립트 순서로 만들었고, 4월 말에는 두 서버의 DB가 동시에 쓰이지 않게 막는 장치를 더했어요.
지금의 구조
결제에서 시작해 상담이 끝날 때까지, 사람 사이에서 정보가 끊기던 지점을 이었어요.
- 1
쇼핑몰 주문을 1분마다 가져와요.
- 2
예약 세션과 주문을 전화번호, 상품 코드, 만료 시각으로 맞춰 학생과 연결해요.
- 3
상담은 9단계 상태 머신을 따라가고, 허용된 전이만 일어나요.
- 4
슬롯 가드가 결제 완료부터 진행 중까지의 신청을 '점유'로 보고 중복 배정을 막아요.
- 5
상태가 바뀌면 DB 커밋이 끝난 뒤에 알림톡과 문자를 보내요.

핵심 결정
문자는 DB 커밋이 끝난 뒤에 보내기
- 이유
- 상태 변경과 함께 바로 보내면, 저장이 롤백돼도 문자는 이미 나가 버려요.
- 다른 선택지
- 상태가 바뀌는 순간 시그널 안에서 바로 발송
- 감수한 것
- 커밋 직후 프로세스가 죽으면 메시지가 빠질 수 있어서, 이를 메우는 설계를 따로 문서로 남겼어요.
확정된 일정 충돌은 누구도 우회할 수 없게
- 이유
- 확정된 충돌은 막고, 아직 희망 단계인 충돌만 관리자가 명시적으로 넘길 수 있게 했어요. DB 제약으로 막는 방안은 대안 5개를 비교해 설계 문서로 남겼어요.
- 다른 선택지
- 충돌을 경고만 하고 관리자 판단에 맡기기
- 감수한 것
- 지금은 애플리케이션에서 막고 있어서, DB 제약으로 옮기는 일이 다음 단계로 남아 있어요.
어려웠던 문제
겪었거나 배포 전에 막은 문제를 어떻게 알아챘고, 무엇을 의심해 원인을 찾았는지, 다시 일어나지 않게 무엇을 바꿨는지 기록했어요.
결제한 신청자의 시간에 다른 사람이 매칭되던 문제
결제는 끝났지만 아직 컨설턴트가 정해지지 않은 신청자의 시간에, 다른 신청자를 매칭할 수 있었어요. 성수기에 매칭이 막힌 운영 사례를 조사하다가 찾았어요.
왜 어려웠나
같은 매칭 화면에 정반대 증상 두 개가 섞여 있었어요. 막혀야 할 중복은 통과하고, 지난 시간대 잠금 때문에 정상 매칭은 막혔어요. 시간대를 점유했는지 판단하는 코드도 여러 곳에 흩어져 있었어요.
추적 과정
1. 예약 가능 시간은 무엇을 점유로 볼까?
예정, 진행 중, 완료 상태의 상담만 점유로 봤어요.
2. 예약 쪽은 무엇을 점유로 볼까?
결제를 기다리는 중이거나 확정된 예약만 봤어요. 결제는 끝났지만 아직 매칭 전인 상태는 어느 쪽에서도 점유로 세지 않았어요.
3. 매칭을 확정할 때는 확인할까?
확정 단계에는 충돌 검사 자체가 없었어요.
4. 운영에서 왜 막혔는지 어떻게 보여 줄까?
막힌 이유를 단계별로 출력하는 읽기 전용 진단 명령을 만들어, 데이터를 바꾸지 않고 원인을 확인했어요.
원인
점유 상태 목록에서 결제 완료부터 매칭 대기까지의 상태가 빠져 있었고, 매칭을 확정할 때 충돌을 검사하지 않았어요.
- 110시로 예약하고 결제, 아직 매칭 전
- 210시가 빈 시간으로 보임
- 3B를 10시에 매칭 확정
- 4A를 매칭하려니 같은 시간 충돌
해결
결제 완료부터 진행 중까지를 점유 상태로 정의하고, 매칭을 확정할 때 같은 컨설턴트의 점유 상담과 살아 있는 예약이 앞뒤 여유 시간을 포함해 겹치는지 검사해요. 겹치면 '이미 예약된 시간'으로 거부해요.
고치기 전
confirm_matching(consultation, consultant, time):
save() # 충돌 검사 없음고친 뒤
HOLDING = {결제 완료, 설문 단계, 매칭 대기, 매칭됨, 승인, 예정, 진행 중}
conflict = any(other.consultant == consultant
and other.status in HOLDING
and overlaps_with_buffer(time, other)
and other != consultation)
or any(살아 있는 예약이 겹침, 본인 예약 제외)
if conflict: raise "이미 예약된 시간"검증
- 회귀 테스트 9개 (다음 날과 당일 허용, 본인 예약 허용, 상담 점유로 차단, 결제된 예약 점유로 차단, 안 겹치면 허용, 지난 시간대 3개)
- 읽기 전용 진단 명령으로 운영 데이터를 바꾸지 않고 원인을 확인
재발 방지
- 희망 시간까지 점유로 보자 매칭이 실패하고 알림톡이 안 나가는 부작용이 생겼어요. 그래서 아직 희망 단계인 시간은 관리자가 확인한 뒤 진행할 수 있게 하고, 확정된 일정과 겹치면 누구도 우회하지 못하게 나눴어요.
- 애플리케이션 검사만으로는 확인과 저장 사이에 경쟁이 남아요. DB의 시간 구간 배타 제약으로 막는 방안을 대안 5개와 비교해 설계 문서로 남겼어요.
남긴 원칙
결제와 배정 사이의 중간 상태도 자원을 점유해요.
결제 뒤 예약 정보가 어긋나던 네 가지 문제
쇼핑몰 결제와 상담 예약을 잇는 동기화에서, 엿새 사이 서로 다른 버그 네 개를 고쳤어요. 결제 시각이 9시간 밀리고, 취소한 주문의 상담이 다시 생기고, 다른 옵션의 시간과 컨설턴트로 상담이 만들어지고, 상품 소요시간을 바꾸면 기존 예약과 겹쳤어요.
왜 어려웠나
증상은 모두 '예약 정보가 이상하다'로 비슷했지만 원인은 넷이었어요. 특히 시간대 버그는 값은 그대로 두고 표기만 UTC로 바뀌어서, 저장된 값만 봐서는 틀린 줄 알기 어려웠어요.
추적 과정
1. 소요시간을 바꾼 상품은 왜 겹칠까?
기존 예약에 저장된 옛 소요시간(예: 30분)으로 점유 구간을 계산하고 있었어요.
2. 취소한 주문의 상담은 왜 다시 생길까?
다시 동기화할 때 '결제된 적이 있다'는 조건이 참이었고, 지운 상담은 기본 조회에서 숨겨져 '상담이 빠졌다'고 판단해 다시 만들었어요.
3. 다른 옵션의 시간은 왜 들어갈까?
첫 번째 매칭이 상품 코드만 비교했어요. 옵션 필터는 두 번째 대체 단계에만 있어서, 첫 단계에서 다른 옵션의 예약이 잡히면 옵션 검사는 돌지도 않았어요.
4. 결제 시각은 왜 9시간 밀릴까?
'+09:00'이 붙은 시각을 파싱한 뒤 표기만 UTC로 바꿨어요. 09:22 한국 시간이 09:22 UTC, 즉 한국 시간 18:22가 됐어요.
원인
결제 시각 파서가 시간대가 붙은 값을 변환하지 않고 표기만 UTC로 바꿨어요. 이 결제 시각은 날짜 필터, 정렬, 알림 문구의 결제일에 쓰여서, 한국 시간 오후 3시 이후 결제는 다음 날 결제로 보였어요.
- 1결제결제 시각 저장, 상담 생성
- 2취소상담 취소, 소프트 삭제
- 3다음 동기화취소 상태 반영, 결제 시각은 그대로
- 4'결제됨'이고 상담이 안 보여 다시 생성
해결
시간대가 없는 값만 UTC로 지정하고, 시간대가 있으면 UTC로 변환해요. 취소된 주문은 결제 여부와 상관없이 상담을 만들지 않아요. 상품과 옵션 필터는 모든 매칭 단계에 걸고, 점유 구간은 저장된 소요시간과 현재 상품 소요시간 중 긴 쪽으로 계산해요.
고치기 전
return parse(s).replace(tz=UTC) # 09:22+09:00 -> 09:22Z (= 18:22 KST)고친 뒤
p = parse(s)
return p.replace(tz=UTC) if is_naive(p) else p.astimezone(UTC)
effectively_paid = not cancelled and (now_paid or paid_at)
busy_minutes = max(saved_duration, current_product_duration)검증
- 버그마다 재현 테스트 하나씩: 결제 시각의 순간 보존, 취소 주문의 상담 재생성 방지, 옵션이 다른 예약과 매칭하지 않음, 소요시간 변경 뒤 겹침 차단
재발 방지
- 옵션 같은 구체적인 조건은 대체 단계가 아니라 모든 단계에 걸어요.
- 소요시간처럼 바뀔 수 있는 값은 저장값과 현재값을 함께 봐요.
남긴 원칙
시간대가 붙은 값은 표기를 바꾸지 말고 변환해야 해요.
결과
- 백엔드 테스트
- 테스트 함수 913개
- 서버 이전 스크립트
- 12개, 3,805줄. 검증 스크립트부터 작성
- 이전 절차
- DNS TTL을 48시간 전에 낮추고, 전환 뒤 7일 동안 검증
- 기존 서버에서 AWS Lightsail로 이전을 마쳤어요.
- 결제, 매칭, 상담, 보고서, 문자 발송이 한 시스템 안에서 이어져요.
