교회 재정 관리 앱(Oikos)에 비밀번호 초기화 기능을 추가하면서 사용자 피드백으로 3번 방향이 바뀌었다. "간단한 기능"이라고 생각했는데, 실제로는 보안과 UX 사이의 균형을 잡는 설계 문제였다.

3단계 진화

1차: 관리자가 초기화

처음 설계는 remove_user 패턴을 그대로 따라 AdminPage에서 관리자가 특정 사용자의 비밀번호를 NULL로 초기화하는 방식이었다. 자기 자신 초기화 차단, 세션 삭제까지 구현했다.

피드백: "비밀번호 초기화는 본인이 직접해야 하는데?"

2차: 셀프서비스 즉시 초기화

관리자 의존을 제거하고, 로그인 페이지에서 이메일만 입력하면 즉시 비밀번호가 NULL로 초기화되는 방식으로 변경했다.

피드백: "비밀번호 초기화 메일을 보내나요?" — 이메일 주소를 아는 누구나 남의 비밀번호를 초기화할 수 있는 보안 구멍이 있었다.

3차: 이메일 토큰 인증 + 새 비밀번호 설정

최종 설계:

  1. 로그인 페이지에서 이메일 입력 → 토큰 생성(30분 유효) → SMTP로 초기화 링크 발송
  2. 이메일 링크 클릭 → GET 엔드포인트에서 토큰만 검증 → 프론트엔드로 리다이렉트
  3. 프론트엔드 set-password 모드: 새 비밀번호 + 확인 2회 입력
  4. POST 엔드포인트에서 토큰 + 새 비밀번호 → bcrypt 해싱 후 저장

피드백: "이메일 링크를 클릭하면 새로운 비밀번호 등록하는 화면이 나와야 할 것 같은데. 새 비번 2번 입력하는..." — 기존 register 플로우로 보내는 것보다 전용 화면이 직관적이었다.

기술적 삽질 포인트

PostgreSQL TIMESTAMP 타임존 비교

토큰 만료 체크에서 30분이 지나지 않았는데도 항상 만료로 판정되는 버그가 있었다.

원인: datetime.now(timezone.utc).isoformat()2026-02-19T10:54:17+00:00 형태의 문자열을 반환하는데, PostgreSQL의 TIMESTAMP (without timezone) 컬럼은 naive datetime 객체를 반환한다. 문자열 비교 시 +00:00 접미사 때문에 항상 크다고 판정된다.

해결: 문자열 비교 대신 datetime 객체끼리 비교. naive datetime에 UTC timezone을 명시적으로 붙여준 후 비교.

SMTP 환경변수 미로딩

email_sender.py는 자체적으로 load_dotenv(scripts/.env)를 호출하지만, auth.py는 API 모듈이라 별도 로딩이 없었다. 같은 프로젝트의 .env 파일인데도 모듈마다 독립적으로 로딩해야 한다는 점을 놓쳤다.

API/프론트엔드 포트 분리

개발 환경에서 API(8000)와 프론트엔드(3000)가 분리되어 있는데, 리다이렉트 URL을 localhost:8000으로 보내면 구버전 빌드된 SPA가 응답한다. FRONTEND_URL 환경변수로 분리하여 해결.

uvicorn --reload와 백그라운드 실행

--reload 모드는 파일 변경을 감지하면 프로세스를 재시작하는데, 백그라운드로 실행하면 Task 완료로 보고되어 서버가 내려간 줄 모른다. nohup으로 실행해야 안정적.

아키텍처 결정

  • 토큰 저장: 별도 테이블 대신 allowed_userreset_token + reset_token_expires 컬럼 추가. 이 앱 규모에서는 이것으로 충분.
  • GET/POST 분리: GET은 이메일 링크 클릭 시 토큰 검증 + 프론트엔드 리다이렉트만, POST가 실제 비밀번호 설정. 토큰은 1회 사용 후 삭제.
  • LoginPage 4개 모드: login, register, reset(이메일 입력), set-password(새 비번 설정). URL 쿼리 파라미터 ?reset-token=xxx로 모드 자동 전환.

배운 것

"비밀번호 초기화"처럼 명확해 보이는 기능도 실제로 만들면 보안, UX, 인프라의 교차점에서 여러 결정을 내려야 한다. 처음부터 완벽한 설계를 하기보다, 빠르게 구현하고 사용자 피드백으로 방향을 잡는 게 더 효율적이었다.