Poly Journal
AboutPortfolio

신청서 시스템을 만들면서 겪은 시행착오

2026-05-04 · DEV

이메일 인증, 임시저장, 제출, 결제, 어드민 처리까지 이어지는 신청서 시스템을 만들면서 겪은 동시성, 멱등성, 회차 관리, 첨부파일 정리의 기록입니다.

처음에는 꽤 단순한 작업이라고 생각했어요.

신청서 시스템이라고 해도 결국 흐름은 비슷하니까요.

이메일 입력
  -> 인증 코드 확인
  -> 신청서 작성
  -> 결제하기

처음 머릿속에 있던 그림은 딱 이 정도였습니다. 사용자는 이메일 인증을 하고, 폼을 채우고, 마지막에 결제 버튼을 누르면 끝. 겉으로 보기에는 그냥 긴 폼 하나처럼 보였어요.

그런데 막상 만들기 시작하니 생각보다 분기가 많았습니다.

처음 들어온 사용자인지, 작성하다가 나갔다가 다시 들어온 사용자인지, 이메일 인증은 했지만 비밀번호는 아직 안 만든 상태인지, 이미 제출한 사람인지, 어드민이 참가 확정 처리한 뒤 다시 보류로 되돌릴 수 있는지, 같은 이메일이 다음 회차에 다시 신청할 수 있어야 하는지.

이런 질문들이 하나씩 나오면서, 단순한 신청서가 아니라 인증, 임시저장, 제출, 결제, 어드민 상태 변경, 첨부파일 정리까지 이어지는 상태 머신에 가까워졌습니다.

이 글은 그 과정에서 제가 실제로 부딪혔던 문제와, 그때 어떤 식으로 정리했는지를 남긴 기록입니다. 특정 프로젝트 정보가 드러나지 않도록 이름이나 값은 일반화해서 적었습니다.


이메일 인증은 가볍게 만들고 싶었다

처음부터 회원가입 같은 구조를 만들고 싶지는 않았습니다.

사용자는 신청서를 작성하러 들어온 것이지, 서비스에 가입하러 들어온 게 아니니까요. 그래서 로그인 세션을 크게 만들기보다는, 이메일 인증을 통과하면 인증용 JWT를 쿠키로 발급하고 이후 API는 이 쿠키를 기준으로 사용자를 식별하도록 했습니다.

대략적인 흐름은 이랬습니다.

1. 이메일 입력
2. 인증 코드 발송
3. 인증 코드 해시 저장
4. 인증 성공 시 인증용 쿠키 발급
5. 이후 API는 쿠키 기준으로 사용자 식별

처음에는 이 정도면 충분하다고 봤습니다. 그런데 인증 코드 발송부터 바로 운영적인 문제가 보였어요.

사용자는 인증 메일이 조금만 늦어도 버튼을 다시 누릅니다. 새로고침을 하기도 하고, 두 탭을 열기도 하고, 그냥 불안해서 다시 누르기도 합니다. 이걸 그대로 두면 메일 발송 비용도 문제지만, 발송 평판에도 좋지 않을 수 있습니다.

그래서 재발송에는 제한을 걸었습니다.

재발송 대기 시간
재발송 가능 횟수
인증 코드 입력 가능 횟수

숫자를 글에 그대로 박아두기보다는 정책의 방향만 적는 게 맞을 것 같아요. 핵심은 사용자가 실수로 몇 번 누르는 건 허용하되, 짧은 시간에 계속 때리는 흐름은 막는 것이었습니다.

여기까지는 흔한 인증 로직처럼 보였는데, 실제로 더 신경 쓰였던 부분은 동시 요청이었습니다.

처음에는 이런 식으로 생각했습니다.

기존 인증 row 조회
없으면 INSERT
있으면 UPDATE

그런데 두 탭에서 거의 동시에 인증 코드를 요청하면 둘 다 기존 row가 없다고 판단할 수 있습니다. 그러면 둘 다 INSERT를 시도하고, 하나는 UNIQUE 제약에 걸립니다. 사용자 입장에서는 인증 코드 요청 버튼을 눌렀을 뿐인데 오류를 보게 되는 상황이 되는 거죠.

그래서 이 부분은 UNIQUE + ON CONFLICT DO UPDATE 방식으로 바꿨습니다.

인증 정보 UPSERT
  conflict key: 회차키 + 신청유형 + 이메일
  update: 인증코드, 만료시간, 재발송횟수

이렇게 해두면 두 요청이 동시에 들어와도 결국 하나의 row만 남습니다. 먼저 들어온 요청이든, 나중에 들어온 요청이든 같은 기준으로 갱신되기 때문에 5xx로 튀는 상황도 줄어듭니다.

