Skip to content

Latest commit

 

History

History
1909 lines (1355 loc) · 145 KB

worklog.md

File metadata and controls

1909 lines (1355 loc) · 145 KB

작업 기록

날짜순으로 한 일 · 막힌 것 · 결정한 것을 남긴다. 최신이 위.

왜 이걸 쓰는가

세 가지 용도가 있고, 셋 다 나중에 값을 한다.

용도 설명
실패 문서의 원재료 막힌 순간의 증상과 에러 원문은 그때 적어두지 않으면 복원되지 않는다. failure-scenarios.md의 결과 칸은 여기서 옮겨 적는다
계측의 맥락 "이 수치를 잴 때 무엇이 달랐는가"는 숫자 옆에 없으면 사라진다
면접 답변 "어떻게 진행하셨나요"에 날짜와 사건으로 답할 수 있게 된다. 결과만 아는 사람과 과정을 아는 사람은 다르게 들린다

쓰는 규칙

  • 막힌 것을 먼저 적는다. 잘 된 건 커밋 로그에 남지만 막힌 건 아무 데도 안 남는다
  • 에러 메시지는 원문 그대로 붙인다. 요약하면 검색이 안 된다
  • 추측과 확인을 구분한다 — [추정] / [확인]
  • 하루가 비어도 빈 채로 둔다. 채워 넣지 않는다

2026-09-20 (일) · D-1 — 결제 이력 화면: 누구에게 무엇을 보여도 되는가

막힌 것

1. 출처를 접두어로 갈랐다가 운영자 스크립트 결제를 "방문자 버튼" 으로 셌다

증상 운영 화면을 찍어 보니 09-17 행이 "카드 없는 버튼" 이었다. 그 버튼은 09-19 에 생겼다. 원인 [확인] demo- 를 먼저 쓴 것은 운영자 스크립트 scripts/demo-cycle.sh(demo-<유닉스초>)였다. 버튼은 demo-<UUID> 다. 접두어를 정하려고 운영 결제를 GROUP BY 했을 때 예시 칸(MIN(idempotency_key))에 이미 demo-1789619982 가 떠 있었다 — 봤는데 넘겼다. 대응 접두어가 아니라 키 모양으로 가른다 — 방문자 둘은 접두어 + UUID 까지 맞아야 센다(f472bb8). demo-<숫자> 는 "1사이클 데모 스크립트". 배운 것 문자열 규칙은 지금 그 문자열을 만드는 코드만이 아니라 예전에 만든 코드 전부와 대조한다. grep 'demo-' 한 번이면 나왔다.

2. 흐름 칸이 폰에서 접힌 원인을 폭으로 짚고 한 번 배포했다

증상 390 폭에서 장부 흐름이 cre / ated 처럼 글자마다 접혔다. 상태를 <code> 하나씩으로 나누고 표에 최소 폭을 줬더니 이번엔 상태마다 한 줄(칸 높이 147px). 칸에 최소 폭을 더 줬다(0a56ed7) — 높이가 그대로였고, PC(1132px)에서도 172px 였다. 원인 [확인] 칸 클래스를 .flow 로 지었는데, 안내 화면 흐름도가 이미 .flow { display: grid } 였다. 칸 안의 <code> 와 화살표가 grid 의 행이 됐다 — 두 번째 증상은 내가 만든 것이다. 첫 증상(글자마다 접힘)은 좁은 칸 + code { overflow-wrap: anywhere } 로 본다 [추정 — 따로 떼어 재지 않았다]. 대응 .ledger-path 로 바꿨다(73c275a). 다시 재서 칸 높이 147 → 41px(폰 · 한 줄), 페이지 가로 스크롤 없음, 표만 332 → 909px 로 민다. 배운 것 고쳐도 숫자가 안 바뀌면 원인이 틀린 것이다 — 폭을 더 주기 전에 PC 에서도 똑같다는 것이 폭이 원인이 아니라는 증거였다. 클래스 이름은 grep 한 번으로 겹치는지 본다.

3. 확인 스크립트가 "링크가 없다" 고 했는데 있었다

헤드리스 확인에서 /pay 화면의 이력 링크가 빈 값으로 나왔다. 서버에서 curl 로 받아 보니 <a href="/pay/history">결제 이력</a> 이 있었다 — PowerShell 문자열 안의 CSS 선택자 따옴표가 깨진 것이다. 도구가 "없다" 고 하면 다른 경로로 한 번 더 본다.

4. 쿠키 정책표가 설계 때 그대로였다

새 쿠키를 적으려고 domains-and-cookies.md 3장을 열었더니 발급하지 않는 쿠키 셋(ab_sid · ab_tid · ab_g4cid)이 있고 실제로 나가는 쿠키 일곱이 없었다. config.php · Visitor.php 주석이 "이 표와 일치해야 한다" 고 적고 있었다. 운영 응답의 Set-Cookie 원문으로 다시 썼다 — 그러다 tp_csrf 가 SameSite=Strict 인 것과, 개인정보 처리방침 표에 /diag 의 tp_probe_tid 가 빠져 있는 것을 찾았다(추가).

한 일

결제 이력 화면 app./pay/history (d028163..73c275a) — 면접관·방문자가 "결제가 됐나, 이 시스템에 무엇이 있었나" 를 본다.

결정(사용자) 구현
내 결제 + 전체 목록 위: 이 브라우저에서 만든 결제 — 상세 링크까지. 아래: 최근 50건 — uid 앞 12자리(UUIDv7 의 시각)만, 링크 없이
확인용 행도 보이고 출처 표시 키 모양으로 출처(PaymentOrigin) — 카드 없는 버튼 · 카드 결제창 · 면접 시연 대본 · 동시성 측정 · 환불 시험 · 매체 전송 확인 · 배포 확인 · 1사이클 데모 스크립트. 기록에서 못 찾은 vt- 는 "기타 확인". 대사에서 뺀 대조군은 사유를 함께
입장권 없이 공개 상세(결과 화면)는 지금처럼 입장권
  • 남의 uid 를 다 보이지 않는 이유: 카드 없는 확정(/pay/stub/confirm)은 누가 만든 결제인지 보지 않는다(스텁 · created · demo- · 10분만). 전체 uid 가 공개되면 남이 방금 만든 시연 결제를 대신 확정할 수 있다. 그 컨트롤러 주석에 이 화면을 링크했다
  • "내 결제" 는 쿠키 tp_pay_mine(uid 최대 20 · 30일 · Path=/pay · 서명 없음 — 상세는 원래 입장권만 있으면 아무 uid 나 열어서, 쿠키가 여는 문이 새로 생기지 않는다)
  • "이니시스로 승인까지 간 결제 N건" 은 장부에서 센다(지금 0). 고정 문장으로 "없다" 고 쓰지 않는다
  • 개인정보 처리방침: tp_pay_mine 을 쿠키보다 먼저 올렸다. 시행일 9월 20일
  • 안내 화면 · 결제 화면 · 결과 화면에서 이력으로 가는 링크. README 「30초 안에 확인하기」 4단계

운영 확인 (헤드리스 Chrome, 카드 입력 없음)

확인 결과
쿠키 없이 이력 200 · 결제 27건 · 표 행 27 · "아직 없습니다" · 32자 uid 0개
안내 → 입장권 → 카드 없이 끝까지 → 결과 → 이력 링크 "이 브라우저에서 만든 결제" 1행(created → captured · 무시 1 · 30개 · 장부 보기). 아래 표 맨 위 행에 "내 결제" 배지. 32자 uid 2개 — 둘 다 내 결제의 링크
쿠키 tp_pay_mine app. 전용 · Path=/pay · Secure · HttpOnly · Lax · 30일 · 32자(1건)
폰(390) 페이지 가로 스크롤 없음 · 표만 민다 · 흐름 칸 한 줄

운영에 남긴 행: 01a0ba360cf2…(stub · captured, 이력 확인용 — GA4·Meta 로 구매 전환이 갔다). 다시 확인할 때는 결제를 새로 만들지 않고 이 uid 를 쿠키에 넣어 봤다.

단위 테스트 518 → 543.

결정한 것

결정 근거
출처는 키 모양(접두어 + 형식), 자기 신고라고 화면에 적는다 /purchase(스텁)는 인증이 없어 누구나 아무 키나 보낸다. 키 원문은 남이 정한 문자열이라 화면에 내지 않는다
쿠키를 서명하지 않는다 남의 uid 를 넣어도 상세는 원래 열린다. 막는 것은 uid 를 모른다는 사실이고, 화면이 그것을 지킨다
목록을 프라이머리에서 읽는다 방금 만든 결제가 "내 결제" 에 바로 떠야 한다 → ADR-007

2026-09-19 (토) · D-2 — 멀티 PG 0단계(이니시스): 원문을 받아 읽자 "확인"이 확인이 아니었다

막힌 것

1. "매뉴얼 대조 벡터 [확인-계산]" 이라고 적은 값을 매뉴얼과 대조한 적이 없었다

증상 공식 매뉴얼(2022-07-02 아카이브)의 signature 예시:

PlainText: oid=INIpayTest_1361252896871&price=1004&timestamp=1361252896871
Hash 데이터: ec1e9c63ddad0b81f316ada2e3861e81db5f4f6652d9ff0f255b4cb5499ce893

plan-multi-pg.md 4장에 "매뉴얼의 공개 대조 벡터" 로 적은 값은 422a0e78…b9192 였다. 원인 [확인] 422a0e78 은 검색 요약에서 온 값이었다. 내가 한 "재계산" 은 같은 평문을 다시 해시한 것이라 순환 확인이었다 — 매뉴얼 원문은 그때 보지 않았다. 그런데 매뉴얼 값 쪽도 재현되지 않았다. 변형 7개(&amp; · 끝 & · signKey/signkey 포함 · mid 포함 · 개행)를 돌려도 ec1e9c… 는 안 나온다. 이니시스 공개 해시 도구(/pay/demo/hash-sha256.php)에 같은 평문을 한 번 넣으니 422a0e78… 을 돌려줬다. [확인] 매뉴얼의 예시 해시가 매뉴얼의 예시 평문과 맞지 않는다. 우리 벡터는 맞지만 근거는 "매뉴얼" 이 아니라 "이니시스 해시 도구" 다. 대응 계획 문서의 근거를 고쳤다. 나머지 두 벡터는 원문과 직접 대조했다 — mKey(SHA256, 3a9503…)와 INIAPI 환불 hashData(SHA512, b2dc4d43…) 둘 다 원문 값과 일치. 배운 것 같은 입력을 두 번 해시한 것은 확인이 아니다. 대조 대상은 독립된 출처여야 한다. 그리고 공식 예시도 틀릴 수 있다 — 최종 판정은 스테이징이 우리 서명을 받아 주는가다.

2. 공식 샘플은 가맹점 로그인 뒤에 있다

증상 매뉴얼의 Download 는 download_popup('general_pc.zip') → /download/download.php?filename=general_pc.zip. 받으면 zip 이 아니라 로그인 폼이다.

<title>KG INICIS LOGIN</title>
<input type="text" name="mid_id" id="mid_id" placeholder="MID">
<input type="password" name="mid_pw" id="mid_pw" placeholder="상점 비밀번호">

해시 도구 페이지도 "테스트계정 signkey 는 샘플다운로드 시 확인가능합니다" 라고 적는다 — 현행 매뉴얼에서 테스트 키의 공식 경로는 로그인 뒤다. 대응 받지 않았다(비로그인 공개 자료만). 테스트 키는 이니시스가 예전에 공개했던 매뉴얼 원문을 웹 아카이브에서 확인했다 — std-info.php (2022-07-02) · iniapi/api-info.php (2021-06-19). [미확인] 그 키가 지금도 유효한가 — 스테이징 첫 결제·첫 환불에서 판정한다. 서명 평문의 필드 이름 대소문자(signKey)도 샘플에만 있어 같은 자리에서 판정한다.

3. 요약 도구가 PC 와 모바일 규격을 섞었다

증상 앞서 받은 요약: "Network cancellation can be requested within 10 minutes based on the authentication TID or 1 minute based on the approval TID". 원인 [확인] 원문을 받아 보니 그 문장은 모바일 절이다. PC 는 "인증결과 응답 후 10분 이내" 하나뿐이다. 대응 매뉴얼을 curl 로 받아 태그를 걷어 내고 읽었다. 이후 인용은 전부 그 텍스트에서 옮긴다.

4. 그대로 배포했으면 모든 이니시스 결제가 망취소됐다 — payment_events.source ENUM

증상 없음 — 배포 전에 찾았다. 코드를 다 쓰고 스키마를 대조하다가 봤다.

source      ENUM("api","webhook","admin") NOT NULL,

원인 [확인] 복귀 승인을 source='return' 으로 적게 짰는데 ENUM 에 없다. strict 모드라 INSERT 가 오류 → applyEvent 롤백 → db-error → 복귀 처리가 망취소한다. 단위 테스트는 DB 를 안 타서 502개가 다 통과했다. 대응 ENUM 끝에 return 을 더하는 마이그레이션(20260919000200, 메타데이터 변경만). 통합 테스트에 8단계를 더했다 — 동시 복귀 8개, 저마다 다른 tid → captured 전이 1 · return 이벤트 8 · 붙은 PG 번호 1. CI 와 같은 조건(로컬 MySQL 8.0)에서 마이그레이션 왕복 15개와 통합 1~8단계 통과. 배운 것 "문서는 기동 검증을 대신하지 못한다" 의 스키마판이다. 새 값을 쓰는 코드를 짜면 그 컬럼의 정의를 먼저 연다.

5. 브라우저 입력으로 결제를 failed 로 만들면 위조 요청 하나로 남의 결제를 막을 수 있다

원인 [확인] 처음 설계는 승인 전 거절(위조 authUrl · 다른 mid · 인증 실패 코드)도 failed 로 적었다. 전부 브라우저가 가져온 값이다. payment_uid 를 아는 사람이 위조 POST 하나로 정상 결제를 failed 로 만들면, 진짜 복귀가 왔을 때 CAS 가 막는다. 대응 결과 타입을 failed(PG 가 거절 → 장부 반영)와 rejected(요청 전 우리가 거절 → 장부 불변, 로그만)로 갈랐다 → plan-multi-pg.md C9. 운영에서 위조 복귀를 보내 created 그대로인 것을 확인했다.

6. .env 값 뒤의 주석을 sed 로 같이 읽었다 — 스모크 11건 전부 000

증상 서버 안에서 돌린 확인 11건이 모두 000. 앱 로그에 그 시간대 요청이 없었다. 원인 [확인] SHOP_DOMAIN=sshwan.com # 광고주 측 → … 를 sed -n 's/^SHOP_DOMAIN=//p' 로 읽어 URL 에 주석까지 붙었다. 같은 방식으로 만든 PAY_DEMO_BUYER_EMAIL 줄에도 주석이 복사됐다(Compose 는 값 뒤 # 을 주석으로 떼어 값은 멀쩡했다). 대응 값만 읽는다: sed -n "s/^KEY=\([^ #]*\).*/\1/p". 이메일 줄을 고치고 다시 돌려 11건 전부 기대대로. 배운 것 09-14 의 META_TEST_EVENT_CODE(주석이 값으로 들어감)와 같은 부류다. 이 .env 는 값 뒤에 주석이 붙어 있다 — 읽는 쪽이 그걸 안다고 가정해야 한다.

7. 운영에서 cli/seed 가 막혀 있다

production 에서는 막혀 있습니다. .env 에 SEED_ALLOWED=true 를 넣으세요.

대응 운영의 안전장치를 풀지 않았다. 09-17 에 만든 시드 회원(7번)을 PAY_DEMO_USER_UID 로 쓴다. [미확인] 그 원격 스크립트는 set -e 로 멈추면서 내가 넣은 안내 문구도 출력하지 않았다. 원인을 보지 않고 넘어갔다.

8. 시연 결제 화면의 제목이 한 글자씩 접혔다

원인 [확인] .sheet 는 12열 격자인데 레이아웃 클래스 없이 패널을 넣어 1/12 칸에 갇혔다. 결제창 확인 스크린샷에서 봤다. 대응 .layout-pay (bf9e085). 다시 찍어 한 칸으로 나오는 것을 확인.

운영에서 확인한 것 (실카드 없이)

확인 결과
토큰 없음 · 틀린 토큰 /pay 404 · 404
토큰 → 쿠키 → 화면 303 + tp_pay → 200, 스테이징 JS 1, 상품 버튼 3
/purchase pg=inicis 쿠키 없이 403 · 쿠키로 201 (created)
/pay/inicis/start currency=WON · acceptmethod=centerCd(Y) · price=3300 과 서명 필드
위조 복귀(공격자 authUrl) 303, 결제는 created 그대로 — 승인 요청 없음 (C1 · C9)
모르는 주문 복귀 200 안내, 승인 요청 없음
스텁 경로 그대로 201
무과금 ① 결제창 헤드리스 Chrome 으로 버튼 → /purchase 201 → start 200 → stgstdpay.inicis.com/payMain/pay 200. 결제창에 "코인 30개 · 3,300원" 과 카드사 선택이 떴다 — 우리 서명을 받았다. X → "결제를 취소하시겠습니까?" → 확인 → GET /pay/inicis/close 200 → 오버레이 사라짐. 카드는 입력하지 않았다
무과금 ② INIAPI 키 없는 tid 로 환불 → 01 해당거래 없음(detail 400623). 대조군 틀린 키 → ERR205 hashData 불일치. 공개 테스트 키가 지금도 유효하고 해시 형식·KST 시각을 받아들였다 [확인]
닫기 페이지와 X-Frame-Options 닫기 URL 이 불리고 결제창은 닫혔다. 우리 페이지가 프레임 안에서 그려졌는지는 가려내지 못했다

확인하지 못한 것: 승인 요청(STEP3)의 서명 · 망취소 · 환불 성공. 실카드 없이는 닿지 않는다(사용자 결정).

운영에 남긴 확인 데이터 — 전부 created, 승인된 것 없음. 결제창 확인은 GA4·Meta·수집 요청을 막고 찍었다.

payment_uid pg 무엇
01a0b6f0e0b97e628664529b32848cbd inicis 스모크 — 위조 복귀를 받은 결제 (verify-pay-…)
01a0b6f0e2bc762bbd3d787c1e56403d stub 스모크 — 스텁 경로 (verify-stub-…)
01a0b6f2b4bf… 01a0b6f3a762… 01a0b6f45072… inicis 헤드리스 결제창 확인 3회 (키가 pay-<uuid> 라 이름으로는 못 가린다 — 여기 적어 둔다)

오래된 결제 보고(D3)에 이 5행이 잡힌다. 지우지 않는다(09-18 규칙).

오후 — 안내 탭에서 결제를 해 본다 (C4 번복, 5f46670)

