주요 기술 의사결정 요약¶
이 문서는 구현·검증된 결정과 구현 전에 작성한 설계의 출발점을 구분합니다. 2차 기능은 현재 제품 코드에 반영됐지만, 모든 상세 경계 Test-ID가 재실행됐다는 뜻은 아닙니다. 상태에는 구현과 검증을 따로 적습니다.
DEC-01. 같은 출처의 단일 Django ASGI 애플리케이션¶
- 이유: 세션, CSRF, Origin, 객체 권한을 하나의 애플리케이션에서 일관되게 처리하기 위해서입니다.
- 결정: Django 5.2 LTS, Templates, vanilla JavaScript, Channels를 사용합니다.
- 데이터 경계: PostgreSQL은 영속 데이터의 기준이고 Redis는 실시간 전달에만 사용합니다.
- 상태: 구현·자동 검증 PASS.
DEC-02. 채팅은 DB 저장 성공을 수락 기준으로 사용¶
- 문제: Redis 전달 성공을 먼저 반환하면 DB 저장에 실패했을 때 화면에만 보이는 메시지가 생길 수 있습니다.
- 결정: client UUID로 중복을 식별하고 PostgreSQL 커밋 후 수락 응답을 보냅니다.
- 장애 처리: Redis 전달에 실패해도 저장은 유지하고 클라이언트는 마지막 메시지 ID 이후 이력을 다시 조회합니다.
- 상태: 구현·자동 검증 PASS.
DEC-03. 제재 상태는 DB 현재 시각으로 계산¶
- 문제: 별도 상태 플래그나 예약 작업에만 의존하면 만료 시점에 HTTP·WebSocket·조회 경로마다 상태가 다르게 나타날 수 있습니다.
- 결정: 시작·만료 시각과 DB 현재 시각으로 사용자 휴면과 상품 비노출 효력을 계산합니다.
- 제약: 제재는 가역적이며 물리 삭제하지 않습니다.
- 상태: 구현·자동 검증 PASS.
DEC-04. 안전한 이미지 직접 업로드¶
- 이유: 상품당 0~4장 이미지를 선택적으로 지원하면서 스크립트·메타데이터·과대 이미지 위험을 줄이기 위해서입니다.
- 결정: JPEG·PNG·WebP, 5MiB, 4096×4096으로 제한하고 완전 디코딩 뒤 새 파일로 재인코딩합니다.
- 상태: 구현·자동 검증 PASS. 이미지가 없는 상품 등록도 허용합니다.
DEC-05. 모의 이체는 온라인 잔액과 불변 이중 분개를 함께 사용¶
- 문제: 잔액만 저장하면 변경 근거를 증명하기 어렵고, 원장만 매번 합산하면 온라인 조회와 잠금 비용이 커집니다. 재전송·동시 요청·프로세스 종료로 중복 또는 부분 이체가 발생할 수도 있습니다.
- 결정:
MockAccount.balance를 온라인 권위값으로 사용합니다. 모든 journal은 정확히 두 entry와 합계 0을 가지며 deferred constraint trigger가 커밋 시 검증합니다. journal/entry UPDATE·DELETE는 DB trigger로 거부하고 seed는 사용자 +100,000.00·SEED_RESERVE-100,000.00입니다. seed만 대상으로 하는 부분 고유 제약으로 계정당 한 번만 발행하되TRANSFER·COMPENSATION은 허용합니다. - 이체 트랜잭션: 내부
Decimal(12,2)와 원장은 기존 정밀도를 유지하지만, HTTP·화면 입력은 1~99,999,999 정수 원화만 받습니다. 화면·알림·응답은100,000원처럼 소수 없이 표시합니다. canonical payload는 버전, 변형하지 않은 수신자 아이디 원문, 금액 고정 2자리 문자열로 구성하며 키 순서가 고정된 UTF-8 JSON입니다. stable digest(sender,key)advisory lock 뒤 저장 결과를 확인하고 두 계정을 PK 오름차순으로 잠급니다. - 재시도 계약: safety shared lock과 멱등성 lock 뒤 같은 키·payload의 최초 성공 또는 업무상 거부 HTTP 상태·body를
TransferSafetyState=BLOCKED중에도 영구 재현하고, 다른 payload는 409로 거부합니다. OPEN 검사는 저장 결과가 없는 새 키에만 적용합니다. PostgreSQL40001·40P01만 최초 시도 뒤 재시도할 때마다 새 트랜잭션을 열어 최대 3회 처리하고 네 번 모두 실패하면 503으로 종료합니다. - 금지: Redis 원장, 실제 결제, 직접 잔액 수정, 원장 수정·삭제, 관리자 임의 조정은 허용하지 않습니다. 조정은 정해진 형식의 반대 분개로만 수행합니다.
- 계정 종료: 인증된 소유자만 CSRF 보호 POST로 종료할 수 있습니다. 이체와 같은 계정 행 잠금에서 잔액 0·열린 상태를 다시 확인해 닫고 원장·감사는 보존하며 이후 송수신을 거부합니다.
- 대안: balance-only는 감사·복구에 취약하고 ledger-only는 조회 비용과 구현 복잡도가 커서 선택하지 않았습니다. 별도의 처리 중 카운터는 행 잠금과 충돌하고 stale 상태를 만들 수 있어 두지 않습니다.
- 결과와 복구: 새 이체·종료는 safety shared lock 안에서 OPEN을 확인하고, 대사·재개는 exclusive lock을 사용해 BLOCKED 전환 뒤 늦은 새 커밋을 막습니다. 저장된 같은-payload 201·422 재현은 BLOCKED 중에도 유지합니다. maintainer는 전체 불일치 0건과 정확한 incident를 확인해야 재개할 수 있습니다. 구현 뒤 barrier 경합과 무권한·불완전·정상 복구를 검증합니다.
- 상태: 구현됨·PR #43의 필수 CI PASS, 상세 경계·장애 재검증 필요.
DEC-06. 검색은 공개 범위를 먼저 고정하고 결정적으로 페이지를 나눔¶
- 문제: 검색 후 공개 여부를 걸러내면 숨김 상품의 존재나 결과 수가 새고, 임의 정렬·큰 페이지·비결정적 동률 순서는 주입과 자원 고갈, 중복·누락을 일으킬 수 있습니다.
- 결정: 기존 상품을 NFC로 backfill하고 쓰기 서비스와 DB CHECK로 저장값의 NFC를 강제합니다.
q도 trim→NFC 뒤 0~100자로 제한합니다. 공개 범위·allowlist 정렬·1..500페이지를 적용합니다. - 오류·비용 계약: 잘못된 입력과 501 이상은 상품 쿼리 전에 400입니다. 검색 서비스 블록은 middleware/auth/session을 제외하고 count 1회+slice 1회만 사용하며 최대 20행을 반환합니다. 내부 SQL·검색어 원문은 기록하지 않습니다.
- 대안: 전체 집합을 검색한 뒤 애플리케이션에서 필터링하거나 임의 ORM 정렬 문자열을 쓰면 정보 노출과 주입 위험이 있어 허용하지 않습니다. 현재 보고서가 요구하는 번호 페이지와 전체 건수를 유지하기 위해 cursor 방식 대신 제한된 OFFSET을 사용합니다.
- 결과와 검증: 조합/분해 문자, 정규화 전후 길이, 비노출 제외, 주입, 동률, 500/501, 인접 페이지와 서비스 블록 query budget을 검증해야 합니다.
- 상태: 구현됨·PR #43의 필수 CI PASS, 세부 query budget·경계 재검증 필요.
DEC-07. 관리 기능은 작업 권한·객체 범위·가역 변경으로 제한¶
- 문제:
is_staff하나나 Django 기본 CRUD만 사용하면 역할보다 넓은 열람·변경, CSRF, 오래된 화면 덮어쓰기, 감사 삭제가 가능해집니다. - 결정: 일반 작업에는 staff·codename·대상 grant가 모두 필요합니다.
AdminScopeGrant는 USER/PRODUCT FK 중 정확히 하나와 활성 중복 제약을 갖습니다.manage_admin_scope만 콘텐츠 grant가 필요 없는 meta-scope 예외이며 superuser와 직접 permission을 모두 요구합니다. - 권한 수명: scope 변경에도 300초 재인증, 10~500자 사유, CSRF, staff
auth_epoch/grant version이 필요합니다. 최초·복구 permission은 유효 최고관리자 0명일 때scope_bootstrap역할의 관리 명령만 부여하며, 전용 advisory lock 안에서 0명을 다시 검사하고 permission과 감사를 한 트랜잭션으로 커밋합니다. - 허용 범위:
view_report,apply_sanction,release_sanction,view_admin_audit만 grant 안에서 허용합니다. 비인증 HTML은 302, staff/codename 부족은 403, 미존재·범위 밖 대상은 같은 404입니다. 상태 변경에는 UTC 재인증 나이 300초 이하, NFC code point 10~500자 사유, CSRF와 최신 version이 필요합니다. - 금지: 비밀번호 hash·세션·비밀값·채팅 본문 열람, 잔액·원장·감사의 수정·삭제, 영구 삭제, break-glass 우회는 허용하지 않습니다.
- 대안: staff-only와 범용 superuser CRUD는 최소 권한 원칙에 맞지 않아 배제했습니다. 외부 object-permission 의존성 대신 작은 DB-backed grant를 사용해 대상과 취소 의미를 명시합니다.
- 결과와 검증: 감사 INSERT에 실패하면 업무와 함께 롤백해 503·DB 감사 0건이 되고 기존 감사 변경은 DB가 거부합니다. 제재는 적용 경합 시 활성/감사 각 1건, 정확한 7일 만료에는 활성 0·해제 기록 0, 만료 뒤 해제에는 409·충돌 감사 1건·
SanctionRelease0건, 조기 해제 경합에는SanctionRelease/감사 각 1건입니다. 역할·범위·시간·사유·bootstrap 경합·감사 장애와 기록 수를 검증합니다. - 상태: 구현됨·PR #43의 필수 CI PASS, 전체 HTTP·감사 장애·경합 재검증 필요.
DEC-08. 상품에서 시작하는 1:1 채팅¶
- 문제: 공개 프로필에서 별도 방을 무분별하게 만들면 상품 맥락과 판매자 권한을 잃고 기존 대화 이력을 나눌 수 있습니다.
- 결정: 상품 상세에서 판매자와 구매자의 고유 상품 방을 재사용합니다. 로그인하지 않은 사용자는 채팅·송금 화면에 들어갈 수 없습니다.
- 연결 상태: 연결 전·성공·종료 상태를 화면에 구분하고, 비정상 종료는 5회 지수 백오프로 제한합니다. 10초 안정 연결 뒤에만 재시도 횟수를 초기화합니다.
- 상태: 구현·PR #43·#44의 필수 CI PASS.
DEC-09. 전역 가로형 브랜드 로고¶
- 문제: 기호만 있는 정사각형 로고는 Stitch 화면의 브랜드 가로 비율과 맞지 않았습니다.
- 결정:
src/static/images/brand/wordmark.png를base.html의 공통 링크에서 사용하고, CSS에서 데스크톱·모바일 폭을 고정된 반응형 값으로 관리합니다. - 상태: 구현·PR #43의 browser-a11y PASS.