이때부터 SELECT 후 없으면 INSERT 패턴을 보면 한 번 더 의심하게 됐습니다.

인증은 됐는데 비밀번호는 아직 없는 상태

이메일 인증에서 또 하나 놓칠 뻔한 상태가 있었습니다.

사용자가 이메일 인증까지는 끝냈지만, 비밀번호 설정 화면에서 나가버리는 경우입니다. 처음에는 상태를 단순하게 봤습니다.

인증 안 됨
인증 됨

그런데 실제 플로우에서는 이 둘만으로 부족했습니다.

이미 인증은 됐지만 비밀번호가 없는 사용자가 다시 들어왔을 때, 인증 코드를 다시 보내는 건 UX가 이상합니다. 그렇다고 그냥 “이미 인증됨”만 내려주면 프론트는 어디로 보내야 할지 애매해집니다.

그래서 인증 코드 발송 응답을 조금 더 나눴습니다.

발송됨
이미 제출됨
인증됨, 하지만 비밀번호는 아직 없음

이렇게 나누고 나니 프론트는 응답 상태만 보고 다음 화면을 결정할 수 있었습니다. 여기서 느낀 건, 인증 로직은 단순히 성공/실패가 아니라 사용자가 지금 어느 단계에서 멈췄는지를 표현해야 한다는 점이었습니다.


테이블을 하나로 끝내지 않은 이유

처음에는 신청서 데이터니까 테이블 하나로 끝낼 수 있을 거라고 생각했습니다.

하지만 만들다 보니 라이프사이클이 서로 달랐습니다. 결국 아래처럼 나누는 쪽이 더 자연스러웠어요.

| 구분 | 역할 | 생성 시점 | 삭제 시점 | | ---------------- | ----------------------------------- | ------------------------------ | ------------------- | | 이메일 인증 | 이메일 인증 코드와 인증 상태 | 인증 코드 발송 시 | 개별 신청서 삭제 시 | | 임시저장 신청서 | 작성 중인 폼 데이터와 비밀번호 해시 | 비밀번호 설정 또는 자동저장 시 | 제출 시 | | 제출 완료 신청서 | 제출 완료된 신청서 | 결제 버튼 클릭 시 | 어드민 삭제 시 |

처음에는 status 컬럼 하나만 두고 임시저장과 제출 완료를 같은 테이블에서 관리해도 되지 않을까 생각했습니다. 하지만 금방 애매해졌습니다.

작성 중인 신청서는 아직 검증되지 않은 데이터입니다. 반면 제출된 신청서는 어드민이 검토해야 하는 확정 데이터입니다. 둘을 같은 테이블에 두면 모든 쿼리에 “이 row가 작성 중인가, 제출 완료인가”라는 분기가 따라붙습니다.

제출 완료 데이터에만 필요한 값들도 있었습니다.

신청번호
결제 확인 시각
심사 결과
점수
어드민 메모

이 값들은 임시저장 단계에는 없어도 되는 값입니다. 한 테이블에 몰아넣으면 NULL 컬럼이 늘어나고, 이 row가 어떤 상태인지 계속 문맥을 봐야 합니다.

그래서 임시저장 데이터와 제출 완료 데이터는 분리했습니다. 제출 시에는 임시저장 데이터를 제출 완료 데이터로 옮기고, 임시저장 데이터는 삭제하는 방식으로 정리했습니다.

이메일 인증도 따로 뺐습니다. 비밀번호를 만들기 전에도 이메일 인증은 일어날 수 있기 때문입니다. 인증 정보가 임시저장 신청서에 붙어 있으면 “인증은 됐지만 아직 임시저장 row는 없는 상태”를 표현하기 어렵습니다.

처음에는 테이블이 늘어나는 게 부담스럽게 느껴졌는데, 오히려 나누고 나니 각 데이터가 언제 생기고 언제 사라져야 하는지가 더 명확해졌습니다.


회차를 무엇으로 구분할 것인가

이 부분은 생각보다 오래 고민했습니다.

신청은 한 번만 열리는 게 아니라 주기적으로 다시 열릴 수 있습니다. 같은 이메일이 이번 회차에도 신청하고, 다음 회차에도 다시 신청할 수 있어야 합니다. 그러면 UNIQUE 키를 단순히 이메일 하나로 잡을 수는 없습니다.

처음에는 자연스럽게 회차 id를 떠올렸습니다.

(회차 id, 이메일) UNIQUE

