날짜순으로 한 일 · 막힌 것 · 결정한 것을 남긴다. 최신이 위.
세 가지 용도가 있고, 셋 다 나중에 값을 한다.
| 용도 | 설명 |
|---|---|
| 실패 문서의 원재료 | 막힌 순간의 증상과 에러 원문은 그때 적어두지 않으면 복원되지 않는다. failure-scenarios.md의 결과 칸은 여기서 옮겨 적는다 |
| 계측의 맥락 | "이 수치를 잴 때 무엇이 달랐는가"는 숫자 옆에 없으면 사라진다 |
| 면접 답변 | "어떻게 진행하셨나요"에 날짜와 사건으로 답할 수 있게 된다. 결과만 아는 사람과 과정을 아는 사람은 다르게 들린다 |
- 막힌 것을 먼저 적는다. 잘 된 건 커밋 로그에 남지만 막힌 건 아무 데도 안 남는다
- 에러 메시지는 원문 그대로 붙인다. 요약하면 검색이 안 된다
- 추측과 확인을 구분한다 —
[추정]/[확인] - 하루가 비어도 빈 채로 둔다. 채워 넣지 않는다
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 |
1. "매뉴얼 대조 벡터 [확인-계산]" 이라고 적은 값을 매뉴얼과 대조한 적이 없었다
증상 공식 매뉴얼(2022-07-02 아카이브)의 signature 예시:
PlainText: oid=INIpayTest_1361252896871&price=1004×tamp=1361252896871
Hash 데이터: ec1e9c63ddad0b81f316ada2e3861e81db5f4f6652d9ff0f255b4cb5499ce893
plan-multi-pg.md 4장에 "매뉴얼의 공개 대조 벡터" 로 적은 값은 422a0e78…b9192 였다.
원인 [확인] 422a0e78 은 검색 요약에서 온 값이었다. 내가 한 "재계산" 은 같은 평문을 다시 해시한 것이라 순환 확인이었다 — 매뉴얼 원문은 그때 보지 않았다.
그런데 매뉴얼 값 쪽도 재현되지 않았다. 변형 7개(& · 끝 & · 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 규칙).
결정 면접관이 별도 링크 없이 해 볼 수 있게, 공개로 열되 안내 화면(/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. 것이라 토큰을 실을 수 없다" 고 썼다가 설정을 열어 보니 .<도메인> 이었다 — 고쳤다. "있다/없다" 를 적기 전에 확인한다 를 같은 날 또 어길 뻔했다.
판단 면접관은 데모를 눌러 되는지만 빠르게 보고, 충돌 테스트는 면접에서 물을 것이다(과제 전형이 없다). 그런데 모르는 포트폴리오에 실카드를 넣는 사람은 드물다 — 이니시스 경로로는 "결제창이 뜬다" 까지만 보인다. 우선순위를 사용자와 정했다: ① 카드 없는 길 ② 모바일 ③ 닫은 뒤·가벼운 괴롭힘 ④ 면접 시연 대본. 페이팔은 보류.
모바일 실측(질문 전에) 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. 휴대폰으로 결제를 눌러 본 누군가 [추정: 사용자의 확인] |
한 일 모바일에서는 이니시스 버튼·스크립트를 그리지 않고 "카드 결제창은 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 를 요청마다 다시 읽고 있다 — 성능 계측 수치의 전제일 수 있어 따로 본다
한 일 결제창을 카드 없이 닫으면 결과 화면으로 간다 — 닫기 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 로 전송)
면접 시연 대본 충돌 상황은 면접에서 "보여 줄 수 있나요?" 로 올 것이라, 운영 서버에서 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 | 휴대폰 흉내 "카드 없이 끝까지" — 구매 전환 |
막힌 것 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을 수정해 주세요." | 같은 쪽 |
- 구매자 이름·휴대폰·이메일이 결제 요청 필수다(
buyername*buyertel*buyeremail*). 가입이 없어 받을 곳이 없다. 이니시스는 이 값을 승인 결과로 되돌려준다(buyerNamebuyerTelbuyerEmail) → 원문 허용 목록(C5)에서 뺀다 - 이번 범위는 PC 웹표준뿐이다. 모바일은 파라미터(
P_)·금액 해시(SHA512)·망취소 창이 다른 별개 규격이라, 모바일 브라우저에서는 결제할 수 없다 - 개인정보 처리방침이 "실제 회원가입과 결제(청구)는 없습니다" 라고 적고 있다. 테스트 MID 는 실승인이다. 방침 스스로 "실제 가입·결제 기능이 생기면 이 표를 먼저 고칩니다" 라고 약속했으므로 실카드 결제 전에 방침부터 고친다
- 이니시스도 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@<도메인>— 받을 수 있는 주소인지 확인하지 않았다
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.md6장 ·failure-scenarios.mdC-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건 (79cf75db4be711) - 랜딩 시트 2장 — 작품 제목·시놉시스, 기록은 값 있는 필드만. 없는 번호는 404 대신 "없는 작품" (
7b5d4aef8ca053) - 지표 시트 4장 — 파이프라인 순서(① 유입 → ② 전환·적재 → ③ 매체 전송), 표 첫 열 고정 (
0c25a24) - 방침도 칸으로 — 문구·절 번호·앵커는 그대로, 차례만 추가 (
9bfbd52) benchmarks.md3-2 — 화면의 p95 가 무엇을 고르는지 재고 틀린 설명을 고침 (a4d54fd)- 점검: 6화면 × 5뷰포트 × 라이트·다크 60장면 — 가로 넘침 0 · 대비 실패 0 · 포커스 링 누락 0 · 고정 열 8/8
- 폐기된 결정에 묶인 토큰 16개와 오래된 주석 정리, README 랜딩 링크
/l/8733→/l/1(032dff2) - 진단 화면도 칸으로 (
028b487d1fd7bc) — 사이트에 옛 화면이 남지 않았다 - 디자인 리뷰 — 브리프의 S1~S4 를 실측. Must fix 0, Should fix 4 중 3 을 그날 고침
- CTR 열이 퍼센트 하나였다 →
m_rate(클릭, 노출)(d67687f). 45개 중 44개가 분모와 함께(남은 1개는 산문) - 진단에서 Meta 픽셀 제거 · 방침의 Meta 링크에 "로그인 필요" · 진단 noindex (
5e105e1)
- CTR 열이 퍼센트 하나였다 →
- ADR-009 의 "시각적 완성도가 낮다 · 의도된 것" 삭제
- 단위 437 통과
리뷰 뒤 — 홈에서 설명을 떼어 /tour 로 (efd07d9 c931289)
- 홈이 설명과 서비스를 겹쳐 들고 있었다. 심사자가 먼저 보는 것이 "이건 시연입니다" 라는 안내면 정작 측정할 대상이 흐려진다. 홈은 웹툰 서비스로만 두고 설명을 새 화면으로 뺐다
/tour— 파이프라인을 번호 목록이 아니라 흐름도로. 칸마다 레인(브라우저·서버·매체)을 적어 어디서 일어나는 일인지 보이게. 넓으면→, 좁으면↓. SVG 가 아니라 글자와 선이라 좁은 화면에서 글자가 줄지 않고 스크린리더는<ol>로 순서를 읽는다. 화면이 있는 세 걸음만 링크고, 나머지 둘은 링크가 없는 이유를 화면이 말한다- 칸 테두리 먹선
2px→ 희미한1px(라이트#cfcabe1.58:1 · 다크#38352f1.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_keyUNIQUE · 같은 트랜잭션 ·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
- 자리를 만들려고 배너 CTR 을 광고 시트로 옮기고, 요약 칸을 7열로 넓히고, 결론 표 줄 높이를 74px → 40px 대로 줄이고,
- 안내 화면 마지막 칸을 귀속 결정으로 (
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뷰는 라우트가 없어 닿지 않는다 — 지울지 라우트를 붙일지
조사 출금·입금 불일치 실제 사고 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 줄을 보여 주게 했다.
막힌 것. 환불 시험 끝에 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 접근 로그는 여전히 컨테이너 출력뿐이다. 엣지는 재생성이 드물지만 같은 문제를 갖고 있다.
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 2GET /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 형식)로 확인했다.
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건(noop3 ·ga46, 전부 실험). 켜면 실험 전환이 운영 GA4 로 나간다 → 켜지 않고 어댑터를 직접 불러 쟀다. 운영 전송 경로가 사흘째 멈춰 있었다는 사실 자체가 기록할 일이다[확인]
- 대기 실험 행 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_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건
이 방침은 코드와 대조한 사실 기술이지 법률 검토를 거친 문서가 아니다.
- 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
어댑터는 전송을 격리한다. 수집은 격리하지 못한다.
- GA4 반영률 확정 — 네 시점에 걸쳐 재고 수렴 확인. 문서 반영
benchmarks.md5장을 도달률 / 반영률 두 줄로 분리
전송 로그 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 의내가 09-12 에 curl 로 보낸 시험 이벤트였다 (아래 정정 참조)TEST-0001출처
이 항목 위쪽의 "반영률 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/ 에 넣었다.
- 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 |
값은 남지만 어느 방문이었는지 영영 모른다 |
셋 다 틀린 자리라 컬럼 주석에 이유를 적어 뒀던 것이다. 참조 무결성을 일부러 포기했으면 그 이유가 스키마에 있어야 한다.
- 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.md4장 닫는 말, 그리고 이 표. 문장으로 아는 것과 표를 채울 때 아는 것이 달랐다. 개념을 고쳤으면 그 개념이 쓰인 모든 형태를 찾아야 한다
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-0001purchase 이벤트 — 우리가 보낸 것이 아니다. 출처 확인
- 공고 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 대로 주장을 내린다 |
두 번째 도메인을 사지 않기로 했다 → 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 에 미리 적어 둔 문장
로드맵 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 을 주고 막는 쪽은 브라우저다 — 그 오류 원문을 브라우저로 받아 적는 것이 다음 할 일이다.
track.js 를 붙이고 브라우저로 실제 동작을 봤다. 예상과 다른 것이 하나 나왔고, 그게 오늘 최고 수확이다.
엣지 로그에서 메서드만 뽑은 것이다. 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_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 는 같이 못 쓴다" 는 문서로 안다. 그 설정이 데이터를 어떻게 오염시키는지는 해 봐야 안다.
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 가 사라진다
측정 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 를 읽는다")가 실측으로 확인됐다.
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는 "보냈다" 이지 "집계됐다" 가 아니라는 것을 어댑터 주석에 적어 뒀는데, 그 문장이 왜 필요한지가 여기서 분명해졌다 — 확인은 사람이 보고서를 여는 수밖에 없다.
| # | 조건 | |
|---|---|---|
| 1 | 전환 1건 → 채널 수만큼 pending |
✅ |
| 2 | 워커가 집어 sent. 검증 응답 validationMessages: [] |
✅ |
| 3 | 워커 4개에서 중복 전송 0 | ✅ 2000건, (outbox_id, attempt) 중복 0 |
| 4 | 실패가 백오프대로 재시도되고 결국 dead |
◐ 재시도·지터 확인. dead 까지 소진은 미측정 |
| 5 | EXPLAIN 이 ix_poll 을 쓴다 |
✅ 단, 분포에 따라 갈린다 |
| 6 | GA4 실시간 보고서에 이벤트가 보인다 | ✅ |
하루에 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 타입 불일치를 "고침" | 테스트가 실패해서 |
넷 다 확인할 수 있는 것을 확인하지 않고 추론한 경우다. 원문·실측·테스트가 각각 잡아 줬다.
- 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.md3-1 은 오히려 더 나은 이야기가 됐다 — 조사가 설계를 바꿨고, 실측이 그 조사를 다시 바꿨다
| 예상 | 실제 |
|---|---|
| "301이 위험하다" | 301 자체는 아니다. no-store 붙인 301은 매번 서버에 온다. 위험한 건 영구 캐시를 허용하는 301이고, 그건 롤백이 안 된다(고친 뒤 같은 URL 2회 → 서버 도달 0회) |
"SKIP LOCKED 를 끄면 처리량 급감" |
안 떨어졌고 오히려 조금 빨랐다. 잠금 구간에 외부 I/O 가 없어 대기가 마이크로초다. 경합은 실제로 있었다 — 로그에 #10610 #10612 #10614 처럼 건너뛴 자국이 있다 |
"?return= 오픈 리다이렉트" |
그런 파라미터가 없다. 과녁은 Host 헤더였다 |
SKIP LOCKED는 처리량 장치가 아니라 보험으로 다시 적는다. 중복을 막는 것은 잠금과 "선점+상태변경 한 트랜잭션" 규칙이다.benchmarks.md2장이 공을SKIP LOCKED에 돌리고 있어 같이 고쳤다- 지표 화면은 채널별로 쪼갠다. 전송 성공률 100% 는 9303행 중 9300행이
항상 204 를 돌려주는
NoopChannel이라 나온 값이다 - 어트리뷰션 보존율의 정의를 바꾼다. 접점이 남은 방문 ÷ 전체 방문 으로
재면 99.8% 인데, 직접 유입도 규칙 4로
last접점을 받으니 뭘 재든 100%에 붙는다. 광고 유입 방문 중 보존된 비율 로 고쳐 20/20. 목표치를 처음부터 넘고 있는 지표는 대개 분모를 잘못 고른 것이다
| 유휴 | 부하(워커 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 매체 타임아웃 →완료 — 5xx·4xx·타임아웃·복구 네 경로 전부dead까지 -
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-ec2About 설명문:
광고 유입 → 전환 → 매체 재전송 파이프라인. PHP 8.2 · CodeIgniter 3 · MySQL 복제 · OpenResty. 실패 시나리오 12건 중 8건을 운영 서버에서 실측했습니다. -
research/postings/shots/에 공고 PNG 저장 (내가 못 함)
로드맵 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()면 통과. 진단 경로를 막는 방어는 방어가 아니다
| 시험 | 결과 |
|---|---|
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 채널
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의 값어치를 증명한다. "복제본을 흉내내지 않고 실물로 띄운다" 고 정했기 때문에 이 한 줄이 빠진 걸 알 수 있었다. 지연 주입 시뮬레이션이었다면 영영 몰랐다.
호스트 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.com1순위 - EC2 t3.small 기동 + 예산·이상탐지 알림 설정
- GA4 속성과 API secret 발급
- 채널 어댑터(
src/Channel/)와Channels브리지 -
track.js—fetch경로와sendBeacon경로 두 벌
- 도메인 등록 문서와 이름 규칙 정리 (
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용 강조 문단 초안
- 리서치 완료 — 공개 자료 조사로 도메인 모델 역추론. 관측 문서 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