결정 면접관이 별도 링크 없이 해 볼 수 있게, 공개로 열되 안내 화면(/tour#pay)에 입장권 받기 버튼을 둔다(사용자 결정 · 추천은 "토큰 유지 + 안내에서 연결"이었다). 받아들이는 것은 plan-multi-pg.md 6장 「C4 를 뒤집은 것」.

  • 입장권을 방문자마다 서명해 준다(v1.<만료>.<임의값>.<HMAC>, 만료 1일). 공유 비밀의 해시를 모두에게 똑같이 주던 쿠키보다 만료가 있고, 입장권이 새도 비밀은 안 샌다
  • 버튼은 POST(크롤러는 받지 않는다). 입장권 없이 /pay 에 오면 404 대신 받기 안내
  • 규칙 7로 따라가다 안내 화면의 낡은 문장 둘을 찾았다 — "실제 PG 는 붙이지 않았습니다", 흐름도 "결제 확정은 웹훅으로 온다". 오전에 grep 한 패턴(실제 PG 연동은 범위 밖 등)에 이 표현이 없어서 놓쳤다. 개념 grep 이 문구 grep 을 대신하지 못했다

확인(운영) 새 프로필 헤드리스 Chrome 으로 /tour#pay → 버튼 → app./pay 도착, 결제 스크립트 로드 · 상품 버튼 3. 결제는 만들지 않았다. POST /pay/access 는 303 /pay + tp_pay=v1.<만료>.…(만료 1일). 중간에 CSRF 설정 주석에 "CSRF 쿠키는 app. 것이라 토큰을 실을 수 없다" 고 썼다가 설정을 열어 보니 .<도메인> 이었다 — 고쳤다. "있다/없다" 를 적기 전에 확인한다 를 같은 날 또 어길 뻔했다.

오후 ② — 면접관이 "되는지" 볼 수 있게: 카드 없는 한 바퀴 (25abec2)

판단 면접관은 데모를 눌러 되는지만 빠르게 보고, 충돌 테스트는 면접에서 물을 것이다(과제 전형이 없다). 그런데 모르는 포트폴리오에 실카드를 넣는 사람은 드물다 — 이니시스 경로로는 "결제창이 뜬다" 까지만 보인다. 우선순위를 사용자와 정했다: ① 카드 없는 길 ② 모바일 ③ 닫은 뒤·가벼운 괴롭힘 ④ 면접 시연 대본. 페이팔은 보류.

모바일 실측(질문 전에) iPhone 흉내로 결제 버튼을 누르니 이니시스가 경고창을 띄우고 결제창을 열지 않았다:

[INIStdPay / Dev. Error]

해당기기로는 정상적인 결제가 진행되지 않을 수 있습니다. PC로 결제 진행을 부탁드립니다.

한 일 /pay 맨 위에 "카드 없이 끝까지" — /purchase(스텁, demo- 키) → POST /pay/stub/confirm 이 서명한 captured 웹훅을 자기 /webhooks/pg 로 같은 바이트 두 번 → 결과 화면. 결과 화면에 코인 지급 · 매체별 전송 상태를 더했고, "한 번만 나갔다" 를 고정 문장으로 말하지 않고 장부에서 센 값을 보인다. 확정 엔드포인트는 "그 버튼이 방금 만든 결제만"(스텁 · created · demo- · 10분). 스텁 본문은 cli/pg 와 같은 함수(StubWebhook::body)로 옮겼다.

확인(운영) 헤드리스로 /tour#pay → 입장권 → "카드 없이 끝까지" → /pay/result/01a0b7b50b80…. 화면: 승인 — 코인 지급 · 30개(지급 건 1) · GA4 sent · Meta sending · 확정 전이 1 · 무시 1. DB 대조: transitions 1 · ignored 1 · lots 1, 아웃박스 ga4 sent/1 · meta sent/1. 두 웹훅은 59ms 간격(03:28:11.017 · .076).

받아들인 것 매체로 그대로 보낸다(사용자 결정) — 이 확인 한 번도 운영 GA4·Meta 에 3,300원 구매 전환을 남겼다. "서명이 GA4 오염을 막는 실질 방어선" 이라는 전제에 서버가 대신 서명하는 문이 생겨서, 그 전제를 적은 코드·문서 다섯 곳에 예외를 링크했다(규칙 7).

운영에 남은 행(오후)

payment_uid pg · 상태 누구
01a0b799c3fb… inicis · created 내 모바일 흉내 (UA iPhone OS 18_5)
01a0b7b50b80… stub · captured 내 카드 없는 한 바퀴 확인 — GA4·Meta 전송됨
01a0b78e2145… inicis · created 내 요청이 아니다 — UA iPhone OS 18_7 실기기, 02:45:40 UTC. 휴대폰으로 결제를 눌러 본 누군가 [추정: 사용자의 확인]

오후 ③ — 모바일은 안내로 (B10 번복, dec358f)

한 일 모바일에서는 이니시스 버튼·스크립트를 그리지 않고 "카드 결제창은 PC 에서" 를 보인다. 카드 없는 길은 그대로. 판별은 엣지의 map $is_mobile 을 fastcgi_param AB_IS_MOBILE 로 넘겨 한 곳에 둔다.

막힌 것 — 원격 스크립트가 reload 전에 끝났다 ssh … 'bash -s' <<REMOTE 로 넘긴 스크립트에서 nginx -t 출력 뒤로 아무것도 나오지 않았다. 원인 [확인] 스크립트 본문이 bash 의 stdin 인데, 그 안의 docker compose exec -T 가 남은 stdin 을 읽어 버렸다 — 뒤의 reload·확인 명령이 통째로 먹혔다. 오전의 "원격 스크립트가 안내 문구도 없이 끝났다"(막힌 것 7 의 [미확인]) 도 같은 원인일 가능성이 크다 [추정] — 그 스크립트도 docker compose exec -T 뒤에 있었다. 대응 원격 스크립트 안의 docker compose exec 에 </dev/null.

확인(운영) nginx -t 통과 → nginx -s reload(rc 0). 같은 입장권으로 UA 만 바꿔 /pay:

UA 이니시스 스크립트 이니시스 버튼 PC 안내 카드 없는 버튼
iPhone 없음 없음 있음 있음
PC 있음 3 없음 있음

헤드리스 iPhone 흉내 스크린샷: 가로 스크롤 없음(폭 390). lp. 의 모바일 302 는 그대로(302).

찾은 것 둘 — 이번엔 적어만 둔다

  • .env.example 의 MOBILE_REDIRECT_ENABLED 를 읽는 코드가 없다. 302 는 늘 켜져 있다 — 문서가 없는 스위치를 있다고 말하고 있었다(주석을 고쳤다)
  • reload 때 엣지가 lua_code_cache is off; this will hurt performance 경고를 낸다(nginx.conf:44). 운영에서 Lua 를 요청마다 다시 읽고 있다 — 성능 계측 수치의 전제일 수 있어 따로 본다

오후 ④ — 닫은 뒤와 가벼운 괴롭힘 (e1002fb)

한 일 결제창을 카드 없이 닫으면 결과 화면으로 간다 — 닫기 URL 에 결제 uid 를 싣고(?uid=), 닫기 페이지가 상위 화면을 /pay/result/{uid}?closed=1 로 옮긴다. 결과 화면은 created 를 "결제 생성 — 승인 없음 · 청구되지 않음" 으로, 없는 결제는 JSON 대신 화면(404)으로 말한다.

운영에서 눌러 본 것 (헤드리스 PC, 카드 입력 없음)

동작 결과
이니시스 결제창 → X → 확인 /pay/result/01a0b7cc66d2…?closed=1 — "결제창을 닫았습니다. 승인을 요청하지 않았고, 청구되지 않았습니다." 닫기 페이지가 우리 화면을 부모로 둔 프레임에서 열린다는 [추정]이 맞았다
결과 화면 새로고침 같은 화면
"카드 없이 끝까지" 를 같은 틱에 두 번 click() 결제 1건(01a0b7ccbb7e…, captured) — 버튼 비활성이 두 번째를 막았다
결과 화면에서 뒤로 가기 /pay, 버튼 켜짐 · 문구 비어 있음

잘못 짚은 것 뒤로 가기를 하면 bfcache 가 "비활성 버튼 + 보내는 중…" 을 그대로 되살릴 거라고 예상하고 재현부터 하려고 수정을 미뤘다. 재현되지 않았다. /pay 응답이 Cache-Control: no-store, no-cache, must-revalidate(PHP 세션이 붙인다)라 브라우저가 이 페이지를 bfcache 에 넣지 않는다 — Chrome 은 실측, Safari 는 [추정]. 재현되지 않은 버그라 코드를 넣지 않았다.

운영에 남은 행: 01a0b7cc66d2…(inicis · created, 닫기 확인) · 01a0b7ccbb7e…(stub · captured, 두 번 누르기 확인 — GA4·Meta 로 전송)

오후 ⑤ — 면접 시연 대본과 README

면접 시연 대본 충돌 상황은 면접에서 "보여 줄 수 있나요?" 로 올 것이라, 운영 서버에서 3분 안에 도는 대본을 만들었다 — 서버 접속 명령이 들어 있어 저장소 밖에 둔다. 한 번 돌려 출력 원문을 예비로 남겼다(현장 네트워크가 막힐 때).

장면 운영 결과
같은 captured 웹훅 8개 동시(cli/pg sign + xargs -P8, D-3 과 같은 방식) applied 1 · ignored 7 · 코인 지급 건 1 · 전환 1
순서 역전 — captured 뒤 pending 200 ignored · captured_at 불변
캡처 전 환불 409 → 캡처 200 → 환불 재전송 200 · coins_revoked 30 · 전환 2(구매+환불)
위조 복귀(공격자 승인 URL) 303 · created 그대로 · 로그 승인 전에 거절했다(장부 불변) … untrusted-auth-url

README 맨 위에 "결제 해 보기" 와 30초 안에 확인하기(안내 → 입장권 → 카드 없이 끝까지 → 결과 화면). "아직 없는 것" 의 PG 줄을 지금 상태로(카드 결제창은 PC 만 · 페이팔 보류) 고치고, 시연 결제가 매체로 구분 없이 간다는 줄을 더했다. "휴대폰에서도 됩니다" 라고 쓰고 보니 휴대폰 흉내에서는 버튼이 보이는 것까지만 확인했었다 — 눌러서 결과 화면까지 가는 것을 확인한 뒤에 남겼다.

운영에 남은 행(대본 · README 확인) — 대본은 멱등 키 interview-*

payment_uid pg · 상태 무엇 · 매체
01a0b7d1b6de… stub · captured 장면 1·2 — 구매 전환 GA4·Meta
01a0b7d1bdc5… stub · refunded 장면 3 — 구매 + 환불 전환
01a0b7d1c289… inicis · created 장면 4
01a0b7d3052d… stub · captured 휴대폰 흉내 "카드 없이 끝까지" — 구매 전환

저녁 — ADR-020, 그리고 결정만 하고 빠뜨린 것 (b204461, 40789ae)

막힌 것 1 — "오래된 결제 보고" 가 없었다 증상 ADR-020 결정 7에 "cli/verify payments 의 오래된 결제 보고" 를 적으려고 코드를 열었더니 그런 항목이 없었다. 원인 [확인] 오전에 D1·D3 을 "오래된 결제 보고 + 당일 환불" 로 정하고(plan 6장) 구현하지 않았다. 그런데 그 보고를 근거로 드는 곳이 다섯 군데였다 — Pay.php · Purchase.php · pay/index.php 주석, api-spec.md, 이 기록의 오전 절("오래된 결제 보고(D3)에 이 5행이 잡힌다"). 대응 넣었다(b204461). 1시간 넘은 created·pending 을 PG·상태별로 보이고, 판정(종료 코드)에는 넣지 않는다 — 결제창을 닫고 떠난 결제는 정상이라 넣으면 cron 이 영원히 실패한다. 배운 것 "결정했다" 와 "있다" 는 다르다. 결정 표에 적은 대응은 코드에서 grep 해 보기 전까지 없는 것으로 친다.

운영 확인 배포 뒤 cli/verify payments 90:

결제 대사 — 최근 90일 · 결제 25건
  제외 01a09e00baa67f84b1de97629bdab651  D-3 대조군 1차 (2026-09-14, 재현 안 됨)
  제외 01a09e02e4337df0869e2da42a5ba74b  D-3 대조군 2차 (2026-09-14, 코인 2회 지급을 일부러 재현)
  오래된 created·pending 9건 (60분 넘게) — 보고만, 판정에 넣지 않음 · inicis created 8 · stub created 1
  …
  어긋난 것 없음

9행 모두 위 표들에 있는 것이다 — 스모크 2 · 헤드리스 결제창 3 · 실기기(iOS 18.7) 1 · 내 모바일 흉내 1 · 닫기 확인 1 · 면접 대본 장면 4 1. 모르는 행은 없다.

막힌 것 2 — 설정을 보지 않고 CSRF 제외 범위를 적었다 ADR-020 초안에 "복귀·닫기 경로만 제외 목록에" 라고 썼다. config.php 를 여니 pay/inicis/.* 전체(시작 포함)이고, 경로마다 CSRF 대신 무엇이 막는지가 주석에 있다. 고쳤다. 오후에 CSRF 쿠키 도메인을 두고 한 실수와 같은 부류다 — 같은 날 두 번째.

따라가다 찾은 것 둘

  • decisions/README.md 표에 ADR-019 가 없었다. 09-14 에 ADR 을 쓰고 목록을 고치지 않았다. 019·020 을 넣고 012→019 · 016→020 링크를 걸었다
  • ADR-017 의 디렉터리 목록에 Notify/(NotifierInterface · WebPushNotifier · SmsNotifier)가 있는데 그런 디렉터리가 없다. 알림은 ADR-016 의 Phase 2 다. "없음" 을 달았다

ADR-020 에 적은 것 중 가장 불편한 것 — ADR-016 은 "두 번째 PG 에서 인터페이스가 검증된다" 고 했다. GatewayInterface 의 구현체는 InicisGateway 하나이고, 스텁은 어댑터가 아니다. 이번에 두 경로(웹훅 · 동기 복귀)로 검증된 것은 인터페이스가 아니라 상태 전이다. 인터페이스는 페이팔을 붙이기 전까지 검증되지 않은 채다.

한 일

  • 멀티 PG 계획 공개본, 리스크별 결정(6장), 세금 인터페이스와 통과 구현 (349e597, 731baff)
  • 안내 화면: 화면이 없는 걸음에 코드 링크 (0e5b80b)
  • 0단계(이니시스): 현행 매뉴얼 3쪽(PC 일반결제 · 취소/환불 · 모바일)과 아카이브 2쪽을 원문으로 받아 읽었다. 요청은 매뉴얼 페이지 5 · 해시 도구 1 · 아카이브 조회 몇 번 (23319c7)
  • 1단계(이니시스): GatewayInterface · InicisGateway · 호스트 허용 목록 · 해시 · PayloadRedactor · 시연 토큰 · /pay 화면 · cli/pg refund·list · PG 번호 테이블 · 개인정보 처리방침 (4af9563). 단위 테스트 502개, 게이트웨이 45개. 호스트 검사를 endsWith 로 바꾸는 변이에서 3건 실패하는 것을 확인했다
  • 배포: 마이그레이션 20260916000100 → 20260919000200, .env 에 이니시스 테스트 설정(백업 .env.bak-20260919), 앱 재생성. 레이아웃 수정 (bf9e085)

원문으로 확인한 규격

항목 원문 (축자) 출처
승인 호스트 스테이징 stgstdpay.inicis.com · 운영 fcstdpay.inicis.com ksstdpay.inicis.com PC 일반결제 방화벽정보
idc_name "IDC센터코드 [fc, ks, stg] 승인요청 시 authUrl 과 비교검증 필요" 같은 쪽 STEP2
authUrl "이니시스 제공 승인API 가 맞는지 확인 필요 (IDC센터코드와 비교 검증 필요)" 같은 쪽 STEP2·3
센터코드 수신 acceptmethod 에 "centerCd(Y)" IDC센터코드 수신 사용옵션 세팅 필수 — 없으면 idc_name 이 안 온다 같은 쪽 STEP1
JS 운영 https://stdpay.inicis.com/stdjs/INIStdPay.js · 테스트는 "상용JS (테스트JS 에서 stg 제거)" 같은 쪽 · std-info(2022)
signature "NVP 방식으로 연결한 데이터를 SHA256으로 Hash" · "필드 순서 유지(알파벳순), 마지막 &는 생략, 공백생략" std-info(2022)
승인 timestamp "인증요청 시 timestamp값과 상이" std-info(2022)
망취소(PC) "인증결과 응답 후 10분 이내" · "망취소를 일반 결제취소 용도로 사용하지 마십시오." · netCancelUrl "승인요청 후 승인결과 수신 실패 / DB저장 실패 시" PC 일반결제
테스트 MID "결제테스트 시 지불수단별로 거래가 실승인 됩니다." · "당일 자정 이전에 자동취소 됩니다. (매입전송X)" · "부분취소 테스트는 권장하지 않음" std-info(2022) · api-info(2021)
스테이징 "테스트MID 만 사용가능" 같은 두 쪽
환불 POST https://iniapi.inicis.com/api/v1/refund(스테이징 stginiapi.inicis.com) · form · type "Refund" 고정 · timestamp [YYYYMMDDhhmmss] · hashData "SHA512 … 대상 : INIAPIKey + type + paymethod + timestamp + clientIp + mid + tid" · 성공 resultCode "00" (승인은 "0000") 취소/환불
통화 "WON":한화,"USD":달러 · "USD는 카드 결제만 가능" · price "1달러는 100으로 시작" PC 일반결제 STEP1
returnUrl "결제요청페이지 도메인과 일치하도록 리턴 URL을 수정해 주세요." 같은 쪽

새로 나온 리스크

  1. 구매자 이름·휴대폰·이메일이 결제 요청 필수다(buyername* buyertel* buyeremail*). 가입이 없어 받을 곳이 없다. 이니시스는 이 값을 승인 결과로 되돌려준다(buyerName buyerTel buyerEmail) → 원문 허용 목록(C5)에서 뺀다
  2. 이번 범위는 PC 웹표준뿐이다. 모바일은 파라미터(P_)·금액 해시(SHA512)·망취소 창이 다른 별개 규격이라, 모바일 브라우저에서는 결제할 수 없다
  3. 개인정보 처리방침이 "실제 회원가입과 결제(청구)는 없습니다" 라고 적고 있다. 테스트 MID 는 실승인이다. 방침 스스로 "실제 가입·결제 기능이 생기면 이 표를 먼저 고칩니다" 라고 약속했으므로 실카드 결제 전에 방침부터 고친다
  4. 이니시스도 USD 카드 결제를 받는다. A 장애의 폴백 후보가 될 수 있지만(해외카드 계약 조건은 미확인) 이번 결정은 바꾸지 않는다

결정한 것

결정 근거
테스트 모드는 스테이징 호스트(stgstdpay · stginiapi)만 쓴다 원문 "테스트MID 만 사용가능". 운영 호스트에서 테스트 MID 가 되는지는 확인하지 않았다
authUrl·netCancelUrl 허용 목록 = idc_name 1:1 (fc→fcstdpay · ks→ksstdpay · stg→stgstdpay), https, 호스트 완전 일치 원문 방화벽 표와 STEP2
대조 벡터 셋 — signature(이니시스 해시 도구) · mKey(원문) · INIAPI hashData(원문) 막힌 것 1
D1 의 조건부는 당일 INIAPI 환불로 푼다 테스트 MID 의 INIAPI 키가 공개 원문에 있다. 유효성은 첫 환불에서
공개 테스트 키 값은 문서에 옮기지 않고 링크만 건다 문서는 설명이고, 값이 필요한 곳은 테스트 고정값과 서버 .env 다
실카드 검증을 하지 않는다 (사용자 결정, 추천과 다름) 대신 무과금 확인 둘 — 스테이징 결제창을 열고 닫기, 없는 tid 로 INIAPI 환불 1회. 승인 뒤 경로는 운영 미확인으로 남는다
구매자 정보(B9)는 시연용 고정값 (.env) 실제 개인정보가 서버를 지나지 않는다
모바일도 PC 결제창으로 시도한다 (사용자 결정, 추천과 다름) 모바일 규격도 안내도 없다. 실패를 사용자가 먼저 발견하는 것을 받아들였다
개인정보 처리방침은 실PG 경로 배포 전에 고친다 실카드 검증을 안 해도 토큰이 있으면 실승인이 가능하다

| 운영 시연 회원은 09-17 시드 회원(7번) | 운영의 SEED_ALLOWED 차단을 풀지 않는다 | | 결과를 failed / rejected 로 가른다 | 막힌 것 5 | | README 는 운영에서 확인한 것만 — "실카드 승인·망취소·환불 성공은 운영 미확인" 을 그대로 적었다 | 포트폴리오에서 없는 것을 있다고 적는 것이 가장 나쁘다 |

→ plan-multi-pg.md 6장 「0단계 뒤에 추가로 정한 것」 · 「1단계 중에 정한 것」 · 「운영 확인 결과」

다음

  • 개인정보 처리방침(C8) — 4af9563, 시행일 9월 19일
  • 1단계: 게이트웨이 인터페이스 + InicisGateway + 대조 벡터 셋 테스트
  • 무과금 확인 둘 (결제창 열기 · 없는 tid 환불)
  • 페이팔 0단계 — 사람: 개발자 계정 · 샌드박스 앱(client id/secret) · 웹훅 등록(webhook id)
  • ADR-020 — 이니시스 편 (40789ae)
  • 오래된 결제 보고(D1·D3) — 결정만 하고 빠져 있었다 (b204461)
  • 구매자 이메일(PAY_DEMO_BUYER_EMAIL)은 지금 pay-demo@<도메인> — 받을 수 있는 주소인지 확인하지 않았다

2026-09-18 (금) · D-3 — 화면 재설계, 그리고 확인 요청이 남긴 운영 데이터

막힌 것

1. 확인 작업이 운영 지표를 틀었다 — 노출 68건 · 방문 약 28건

증상 랜딩 스크린샷의 "수집 이벤트" 표에 impression · beacon 이 8줄. DB 로 세니 노출일 09-17 lp_related 노출 80건 중 68건이 이날 확인 작업에서 나왔다. /metrics CTR 이 2/12(16.67%) → 2/80(2.5%).

방문 남긴 것 원인
928 노출 52 운영 랜딩 스크린샷(DevTools 프로토콜) — 장면 16개를 넘길 때마다 노출 beacon
922~925 방문 4 · 노출 16 로컬 미리보기 HTML 이 운영 track.js 를 불러와 운영 api. 로 노출을 보냈다. 쿠키가 없어 요청마다 방문이 새로 생겼다(landing_path=/impression)
896~915 · 920 방문 21 링크 확인 curl — 쿠키 없이 /l/ · /go 를 열 때마다 방문 1
897 · 906 / 926 · 927 위에 포함, 광고 유입 4 /go?pid=home… 2 · 동선 확인 /go?pid=verify&utm_source=verify 2

원인 [확인] 스크린샷 도구는 Network.setBlockedURLs 로 *://api.*/impression* 을 막았고 page_view(fetch)는 실제로 막혔다. 그런데 노출 행은 남았다. [추정] 장면을 넘길 때 pagehide 에서 보내는 sendBeacon 이 페이지 단위 차단을 빠져나간다. [확인] 미리보기(file://)의 노출은 origin 이 null 인데도 기록됐다 — 방문 922~925 의 16행 전부 origin='null'. CorsPolicy::normalize() 가 'null' 을 "Origin 없음"으로 바꾸고, actual() 은 Origin 없음을 서버 간 호출로 보고 통과시킨다. 주석(CorsPolicy.php:167)은 "null 문자열은 … 허용하지 않는다" 인데 본 요청에서는 허용 목록 검사를 건너뛴다. preflight 는 막힌다(preflight() 가 Origin 없음을 405).

대응 지우지 않고 기록만 한다(사용자 결정). 09-17 CTR 행은 노출일 7일 창이 지나는 09-24 까지 2.5% 로 보인다.

  • 스크린샷 도구가 *track.js* 자체를 차단 — 수집 URL 이 아니라 발신원을 막는다
  • 재측정: 같은 미리보기 4장면을 다시 찍기 전후로 MAX(visits.id)=929, COUNT(collect_events)=131 변화 없음
  • 확인 규칙: curl 은 쿠키 저장소 하나 재사용 · 추적 경로(/go /l/ /click /impression)는 링크 문자열 대조 · 실제로 밟을 때는 pid=verify 로 표시
  • 쿠키 없는 /click 은 기록 없이 이동만 한다(Collect::recordClick) — 링크 확인 curl 의 클릭 4건은 남지 않음 [확인, DB]

같은 시간대의 방문 916~919 · 921 · 929 는 내 요청 기록에 없다 — 손대지 않았다.

배운 것 확인 요청도 운영 데이터다. 막을 때는 목적지 URL 이 아니라 보내는 코드를 막는다.

2. headless Chrome 은 375px 로 줄어들지 않는다

증상 --window-size=375,812 로 찍은 모바일 스크린샷에서 본문이 오른쪽으로 잘림. 원인 [확인] 같은 창에서 innerWidth=500 — 창 최소 폭이 500 이다. CSS 문제가 아니었다. 대응 DevTools 프로토콜 Emulation.setDeviceMetricsOverride 로 뷰포트를 정한다(innerWidth=375 확인). Playwright 도커 이미지는 받는 중 메모리 부족으로 중단됐다.

3. /?lang=ja%00 이 302 없이 그려졌다

증상 LocaleChoice 단위 테스트는 "ja\0" 을 거절하는데 운영에서는 200. 원인 [확인] CI3 Input::_sanitize_globals() → remove_invisible_characters() 가 $_GET 의 제어 문자를 컨트롤러보다 먼저 지운다(system/core/Input.php:739). 판정에는 "ja" 로 도착한다. 대응 <link rel="canonical"> (b4be711). 그 전에 trim() 기본값이 널 바이트까지 지워 같은 입력을 살리던 것은 테스트가 잡아 공백·탭만 지우게 했다.

4. 광고로 들어왔는데 랜딩이 "직접 유입"이라고 말했다

증상 pid=verify 로 /go → /l/1 을 따라가자 이번 요청이 "기존 방문 · 직접 유입 · kept". 원인 [확인] 기록은 맞다. VID 방식 브리지가 광고 유입을 먼저 기록하고 랜딩에는 vid 만 넘긴다(C-2). 전에도 같은 값이었지만, 첫 시트에 크게 놓이자 오해로 읽혔다. 대응 ?vid= 가 있으면 "브리지에서 넘어옴 — 이 요청엔 vid 만" (f8ca053).

5. 점검 도구가 스스로를 못 믿게 만든 것들

브라우저에서 직접 재는 점검을 만들었다(check-ui.ps1 + audit.js — 대비·터치 대상·고정 열·Tab 순서). 처음엔 전부 "ok" 였다. 통과만 하는 점검은 통과의 뜻이 없어서, 일부러 틀린 대조 페이지를 만들어 8종을 심었다.

심은 결함 결과
중복 id · scope 없는 th · 제목 단계 건너뜀 · 이름 없는 스크롤 영역 · 대비 미달 · 고정 안 된 첫 열 잡았다
aria-hidden 안의 글자뿐인 링크 놓쳤다 — innerText 로만 이름을 셌다
20×20 버튼 놓쳤다 — inline-block 을 "문장 속 링크" 로 보고 검사에서 뺐다(startsWith('inline'))

둘을 고친 뒤 8/8. 그 상태로 운영을 재니 세로 폰에서 시연 바 링크가 40×44 로 잡혔다(→ 315a610). 대비는 요소 뒤에 실제로 깔린 배경을 조상까지 올라가 합성해 계산한다 — 60장면 6,048회 · 실패 0. 표 고정 열은 실제로 200px 밀어 보고 첫 칸 이동량을 쟀다 — 지표 375 의 표 8개 전부 0px.

또 하나. 카운터를 실패만 세도록 짜서 "대비쌍 0" 이 찍혔다 — 검사를 안 한 것과 구별되지 않았다. 검사한 수를 따로 센다.

6. 도구 쪽에서 30분씩 태운 것

  • Get-Content 가 돌려주는 문자열은 PSObject 로 감싸여 있다. 그대로 JSON 직렬화하면 A circular reference was detected while serializing an object of type 'System.Reflection.RuntimeModule' — PSProvider 를 타고 들어간다. [string] 캐스팅으로 벗긴다
  • PowerShell 파일에 LF 줄이 섞이자 here-string 의 끝을 못 찾아 자바스크립트 본문을 코드로 파싱했다. 증상은 엉뚱하게 Math.round 에서 "You cannot call a method on a null-valued expression" — 파일을 CRLF 로 통일
  • @() += Dictionary 가 어느 순간 배열이 아니라 사전 병합이 되어 Item has already been added. Key in dictionary: 'tag' → ArrayList.Add()
  • PowerShell 5.1: OrderedDictionary.Keys + .Keys 가 조용히 실패해 비교 결과가 mismatches= (빈 값)로 찍힘 → 문자열 배열로 비교해 28 = 28 확인
  • 브리프에 "docs 에 없다"고 적은 해설 셋 중 둘이 이미 있었다(data-model.md 6장 · failure-scenarios.md C-2). 문구로만 grep 해서 개념을 놓쳤다

7. 새 이름을 지었더니 스타일이 없는 채로 배포될 뻔했다

증상 /tour 첫 미리보기에서 경로 목록이 1. ① 광고 클릭을… 처럼 번호가 두 번 붙었다.

원인 [확인] 마크업에 .route-list 라는 새 이름을 지었는데 site.css 에는 그 이름이 없다. <ol> 기본 번호가 그대로 나왔고, 안에 적은 ①②③ 과 겹쳤다. 같은 일을 하는 .step__num · .step__name · .step__desc 가 이미 있었다(404 화면이 쓴다).

대응 그 목록은 흐름도와 같은 세 곳을 또 가리키는 중복이라 아예 지웠다. 흐름도 칸 자체가 링크다.

배운 것 새 클래스 이름을 지을 때는 grep 으로 먼저 찾는다 — 쓰던 이름이 있으면 그걸 쓰고, 없으면 CSS 를 같이 낸다. "있다"를 적기 전에 확인하라는 규칙은 클래스 이름에도 해당한다.

8. 작품 카드가 제목보다 21px 안쪽에서 시작하고 있었다 — 며칠째

증상 홈 "오늘" 칸에서 제목(h2)은 칸 왼쪽에 붙는데 카드는 들여쓰여 있었다.

원인 [확인] .works { padding: 0 }(0,1,0) 을 .panel ul { padding-left: 1.3em }(0,1,1) 이 덮고 있었다. 브라우저에서 계산값을 찍어 보고서야 padding-left: 20.8px 을 봤다.

대응 .panel .works, .panel .next-up__list { margin: 0; padding-left: 0 } 로 같은 급의 규칙을 더했다.

배운 것 눈으로 "좀 들어갔네" 하고 넘긴 것이 며칠 갔다. 계산된 값을 찍어 보면 5분이다. 스크린샷은 증상만 보여 주고 원인은 계산값이 말한다.

9. 미리보기가 옛 CSS 로 찍혔다

증상 새로 만든 .next-up 이 불릿 목록으로 나왔다. list-style: none 을 분명히 적었는데.

원인 [확인] 미리보기 폴더의 site.css 는 사본이다. 규칙을 추가한 뒤 사본을 갱신하지 않고 찍었다.

대응 사본을 갱신하고 다시 쟀다. 그 사이 잰 종횡비도 다시 쟀다 — 옛 CSS 로 잰 값이었다.

배운 것 사본을 보는 도구는 찍기 직전에 복사하는 단계를 스크립트 안에 둔다. 사람이 기억할 일이 아니다. (같은 날 고침: render.php 가 렌더할 때마다 site.css 사본을 갱신한다. 20바이트로 망가뜨려 놓고 렌더해 42,401바이트로 돌아오는 것을 확인했다.)

10. 반영률 +0h 가 어느 분모로도 나오지 않는 값이었다

증상 지표 화면에 반영률 표를 옮겨 적다가, 문서의 +0h · 누락 537 · 1.0% 가 계산이 안 맞았다. 543 − 537 = 6 이고 6 / 543 = 1.1% 다.

원인 [확인] 원본 기록은 이 파일 09-13 항목의 우리가 보낸 것 508 / 매체가 집계한 것 6 — 그때는 508건까지만 보낸 상태였다. 나중에 표를 만들면서 분모를 최종 543 으로 맞췄고, 그러면 그 시점에 보내지도 않은 35건까지 누락으로 세게 된다.

대응 화면과 문서 모두 첫 줄만 분모 508 로: 6 / 508 (1.2%). benchmarks.md 5-1 · outbox-and-channels.md · ADR-019 에 각주를 달고 고쳤다. 이 파일의 09-13 기록은 그날 무엇을 알고 있었는지의 증거라 손대지 않았다.

배운 것 시점마다 분모가 자라는 지표를 표로 만들 때, 분모를 최종값으로 통일하면 표가 거짓말을 한다. 각 줄에 그때의 분모를 적어야 한다. 화면의 규칙("비율은 분모와 함께")이 이 경우엔 "어느 시점의 분모인지와 함께" 였다.

한 일

운영 화면 재설계 — 30초 안에 무엇을 봐야 하는지 알게 (40e273d..f8ca053)

  • 호스트를 건너는 링크 절대 URL — 루트 홈의 작품 링크 전부와 m. 랜딩의 방침 링크가 404 였다 (40e273d)
  • public/assets/site.css 한 장 — 뷰 6개의 인라인 스타일 대신. 토큰 대비 64쌍 WCAG 계산 통과 (53fbeb2)
  • 칸 그리드 · 4:3 시트 — 가로 폴더블·태블릿에서 시트 한 장이 화면을 채운다. 세로 폰은 칸 한 줄 (3d6ca1a)
  • 홈 시트 3장 · "오늘"을 KST 로(PublishDay) — UTC 목 17:29 = KST 금 02:29 에 운영에서 "금요일" 확인 (adb20ba)
  • 언어 전환이 실제로 동작 — LocaleChoice, 운영 판정 12건 (79cf75d b4be711)
  • 랜딩 시트 2장 — 작품 제목·시놉시스, 기록은 값 있는 필드만. 없는 번호는 404 대신 "없는 작품" (7b5d4ae f8ca053)
  • 지표 시트 4장 — 파이프라인 순서(① 유입 → ② 전환·적재 → ③ 매체 전송), 표 첫 열 고정 (0c25a24)
  • 방침도 칸으로 — 문구·절 번호·앵커는 그대로, 차례만 추가 (9bfbd52)
  • benchmarks.md 3-2 — 화면의 p95 가 무엇을 고르는지 재고 틀린 설명을 고침 (a4d54fd)
  • 점검: 6화면 × 5뷰포트 × 라이트·다크 60장면 — 가로 넘침 0 · 대비 실패 0 · 포커스 링 누락 0 · 고정 열 8/8
  • 폐기된 결정에 묶인 토큰 16개와 오래된 주석 정리, README 랜딩 링크 /l/8733 → /l/1 (032dff2)
  • 진단 화면도 칸으로 (028b487 d1fd7bc) — 사이트에 옛 화면이 남지 않았다
  • 디자인 리뷰 — 브리프의 S1~S4 를 실측. Must fix 0, Should fix 4 중 3 을 그날 고침
    • CTR 열이 퍼센트 하나였다 → m_rate(클릭, 노출) (d67687f). 45개 중 44개가 분모와 함께(남은 1개는 산문)
    • 진단에서 Meta 픽셀 제거 · 방침의 Meta 링크에 "로그인 필요" · 진단 noindex (5e105e1)
  • ADR-009 의 "시각적 완성도가 낮다 · 의도된 것" 삭제
  • 단위 437 통과

리뷰 뒤 — 홈에서 설명을 떼어 /tour 로 (efd07d9 c931289)

  • 홈이 설명과 서비스를 겹쳐 들고 있었다. 심사자가 먼저 보는 것이 "이건 시연입니다" 라는 안내면 정작 측정할 대상이 흐려진다. 홈은 웹툰 서비스로만 두고 설명을 새 화면으로 뺐다
  • /tour — 파이프라인을 번호 목록이 아니라 흐름도로. 칸마다 레인(브라우저·서버·매체)을 적어 어디서 일어나는 일인지 보이게. 넓으면 →, 좁으면 ↓. SVG 가 아니라 글자와 선이라 좁은 화면에서 글자가 줄지 않고 스크린리더는 <ol> 로 순서를 읽는다. 화면이 있는 세 걸음만 링크고, 나머지 둘은 링크가 없는 이유를 화면이 말한다
  • 칸 테두리 먹선 2px → 희미한 1px(라이트 #cfcabe 1.58:1 · 다크 #38352f 1.49:1). 칸은 경계를 알리는 것이지 강조가 아니다
  • 홈 시트 1 재배치 — 세 배치를 놓고 시트가 4:3 에 드는지 재고 골랐다: 오늘(12열) 0.750 이지만 작품 하나뿐인 날 빈 띠 · 오늘(7)+최근(12) 0.800 · 오늘(5)·최근(7)/순위 띠(12) 0.750 채택. DOM 순서도 배치와 같게 바꿔 탭이 되돌지 않는다
  • "다음 업데이트" — 이미 넘겨받는 $byDay 로 다음 세 요일. 질의를 늘리지 않고 빈 칸을 서비스 정보로 채웠다
  • 점검 30장면(홈 ko·ja · /tour × 5뷰포트 × 명암): 대비 2,970건 실패 0 · 가로 넘침 0 · 포커스 링 누락 0 · 시트 종횡비 전부 0.750. 운영에서 /tour 루트 200 · 다른 호스트 404 확인

두 화면이 "이 시스템이 무엇인가"에 답하게 (238419d 47d6094)

  • 화면을 열어 대조해 보니 지표는 배관 이야기만 하고 있었다. 아홉 절이 전부 적재율·도달률·재시도·지연인데, 정작 "어느 광고가 결제를 만들었나" 가 없었다. 광고비를 쓰는 쪽이 묻는 질문이 그거다
  • 광고가 만든 것 시트 — 매체별 전환을 최초 유입 기준과 마지막 유입 기준으로 나란히. 같은 결제도 기준에 따라 매체가 달라지고 정산에서 다투는 자리가 거기라, 한 기준을 고르는 대신 둘과 "차이" 를 낸다. 금액도 함께
    • 분모는 광고 접점이 붙은 전환이다. 전환 전체(9,873)를 분모로 쓰면 "광고가 다 만들었다" 로 읽힌다 — 부하 시험으로 서버에서 만든 전환에는 방문이 없다(운영 실측 5 / 9,873)
    • 금액은 마지막 기준으로만 한 번 더한다. 두 기준에 다 더하면 매출이 두 배가 된다. 이 규칙만 순수 계산으로 떼어 테스트 9개를 붙였다(src/Metrics/AdAttribution.php)
  • 반영률 시트 — 도달률 옆에서 "이 화면은 알 수 없다" 로 끝나던 이야기를 사이트 안에서 끝맺는다. +0h → +40h 표와 그때 내린 두 번의 오판, 측정 시각·방법·대상 건수까지
  • /tour 흐름도 5 → 7걸음(결제 · PG 웹훅). 한 줄 설명이 "결제까지" 인데 흐름도가 랜딩에서 전환으로 건너뛰고 있었다. 마지막 칸도 "보냈다" 가 아니라 "매체 보고서에 남았다(543/543, +40h)" 로 끝난다
  • "어려운 자리는 여기다" 칸 — 흐름도는 잘 풀린 한 바퀴만 그린다. 덮어쓰지 않는 귀속 · dedup_key UNIQUE · 같은 트랜잭션 · 2xx 는 반영이 아니다 + 실패 시나리오 12건 링크
  • 점검 18장면 대비 4,218건 실패 0 · 시트 종횡비 전부 0.750 · 테스트 446
  • 지표 첫 화면에 결론 세 줄 (a1bbc46) — 제목이 "숫자는 분모·표본·측정 시각과 함께만" 이었다. 태도 선언이지 결과가 아니다. 도달률(가짜 채널 뺀 값) · 반영률(되읽은 기록 +40h) · 광고가 만든 결제(건수·금액)를 제목 바로 아래로. 새 질의는 없고 아래 표들이 쓰는 값을 합칠 뿐이다
    • 자리를 만들려고 배너 CTR 을 광고 시트로 옮기고, 요약 칸을 7열로 넓히고, 결론 표 줄 높이를 74px → 40px 대로 줄이고, panel--bottom(짧은 칸일 때 옆 칸과 바닥선을 맞추던 것)을 뗐다
    • 시트 1 종횡비 0.891 → 0.836. 결론 줄을 넣기 전(0.858)보다 낮다
    • 운영: 도달률 552 / 554 (99.6%) · 반영률 543 / 543 · 광고가 만든 결제 5 / 9,873 · 42,900 KRW
  • 안내 화면 마지막 칸을 귀속 결정으로 (4c6bf58) — 시트 2 의 절반이 웹툰 스키마 해설(연재 요일·기다리면 무료·언어 축·순위 기준)이었다. 그건 측정 대상의 사실이지 이 시스템의 설명이 아니다
    • 네 규칙을 표로 내고, 셋째 줄(직접 유입이 마지막을 덮지 않는다)이 왜 핵심인지 화면이 말한다 — 덮으면 그 결제가 어느 매체에도 붙지 않는다
    • 결제의 한 층 더: 결제 시점 방문 우선, 없으면 가입 접점. 전에는 고를 수 없었다(결제에 방문을 적을 칸이 없었다)는 것까지 적고 마이그레이션으로 링크
    • 스키마 해설은 지우지 않고 문서로 보냈다. 화면에는 어디 있는지 한 줄만 남겼다 — "화면에서 지우는 해설은 docs 에 먼저 옮긴다"
    • 칸 6:6 → 5:7, 시트 2 종횡비 0.895 → 0.750. 화면의 링크 7개 전부 200 확인
  • 지표 제목을 평범한 말로 (3846c29) — 사용자가 "제일 크게 보이는 문장이 무슨 말인지 모르겠다"고 했다. 숫자는 분모·표본·측정 시각과 함께만 은 화면의 정체가 아니라 표기 규칙이고, 이 프로젝트의 어휘를 모르면 뜻이 닿지 않는다
    • 제목은 "무엇이 들어왔고, 무엇이 매체로 돌아갔나". 규칙 문장은 규칙 목록의 머리로 내렸다 — 거기서는 넉 줄을 묶는 이름이라 제자리다
    • 결론 세 줄도 용어를 앞세우지 않는다: "매체가 받았다"(도달률 · 응답 2xx) · "매체 보고서에 남았다"(반영률 · 되읽어 확인). 용어는 한 단계 작게 아랫줄
    • 규칙 첫 줄을 예시로: "'95%' 는 20건 중 19건일 수도, 500건 중 475건일 수도 있다"
    • 배운 것: 정직한 표기를 자랑으로 삼는 것과, 그 자랑을 제일 큰 글자로 거는 것은 다르다. 자랑이 화면의 정체를 가렸다
  • 용어를 앞세운다 (94c9f24) — 위 항목에서 "매체가 받았다 / 보고서에 남았다" 로 풀어 썼는데 사용자가 되돌렸다: "용어를 과감하게 써, 그게 더 정확하다". 맞다. 도달률·반영률·귀속·first-touch 는 뜻이 정해진 말이고 풀어 쓰면 흐려진다
    • 용어를 쓰되 바로 아래 한 줄로 분자와 분모를 적는다(도달률 / 매체가 2xx 를 돌려준 전송 / 전송 시도). 아는 사람은 용어로, 모르는 사람은 그 줄에서 배운다
    • /tour 의 귀속 표는 코드의 규칙 번호(1~4)와 first/last 를 그대로 쓴다 — 풀어 쓰면 TouchpointResolver 와 대조할 수 없다. 대조할 수 있는 것이 그 화면의 값어치다

12. 용어집을 세워 놓고 화면이 그 용어를 안 가르쳤다

증상 사용자가 운영 소스에서 혼란스러운 표현 아홉 개를 찾아 왔다 — 읽기 대상 rdb1 (복제본) · 무엇이 들어왔고, 무엇이 매체로 돌아갔나 · 접점 · 광고가 만든 것 · 아웃박스 적재 · 재시도 회복, 분모는 재시도를 겪은 적재다 · 숫자는 분모·표본·측정 시각과 함께만 · 브리지 302 · 전환 → 아웃박스.

원인 [확인] 원인은 아홉 개가 아니라 하나였다. docs/glossary.md 는 세웠는데 화면이 용어를 처음 쓰는 자리에서 정의하지 않았다. 용어집은 찾아 읽는 사람만 읽는다.

대응 규칙을 세웠다 — 용어를 앞에 두고 바로 아래 한 줄로 무엇을 세는지 적는다. 지표·안내·랜딩에 적용하고, 상관 ID 처럼 모든 화면에 나오는 말은 바닥글에 한 번만 정의했다. 방침은 이미 "용어(괄호 설명)" 로 쓰여 있어 고칠 곳이 없었다 — 그 화면이 본보기였다.

배운 것 용어집은 원본이지 교재가 아니다. 읽는 사람은 화면에서 배운다. 그리고 순서가 있다: 새 용어는 용어집에 먼저 적고 화면에 쓴다. 브리지 를 화면에 먼저 쓰고 나중에 용어집에 넣었는데, 그 순서가 뒤집히면 용어집이 화면을 따라다니게 된다.

대가 정의 줄만큼 시트가 길어졌다(지표 0.889 → 0.930, 랜딩 0.963 → 1.04). 규칙 목록을 옮겨 만회해 보려다 더 나빠져서(1.005) 되돌렸다. 읽는 사람이 뜻을 모르는 것보다 시트가 4% 긴 쪽이 낫다.

11. 환불이 매출로 잡히고 있었다 — 이름을 정확히 붙이려다 찾았다

증상 결론 첫 줄을 "광고가 만든 결제" 라고 불렀는데, 세는 것은 전환 전체였다(결제 9,868 · 환불 3 · 가입 2). 이름을 맞추려고 질의를 들여다봤다.

원인 [확인] SUM(COALESCE(c.value_minor, 0)) 을 유형 없이 더하고 있었다. 그런데 환불 전환의 value_minor 는 양수다 — 원 결제 금액을 그대로 싣는다(Payment_model::refundConversion). 9,900원을 환불한 자리가 19,800원 매출로 잡히는 계산이었다.

대응 모델이 c.type 을 함께 내리고, AdAttribution 이 purchase 만 매출로 · refund 는 따로 센다. 금액 없는 유형(가입)은 어느 쪽에도 안 더한다. 표에 "환불" 열을 갈랐다. 테스트 3개를 붙였다 — 그중 하나는 type 이 없는 행이 오면 금액을 더하지 않는다(조용히 매출을 만들지 않는다).

운영에서는 값이 그대로였다 — 광고 귀속 전환 5건이 전부 결제여서 42,900 KRW 그대로다. 잠재 버그였고, 광고 유입 방문에서 환불이 한 번만 일어나도 드러났을 자리다.

배운 것 이름이 틀렸다는 것은 대개 세는 것이 틀렸다는 뜻이다. 화면의 라벨을 정확히 붙이려는 시도가 계산의 오류를 끌어냈다. 그리고 금액을 다루는 집계는 유형을 보지 않으면 안 된다 — 부호가 없는 스키마에서는 유형이 부호다.

결정한 것

결정 근거
칸 그리드 · 가로 4:3 기준 사용자 — 펼친 폴더블·태블릿 사용이 많다. 16:9 는 좌우 베젤 감수
강조색 없음(먹색) · 시스템 글꼴 유채색은 의미에만. 외부 글꼴 요청은 개인정보처리방침을 바꾼다
없는 작품 번호도 404 로 끊지 않음 방문·유입은 작품과 무관하게 남고, 문서 재현 명령이 없는 번호 8733 을 쓴다
오염된 운영 데이터를 지우지 않음 사용자 결정. 대신 방문 번호와 건수를 여기 남긴다
진단에서 Meta 픽셀만 뺀다 진단이 보여 주는 것은 _ga·client_id 뿐이다. gtag 는 그 두 줄 때문에 남기고, 남은 GA4 집계는 화면에 적는다
방침의 Meta 링크는 주소를 두고 "로그인 필요"를 덧붙인다 수집 항목도 전송처도 바뀌지 않으므로 시행일은 그대로. 링크가 로그아웃에서 오류인 것도 사실이라 적는다
확인 트래픽은 거르지 않고 표시한다 — 확인용 요청에 ?qa=1 지표가 분모를 조용히 줄이지 않는다는 규칙이 우선이다. 표시는 visits.landing_path 에 남아 나중에 골라낼 수 있다

다음

  • 지표 화면 시트 구조 — 파이프라인 순서 3묶음, 모바일 표 첫 열 고정
  • 개인정보처리방침 토큰 적용
  • 호스트가 틀린 경로(app. 의 /)가 브라우저에도 JSON problem 을 주는 것 — 고칠지 결정
  • Origin: null 본 요청이 수집에 기록된다 — 주석의 의도(허용하지 않음)와 동작이 다르다. 고칠지 결정하고, 고치면 CorsPolicyTest 에 'null' 케이스
  • /diag 를 운영에서 계속 열어 둘 것인가 — 화면은 맞췄고 Meta 픽셀은 뺐다(5e105e1). 남은 판단은 라우트 주석("개발 환경 전용")대로 막을지, 지금처럼 열어 둘지다. 열어 둔다면 주석을 사실에 맞게 고친다
  • landing/index 뷰는 라우트가 없어 닿지 않는다 — 지울지 라우트를 붙일지

2026-09-16 (수) · D-5 — 실제 결제 사고를 테스트로

조사 출금·입금 불일치 실제 사고 15건 → incidents/payment-incidents.md. 테스트 코드로 옮길 수 있는 것을 실패 모양 8가지로 묶었다 → incidents/testable-cases.md

조사 중 찾은 빈틈. RefundPolicy::revocation(100, 0) 은 "100 을 썼다" 를 돌려준다. 첫 회수가 remaining 을 0 으로 만든 뒤 한 번 더 불리면 거짓 경보다. 안전이 결제 상태 CAS 한 줄에만 걸려 있었다 → lot 에 revoked_at.

구현 코인 회수 멱등 · 결제 내부 대사(cli/verify payments) · GA4 대사에 처리 창 · CI DB 통합(동시성 · 마이그레이션 왕복). 단위 413 · 통합 로컬·GitHub Actions 통과.

확인한 것 [확인]

  • 통합 테스트가 깨질 수 있는가: 최신 마이그레이션의 down() 을 비워 돌렸더니 Duplicate column name 'revoked_at' 으로 9건 실패. 되돌렸다
  • 대조군(먼저 조회 후 판정 + 400ms)은 동시 8개에서 코인 8번 지급 — 09-14 운영 실측(2번)보다 크게 뚫렸다. 로컬 컨테이너에서는 프로세스 기동이 겹쳐 검사 창에 더 많이 들어간다
  • 운영 DB 에 대사: 11건 중 1건 coin_duplicated = 09-14 D-3 대조군 결제(ctl2-…). 그 밖엔 없음

운영 cron 추가 매일 05:13 UTC(14:13 KST, 파기 배치 30분 뒤) cli/verify payments 90 → ~/logs/verify-payments.log. 수동 1회 실행으로 로그 형식 확인(시각 · 결과 · exit=1). 처음엔 대조군 1건 때문에 매일 exit=1 이었다 → D-3 대조군 결제 2건을 uid·사유로 제외(config/reconciliation.php, 매 실행 "제외" 로 출력). 이후 결제 9건 판정 · 어긋남 없음 · exit=0.

막힌 것 — 없음. 다만 테스트 환경의 CI3 로그(임계 4)가 결과 줄을 덮어서 stderr 를 파일로 돌리고 실패할 때만 ERROR 줄을 보여 주게 했다.


2026-09-15 (화) · D-6 — 저녁: 앱 로그 보존

막힌 것. 환불 시험 끝에 app 을 재생성하자 "이미 쓴 코인이 있는 결제가 환불됐다" error 줄이 사라졌다. 앱 로그는 stderr → 도커 json-file 드라이버였고, 그 로그는 컨테이너와 수명을 같이한다. MY_Log 주석은 "로테이션은 도커 드라이버가 한다" 고만 적었지 보존은 따지지 않았다.

고른 방식. journald 드라이버도 봤지만 보존기간이 호스트 설정에 묶여 앱이 3개월을 약속할 수 없다. 그래서 stderr 는 그대로 두고, 같은 줄을 명명 볼륨 app_logs 의 app-{SAPI}-{UTC날짜}.jsonl 에 붙인다.

  • SAPI 로 파일을 나눴다 — CLI(root)가 먼저 만든 파일에 fpm(www-data)이 못 붙여 쓰면 또 조용히 사라진다
  • 쓰기 실패는 프로세스당 한 번 stderr 로 알린다
  • 파기는 이름의 날짜로(mtime 아님), 이름 규칙에 맞는 파일만. 방침에 "서버 로그 3개월"

실측 [확인]

허용 안 된 오리진으로 /collect → 403 · error 1줄
app·worker --force-recreate 후
  docker compose logs app 에서 표식 검색   0
  /var/log/touchpoint/*.jsonl 에서 표식    1
worker 컨테이너에서도 같은 파일이 보인다
가짜 옛 파일 app-cli-2026-06-01.jsonl + notes.txt → purge run → 옛 파일만 삭제

남은 것 — OpenResty 접근 로그는 여전히 컨테이너 출력뿐이다. 엣지는 재생성이 드물지만 같은 문제를 갖고 있다.


2026-09-15 (화) · D-6 — 오후: 환불 웹훅 · 노출/클릭 수집

환불 웹훅

  • refunded 를 받는다. 남은 코인 회수(FOR UPDATE) · 같은 트랜잭션에서 refund 전환 적재 · GA4 는 원래 구매의 transaction_id 로 refund. Meta 는 표준 환불 이벤트가 없어 적재하지 않는다(Channels::namesFor) — 매체 능력 차이가 적재 쪽으로 새는 두 번째 자리
  • 캡처 전에 온 환불은 409. 다른 역전처럼 무시(200)하면 PG 가 멈추고, 뒤이어 온 captured 가 결제를 살려 둔다
  • 쓴 코인은 음수로 깎지 않는다. PG 환불은 거절할 수 없는 사실이라 적용하고, coins_spent 로 드러낸다
  • 운영 서버 실측(CHANNELS=noop): 안 쓴 환불 100 회수 · 30 쓴 뒤 환불 70 회수/30 사용 · 캡처 전 환불 409 → 캡처 → 재전송 적용 · 중복 환불 ignored · 실패 결제 환불 ignored. GA4 검증 엔드포인트가 refund payload 를 통과시켰다(적재는 안 됨)

막힌 것 — 약속과 구현이 또 어긋나 있었다. applyEvent 주석은 "DB 오류면 롤백하고, PG 가 재전송해 다시 기회가 온다" 고 적었는데, 컨트롤러는 error 를 보지 않고 200 ignored 를 줬다. PG 는 성공으로 받고 끝낸다 → 503 + Retry-After

못 확인한 것 — 30코인 사용 케이스의 error 로그 줄. 로그가 stderr(컨테이너 로그)라 시험 끝에 app 을 재생성하자 사라졌다. 앱 로그가 컨테이너 수명을 넘기지 못한다는 것 자체가 발견이다. 결과는 응답 본문(coins_spent:30)과 DB 로 확인했다.

노출·클릭 수집

라우트(/impression·/click)는 09-10 부터 있었는데 메서드가 없어 404 였다. 붙이려고 보니 CTR 을 묶을 열(자리·기준일)도 없었다.

  • POST /impression: 틀린 항목만 버리고 받는다(beacon 은 응답을 못 읽는다). 실측 5건 → accepted 3 · dropped 2
  • GET /click: 302 는 언제나 나간다. 막는 건 목적지가 틀릴 때(400) 하나 — 오픈 리다이렉트 우회 4종 전부 400. 새로고침 중복·봇·HEAD·쿠키 없음은 302 만 나가고 기록 없음
  • sd=노출일. 어제 노출분 클릭이 어제로 집계되는 것을 확인. /metrics 자리별 CTR: 노출 3 · 클릭 1 · 33.33%, 노출 없는 날은 "노출 없음"(0 나누기로 100% 를 만들지 않는다)
  • 실제 브라우저로 배너 클릭 → api./click → lp./l/6 착지, 행 기록 확인

못 확인한 것 — "화면에 절반 이상 보인 배너만 센다" 는 IntersectionObserver 경로. 인앱 브라우저 창이 숨겨진 상태(innerHeight 0, visibilityState: hidden)라 관찰자가 한 번도 호출되지 않았다. 서버 쪽 배치 경로는 curl(beacon 형식)로 확인했다.


2026-09-15 (화) · D-6 — 결제 브라우저 맥락

결정

Meta 에 보낼 브라우저 맥락을 별도 테이블 · 3개월 · /purchase 에서 수집으로 정했다. 처음 권고(보내고 즉시 삭제)는 실무 방식을 확인하고 버렸다 — 결제 경로(웹훅엔 브라우저가 없다)를 풀지 못하고, 재시도·대조 근거가 사라진다 → ADR-005 「결정」

한 일

  • payment_client_context 마이그레이션 · /purchase 수집 · captured 웹훅이 payload 에 싣기
  • 파기 배치에 payment_client_context · dispatch_outbox 추가. 후자는 마이그레이션 주석이 약속만 하고 배치에 없던 자리였다
  • 운영 서버에서 종단 실측: /purchase → 맥락 행(쿼리·조각 제거, 틀린 _fbc 거절) → 웹훅 → 아웃박스 payload 11키 → Meta 200 · 468ms

막힌 것

  • 원격 bash -s 스크립트가 첫 docker compose exec 에서 끝났다. exec -T 가 표준입력(=남은 스크립트)을 먹었다. 오류도 없이 조용히 끝나서, .env 를 바꾼 뒤였다면 운영 설정이 바뀐 채 남았을 것이다. → 파일로 올려 실행 · exec </dev/null · trap 으로 원복
  • 워커가 09-12 22:49 이후 떠 있지 않았다. profiles: ["worker"] 라 docker compose up -d 로는 안 뜬다. 대기 행 9건(noop 3 · ga4 6, 전부 실험). 켜면 실험 전환이 운영 GA4 로 나간다 → 켜지 않고 어댑터를 직접 불러 쟀다. 운영 전송 경로가 사흘째 멈춰 있었다는 사실 자체가 기록할 일이다 [확인]

아침 — 워커 복구 · /conversion · 그리고 내가 낸 502

  • 대기 실험 행 9건을 dead(이유 기록)로 정리하고 --profile worker 로 워커를 올렸다. 도커 서비스는 부팅 시 자동 시작(enabled), 워커는 restart: unless-stopped
  • /conversion 에 브라우저 맥락: 본문 page_url + Origin 이 우리 도메인일 때만 UA·IP·쿠키. 검증 규칙은 ClientContext 하나로(테스트 17개, 전체 347 통과)
  • 픽셀 스니펫은 넣되 META_PIXEL_BROWSER=false. 켜기 전에 고지가 먼저다
  • 워커로 세 경로 관통: 브라우저 결제 sent · 서버 간 결제는 dead(서버 UA 를 보내지 않음) · 브라우저 전환 sent → ADR-005

막힌 것 — 내가 사이트를 40초 내렸다. CHANNELS 를 바꾸며 app 과 worker 를 함께 올렸다.

connect() failed (111: Connection refused) while connecting to upstream,
upstream: "fastcgi://172.20.0.4:9000"
touchpoint-app-1     172.20.0.6     ← 재생성으로 새 IP
touchpoint-worker-1  172.20.0.4     ← app 의 옛 IP 를 받았다

fastcgi_pass app:9000; 은 nginx 가 기동 때 한 번 풀어 둔다. 어제 첫 종단 실측에서 app 을 재생성했을 때는 같은 IP 를 돌려받아 멀쩡했을 뿐이다 — 운이었다. 오늘은 worker 가 먼저 그 IP 를 가져가 엣지가 PHP 요청을 워커로 보냈다. nginx -s reload 로 복구.

→ resolver 127.0.0.11 valid=10s + 변수 fastcgi_pass. 재현해서 확인했다: 더미 컨테이너로 app 의 IP 를 빼앗고 app 을 다시 올리자, 10초 캐시 창 안의 요청 1건만 502 였고 이후 reload 없이 새 IP 로 갔다.

교훈 — "재생성해도 됐다" 는 한 번의 성공은 IP 가 우연히 같았다는 뜻일 수 있다. 조건(IP 가 바뀐다)을 일부러 만들어야 확인이 된다.

하나 더 — 웹 루트에 웹훅 본문이 하루 동안 공개돼 있었다. 서버 git status 에 ?? public/tmp 가 떠서 봤더니 09-14 03:38 에 생긴 198바이트 JSON 이었다. cli/pg sign … /tmp/body.json 을 줬을 때 CI3 가 인자를 / 로 쪼개 tmp 만 남기고, 작업 디렉터리 public/ 에 떨어뜨린 그 파일이다(그때 경로를 상수로 고쳤지만 이미 생긴 파일은 치우지 않았다). 네 호스트 모두에서 /tmp 가 200 으로 내려갔다. 실험 결제의 uid·금액뿐이고 서명 헤더는 없어 재전송은 불가능하지만, 지웠고 404 를 확인했다.

버그를 고칠 때는 그 버그가 이미 남긴 것도 찾는다.

오전 — Meta 운영 전송 · 개인정보처리방침 · 픽셀

  • META_TEST_EVENT_CODE 를 비워 운영 전송으로. resolver 수정 뒤라 app·worker 재생성이 무중단이었다
  • 픽셀보다 방침을 먼저 올렸다. /privacy 는 코드가 실제로 하는 일을 옮겼고, 표 옆 주석에 근거 위치를 남겼다. /privacy/ads 거부 쿠키는 픽셀을 끄고, 서버 전환에 브라우저 맥락을 싣지 않게 하고, 기존 _fbp·_fbc 를 지운다. GA4 는 분석이라 끄지 않고 Google 차단 도구를 안내한다
  • 방침에 없는 쿠키가 둘 있었다. 거부 응답 헤더를 찍어 보니 tp_csrf(CSRF 토큰)와 ab_rdb(복제본 배정)가 같이 내려왔다. 코드를 뒤져 적은 표였는데 프레임워크 설정과 엣지 Lua 가 거는 쿠키를 빠뜨렸다 → 추가. 표는 코드가 아니라 실제 응답과 대조해야 닫힌다
  • 실제 브라우저로 확인: 픽셀 로드 → facebook.com/tr/ 전송 · _fbp 생성 → 거부 클릭 → _fbp 삭제 → 랜딩 재방문 시 fbq 없음 · Meta 요청 0건

이 방침은 코드와 대조한 사실 기술이지 법률 검토를 거친 문서가 아니다.


2026-09-14 (월) · D-7 — 오후: ADR-005 검증

막힌 것

  • Meta 어댑터를 다 짰는데 보낼 수 있는 전환이 0건이다. action_source=website 는 event_source_url 과 원문 client_user_agent 가 필수인데, 아웃박스 payload 에 둘 다 없다. [확인] 공식 문서 파라미터 표
  • system_generated 로 바꾸면 통과한다. 그런데 정의가 자동 갱신 결제 라 사람이 누른 결제를 거짓 분류하게 된다 → 쓰지 않고 dead + 이유
  • Graph API 버전을 자격 증명 없이 확인하려 했다. v21~v99 가 전부 같은 OAuthException code 190 을 돌려줘 판별이 안 된다 [확인]. 버전은 변경 이력 문서(v26.0, 2026-07-29)로 정하고 .env 로 뺐다

한 일

  • MetaChannel + 테스트 13개. 커밋 62cf131 의 diff 가 측정값이다 — 6파일, 주장은 2파일
  • 레이트리밋이 HTTP 400 + 오류 코드라, 상태 코드만 보는 공통 백오프로는 dead 로 버려진다 → 어댑터가 코드를 먼저 본다
  • 통화 변환을 MinorUnits 로 꺼냈다. 두 번째 매체가 기존 어댑터를 고치게 만든 첫 사례
  • ADR-005 에 「검증」 절, 인터페이스 문서 드리프트(buildPayload) 수정

결정 보류

브라우저 맥락을 보존하면 visits 의 "원본 저장 안 함" 과 충돌한다. dispatch_outbox 는 파기 대상이 아니다. 권고는 전송 후 필드 삭제지만 보존 정책 변경이라 실매체 test_event_code 확인 뒤로 미룬다 → ADR-005

저녁 — 실자격 증명으로 테스트 전송

막힌 것 ① 값이 서버에 없었다. ".env 에 넣었다" 는 노트북 .env 였다. 서버 .env 는 셋 다 빈 값. 값은 출력하지 않고 서버로 옮겼다(백업 후 600).

막힌 것 ② 컨테이너 안의 META_TEST_EVENT_CODE 가 주석 문장이었다. [확인] Compose 5.5.1 docker compose config:

EMPTY_SP=    # c1   →  '# c1'      ← 주석이 값이 된다
VAL_SP=x    # c4    →  x           ← 값이 있으면 잘린다
QUOTED=""   # c6    →  ""

.env.example 에 이 모양이 7줄(ENCRYPTION_KEY·MYSQL_ROOT_PASSWORD·REPL_PASSWORD·GA4 3개·META_TEST_EVENT_CODE) 있었다. 복사해서 값을 안 채우면 주석이 비밀번호·암호화 키로 들어간다. check-env.sh 는 반대로 가정(빈 값)하고 있어서 잡지 못했다 → 주석을 윗줄로 옮기고, 검사는 이 모양을 오류로 센다. 옛 예시에 돌리면 7건을 정확히 짚는다.

발견 — Meta 도 "접수 ≠ 반영" 이다. 필수라던 event_source_url·client_user_agent 를 빼고 보내도 HTTP 200 {"events_received":1}. 어댑터의 사전 거절은 풀지 않고, Events Manager 테스트 이벤트 탭을 20분 창 뒤에 본다 → ADR-005

오늘의 한 줄

어댑터는 전송을 격리한다. 수집은 격리하지 못한다.


2026-09-14 (월) · D-7

한 일

  • GA4 반영률 확정 — 네 시점에 걸쳐 재고 수렴 확인. 문서 반영
  • benchmarks.md 5장을 도달률 / 반영률 두 줄로 분리

오늘의 한 줄

전송 로그      543건 전부 204   (도달률 100%)
매체 보고서    517건            (반영률 95.2%)

같은 500건을 두고 100% 와 95.2% 가 동시에 참이다. 지표 이름 하나로 뭉쳐 두면 둘 중 편한 쪽이 화면에 뜬다. 그래서 쪼갰다.

네 시점 — 한 번만 쟀으면 전부 틀렸다

경과 반영률 그때 적었다면
+0h 1.0% "매체가 99%를 버린다" ← 완전한 오독
+7h 93.6% "5% 넘게 유실" ← 과대평가
+15h 95.0% 거의 맞음
+25h 95.2% 수렴
  • 배운 것: 이 문서에 "고쳤으면 다시 잰다" 를 적어 뒀는데, 오늘은 "한 번 재고 끝내지 않는다" 가 더 정확하다. 값이 움직이는 중인지 멈춘 것인지는 여러 시점을 찍어야만 안다. 유입 속도가 1.10 → 0.75 → 0.10건/h 으로 떨어지는 걸 보고서야 수렴이라 말할 수 있었다
  • 사용자가 "한번 더 재봐" 라고 세 번 물어봐 준 것이 결과를 바꿨다. +7h 에서 멈췄으면 5%를 7%로 적었을 것이다

가설 셋을 세우고 둘을 기각했다

가설 판정 근거
내부 트래픽 필터 기각 들어간 5건 중 3건이 traffic_type=internal 을 단 배치 소속
버스트 스로틀링 기각 가장 빠른 무리(36건/초)의 누락률이 가장 낮았다(3.2% vs 6.4%)
처리 지연 부분 참 +25h 까지 꼬리. 다만 잔여 5%는 안 들어온다
  • 배운 것: 첫 가설은 내가 전날 붙인 기능이었다. 최근에 바꾼 것을 의심하는 건 합리적이지만, 증거를 먼저 봐야 한다. 관측된 목록을 통째로 찍게 고친 뒤에야 기각할 수 있었다 — 몇 개가 빠졌나 가 아니라 무엇이 들어갔나가 원인을 가른다

우리 쪽 오탐도 하나

누락 40건 중 5건은 매체 잘못이 아니었다. /debug/mp/collect 로 나간 것들이고, 거기는 검사만 하고 적재하지 않는다. 응답이 성공이라 아웃박스는 sent 로 적는다.

http_status 204 → 543건   운영. 적재됨
http_status 200 →   5건   검증. 적재 안 됨
  • 배운 것: sent 라는 상태 이름이 "보냈다" 까지만 뜻한다. 보낸 것 · 받아들여진 것 · 집계된 것은 셋 다 다르고, 이 프로젝트는 오늘 그 셋을 전부 다른 숫자로 봤다
  • 상태 코드로 가르는 건 암묵적이라, "어느 엔드포인트로 갔는가" 를 행에 남기는 자리가 스키마에 없다는 것도 적어 뒀다

남은 것

  • 잔여 5%의 원인 — 매체 내부라 밖에서는 못 본다. 규모를 아는 것까지가 우리 몫
  • 결제 웹훅 (D-3 의 못 잰 두 줄)
  • 파기 배치 cron
  • 저장소 Topics (웹 UI. 값은 09-12 항목에)
  • GA4 의 TEST-0001 출처 내가 09-12 에 curl 로 보낸 시험 이벤트였다 (아래 정정 참조)

정정 (09-14 밤) · "4.8% 영구 유실" 은 틀렸다 — +40h 에 100%

이 항목 위쪽의 "반영률 95.2%, 잔여 4.8% 영구 유실" 은 틀린 결론이다. 기록은 지우지 않고 여기서 바로잡는다.

사용자가 GA4 보고서를 CSV 로 내려받아 보여 줬다 — Purchasers 544. 우리 543 + 수동 시험 1 이다. 다시 대조했다.

+25h (09-13 22:35)  95.2%  ← 여기서 "수렴·영구 유실" 로 판정
+40h (09-14 13:46) 100.0%  ← 543 / 543, 누락 0
  • 원인 [확인]: 유입 속도 1.10 → 0.75 → 0.10건/h 를 보고 감속을 수렴으로 읽었다. GA4 가 적는 처리 창이 24~48시간인데 25시간에 판정했다
  • 같은 실수 두 번째다. +0h 에 1% 를 보고 "99% 버린다" 로 갈 뻔한 것과 같다 — 너무 일찍 물은 것이고, 이번엔 문서 다섯 곳과 지표 화면에까지 박혔다
  • 스스로 못 잡았다. 사용자가 CSV 를 내밀어서야 알았다
  • 고친 곳: README 발견 1 · benchmarks 5장 · outbox 14장 · ADR-019 · /metrics 화면
  • 남은 교훈은 바뀐다: "204 를 받고도 5%가 사라진다" 가 아니라 "204 와 집계 사이에는 최대 40시간이 있고, 그 창을 다 기다려야 결론을 낼 수 있다"
  • 속도별 비교(워커 1·4개, A·B군)는 누락률이 아니라 도착 지연을 잰 것이었다. 스로틀링이 아니라는 결론은 맞았지만 근거가 틀렸다

TEST-0001 도 풀렸다 — 내가 보낸 것이다. 09-12 GA4 연결 확인 때 아웃박스를 거치지 않고 curl 로 직접 쏜 시험 이벤트라 conversions 에 행이 없다. 사용자에게 출처 확인을 두 번 부탁했는데 내 대화 기록을 먼저 뒤졌어야 했다.

그리고 공고 ② 서비스&인프라 운영이 조기 마감됐다. 지원 대상은 ① · ③ 두 건. 공고 PNG 두 장은 사용자가 research/postings/shots/ 에 넣었다.

2026-09-13 (일) · D-8

한 일

  • E-1 · E-2 실측 → 범위 안 12건 중 10건
  • cli/purge 구현 — 설계가 약속만 하고 없던 파기 배치
  • README·아키텍처 다이어그램·계측 5·6장 마감 (전날 밤에 이어)

막힌 것 / 잘못한 것

① 내 실험 설계가 두 번 틀렸다 — 코드는 멀쩡했다

E-1 을 재려고 같은 쿠키 항아리로 랜딩을 8번 불렀더니 8번 다 rdb1 이었다. 09-09 의 배정 쏠림 버그(12/12)와 같은 모양이라 "돌아왔구나" 했다.

local COOKIE_NAME = "ab_rdb"   -- 있으면 고정한다
  • 원인 [확인]: 버그가 아니라 의도한 고정이다. 플랫폼 A 의 …slave=sdb3 쿠키를 재현한 부분이고, 한 사용자가 요청마다 다른 신선도를 보는 걸 막는다. curl -c/-b 로 항아리를 물려주면 당연히 고정된다
  • 같은 회차에 /collect 로 보낸 페이로드가 422 로 거절됐다. event 화이트리스트(page_view·impression·click)에 없는 이름을 보냈다
  • 배운 것: 두 번 다 코드가 옳고 내 실험이 틀렸다. 09-12 에 "통과하는 테스트가 곧 의도한 동작은 아니다" 를 배웠는데, 이번엔 그 반대다 — 실패하는 관측이 곧 코드의 결함도 아니다. 어느 쪽이든 결론 전에 "왜 그렇게 나오는가" 를 한 번 더 물어야 했다
  • 다행인 것 하나: 422 가 조용히 200 을 주지 않았다. 화이트리스트가 없었으면 오타 난 이벤트 이름이 그대로 적재돼 집계에 섞였을 것이다

② 보존정책이 문장으로만 있었다

data-model.md 7장에 보존기간 표와 DELETE 두 줄이 적혀 있는데, grep 해 보니 그걸 도는 코드가 없었다. 마이그레이션에 ix_purge 인덱스까지 만들어 놓고 정작 파기가 없다.

  • 배운 것: 파기되지 않는 보존정책은 정책이 아니라 문장이다. 인덱스가 있으면 배치도 있는 줄 알았다 — 준비물과 실행은 다르다
  • 대응: cli/purge plan / run 을 만들었다. 통째로 지우지 않고 한도씩 끊는다. 큰 DELETE 는 긴 트랜잭션이 되고 복제본이 그걸 단일 스레드로 다시 적용하느라 파기 배치가 E-1 을 일으킨다 — 6장에서 복제본 CPU 를 재면서 알게 된 성질이 여기로 이어졌다

확인한 것

E-1 — 같은 URL 이 같은 순간에 다른 답을 준다

rdb1 rdb2
쓴 직후 1개 0개
12초 뒤 1개 1개

Seconds_Behind_Source 가 1 → 12 로 오르다 +10초에 도착했다. 10을 넘긴 것이 읽을 만하다 — 이 값은 "지연 설정"이 아니라 "가장 오래된 미적용 이벤트의 나이" 라서 의도한 지연과 밀린 정도가 한 숫자에 섞인다. SOURCE_DELAY 를 쓰는 환경에서 이 값으로 경보를 걸면 상시 경보가 된다.

E-2 — 같은 질문, 두 경로, 다른 답

"이 회원은 어느 캠페인으로 들어왔나"
  touchpoints 조인   e2camp → NULL      (파기됨)
  users.signup_pid   e2camp → e2camp    (남음)

signup_visit_id 는 이제 없는 행을 가리킨다. FK 를 안 건 것이 설계다.

FK 였다면
RESTRICT 파기가 막혀 법정 보존기간을 못 지킨다
CASCADE 방문이 사라질 때 회원까지 지워진다
SET NULL 값은 남지만 어느 방문이었는지 영영 모른다

셋 다 틀린 자리라 컬럼 주석에 이유를 적어 뒀던 것이다. 참조 무결성을 일부러 포기했으면 그 이유가 스키마에 있어야 한다.

추가 (09-13 · 되읽기) · 되읽기를 붙였더니 반영률이 1.0% 였다

한 일

  • GA4 Data API 되읽기 구현 — src/Verify/ 3개 + cli/verify + 테스트 7건
  • A-3 · D-3 실측 → 범위 안 12건 전부 착수(10건 완료 + 2건 부분)
  • GA4 실전송 500건에 traffic_type=internal 표식, 통화 5종

오늘의 결과 — 이 프로젝트에서 제일 값어치 있는 한 줄

우리가 보낸 것  508      (전송 로그: 508건 전부 204)
매체가 집계한 것   6
반영률         1.0%

204 는 "받았다" 지 "집계했다" 가 아니라는 것을 숫자로 확인했다. 어제 "500/500 성공" 을 적어 놓고 지표에 ✅ 를 못 붙였는데, 그 판단이 맞았다. 원인은 아직 확정 못 했다 — 처리 지연과 버스트 스로틀링을 가르는 실험(A군 20건 1초 간격 / B군 20건 몰아서)을 걸어 두고 대기 중이다.

막힌 것 / 잘못한 것

① 속성 ID 로만 세 번 어긋났다

회차 넣은 값 정체
1 108568046735462227603 서비스 계정 client_id (키 파일 안의 값)
2 553839834 다른 속성. 권한은 통과하는데 90일에 0건
3 553897149 맞는 값
  • 원인 [확인]: GA4 주변에 "속성 ID처럼 보이는 숫자" 가 셋이다 — 측정 ID(G-…), 서비스 계정 client_id, 속성 ID. 내 문서는 "측정 ID 가 아니라 숫자" 라고만 적어 뒀는데, 헷갈릴 숫자가 하나 더 있다는 걸 몰랐다
  • 대응: 403 메시지에 추가할 서비스 계정 이메일과 현재 속성 ID 를 찍게 하고, "서비스 계정 client_id 가 아니다" 를 명시했다. cli/verify props 로 볼 수 있는 속성 목록을 직접 받아 .env 와 대조하는 진단도 만들었다
  • 배운 것: 2회차가 특히 고약했다. 403 도 아니고 오류도 없이 0건이라 "처리 지연인가" 로 한참 갔다. 권한이 통과한다는 것이 올바른 대상이라는 뜻은 아니다 — 성공적으로 엉뚱한 곳을 보고 있을 수 있다

② 서비스 계정 키가 git add -A 에 딸려갈 뻔했다

키 파일을 저장소 루트에 뒀는데 .gitignore 의 *-sa.json 에 안 걸렸다. 구글이 주는 이름은 <프로젝트>-<번호>-<해시>.json 이다.

  • 커밋되지는 않았다 (추적 이력 없음). 다만 내가 계속 git add -A 를 쓰고 있었으므로 다음 커밋에 들어갔을 것이다
  • 대응: 루트의 .json 을 전부 막고 composer.json 만 되살렸다
  • 배운 것: 이름 기반 ignore 는 파일명을 내가 정할 때만 믿을 수 있다. 남이 주는 파일은 이름을 모른다. 비밀이 놓일 자리를 막아야지 이름을 막으면 안 된다

③ 내 유력 가설이 틀렸다 — 필터가 아니었다

반영률이 1%로 나오자 traffic_type=internal 필터를 의심했다. 어제 내가 붙인 표식이라 짚이는 게 있었다. 그런데 들어간 5건 중 3건이 그 표식을 단 500건 배치 소속이었다. 필터라면 하나도 안 들어왔어야 한다.

  • 배운 것: 최근에 내가 바꾼 것이 범인이라는 추정은 합리적이지만 증거를 먼저 봐야 한다. 관측된 목록을 통째로 찍게 고친 뒤에야 기각할 수 있었다. 그래서 cli/verify ga4 가 관측분이 적을 때 전부 출력하도록 바꿨다 — 몇 개가 빠졌나 가 아니라 무엇이 들어갔나가 원인을 가른다

④ A-3 표가 스스로 경고하는 오해를 저지르고 있었다

스켈레톤에 이렇게 적혀 있었다.

| lp. → app. | 사이트 같음 | 오리진 같음(호스트만 다름) |

오리진은 scheme + host + port 다. 바로 아래 주석에 "사이트가 같아도 오리진이 다르면 CORS 는 그대로 걸린다" 고 써 놓고 표에서는 둘을 다시 묶었다.

  • 배운 것: 같은 오해를 이 저장소에서 세 번 했다 — 잃는 시나리오 4개 과대평가, architecture.md 4장 닫는 말, 그리고 이 표. 문장으로 아는 것과 표를 채울 때 아는 것이 달랐다. 개념을 고쳤으면 그 개념이 쓰인 모든 형태를 찾아야 한다

확인한 것

A-3 — lp. 에서 세 곳으로 fetch

호출 브라우저 서버 로그
lp. → lp. 200 200
lp. → app. TypeError: Failed to fetch 200
lp. → api. 200 OPTIONS 204 + POST 200

가운데가 ADR-018 의 요지다. 그리고 서버는 200 을 돌려줬다 — CORS 는 요청이 아니라 응답 읽기를 막는다(B-2 와 같은 성질, same-site 쌍에서도). collect_events 에 visit_id 가 붙은 것으로 Lax 쿠키가 크로스오리진으로 간 것도 확인했다.

D-3 — 같은 dedup_key 로 동시 8개, 3회 반복

NEW 1 · DUP 7 | conversions 1행 · outbox 1행    (3회 전부 동일)

outbox 도 1행인 것이 중요하다. 어긋나면 전환 하나가 매체로 여덟 번 나간다. "먼저 SELECT 해서 없으면 INSERT" 가 왜 틀리는지가 여기서 보인다 — 여덟이 동시에 SELECT 하면 여덟 다 "없음" 을 본다.

다음

  • A/B 결과 확인 — A군(1초 간격 20건) vs B군(몰아서 20건). A군만 들어와 있으면 스로틀링, 둘 다면 어제 것도 지연이었다는 뜻
  • 결제 웹훅 — D-3 의 못 잰 두 줄(중복 수신 · 순서 역전)
  • 파기 배치 cron
  • 저장소 Topics (토큰이 없어 웹 UI. 값은 09-12 항목에)
  • GA4 의 TEST-0001 purchase 이벤트 — 우리가 보낸 것이 아니다. 출처 확인

2026-09-12 (토) · D-9

한 일

  • 공고 3건의 상세요강 원문 확보 및 축자 전사 → research/postings/ (비공개)
  • 로드맵의 지원 대상 정정 — 콘텐츠 플랫폼 공고를 "지원 안 함"에서 지원으로
  • mio/web/CLAUDE.md 작성 — 세션이 바뀌어도 맥락이 유지되도록

막힌 것 / 잘못한 것

① 한 문장을 세 번 다르게 읽었다 — 원인은 해석이 아니라 기록이다

09-08에 사람인 상세요강 이미지를 읽고 로드맵에 이 문장을 인용했다.

"도메인·Redirect 등 다양한 유입 환경에서의 사용자 및 전환 추적 기능 개발"

09-12에 "도메인이 2개나 필요하냐"는 질문을 받고 이 문장을 세 번 다르게 해석했다.

회차 내가 주장한 읽기 판정
1 크로스사이트 실험(서드파티 쿠키)의 근거 과대 해석
2 도메인 간 이동(크로스도메인 연결)의 근거 부분적으로 맞음
3 (지적받고) 클라이언트 환경 = 기기 종류 틀림 — 원문에 기기·디바이스라는 말이 없다
  • 원인 [확인]: 인용 자체는 축자가 맞았다. 문제는 research/ 에 원문을 남기지 않고 결론만 로드맵에 적은 것이다. 그래서 나중에 내 인용이 축자인지 요약인지 나 자신도 확인할 수 없었고, 기억으로 재해석하게 됐다
  • 대응: 세 공고 전문을 research/postings/ 에 축자로 남겼다. 저장소 쪽 문서는 익명화를 유지한다
  • 배운 것: 관측은 결론이 아니라 원문으로 남긴다. 결론만 남기면 그 결론을 검증할 수단이 사라진다. 09-08에도 같은 실수를 했고(태그만 보고 광고로 기울임), 그때 원문을 남겼으면 오늘 비용이 없었다. 같은 값을 두 번 치렀다

② 원문이 답을 갖고 있었다 — 도메인 논쟁

공고 ①의 우대사항에 이 줄이 있었다.

"Domain, Redirect, CORS 등 다양한 웹 환경에서 발생하는 데이터 전달 이슈를 다뤄보신 분"

  • CORS 가 이름으로 박혀 있다. CORS 는 정의상 cross-origin 이고 서브도메인으로는 의미 있는 실험이 안 된다
  • 그러나 필수 목록에는 없다. 필수는 HTTP·Cookie·URL Parameter·MySQL·Git 뿐이고 전부 도메인 1개로 충족된다
  • 결론: 두 번째 도메인은 필수 충족이 아니라 우대 5개 중 1개를 증거로 채우는 비용 2만원. 09-08의 정정("2개가 필수는 부정확")이 옳았고, 09-12에 내가 다시 필수처럼 말한 것이 잘못이었다

③ 콘텐츠 플랫폼 공고를 버릴 뻔했다

09-08에 "지원 안 함"으로 분류했는데, 원문을 보니 그 공고의 우대사항이 이렇다.

PHP 또는 CodeIgniter 기반 / 다국어 또는 대규모 B2C / 기술 부채를 발견하고 지속적인 개선

  • ADR-017(레거시 프레임워크를 갈아엎지 않고 새 로직만 분리)이 "레거시 구조 최적화"에 대한 답 그 자체다
  • ADR 18건과 이 worklog 자체가 "기술 부채를 발견하고 개선"의 증거다
  • 제목만 보고 분류했다. 태그만 보고 판단한 09-08과 같은 종류의 실수다

결정한 것

결정 근거
공고 3건 모두 지원 저장소 하나로 강조점만 바꾼다. ③이 대응도가 가장 높은 축에 속한다
공고 원문은 비공개 폴더에 저장소는 익명화 정책 유지. 회사명을 공개 저장소에 넣지 않는다
CLAUDE.md 를 저장소 밖에 회사명·서버 주소가 들어간다. mio/web/ 은 git 저장소가 아니다
두 번째 도메인은 선택 우대 1개. 사면 CORS·서드파티 쿠키가 열리고, 안 사면 ADR-002 대로 주장을 내린다

추가 (2026-09-12 오후) · 등록 도메인 1개로 확정

두 번째 도메인을 사지 않기로 했다 → ADR-018

그 과정에서 내가 틀린 것을 하나 더 찾았다. 나는 "도메인 1개면 크로스사이트 실험 4개(A-1·A-2·B-2·B-3)를 잃는다"고 말했는데 과대평가였다.

기준 좌우하는 것
오리진 scheme + host + port CORS
사이트 등록 도메인(eTLD+1) 쿠키

lp.sshwan.com → api.sshwan.com 은 사이트는 같고 오리진은 다르다. CORS 는 오리진 기준이므로 preflight 도 Vary: Origin 도 Allow-Origin:* + credentials 충돌도 그대로 일어난다. 잃는 것은 A-1(서드파티 쿠키 차단)과 A-2(SameSite=None) 둘뿐이다.

  • 원인 [확인]: "크로스사이트 = CORS + 쿠키 차단" 으로 두 축을 하나로 묶어서 생각하고 있었다. 흔한 오해이고, 내가 그 오해를 하고 있었다
  • 대응: 수집 호스트를 api.sshwan.com 으로 옮기고(같은 인증서 SAN 에 추가), 문서 전체에서 cross-site 를 cross-origin 으로 정정했다. A-1·A-2 는 지우지 않고 "하지 않기로 한 것" 으로 옮겨 이유를 적었다
  • 배운 것: 범위를 줄일 때 정확히 무엇이 빠지는지 세어 보지 않으면 필요 이상으로 포기하게 된다. 두 배를 포기할 뻔했다

문서에서 내린 주장

ADR-002에 "1개로 간다면 주장도 함께 내린다" 고 적어 뒀고, 그대로 지켰다.

문서 내린 것
README 실패 시나리오 표에서 서드파티 쿠키·SameSite 두 줄 삭제 + "범위에서 뺀 것" 명시
failure-scenarios.md A 장 전체를 "범위에서 뺀 구간" 으로. A-3 은 same-site × cross-origin 대조로 재정의
domains-and-cookies.md 1장을 "사이트와 오리진은 다른 축" 으로 재작성. 5장(브라우저 매트릭스) 철회
architecture.md · api-spec.md cross-site → cross-origin, SameSite=None → Lax
.env.example COOKIE_TID_SAMESITE=None → Lax, PARTITIONED=false
ADR-002 상태를 철회로. 본문의 eTLD+1 분석은 그대로 둔다 — 뒤집힌 것은 구매 결정뿐이다

증거 없는 주장을 남겨두는 것이 도메인을 아끼는 것보다 훨씬 큰 손해다. — ADR-002 에 미리 적어 둔 문장

추가 (2026-09-12 저녁) · /collect 와 CORS 정책

로드맵 M3. api.sshwan.com 에서 수집이 돈다.

  • src/Http/CorsPolicy — 오리진 정확 반향 · Vary: Origin · preflight · 허용 목록. 테스트 16건
  • Collect 컨트롤러 · collect_events 마이그레이션 (보존 3개월, visits 에 CASCADE)

테스트에 "닮은 오리진" 여섯 개를 넣었다. 허용 목록을 문자열 포함으로 검사하면 전부 뚫린다.

https://lp.sshwan.com.evil.example    접미사 위조
https://evil-lp.sshwan.com            접두 위조
https://a.lp.sshwan.com               서브의 서브
http://lp.sshwan.com                  스킴 다운그레이드
https://lp.sshwan.com:8443            포트 추가

본문 형식을 Content-Type 으로 판단하지 않는다. sendBeacon 은 preflight 를 피하려고 text/plain 을 보낸다. 그래서 내용을 JSON 으로 파싱해 보고, Content-Type 은 transport 를 기록하는 용도로만 쓴다 — fetch 로 온 건과 beacon 으로 온 건 중 어느 쪽이 더 많이 유실되는지 재려면 행마다 남아 있어야 한다.

막힌 것

에러 응답의 type URI 가 example.invalid 이었다

{"type":"https://example.invalid/problems/unknown-event", ...}
  • 원인 [확인]: problem() 이 TRACK_DOMAIN 을 기준으로 URI 를 만들고 있었다. 추적 도메인을 안 사기로 하면서 그 값이 영구히 빈 문자열이 됐다
  • 배운 것: 결정을 뒤집을 때 그 값을 참조하던 코드까지 따라가야 한다. 문서는 훑어 고쳤는데 코드 한 줄이 남아 있었다. grep TRACK_DOMAIN 을 한 번 돌렸으면 됐다

운영에서 확인

시험 결과
preflight (허용 오리진) 204 · 오리진 정확 반향 · Vary: Origin · Max-Age: 600
preflight (lp.sshwan.com.evil.example) 403, CORS 헤더 없음
POST fetch (JSON) 200 · transport=fetch · 클라이언트 occurred_at 사용
POST beacon (text/plain) 200 · transport=beacon · preflight 없이 통과
모르는 이벤트 422 + RFC 9457
CORS_ALLOW_ORIGIN_WILDCARD=true Allow-Origin: * + credentials: true 동시 송출

마지막 항목이 B-2 의 재료다. 서버는 200 을 주고 막는 쪽은 브라우저다 — 그 오류 원문을 브라우저로 받아 적는 것이 다음 할 일이다.

추가 (2026-09-12 밤) · track.js 와 B-2 실측

track.js 를 붙이고 브라우저로 실제 동작을 봤다. 예상과 다른 것이 하나 나왔고, 그게 오늘 최고 수확이다.

① Max-Age 가 실제로 왕복을 줄인다

엣지 로그에서 메서드만 뽑은 것이다. DevTools 가 아니라 서버 기록이다.

OPTIONS /collect → 204     page_view (fetch) — 첫 요청이라 preflight
POST    /collect → 200     page_view 본 요청
POST    /collect → 200     click (fetch) — preflight 캐시되어 OPTIONS 없음
POST    /collect → 200     click (beacon) — 애초에 preflight 없음

세 번째 줄이 Access-Control-Max-Age: 600 의 값어치다. 없으면 수집 한 건마다 왕복이 두 번이다.

② CORS 는 요청을 막지 않는다 — 응답을 읽는 것을 막는다

CORS_ALLOW_ORIGIN_WILDCARD=true 로 깨진 조합을 내보냈다. 콘솔 원문:

Access to fetch at 'https://api.sshwan.com/collect' from origin 'https://lp.sshwan.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header
in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

여기까지는 예상대로였다. 그런데 같은 시각 DB 에 그 요청이 들어와 있었다.

id  event      transport  received_at
 7  page_view  fetch      2026-09-12 08:18:04.748   ← 브라우저가 "차단" 한 그 요청
  • 요청은 서버에 도달했고 처리됐다. 막힌 것은 응답을 읽는 쪽이다
  • 스크립트가 받은 것은 TypeError: Failed to fetch 뿐이다. CORS 라는 말이 없다
  • 그래서 재시도를 붙이면 서버에는 이미 들어간 건이 한 번 더 들어간다

증상이 "데이터 없음" 이 아니라 "데이터 두 배" 다. 실무에서 이 설정은 "CORS 가 안 되네" 하고 * 로 바꿨다가 생기는데, 증상이 중복이라 원인을 CORS 로 의심하지 않게 된다.

"* 와 credentials 는 같이 못 쓴다" 는 문서로 안다. 그 설정이 데이터를 어떻게 오염시키는지는 해 봐야 안다.

③ sendBeacon 은 같은 상황에서 통과했다

id  event  transport  received_at                 상태
 8  click  beacon     2026-09-12 08:18:20.760     와일드카드 ON 중 — 통과

응답을 읽지 않으므로 브라우저가 막을 이유가 없다. 반대로 성공했는지 확인할 방법도 없다 — sendBeacon() 의 반환값은 "큐에 넣었다" 이지 "서버가 받았다" 가 아니다.

결과를 안다 CORS 에 걸린다 이탈 중
fetch ✅ ✅ 취소될 수 있음
sendBeacon ❌ ❌ 보장

어느 쪽도 클라이언트에서 확인이 안 된다. 수집 파이프라인이 서버 큐(아웃박스)를 두는 이유가 여기에도 있다.

설계에 반영한 것

  • Blob 의 type 을 text/plain 으로 고정. application/json 으로 두면 단순 요청이 아니게 되어 preflight 가 필요해지는데, sendBeacon 은 preflight 를 기다릴 수 없어 그대로 실패한다. 서버가 Content-Type 이 아니라 본문 내용으로 파싱하는 이유
  • 이탈 감지를 unload 가 아니라 pagehide + visibilitychange 로. 모바일 사파리는 탭을 백그라운드로 보낼 때 unload 를 부르지 않는다
  • ab_vid 는 HttpOnly 라 스크립트가 못 읽는다. 일부러 그렇다 — 읽을 수 있으면 XSS 하나로 방문 이력이 샌다. 쿠키는 브라우저가 credentials: 'include' 로 싣고 서버가 읽는다
  • track.js 를 public/ 이 아니라 컨트롤러로 서빙. public/ 에 두면 nginx 가 모든 호스트에서 내주고, 스크립트가 자기 오리진을 수집 주소로 삼으므로 lp. 에서 로드되면 same-origin 이 되어 CORS 가 사라진다

추가 (2026-09-12 · GA4 연결) · 내가 고친 것이 개악이었다

측정 ID 와 API secret 을 받아 실제로 눌러 봤다. 어제 "문서에서 확인했다" 며 고친 것이 틀려 있었다.

경로 실제
/debug/mp/collect ✅ 200 + validationMessages
/_debug_/mp/collect ❌ 404

.env.example 에는 원래 /debug/mp/collect 로 맞게 적혀 있었다. 그걸 내가 /_debug_/mp/collect 로 "고쳤다".

  • 원인 [확인]: 공식 문서 페이지에는 앱(Firebase) 탭과 웹 탭이 있는데, WebFetch 가 돌려준 요약은 앱 탭 내용이었다. 거기엔 firebase_app_id 와 _debug_ 경로가 나온다. 그걸 웹 스트림에 그대로 옮겨 적었다
  • 더 나쁜 것: 커밋 메시지에 "공식 문서에서 원문으로 잡았다" 고 썼다. 출처를 댔기 때문에 더 믿음직해 보였고, 그래서 더 나빴다
  • 배운 것: 원문을 확보하는 것과 그 원문이 내 경우에 해당하는지 확인하는 것은 다른 일이다. 09-12 아침에 "관측은 결론이 아니라 원문으로 남긴다" 를 배웠는데, 같은 날 저녁에 원문을 잘못 고른 경우에 걸렸다. 눌러 볼 수 있는 것은 눌러 본다

그리고 눌러 봤더니 문서에 없던 것이 나왔다

요청 결과
/debug/mp/collect + 올바른 페이로드 200 · {"validationMessages": []}
/debug/mp/collect + _badEventName 200 · NAME_INVALID
/debug/mp/collect + 틀린 api_secret 200 · 메시지 없음
/mp/collect + 잘못된 이벤트 204 · 본문 0바이트

세 번째 줄이 설계를 바꾼다.

validationMessages: [] 는 "페이로드가 맞다" 는 뜻이지 "인증이 맞다" 는 뜻이 아니다. 검증 서버는 api_secret 을 확인하지 않는다. 자격 증명이 틀렸는지는 GA4 실시간 보고서로만 알 수 있다.

그래서 "검증 통과 → sent" 로 끝내면 안 된다. api_secret 이 틀려도 검증은 통과하고, 운영 엔드포인트는 204 를 주고, 데이터는 아무 데도 안 간다. 세 단계 모두 성공처럼 보이는데 결과가 없는 상태가 만들어진다.

확인한 것

lp.sshwan.com    gtag 가 _ga=GA1.2.954472196.1789205372 를 심음
api.sshwan.com   같은 쿠키가 실려 옴 (다른 오리진, 같은 사이트)
서버             client_id = 954472196.1789205372 파싱
GA4 검증         validationMessages: []  — 페이로드 형식 OK

ADR-018 의 전제("등록 도메인이 하나라 api. 가 _ga 를 읽는다")가 실측으로 확인됐다.

추가 (2026-09-12 밤) · 아웃박스 → GA4 가 끝까지 도달했다

GA4_DEBUG=false 로 운영 엔드포인트에 3건을 보내고 실시간 보고서에서 확인했다.

전송   12:21:46 UTC   #4531 ga4 attempt=1 → sent (http=204, 44ms)
도착   21:21:47 KST   purchase   (속성 reading_time_zone)

이것으로만 증명되는 것이 둘 있다.

왜 여기서만 알 수 있나
api_secret 이 맞다 검증 엔드포인트는 secret 을 확인하지 않는다. 틀려도 200 을 준다
timestamp_micros 가 맞다 마이크로초가 아니라 밀리초를 넣었으면 1970년에 찍힌다. 보낸 시각과 도착 시각이 1초 차이로 맞았다

운영 엔드포인트는 204 · 본문 없음이다. 코드는 집계 여부를 알 방법이 없다. sent 는 "보냈다" 이지 "집계됐다" 가 아니라는 것을 어댑터 주석에 적어 뒀는데, 그 문장이 왜 필요한지가 여기서 분명해졌다 — 확인은 사람이 보고서를 여는 수밖에 없다.

안내서 12장 체크리스트

# 조건
1 전환 1건 → 채널 수만큼 pending ✅
2 워커가 집어 sent. 검증 응답 validationMessages: [] ✅
3 워커 4개에서 중복 전송 0 ✅ 2000건, (outbox_id, attempt) 중복 0
4 실패가 백오프대로 재시도되고 결국 dead ◐ 재시도·지터 확인. dead 까지 소진은 미측정
5 EXPLAIN 이 ix_poll 을 쓴다 ✅ 단, 분포에 따라 갈린다
6 GA4 실시간 보고서에 이벤트가 보인다 ✅

오늘 한 일 요약 (2026-09-12 · D-9)

하루에 M3·M4 가 닫혔다.

오전 공고 원문 3건 확보 · 로드맵 지원 대상 정정 · CLAUDE.md
오후 등록 도메인 1개 확정(ADR-018) · 주장 내리기 · api. 를 같은 도메인으로
저녁 /collect + CORS · track.js · B-2 실측
밤 gtag · 채널 어댑터 · 아웃박스 워커 · GA4 도달 확인

면접에서 쓸 것

  • CORS 는 요청을 막지 않는다. 차단된 요청이 DB 에 기록돼 있었고, 스크립트는 이유를 모른 채 실패로 안다. 증상이 "데이터 없음" 이 아니라 "데이터 두 배"
  • 인덱스는 분포가 있어야 측정된다. 큐가 밀리면 옵티마이저가 PK 를 고르고, 그게 맞는 판단이다
  • 계측 코드도 검증 대상이다. db_ms 가 total_ms 보다 컸다
  • 검증 통과 ≠ 집계됨. GA4 검증 서버는 api_secret 을 보지 않는다
  • JSON 에는 정수/실수 구분이 없다. 없는 문제를 고쳤다가 되돌렸다

내가 틀린 것 — 오늘만 네 번

무엇 어떻게 알았나
한 문장을 세 번 다르게 해석 공고 원문을 받아서
잃는 실패 시나리오를 4개로 과대평가 (실제 2개) 오리진과 사이트를 구분해 세어 보고
GA4 검증 경로를 _debug_ 로 "고침" 실제로 눌러 보고 (404)
JSON 타입 불일치를 "고침" 테스트가 실패해서

넷 다 확인할 수 있는 것을 확인하지 않고 추론한 경우다. 원문·실측·테스트가 각각 잡아 줬다.

추가 (2026-09-12 · C장 마감) · 리다이렉트 3건 · D-1 · 계측 5·6장

한 일

  • C-1 · C-2 · C-3 실측 → failure-scenarios.md. 범위 안 12건 중 7건이 됐다
  • D-1 대조군 — OUTBOX_SKIP_LOCKED 스위치를 만들어 끈 쪽을 돌렸다
  • 계측 5장(핵심 지표) · 6장(리소스) 실측
  • README 4·5·6장을 실측값으로 채우고, 아키텍처 다이어그램에 트랜잭션 경계를 그렸다
  • 엣지 443 에 default_server 명시 (아래 ③)

막힌 것 / 잘못한 것

① 실험이 성립하지 않았다 — 내가 지운 헤더가 다시 붙어 있었다

C-1 을 가르려면 "캐시 헤더 없는 301" 이 필요했다. 브리지가 붙이던 no-store 를 떼고 배포했는데 응답에 그대로 남아 있었다.

cache-control: no-store, no-cache, must-revalidate
expires: Thu, 19 Nov 1981 08:52:00 GMT
pragma: no-cache
  • 원인 [확인]: PHP 의 session.cache_limiter 기본값이 nocache 다. 세션을 쓰는 모든 응답에 위 세 줄이 자동으로 붙는다 (ini_get('session.cache_limiter') = nocache, session.cache_expire = 180 으로 확인)
  • 대응: 실험 분기에서 header_remove() 로 셋을 걷어냈다
  • 배운 것: 브리지의 no-store 는 처음부터 중복이었다. 코드에도 있고 응답에도 있었지만 둘은 같은 줄이 아니었다. 지워 보기 전에는 알 수 없었다. "설정했으니 됐다" 와 "그 설정이 실제로 그 헤더를 만들었다" 는 다른 문장이다

② 93.3초라는 숫자를 그대로 믿을 뻔했다

D-1 에서 batch=5 / SKIP LOCKED 켬 이 93.3초, 끈 쪽이 7.5초로 나왔다. "SKIP LOCKED 가 12배 느리다" 로 적기 직전이었다.

  • 원인 [확인]: 그 회차에만 워커 프로세스가 5개 찍혀 있었다(--scale worker=4 인데). 하나가 전송 중에 죽어 행 하나가 sending 에 남았고, 내 측정 루프는 pending|sending 이 0이 되기를 기다리고 있었다. 1000건 중 정확히 한 건만 attempt=2(id 8613)였고, 999건은 6.5초에 끝났다. 그 한 건이 85.7초 뒤 좀비 회수로 나갔다 (설정값 90초)
  • 배운 것: 잰 것은 처리량이 아니라 회수 대기 시간이었다. 1/1000 짜리 꼬리가 전체를 12배로 만들었다. 총 소요만 보면 안 되고 분포를 봐야 한다 — 이번엔 attempt 컬럼이 꼬리를 잡아 줬다
  • 덤: 계획에 없던 좀비 회수 실측이 남았다. 워커가 죽어도 행이 조용히 사라지지 않는다

③ 막혀 있는 줄 알았는데, 우연히 막혀 있었다

C-3 에서 Host: evil.example 로 /go 를 불렀더니 PHP 까지 가지 않고 엣지에서 302 로 끝났다(rt=0.000, read_target=""). 잘 막혔다고 넘어갈 뻔했다.

  • 원인 [확인]: 443 에 default_server 가 없었다. nginx 가 매칭 실패 시 그 포트의 첫 서버 블록을 고르는데, 마침 첫 블록이 루트 도메인 블록이었고 그 안의 return 302 https://lp.${SHOP_DOMAIN}$request_uri 가 목적지를 하드코딩하고 있었다
  • 왜 중요한가: 브리지는 Location 을 $http_host 로 만든다. 모르는 Host 가 PHP 까지 닿으면 그게 곧 오픈 리다이렉트다
  • 대응: listen 443 ssl default_server 로 명시
  • 배운 것: 통과하는 테스트가 곧 의도한 동작은 아니다. 왜 통과하는지를 묻지 않았으면 블록 순서에 기댄 보장인 줄 몰랐을 것이다

④ 2도메인 흔적이 또 남아 있었다 — 같은 유형 네 번째

ADR-018 로 결정을 뒤집으면서 문서를 훑어 고쳤다고 생각했는데 세 군데가 남아 있었다.

어디 무엇
architecture.md 4장 제목이 "두 도메인이 만드는 세 가지 상황". 게다가 닫는 말이 "서브도메인만 나누면 첫 행이 사라진다" — ADR-018 이 반박한 바로 그 믿음
architecture.md 7장 운영 도메인이 sshwan.com / sshwan.com 으로 중복
domains-and-cookies.md DNS 절이 Caddy 를 쓴다고 적혀 있었다 (OpenResty 다)
research-method.md 3-1 ADR-002 에서 끝나 있었다. ADR-018 이 없다
  • 배운 것: 09-12 아침에 TRACK_DOMAIN 참조를 세 군데 빠뜨렸을 때 "grep 을 한 번 돌렸으면 됐다" 고 적어 놓고 또 같은 일을 했다. 이번엔 코드가 아니라 문서였다. 결정을 뒤집을 때는 코드와 문서 양쪽에 grep 을 돌린다 — 특히 뒤집힌 결론을 근거로 인용하던 문장이 위험하다
  • 부수 효과: research-method.md 3-1 은 오히려 더 나은 이야기가 됐다 — 조사가 설계를 바꿨고, 실측이 그 조사를 다시 바꿨다

예상이 틀린 것 세 개 (이번 회차 수확)

예상 실제
"301이 위험하다" 301 자체는 아니다. no-store 붙인 301은 매번 서버에 온다. 위험한 건 영구 캐시를 허용하는 301이고, 그건 롤백이 안 된다(고친 뒤 같은 URL 2회 → 서버 도달 0회)
"SKIP LOCKED 를 끄면 처리량 급감" 안 떨어졌고 오히려 조금 빨랐다. 잠금 구간에 외부 I/O 가 없어 대기가 마이크로초다. 경합은 실제로 있었다 — 로그에 #10610 #10612 #10614 처럼 건너뛴 자국이 있다
"?return= 오픈 리다이렉트" 그런 파라미터가 없다. 과녁은 Host 헤더였다

결정한 것

  • SKIP LOCKED 는 처리량 장치가 아니라 보험으로 다시 적는다. 중복을 막는 것은 잠금과 "선점+상태변경 한 트랜잭션" 규칙이다. benchmarks.md 2장이 공을 SKIP LOCKED 에 돌리고 있어 같이 고쳤다
  • 지표 화면은 채널별로 쪼갠다. 전송 성공률 100% 는 9303행 중 9300행이 항상 204 를 돌려주는 NoopChannel 이라 나온 값이다
  • 어트리뷰션 보존율의 정의를 바꾼다. 접점이 남은 방문 ÷ 전체 방문 으로 재면 99.8% 인데, 직접 유입도 규칙 4로 last 접점을 받으니 뭘 재든 100%에 붙는다. 광고 유입 방문 중 보존된 비율 로 고쳐 20/20. 목표치를 처음부터 넘고 있는 지표는 대개 분모를 잘못 고른 것이다

리소스 실측 — t3.small 은 성립한다, 다만 CPU 가 먼저 벽에 닿는다

유휴 부하(워커 4 · 3000건)
mysql-primary 376/384MB (97.9%) 378MB · CPU 42.2%
mysql-replica 312/320MB (97.7%) 314MB · CPU 42.1%
swap 141MB 141MB (증가 0)
load average 0.09 2.29 / vCPU 2
  • 복제본이 프라이머리만큼 CPU 를 쓴다. 읽기를 거의 안 받는데도 쓰기를 전부 다시 적용하기 때문이다. 읽기 분산은 공짜가 아니다
  • docker stats 만 보면 스왑을 놓친다. 97.9% 는 이미 108MB 를 내보낸 뒤의 97.9% 다

다음

  • D-2 매체 타임아웃 → dead 까지 완료 — 5xx·4xx·타임아웃·복구 네 경로 전부

  • E-1 복제 지연 read-after-write (SOURCE_DELAY)

  • E-2 3개월 파기 — 어트리뷰션 보존율이 실제로 깨지는 자리

  • 커스텀 수집 /impression · /click

  • 저장소 Topics 설정 — gh 미설치라 웹 UI(저장소 우측 About ⚙)에서. 붙여넣을 값:

    attribution  ad-tracking  ga4  measurement-protocol  cors  http-redirect
    outbox-pattern  skip-locked  php  codeigniter  codeigniter3  mysql
    nginx  openresty  lua  docker  aws-ec2
    

    About 설명문: 광고 유입 → 전환 → 매체 재전송 파이프라인. PHP 8.2 · CodeIgniter 3 · MySQL 복제 · OpenResty. 실패 시나리오 12건 중 8건을 운영 서버에서 실측했습니다.

  • research/postings/shots/ 에 공고 PNG 저장 (내가 못 함)


2026-09-11 (금) · D-10

한 일

로드맵 M2 ①② — 광고 클릭이 방문과 접점으로 남는 경로를 끝에서 끝까지 연결했다.

GET /go?work=8733&pid=Google&utm_source=Google&gclid=..
  → 방문 확인/생성 · ab_vid 발급
  → 접점 판정 (first 보존 · last 갱신)
  → 302 → /l/8733?vid=..
  • src/ (프레임워크 비의존, 테스트 있음)
    • BridgeDestination — 목적지 URL. vid / passthru 두 방식
    • AcceptLanguage — 헤더 → ISO 639-1 두 글자
  • application/ (CI3 관용구)
    • Visit_model · Visitor · Bridge · Landing
  • 랜딩 화면에 기록된 first/last 를 그대로 노출 — 확인하려고 DB 를 열지 않게

기본값을 vid 로 둔 이유는 공유다. passthru 로 보내면 사용자가 주소창을 복사해 보내는 순간 그 방문이 원래 사용자의 광고 클릭으로 집계된다. 유입 하나가 여러 건으로 불어나고 매체 정산이 틀어진다.

막힌 것

① /go 가 404, 로그는 어디에도 없었다

  • 증상: nginx 로그에도 CI3 로그에도 아무것도 안 남고 404 만
  • 원인 [확인]: php-pass.conf 가 SCRIPT_FILENAME 을 $document_root$fastcgi_script_name 으로 만든다. /go 는 실제 파일이 아니므로 /var/www/html/public/go 를 가리키고 PHP-FPM 이 "File not found" 로 404 를 낸다. 프론트 컨트롤러를 아예 거치지 않으니 CI3 로그가 남을 리가 없었다
  • 아이러니: no-store 를 붙이려고 만든 location /go 가 정작 그 응답을 못 만들게 하고 있었다
  • 대응: 지우고 try_files 에 맡긴다. 캐시 정책은 그 응답이 무엇인지 아는 쪽(앱)이 정한다. api. 의 /collect /conversion /impression /click 도 같은 모양이라 같이 지웠다 — 아직 호출해 본 적이 없어 드러나지 않았을 뿐이다

② BINARY(16) 을 이스케이프 끄고 넣고 있었다 — 이번 최대 실수

Error Number: 1064
... near '??? ?s??ǫ????  LIMIT 1'
SELECT `id` FROM `visits` WHERE visit_uid =  ��� �s��ǫ����  LIMIT 1
  • 원인 [확인]: ->where('visit_uid', hex2bin($uidHex), FALSE). 세 번째 인자 FALSE 는 이스케이프를 끈다. 바이너리 16바이트가 SQL 문자열에 그대로 박혔다
  • 더 중요한 것: 이건 구문 오류로 끝난 게 운이 좋았던 것이다. 값이 쿠키에서 오고, 바이트를 고르면 쿼리를 조작할 수 있는 자리였다. 500 이 안 났으면 그대로 배포됐다
  • 대응: UNHEX(?) 에 hex 문자열 바인딩. SQL 로 나가는 것은 32자 hex 뿐이고 변환은 MySQL 이 한다. create() 도 같은 방식으로 맞췄다 — 같은 컬럼을 두 방식으로 다루면 다음 사람이 어느 쪽이 맞는지 알 수 없다
  • 배운 것: "이스케이프를 끈다"는 옵션이 있으면 언젠가 쓰게 된다. 바이너리를 넣으려다 껐는데, 애초에 바이너리를 SQL 에 넣지 않는 방법(UNHEX)이 있었다. 옵션을 끄기 전에 그 옵션이 필요 없는 방법부터 찾는다

③ CLI 로 재현할 수 없었다

  • requireHost() 가 CLI 를 404 로 막고 있었다. CLI 에는 HTTP_HOST 가 없다
  • 그 바람에 ② 를 CLI 로 재현하지 못하고 개발 환경으로 잠깐 전환해서야 원문을 봤다
  • 대응: is_cli() 면 통과. 진단 경로를 막는 방어는 방어가 아니다

확인한 동작 (운영, production)

시험 결과
pid=Google → 저장 google (집계 축은 소문자 정규화)
utm_campaign=romance_sep 원문 보존 (자유 텍스트)
파라미터 없이 재방문 first kept · last kept — 직접 유입이 광고 성과를 덮지 않는다
다른 매체로 재유입 first 는 google 유지, last 만 meta
mode=passthru 화이트리스트만 전달. work·mode·ref 탈락
work=abc / work=https://evil.example 400
UTF-8 파라미터 %EB%84%A4%EC%9D%B4%EB%B2%84 그대로 통과
app.sshwan.com/go 404 (호스트 검증 동작)
m.sshwan.com/go 302 (모바일 허용)

결정한 것

결정 근거
가상 경로에 별도 location 을 두지 않는다 SCRIPT_FILENAME 이 없는 파일을 가리켜 프론트 컨트롤러를 건너뛴다. 캐시·CORS 헤더는 앱이 붙인다
BINARY 컬럼은 UNHEX(?) 바인딩으로만 이스케이프를 끄는 경로를 코드에서 없앤다
랜딩에 접점을 화면으로 노출 확인 비용이 낮아야 실험을 자주 한다. 면접에서도 이 화면 하나로 규칙을 설명할 수 있다
requireHost 는 CLI 를 통과 진단 경로를 막으면 재현이 불가능해진다

다음

  • 추적 도메인 확보(AWS 외 등록기관) → api. 붙이고 크로스사이트 실험(M3)
  • track.js — fetch 와 sendBeacon 두 경로
  • 커스텀 수집 /impression · /click
  • 아웃박스 + GA4 채널

2026-09-09 (수) · D-12

한 일

  • feat/ci3-skeleton → main 병합. 리서치·설계·인프라·도메인 로직이 한 덩어리로 main에 올라감
  • GitHub Actions CI 추가 — 문법 검사 · 단위 테스트 · 경계 검사 · compose 유효성 · OpenResty 문법
  • CodeIgniter 3 스켈레톤 구성 — composer 로 codeigniter/framework:3.1.13 설치, public/index.php 를 프론트 컨트롤러로
  • 스키마 마이그레이션 7개 작성하고 실제 MySQL 8.0 에 up → down → up 왕복 검증 (테이블 18개)
  • MY_Controller — 읽기 커넥션 배정, 상관 ID, 호스트 검증, RFC 9457 에러 응답
  • 진단 페이지를 public/index.php 에서 Diag 컨트롤러로 이관
  • EC2 t3.small 생성 절차와 비용 방어 장치를 setup.md 2·2-1장에 정리
  • 도메인 1개로도 기동되도록 분리 — 추적 도메인 등록이 AWS 쪽 문제로 막혀, api. 서버 블록을 조건부 생성으로 뗌
  • EC2 t3.small 생성 절차와 비용 방어 장치를 setup.md 2·2-1장에 정리

막힌 것

① CI3 를 PHP 8.2 에서 돌리니 요청마다 deprecation 이 쏟아졌다 — ADR-001이 예고한 마찰의 첫 실물

PHP Deprecated:  Creation of dynamic property CI_URI::$config is deprecated
                 in .../system/core/URI.php on line 102
PHP Deprecated:  Creation of dynamic property CI_Router::$uri is deprecated
                 in .../system/core/Router.php on line 128
PHP Deprecated:  Creation of dynamic property CI_DB_mysqli_driver::$failover is deprecated
                 in .../system/database/DB_driver.php on line 372
PHP Deprecated:  Creation of dynamic property Migrate::$migration is deprecated
                 in .../system/core/Loader.php on line 1284
  • 원인 [확인]: CI3 의 로딩 방식 자체가 동적 프로퍼티다. $this->load->library('x') 가 $this->x 를 런타임에 만들어 붙인다. 8.2 에서 이건 deprecated 다. 프레임워크 내부(vendor/)에서 나는 것과 우리 컨트롤러에서 나는 것 두 종류가 섞여 있었다
  • 대응: 두 종류를 다르게 처리했다
    • 우리 컨트롤러 쪽 — MY_Controller 에 #[\AllowDynamicProperties]. 상속되므로 한 곳으로 끝난다
    • 프레임워크 내부 쪽 — MY_Exceptions 로 log_exception()·show_php_error() 를 감싸, 경로가 vendor/codeigniter/framework/ 인 E_DEPRECATED 만 걸러낸다
  • 배운 것: 처음에는 error_reporting 에서 E_DEPRECATED 를 빼려고 했다. 그러면 우리가 새로 쓴 코드의 deprecation 까지 같이 사라진다 — 8.2 위에서 CI3 를 돌리며 배우려던 것을 스스로 지우는 짓이다. 억제 조건을 "발생 위치"로 좁히니 우리 코드의 경고는 그대로 보인다. 그리고 억제한 건수는 세어서 /diag 에 띄운다. 안 보이게 하는 것과 없는 것처럼 구는 것은 다르다

② migration_table 이 NULL 이었다

Database error: Error Number: 1096  No tables used
SELECT *
  • 원인 [확인]: $this->config->item('migration_table') 이 NULL. CI3 의 Migration 라이브러리는 config/migration.php 를 자기 안으로만 가져가고 $this->config 에는 올리지 않는다. $this->db->get(NULL) 이 되어 SELECT * 만 나갔다
  • 대응: 컨트롤러에서 $this->config->load('migration', FALSE, TRUE) 를 한 번 더 호출. 하드코딩하지 않은 이유는 설정을 바꿨을 때 조용히 어긋나기 때문
  • 배운 것: CI3 의 "라이브러리가 설정을 먹는다"와 "설정이 전역에 올라간다"는 다른 일이다. 라이브러리 생성자에 $config 가 넘어가는 프레임워크에서는 매번 확인해야 한다

③ 인덱스 실험은 아직 성립하지 않는다

  • EXPLAIN 을 인덱스 있음/없음 양쪽에서 떠 봤으나 양쪽 다 key: PRIMARY 였다
  • 원인 [확인]: 테이블이 비어 있다(rows: 1). 옵티마이저가 ORDER BY id 만 보고 PK 를 골랐다. 인덱스의 값어치는 데이터가 있어야 드러난다
  • 대응: 인덱스 추가를 별도 마이그레이션(20260909000700)으로 떼어 두었으므로, 적재 후 to 20260909000600 ↔ latest 로 왕복하며 다시 측정한다 → benchmarks.md

④ 엣지 컨테이너가 애초에 뜰 수 없는 상태였다 — 기동 검증을 미룬 대가

/entrypoint.sh: line 21: envsubst: not found
  • 증상: ${SHOP_DOMAIN} 치환에 envsubst 를 쓰고 있었는데, openresty/openresty:alpine 에 gettext 가 없다. 설계한 날부터 지금까지 이 구성으로는 엣지가 한 번도 뜰 수 없었다. 도커가 꺼져 있어 정적 검토로 넘어갔던 그 커밋이다
  • 원인 [확인]: 이미지에 들어 있는 도구를 확인하지 않고 "표준 도구니까 있겠지"로 넘어갔다. 알파인 계열은 gettext 가 기본이 아니다
  • 대응: sed 로 바꿨다. 기동할 때마다 apk add 를 하면 네트워크에 의존하게 된다. 도메인 이름은 | 를 포함할 수 없어 구분자로 안전하고, 잘못된 값이 설정 파일에 박히지 않도록 형식 검증도 앞에 붙였다
  • 배운 것: 2026-09-08 기록에 "정적 검토는 기동 검증을 대신하지 못한다" 라고 적어 놓고, 그 문장을 쓴 파일이 그대로 하루를 더 갔다. 적어 두는 것과 실행하는 것은 다르다. 그래서 이번엔 CI 가 entrypoint.sh 를 실제로 실행하게 만들었다 — 설정 생성 로직을 워크플로에 다시 옮겨 적으면 두 벌이 갈라지고, 갈라진 쪽이 통과하는 CI 는 아무것도 보장하지 않는다

⑤ 로그가 조용히 사라질 뻔했다

  • 증상: EC2 절차를 쓰다 발견. application/logs/ 는 호스트 소유(uid 1000)인데 php-fpm 워커는 www-data 로 돈다. 로컬 윈도우 바인드 마운트는 권한이 느슨해 파일이 잘 써졌고(40KB 쌓여 있었다), 리눅스에서만 터졌을 것이다
  • 원인 [확인]: CI_Log 는 로그 경로에 쓰기 권한이 없으면 예외를 던지지 않는다. 생성자에서 _enabled = FALSE 로 바꾸고 끝이다. 즉 서버에 올린 뒤 "로그가 안 남네"를 한참 뒤에 알아차리는 종류의 실패다
  • 더 나쁜 것: config.php 주석에 "파일이 아니라 stderr 로 보낸다 — 컨테이너 로그가 곧 애플리케이션 로그다" 라고 써 놓고 log_path 는 기본값(파일)이었다. 주석이 코드보다 앞서 나가 있었다
  • 대응: MY_Log 로 write_log() 를 덮어 JSON 한 줄을 stderr 로 보낸다. 임계값 판정은 부모 것을 그대로 써서 log_threshold 의미가 갈라지지 않게 했다. 줄마다 trace_id 를 붙였고, 그게 엣지 로그·응답 헤더의 값과 같다
  • 부수 효과: 로그 로테이션이 우리 문제가 아니게 됐다. 도커 로그 드라이버가 한다
{"ts":"2026-09-08T16:54:30Z","level":"INFO","trace_id":"415096d6...","msg":"Database Driver Class Initialized"}

⑥ composer install 이 어디에도 없었다

  • 증상: EC2 배포 절차를 쓰다 발견. vendor/ 는 .gitignore 이고 Dockerfile 은 composer 바이너리만 복사한다. 클론 직후에는 CodeIgniter 자체가 없다
  • 원인 [확인]: 로컬에서는 처음에 composer require 로 설치해 두고 그대로 작업했다. 한 번도 "빈 상태에서 시작"을 해보지 않았다
  • 대응: setup.md 6-1 로 분리하고, 문제해결 표에 "모든 요청이 500" 항목을 넣었다. --user $(id -u):$(id -g) 를 빼면 vendor/ 가 root 소유로 생겨 나중에 손을 못 댄다는 것도 같이
  • 배운 것: 개발 환경은 누적된 상태다. 배포 문서를 쓰는 것이 그 누적을 드러내는 가장 싼 방법이다

⑦ 사용자 데이터에 Compose v2 설치가 빠져 있었다

docker: 'compose' is not a docker command.
  • 원인 [확인]: Amazon Linux 2023 의 docker 패키지에는 Compose v2 가 들어 있지 않고, dnf 에 별도 패키지도 없다. dnf install -y docker git 한 줄로 끝날 거라고 가정했다
  • 대응: 공식 릴리스 바이너리를 /usr/libexec/docker/cli-plugins/ 에 넣는다. 버전을 문서에 박지 않고 최신 태그를 리다이렉트에서 받아온다 — 존재하지 않는 태그를 적어 두면 몇 달 뒤 404 로 조용히 실패한다
  • 검증: amazonlinux:2023 컨테이너에서 절차를 그대로 돌려 Docker Compose version v5.5.1 까지 확인했다. 문서에 적기 전에 돌려 보는 것이 ④에서 배운 것이다

⑧ 실제 배포에서만 나온 것 다섯

문서를 아무리 정성껏 써도 서버에 올리기 전에는 알 수 없는 것들이 있었다. 하루치 문서가 실제로는 동작하지 않는 절차였다.

# 증상 원인
a compose build requires buildx 0.17.0 or later AL2023 에 buildx 도 없다. Compose v2 만 넣고 끝난 줄 알았다
b MYSQL_ONETIME_PASSWORD: unbound variable → mysql unhealthy init 스크립트의 set -euo pipefail 이 MySQL 엔트리포인트로 샜다
c openresty 가 뜨지 못함 (인증서 없음) listen 443 ssl 이 인증서 파일을 요구 → 80번 블록까지 죽음 → 인증서를 영영 못 받는 교착
d ERROR 1777: @@GLOBAL.GTID_MODE = OFF 복제본 command 에 GTID 를 안 넣었다. 프라이머리에만 넣었다
e 쿠키 없는 요청 12번이 전부 rdb1 내부 리다이렉트로 access 단계가 요청당 두 번 → 카운터가 2씩 → 한쪽으로 고정
f production 전환 후 /diag 가 500 CI3 는 Exceptions 클래스를 "에러가 났을 때" 로드한다. production 에서는 억제할 deprecation 이 없어 로드되지 않는데 그걸 참조했다

c 가 가장 값어치 있다. setup.md 4장은 "docker compose up -d openresty 하고 certbot 을 돌려라" 였는데, 새 서버에서는 첫 줄부터 실패한다. 문서대로 하면 절대 안 되는 절차를 하루 동안 적어 두고 있었다. 443 블록을 별도 파일로 빼서 인증서가 실재할 때만 생성하도록 고쳤다.

e 는 두 번 틀렸다. 처음에 "이미 배정됐으면 건너뛴다" 로 고쳤는데, 서버 블록의 set $read_target "" 도 내부 리다이렉트에서 다시 실행되어 플래그가 매번 지워졌다. 재측정하지 않았으면 고쳤다고 믿고 넘어갔을 것이다. 분포는 여전히 12/12 였다. $request_id 해시로 바꾸니 20회에 13/7 로 갈렸다.

f 는 두 환경의 차이를 잘못 알고 있었다는 증거다. development 와 production 의 차이가 "에러가 화면에 보이느냐" 뿐이라고 생각했는데, 로드되는 클래스도 달랐다. 개발 환경에서만 돌려 봤으면 못 잡았을 종류다.

d 는 ADR-007의 값어치를 증명한다. "복제본을 흉내내지 않고 실물로 띄운다" 고 정했기 때문에 이 한 줄이 빠진 걸 알 수 있었다. 지연 주입 시뮬레이션이었다면 영영 몰랐다.

배포 완료 상태 (2026-09-09)

호스트    sshwan.com · lp. · m. · app.   전부 HTTPS (Let's Encrypt, ~2026-12-07)
인프라    t3.small / eu-north-1 / 2 vCPU / 1.9Gi + swap 2Gi
컨테이너  openresty · app · mysql-primary · mysql-replica
스키마    마이그레이션 20260909000700, 테이블 18개 (프라이머리·복제본 일치)
복제      Replica_IO_Running=Yes  Replica_SQL_Running=Yes  Seconds_Behind_Source=0
배정      쿠키 없이 20회 → rdb1 13 / rdb2 7, 쿠키 있으면 고정·재발급 없음
진단      /diag · /healthz · /readyz 전부 200
갱신      cron 매주 월 03:17, --dry-run 통과

리전이 eu-north-1(스톡홀름)이다. 서울을 권했는데 실제로는 여기에 만들어졌다. 스톡홀름은 온디맨드 단가가 가장 싼 축이라 비용에는 유리하지만, 한국에서 접속하면 왕복 지연이 250ms 안팎 붙는다. benchmarks.md에 수치를 적을 때 어느 리전에서 잰 것인지 반드시 함께 적는다 — 안 그러면 매체 전송 지연과 지리적 지연이 섞인다.

결정한 것

결정 근거
CI3 를 composer 로 설치 (system/ 을 저장소에 복사하지 않음) 업그레이드가 composer update 한 줄이 되고, 저장소 diff 에 프레임워크 코드가 섞이지 않는다
프론트 컨트롤러는 public/index.php 하나 워커도 같은 파일을 쓴다(php public/index.php cli/dispatch work). 부트스트랩을 두 벌 두면 언젠가 갈라진다
마이그레이션 DDL 을 원문 SQL 로 (dbforge 아님) DATETIME(3)·BINARY(16)·인덱스 컬럼 순서·COLLATE 를 dbforge 로는 정확히 못 쓴다. 그리고 data-model.md와 한 줄씩 대조할 수 있어야 한다
FK 규칙: 보존기간이 같으면 걸고, 다르면 걸지 않는다 users.signup_visit_id 에 FK 를 걸면 3개월 파기 배치가 막히거나 CASCADE 로 회원이 지워진다. 보존기간이 설계 제약이 되는 자리
폴링 인덱스를 별도 마이그레이션으로 분리 인덱스 있음/없음 두 상태를 명령 한 줄로 오갈 수 있어야 EXPLAIN 비교가 재현된다
CI 에 경계 검사 추가 (src/ 가 CI3 를 참조하면 실패) ADR-017의 경계는 문서로만 두면 지켜지지 않는다. 깨지는 순간 단위 테스트가 통째로 불가능해진다
CI 에서 자동 배포하지 않는다 서버 한 대다. 잘못 나갔을 때의 손해가 자동화의 이득보다 크다
추적 도메인 없이도 기동 등록이 늦어진다고 나머지 12일을 놀릴 수 없다. 다만 무엇이 성립하지 않는지를 표로 명시해 둔다 — "되는 것처럼" 보이는 게 제일 나쁘다
t3 크레딧을 standard 로 unlimited 는 크레딧 소진 후 vCPU-시간당 별도 과금이다. t3 에서 요금이 새는 유일한 구멍이고, CPU 가 오래 100% 라면 그건 돈으로 덮을 게 아니라 고칠 버그다

다음

  • 추적용 도메인 재선정 — khan-edge.com 등록 거절, AWS 쪽 문제로 재시도 보류. khwanedge.com 1순위
  • EC2 t3.small 기동 + 예산·이상탐지 알림 설정
  • GA4 속성과 API secret 발급
  • 채널 어댑터(src/Channel/)와 Channels 브리지
  • track.js — fetch 경로와 sendBeacon 경로 두 벌

2026-09-08 (화) · D-13

한 일

  • 도메인 등록 문서와 이름 규칙 정리 (0714811, 03e5a2e)
  • ADR-002에 도메인 개수 대안 분석 추가 (fdbd83c) — 공개 접미사 목록을 직접 내려받아 확인
  • 공고 원문 세 건 확보 — 잡코리아·사람인 모두 상세요강이 이미지라, 사람인 상세 iframe에서 PNG를 내려받아 읽음
  • 정정 반영 — ADR-001, 로드맵 의도 층
  • ADR-012(관측 가능성 범위), ADR-013(Caddy vs Nginx) 추가
  • 작업 기록을 재사용 스킬로 분리해 ~/.claude/skills/worklog/에 설치

막힌 것 / 잘못한 것

① 공고를 제대로 읽지 않고 13일치 설계를 세웠다 — 이번 최대 실수

  • 증상: "주요 업무에 광고 관련 내용이 없다"는 지적을 받음

  • 원인 [확인]: 공고 제목과 스킬 태그만 보고 무게중심을 잡았다. 상세요강이 이미지라 텍스트 검색으로는 안 잡혔고, 거기서 멈췄다

  • 대응: 사람인 상세 iframe(view-detail?rec_idx=...)에서 이미지 URL을 뽑아 내려받아 읽음

  • 결과: 다행히 방향은 맞았다. 업무 5줄 중 2줄이 광고·추적이고, 그중 하나는 이 프로젝트를 문장 그대로 서술한다

    "도메인·Redirect 등 다양한 유입 환경에서의 사용자 및 전환 추적 기능 개발"

  • 배운 것: 운이 좋았을 뿐이다. 제목·태그와 본문이 어긋났다면 13일을 통째로 날렸다. 채용 공고가 이미지면 이미지를 열어본다. 텍스트가 안 잡히는 것을 "정보가 없다"로 넘기지 않는다

② 전제 두 개가 틀렸다

틀린 전제 실제
"공고가 PHP·CodeIgniter를 요구한다" 우대사항이다. 필수는 HTTP·Cookie·URL Parameter·SQL·Git
"PHP 실무 없음이 서류 결격이다" 경력 요건이 신입 혹은 3년 이하. 결격이 아니다

로드맵 의도 층의 한 문장("결격을 지운다")이 여기서 무너졌다. "변별점을 만든다"로 교체. 하는 일은 거의 같지만 판단 기준이 달라진다 — "없으면 떨어지나" 가 아니라 "다른 지원자도 들고 오나" 를 묻게 된다.

③ 공고가 하나인 줄 알았다

개발실 웹개발자 공고가 세 개(광고 매체 연동 / 콘텐츠 플랫폼 / 서비스&인프라 운영)였고 전부 같은 날 마감이다. 복수 지원도 가능하다. 첫날 채용 사이트를 훑을 때 확인했어야 했다.

④ 도메인 지침을 두 번 정정했다

  • 처음: "두 도메인이 서로 무관해 보여야 한다" → 철회. same-site 판정은 등록 도메인만 보므로 이름은 실험에 영향이 없다

  • 다음: "2개가 필수다" → 부정확. 공고 태그는 1개로도 전부 커버된다. 두 번째 도메인은 태그 요건이 아니라 변별점을 산다

    둘 다 따져보지 않고 단정한 것이 원인이다. ADR에 "기각한 대안"을 적는 습관이 있었는데도 대안을 다 열거하지 않았다.

결정한 것

결정 근거
무게중심 #1 광고 연동 유지, #3에도 지원 프로젝트가 #1 업무 문장과 일치. 방향 전환 비용 0. 주니어 풀에서 희소성이 큼
절충 = 관측 가능성 승격 어트리뷰션 불변 + 구조화 로그·/health·/metrics·장애 탐지 경로 → ADR-012
자체 지표 화면을 버리고 Grafana로 대체 컷 순서 1순위였던 항목. 시간 상쇄되고 #1·#3 양쪽에 통함
Prometheus·Grafana는 --profile obs로 필요할 때만 t3.micro RAM 1GB. 상시 기동은 OOM 위험
Kubernetes 도입 안 함 #3 우대사항이지만 13일에 불가능. 컨테이너 4개에 K8s는 판단력 문제로 읽힌다
Caddy 유지 (Nginx 아님) M1이 이틀뿐. certbot 배선에 막히면 뒤가 밀림 → ADR-013
두 지원서의 공통 축 = "안 보이는 것을 숫자로 만든다" 방향 미정 상태에서 "왜 이 포지션인가"에 답하기 위한 서사

알아낸 것 (공고 원문)

  • 전형: 서류 → 조직 적합도 진단(온라인) → 면접 → 처우 협의 → 최종 합격. 진단은 "면접 시 참고 자료로만 활용되며 합격 여부에 영향 없음"이라 명시
  • 1년 계약직이나 "계약 기간 만료 시 근무 평가를 통한 정규직 전환 여부를 결정" — 전환 경로가 있다
  • #3 우대사항에 Nginx·Apache / Docker·Kubernetes / Prometheus·Grafana·Jenkins·Airflow / PostgreSQL — 앞서 배포 폴더로 추론한 스택이 공고로 재확인됨

결정한 것 (오후 · 아키텍처 착수 전 정리)

결정 근거
CI3 + Composer PSR-4 병용 컨트롤러·모델은 CI3 관용구, 도메인 로직·어댑터는 src/ PSR-4. src/가 프레임워크 독립이라 테스트가 성립한다 → ADR-017
DI 컨테이너 미도입 어댑터가 3~4개다. 팩토리 하나로 충분
읽기 커넥션은 Lua가 넘긴 값으로 선택 AB_READ_TARGET → read_rdb1 / read_rdb2. 화이트리스트 검증
세션은 database + 프라이머리 고정 세션을 복제본에서 읽으면 로그인이 풀린다. 읽기 분리 도입 시 가장 먼저 터지는 곳
track.js 는 추적 도메인에서 서빙 광고주 도메인에서 주면 first-party가 되어 실험이 성립하지 않는다
entitlements 테이블 추가 여정에 유료 회차 열람을 넣었는데 스키마에 없었다. 접근통제는 서빙 시점 판정
추적 키 언어 축 구현 안 함 관측(pidko)은 했으나 11개 언어를 서비스하지 않는다. 문서로만
Git: 기능 브랜치 → PR → 머지 초안의 "Git 약점 보완" 목표. PR 본문에 ADR 링크
테스트: 단위 + E2E 시나리오 재현 절차가 어차피 필요하므로 순증은 약 +0.5일
추적 도메인 .com으로 .click 은 차단 위험이 측정을 오염시킨다

다음

  • [~] 도메인 등록 — sshwan.com 확정(09-09 ICANN 인증 완료). khan-edge.com 은 거절되어 재선정 필요
  • ICANN 인증 메일 처리 — 3~15일 내. 놓치면 도메인 정지
  • DNS 전파 확인 후 A 레코드 4개 생성
  • AWS 계정 확인 (프리티어 정책이 2025-07-15부로 변경됨)
  • GA4 속성 + API secret
  • .env.example에 관측 프로파일 변수 추가
  • README에 #3용 강조 문단 초안

2026-09-07 (월) · D-14

한 일

  • 리서치 완료 — 공개 자료 조사로 도메인 모델 역추론. 관측 문서 6건 + 표준 부록 11건
  • 설계 문서 세트 작성 — 아키텍처 · 도메인/쿠키 · API 명세 · 데이터 모델 · ADR 11건 (dc9a43b)
  • 인프라 구성 작성 — Compose · Caddyfile · Dockerfile · .env.example · D1~D2 진단 페이지 (0ed3ac7)
  • 로드맵 작성 — 의도 / 기획 / 계획 3층 (65ea26c)
  • 도메인 등록 문서 작성 — domain-setup.md

막힌 것 / 잘못한 것

① 일정 기준일을 2주 가까이 틀리게 잡고 있었다

  • 증상: 모든 일정 문서가 "마감까지 20일"을 전제로 작성됨
  • 원인: 초안 문서의 파일 작성일(2026-09-01)을 "오늘"로 오인. 실제 오늘은 2026-09-07
  • 영향: 남은 기간이 20일이 아니라 13일. 완충 구간이 통째로 사라짐
  • 대응: 로드맵 일정 재작성, ADR 11건의 확정일 정정, research/05의 일정표는 폐기 표시
  • 배운 것: 날짜는 파일이 아니라 시스템에 물어본다. 일정 문서를 쓰기 전에 date를 먼저 친다

② Caddyfile에 지원되지 않는 지시어를 넣었다

  • 증상 [추정]: php_fastcgi 블록 안의 trusted_proxies. 기동 시 파싱 에러가 났을 것
  • 원인: reverse_proxy의 하위 지시어를 php_fastcgi도 받는다고 착각
  • 대응: 제거. Caddy가 엣지라 애초에 불필요
  • 미확인: Docker 데몬이 꺼져 있어 caddy validate로 확인하지 못했다. D1에서 실제 기동으로 검증한다

③ 검증을 못 한 채로 커밋했다

  • Docker Desktop 미실행 → docker compose config, php -l 실행 불가
  • 대신 변수 참조 관계(compose ↔ Caddyfile ↔ .env.example)와 YAML 들여쓰기를 수동 교차 확인
  • 정적 검토는 기동 검증을 대신하지 못한다. D1의 첫 compose up이 실질적 첫 검증

결정한 것

결정 근거
MySQL 8.0 확정 FOR UPDATE SKIP LOCKED 사용. MariaDB였다면 10.6+ 조건이 붙었다
조사 원본은 비공개, 요약만 공개 특정 회사의 추적 식별자·인프라 분석을 공개 저장소에 카탈로그로 남기지 않는다
저장소 문서 전체 익명화 플랫폼 A·B·C. 사내 CI 잡 이름도 일반화
도메인 TLD는 .com 양쪽 저가 TLD는 차단 위험이 있어 실험 변수가 된다 → domain-setup.md 2장
읽기/쓰기 커넥션 분리 추가 조사 중 복제본 세션 고정 쿠키를 관측 → ADR-007

다음

  • 도메인 2개 등록 → ICANN 인증 메일 즉시 처리
  • AWS 계정 확인
  • GA4 속성 + API secret
  • GitHub 저장소 생성 후 push