그런데 실제 운영 방식이 항상 그렇게 깔끔하게 흘러가지는 않았습니다. 운영자가 매번 새 회차 row를 만드는 게 아니라, 기존 row의 요금이나 마감일, 콘텐츠를 갱신해서 운영하는 경우가 있었습니다. 이 경우 회차 id가 그대로라면 다음 회차 신청도 이전 데이터에 막히게 됩니다.

그렇다고 “매번 새 회차 row를 만들어 주세요”라고 운영에 의존하는 것도 불안했습니다. 한 번만 까먹어도 데이터가 섞일 수 있으니까요.

그래서 최종적으로는 운영자가 회차마다 자연스럽게 바꾸는 콘텐츠 값을 회차 식별 키로 사용했습니다.

현재 회차를 식별할 수 있는 키를 가져온다

운영자는 회차가 바뀌면 소개 페이지나 안내 문구를 어차피 수정합니다. 그 값이 바뀌면 같은 이메일도 새 회차에서는 다시 신청할 수 있게 됩니다.

최종 UNIQUE 키는 이런 식으로 잡았습니다.

(회차키, 이메일)

그리고 나중에 신청 유형이 늘어날 가능성이 있는 인증 쪽은 신청 유형까지 포함했습니다.

(회차키, 신청유형, 이메일)

정규화만 보면 더 깔끔한 방법도 있었을 겁니다. 하지만 이때는 운영자가 자연스럽게 하는 행동과 시스템의 기준을 맞추는 쪽이 더 안전하다고 봤습니다.


결제 버튼은 생각보다 위험했다

신청서 작성이 끝나면 사용자는 결제 버튼을 누릅니다. 겉으로는 그냥 마지막 버튼 하나지만, 서버 입장에서는 이 버튼이 제일 조심스러웠습니다.

흐름은 단순합니다.

1. 사용자가 결제하기 클릭
2. 서버가 임시저장 데이터 조회
3. 제출 완료 데이터 생성
4. 임시저장 데이터 삭제
5. 응답

문제는 사용자가 이 버튼을 한 번만 누른다는 보장이 없다는 점입니다.

더블클릭을 할 수도 있고, 응답이 늦어서 새로고침한 뒤 다시 누를 수도 있습니다. 네트워크가 느리면 첫 번째 요청이 아직 끝나기 전에 두 번째 요청이 들어올 수도 있습니다.

처음 구현처럼 “제출 데이터가 없으면 INSERT”로만 처리하면 두 요청이 동시에 들어왔을 때 둘 다 INSERT를 시도합니다. 하나는 UNIQUE 제약에 걸리고, 사용자는 오류를 봅니다. 실제로는 제출이 됐을 수도 있는데 화면에는 실패처럼 보이는 거죠.

그래서 제출 API는 멱등하게 만들었습니다.

제출 데이터 INSERT
  conflict key: 회차키 + 이메일
  on conflict: 아무것도 하지 않음

이후 누가 만들었든 같은 제출 데이터를 다시 조회해서 응답

여기서 방향을 조금 바꿨습니다. 중복 요청 자체를 완전히 막으려고 하기보다는, 중복 요청이 들어와도 사용자에게 같은 결과를 돌려주도록 만든 것입니다.

임시저장 데이터 삭제도 강하게 보장하려고 하지 않았습니다. 이미 제출 완료 데이터가 만들어졌다면 핵심 상태는 넘어간 것입니다. 임시저장 삭제는 한 번 더 시도해도 되고, 이미 삭제되어 있어도 문제될 게 없습니다.

let _ = Draft::delete_by_id(draft_id).exec(&txn).await;

이런 코드를 보면 조금 찝찝할 수 있지만, 여기서는 의도적으로 best-effort로 둔 부분입니다. 중요한 데이터와 정리성 데이터를 같은 무게로 다루면 오히려 실패 지점이 늘어난다고 봤습니다.


어드민 상태와 공개 명단은 분리했다

제출된 신청서는 어드민이 검토하면서 상태가 바뀝니다.

접수 대기 -> 참가 확정
참가 확정 -> 보류
보류 -> 참가 확정

처음에는 제출 데이터 자체에 status가 있으니, 공개 명단을 조회할 때 참가 확정 상태만 필터링하면 되지 않을까 생각했습니다.

하지만 곧 분리하는 쪽이 맞다고 봤습니다.

신청서 데이터는 어드민 내부 데이터입니다. 반면 공개 명단은 외부에 보여지는 데이터입니다. 권한도 다르고, 노출 목적도 다릅니다.

특히 사진 때문에 더 분리해야 했습니다. 신청서에 있는 사진은 신청자가 올린 첨부파일입니다. 신청서를 삭제하면 같이 정리될 수 있습니다. 그런데 공개 명단에 쓰이는 사진은 공개 페이지에서 계속 보여져야 하는 데이터입니다. 신청서가 수정되거나 삭제됐다고 해서 공개 명단의 사진이 갑자기 사라지면 안 됩니다.

그래서 공개 명단용 데이터를 따로 두고, 어드민 상태가 바뀔 때 동기화했습니다.

상태가 참가 확정이 아니면
  공개 명단에서 제거하고 공개용 사진도 정리

상태가 참가 확정이면
  공개 명단에 UPSERT

여기서도 반복 처리 문제가 있었습니다. 어드민이 참가 확정과 보류를 여러 번 반복하면 공개용 사진이 계속 쌓일 수 있습니다.

그래서 공개 명단 사진의 파일 key는 고정했습니다.

공개명단/{신청번호}/사진.{확장자}

같은 신청번호라면 같은 key에 덮어씁니다. DB도 신청번호 기준으로 UPSERT합니다. 이렇게 해두면 상태를 여러 번 바꿔도 row는 하나만 남고, 사진도 계속 누적되지 않습니다.

또 내부 파일 URL인지 외부 URL인지도 확인했습니다. 내부 파일이면 key를 추출해서 복사하고, 외부 URL이면 그대로 사용했습니다. 이미 공개 명단 영역에 있는 URL이면 다시 복사하지 않도록 했습니다. 보류와 확정을 반복할 때 같은 파일을 계속 복사하는 것도 낭비였기 때문입니다.


신청 기간 체크는 한 군데로 모았다

신청 마감 이후에는 새 제출뿐 아니라 폼 수정, 임시저장, 파일 업로드도 막혀야 합니다.

처음에는 각 핸들러마다 if 문을 넣었습니다. 하지만 이 방식은 빠뜨리기 너무 쉽습니다. 새 API를 만들 때 한 군데라도 놓치면 마감 이후에도 데이터가 바뀔 수 있습니다.

그래서 기간 체크를 함수 하나로 모았습니다.

수정 가능한 신청 기간인지 확인한다

그리고 데이터가 바뀌는 API에서는 이 함수를 먼저 호출하도록 했습니다.

임시저장
제출
제출 후 수정
파일 업로드
폼 데이터 변경

날짜 비교도 서비스 기준 시간대로 처리했습니다. 서버 기준 시간과 사용자가 실제로 인식하는 날짜가 다르면 자정 근처에서 이상한 일이 생길 수 있기 때문입니다.

작은 함수 하나지만, 이런 게 빠지면 운영 사고로 바로 이어질 수 있다고 느꼈습니다.


폼 데이터는 JSON으로 저장했다

신청서 본문에는 값이 많았습니다. 이름, 생년월일, 국적, 학력, 옵션, 동행자, 결제 정보, 첨부파일 URL 같은 값들이 계속 붙었습니다.

처음에는 전부 컬럼으로 풀어야 하나 고민했습니다. 그런데 폼 항목은 운영 중에도 자주 바뀔 수 있습니다. 필드가 추가되거나, 라벨이 바뀌거나, 다국어 필드가 늘어날 수 있습니다. 그때마다 마이그레이션을 돌리는 건 부담이 컸습니다.

그래서 본문 데이터는 JSON 컬럼 하나에 저장했습니다. 대신 자주 검색하거나 정렬해야 하는 값은 컬럼으로 뺐습니다.

컬럼으로 분리한 값
- email
- 신청번호
- 회차키
- status

JSON에 남긴 값
- 대부분의 신청서 본문 데이터

대신 비용도 있었습니다. 이름이나 특정 항목으로 검색하려면 JSON 경로를 직접 타야 합니다.

JSON 안의 '신청정보 > 이름' 값을 꺼내서 검색 조건으로 사용

ORM의 타입 안정성을 일부 포기하는 부분도 생겼습니다. 그래도 폼 구조가 계속 바뀌는 상황에서는 이 방식이 더 현실적이었습니다.

국적처럼 언어에 따라 입력값이 달라질 수 있는 값은 저장 전에 정규화했습니다. 같은 의미라도 한글과 영문 값이 다르게 들어올 수 있는데, DB는 그걸 자동으로 알지 못합니다. 그래서 표준화된 코드를 같이 저장해두는 방식으로 처리했습니다.

결국 기준은 단순했습니다.

자주 찾는 값은 컬럼으로 빼고, 폼 구조에 가까운 값은 JSON에 둔다.


결제 금액은 프론트 값만 믿지 않았다

결제 금액도 처음에는 프론트에서 계산한 값을 저장하면 끝이라고 생각했습니다.

하지만 옵션이나 동행자, 특정 조건에 따른 면제 규칙이 들어가면서 금액 계산이 생각보다 복잡해졌습니다. 여기에 요금 설정이 중간에 바뀔 수도 있고, 프론트 계산식에 버그가 있을 수도 있습니다.

그렇다고 서버에서 조용히 값을 고쳐버리는 것도 위험했습니다. 운영자가 왜 금액이 바뀌었는지 모르면 더 큰 혼란이 생길 수 있습니다.

그래서 어드민 화면에서는 저장된 금액과 현재 기준으로 다시 계산한 금액을 둘 다 보여주고, 다르면 경고를 띄우도록 했습니다.

저장된 결제 금액과 현재 기준으로 계산한 합계가 다르면
  어드민 화면에 경고 문구를 표시한다

자동 수정이 아니라 경고를 선택한 이유는, 이 데이터가 운영 판단과 연결되어 있었기 때문입니다. 애매한 값은 시스템이 몰래 고치는 것보다 사람이 확인할 수 있게 보여주는 쪽이 안전하다고 봤습니다.


첨부파일 정리는 하드코딩하지 않았다

신청서에는 첨부파일이 여러 곳에 들어갑니다. 사진, 신분 확인용 파일, 추천서, 악보, 음원, 동행자별 파일처럼 필드가 계속 늘어날 수 있었습니다.

처음에는 특정 경로를 하나씩 꺼내서 삭제했습니다.

신청정보.첨부파일A
신청정보.첨부파일B
신청정보.첨부파일C

처음에는 이게 제일 빠르고 단순했습니다. 문제는 폼 구조가 바뀔 때마다 삭제 코드도 같이 바꿔야 한다는 점이었습니다. 그리고 이런 정리 코드는 자주 놓칩니다.

그래서 JSON 전체를 재귀적으로 돌면서 내부 파일 URL을 수집하도록 바꿨습니다.

fn collect_file_urls(value: &JsonValue, internal_prefix: &str, out: &mut Vec<String>) {
    match value {
        JsonValue::String(s) if s.starts_with(internal_prefix) => out.push(s.clone()),
        JsonValue::Array(arr) => {
            arr.iter().for_each(|v| collect_file_urls(v, internal_prefix, out))
        }
        JsonValue::Object(map) => {
            map.values().for_each(|v| collect_file_urls(v, internal_prefix, out))
        }
        _ => {}
    }
}

이렇게 해두면 폼 구조가 바뀌어도 내부 파일 URL이면 삭제 대상에 들어옵니다.

그리고 첨부파일 삭제는 DB 트랜잭션 밖에서 처리했습니다. 파일 저장소는 외부 시스템이라 느릴 수도 있고 실패할 수도 있습니다. DB 트랜잭션 안에 넣으면 파일 삭제 실패 때문에 DB 변경까지 흔들릴 수 있습니다.

그래서 DB 변경은 먼저 commit하고, 첨부파일 삭제는 이후 best-effort로 처리했습니다.

DB 삭제 commit
이후 첨부파일 삭제 시도
실패하면 로그와 실패 건수만 남김

외부 시스템 정리는 중요하지만, DB 트랜잭션과 같은 수준의 일관성을 기대하면 안 된다고 느꼈습니다.


정리하면서 든 생각

이번 작업을 하면서 제일 많이 느낀 건, 단순해 보이는 폼일수록 실제 운영에서는 단순하지 않다는 점이었습니다.

특히 아래 부분은 다음에 다른 신청서 시스템을 만들 때도 계속 신경 쓸 것 같습니다.

동시 요청은 UNIQUE + ON CONFLICT로 방어하기
제출 API는 멱등하게 만들기
외부 시스템 정리는 트랜잭션 밖에서 처리하기
라이프사이클이 다른 데이터는 분리하기
회차나 시즌 키는 운영 방식과 같이 고민하기
의도적인 비대칭 정책은 주석으로 남기기
어드민 화면에서는 자동 수정보다 경고를 우선하기

처음에는 이메일 인증하고 폼 작성하고 결제하면 끝이라고 생각했습니다. 그런데 실제로 만들어보니 외부 사용자가 여러 번 들어왔다 나가고, 결제 버튼을 여러 번 누를 수 있고, 어드민이 상태를 바꾸고, 파일 저장소와 메일 발송 서비스까지 얽히는 흐름이었습니다.

이번에 겪은 시행착오는 다음 작업을 할 때 꽤 큰 기준이 될 것 같습니다. 단순한 폼이라고 생각했던 기능도, 실제 운영까지 생각하면 하나의 작은 제품처럼 봐야겠다는 생각이 들었습니다.

긴 글 읽어주셔서 감사합니다.