From ec4199375126162ff8eaaef0192a605c7411f55a Mon Sep 17 00:00:00 2001 From: midagedev Date: Thu, 27 Aug 2026 20:53:24 +0900 Subject: [PATCH 1/2] Polish awkward Korean across questions, UI, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용자 피드백 "잘 안 쓰는 단어가 많고 한국어가 좀 이상해요"를 닫는 라운드다. fluent-korean output-style 규칙 위에 번역체·조어·비유 과잉을 얹어 8개 트랙(문항 5파일, UI, README, AUTHORING)을 병렬 교정했다. - 사용자 노출 문자열의 엠대시 116건을 콜론·접속 표현으로 대체 (잔존 0) - 조어·문서체 한자어 교체: 치이반(오타), 체류분, N벌, 상정, 과소 계상, 세계관, 교리, 품질 계단, 감시선 등 - 비유적 어휘 약 90건을 일반 어휘로: 축을 섞었다, 죽은 보기(문서 산문), 보험의 보험료, 옵션을 판다, 분열이 닫힌다 등 - 이중부정·믿음 방향 반전 등 의미가 성립하지 않던 why 4건 복원 - AUTHORING §3-9 자기점검 문장의 자기모순 수정 (정답과→발문과) - README의 낡은 수치 갱신 (89문항→149문항, 88/89→147/149 링크) 기술적 주장·정답 위치·오답의 참거짓·distractorType·source 는 전부 보존했고, 새 게이트 tools/check-prose-only.mjs 가 기준 커밋과 대조해 구조 무변경(368필드 텍스트만 변경, 구조 위반 0)을 단언한다(FAIL-first 확인). validate.mjs 에 엠대시 래칫 게이트 추가(FAIL-first 확인). Co-Authored-By: Claude Fable 5 --- AUTHORING.md | 102 ++++++++++---------- README.md | 46 ++++----- assets/app.js | 14 +-- data/questions/caching.json | 140 +++++++++++++-------------- data/questions/cdn.json | 182 ++++++++++++++++++------------------ data/questions/image.json | 168 ++++++++++++++++----------------- data/questions/storage.json | 78 ++++++++-------- data/questions/video.json | 168 ++++++++++++++++----------------- index.html | 8 +- tools/check-prose-only.mjs | 120 ++++++++++++++++++++++++ tools/validate.mjs | 12 +++ 11 files changed, 585 insertions(+), 453 deletions(-) create mode 100644 tools/check-prose-only.mjs diff --git a/AUTHORING.md b/AUTHORING.md index 2ac331f..94b1459 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -2,7 +2,7 @@ 이 문서는 cachehit의 문항을 어떻게 만드는지 정한다. **읽지 않고 문항을 추가하지 마라.** -객관식 퀴즈의 품질은 정답이 아니라 **오답이 결정한다.** 오답이 허술하면 지식이 아니라 소거법을 시험하게 되고, 그 순간 퀴즈는 학습 도구가 아니라 시간 때우기가 된다. 이 문서의 대부분이 오답에 관한 것인 이유다. +객관식 퀴즈의 품질은 정답이 아니라 **오답이 결정한다.** 오답이 허술하면 지식이 아니라 소거법을 시험하게 되고, 그 순간 퀴즈는 학습 도구가 아니라 시간 때우기가 된다. 이 문서의 대부분이 오답을 다루는 이유가 여기에 있다. --- @@ -10,10 +10,10 @@ | 결정 | 근거 | |---|---| -| 객관식 4지선다 | 단답형과 최종 파지 차이는 작다(Smith & Karpicke, 4개 실험 종합 d=0.07). 형식보다 **인출 성공률**이 중요하고, 웹에서는 채점 가능성과 이탈률이 형식을 결정한다 | -| 보기 4개 (오답 3개) | 기능하는 오답 2~3개일 때 문항 난이도와 변별력이 가장 좋다. 5지선다는 이득 없이 저작 비용만 올린다 | +| 객관식 4지선다 | 단답형과 비교해도 최종 파지의 차이는 작다(Smith & Karpicke, 4개 실험 종합 d=0.07). 형식보다 **인출 성공률**이 중요하고, 웹에서는 채점 가능성과 이탈률이 형식을 결정한다 | +| 보기 4개 (오답 3개) | 기능하는 오답이 2~3개일 때 문항 난이도와 변별력이 가장 좋다. 5지선다는 이득 없이 저작 비용만 올린다 | | 오답은 실제 오개념에서 | 정답과 의미적으로 경쟁하는 오답(competitive distractor)이라야 깊은 처리가 일어난다. 무작위 오답은 재인 훈련에 그친다 | -| 확신도 입력 | 확신했는데 틀린 오류가 가장 강하게 교정된다(hypercorrection). 기제는 *놀람*이라 즉시 피드백이 있어야 작동한다 | +| 확신도 입력 | 확신했는데 틀린 오류가 가장 강하게 교정된다(hypercorrection). 기제가 *놀람*이므로 즉시 피드백이 있어야 작동한다 | | 즉시 해설 + 오답별 해설 | 교정 피드백은 오류 직후에 줄 때 가장 효과가 크다 | | 오답 재도전 라운드 | 피드백 직후 한 번 더 테스트하지 않으면 교정된 오류가 시간이 지나 되돌아온다 | @@ -51,15 +51,15 @@ **필수 불변식** - `options`는 정확히 4개, `correct: true`는 정확히 1개. - **모든 오답에 `distractorType`과 `why`가 있어야 한다.** 하나라도 비면 문항 전체가 탈락한다. -- `source`는 검증 가능해야 한다. "일반적으로 알려짐" 같은 건 근거가 아니다. +- `source`는 검증 가능해야 한다. "일반적으로 알려짐" 같은 것은 근거가 아니다. --- -## 2. 오답 생성 — 발명하지 말고 수집하라 +## 2. 오답 생성: 발명하지 말고 수집하라 ### 2.1 먼저 오개념 원장을 만든다 -문항을 쓰기 전에 그 주제의 **실제 오해 목록**을 모은다. 오답은 이 목록에서 꺼내 쓴다. 원장에 없는 오답을 즉석에서 지어내는 순간 품질이 무너진다. +문항을 쓰기 전에 그 주제에서 **실제로 일어나는 오해의 목록**을 모은다. 오답은 이 목록에서 가져와 쓴다. 원장에 없는 오답을 즉석에서 지어내는 순간 품질이 무너진다. 원장의 출처: @@ -77,7 +77,7 @@ > **이 레포에서는** `data/misconceptions/*.md`에 주제별 원장을 유지한다. 문항의 오답은 원장 항목을 참조하는 형태로 쓴다. -### 2.2 오답 유형 7종 — `distractorType`에 반드시 하나를 태그한다 +### 2.2 오답 유형 7종: `distractorType`에 반드시 하나를 태그한다 | 타입 | 뜻 | 예 (정답: `no-cache`는 "캐시하되 매번 재검증") | |---|---|---| @@ -89,11 +89,11 @@ | `outdated` | 예전엔 맞았던 것 | "HTTP/1.0 호환을 위해 `Pragma`가 필요하다" | | `plausible-number` | 자릿수·단위가 그럴듯하게 틀린 값 | 수치 문항 전용 | -**한 문항의 오답 3개가 전부 같은 타입이면 안 된다.** 최소 2종을 섞는다. 유형이 편중되면 응시자가 패턴을 학습해 내용 없이 푼다. +**한 문항의 오답 3개가 전부 같은 타입이면 안 된다.** 최소 2종을 섞는다. 유형이 편중되면 응시자가 패턴을 학습해서 내용을 몰라도 풀게 된다. -`one-step-short`가 가장 좋은 오답이다. 아는 사람과 어설프게 아는 사람을 가르는 건 대개 "거의 맞았지만 핵심을 빠뜨린 답"이기 때문이다. 문항당 최소 1개는 이 타입으로 넣도록 노력한다. +`one-step-short`가 가장 좋은 오답이다. 아는 사람과 어설프게 아는 사람을 가르는 것은 대개 "거의 맞았지만 핵심을 빠뜨린 답"이기 때문이다. 문항당 최소 1개는 이 타입으로 넣도록 노력한다. -### 2.3 핵심 게이트 — 한 줄 테스트 +### 2.3 핵심 게이트: 한 줄 테스트 > **"이 보기를 고른 사람은 무엇을 오해한 것인가"를 한 문장으로 쓸 수 없으면, 그 보기는 버린다.** @@ -103,15 +103,15 @@ --- -## 3. 금지 사항 — 시험 요령으로 풀리게 만드는 것들 +## 3. 금지 사항: 시험 요령으로 풀리게 만드는 것들 1~8번은 `tools/validate.mjs`가 기계적으로 검사한다. 9~11번은 블라인드 감사(§6.2)에서 -실측으로 드러난 것이고, **9·10번은 기계로 검사되지 않는다** — 검사를 구현해 측정해 봤지만 +실측으로 드러난 것이고, **9·10번은 기계로 검사되지 않는다.** 검사를 구현해서 측정해 봤지만 지목군과 대조군의 분포가 분리되지 않았다(실측 기록은 `tools/validate.mjs` 상단 주석). -11번은 절반만 검사된다(전제를 부정하는 오답은 잡히고, 오답이 결함을 서술하는지는 잡히지 않는다). -그래서 이 셋은 사람이 저작 시점에 지켜야 하고, 확인은 블라인드 감사로만 된다. +11번은 절반만 검사된다(전제를 부정하는 오답은 검출되지만, 오답이 결함을 서술하는지는 검출되지 않는다). +그래서 이 셋은 사람이 저작 시점에 지켜야 하고, 확인은 블라인드 감사로만 가능하다. -1. **길이 편향 금지.** 정답이 오답보다 눈에 띄게 길면 안 된다. 정답 길이는 오답 평균의 **1.4배 이하**. (아는 게 없어도 "제일 길고 자세한 걸 고른다"가 통하면 안 된다) +1. **길이 편향 금지.** 정답이 오답보다 눈에 띄게 길면 안 된다. 정답 길이는 오답 평균의 **1.4배 이하**. (아는 것이 없어도 "제일 길고 자세한 것을 고른다"는 요령이 통하면 안 된다) 2. **"위의 모든 것", "정답 없음", "해당 없음" 금지.** 변별력이 없고 저작 회피의 신호다. 3. **문법 단서 금지.** 조사·어미·단복수가 정답에만 자연스럽게 맞아떨어지면 안 된다. 모든 보기가 같은 문형으로 끝나야 한다. 4. **절대 표현 편중 금지.** "항상/절대/모든/반드시"가 오답에만 몰리면 안 된다. (몰리면 그 단어만 보고 소거된다) @@ -119,29 +119,29 @@ 6. **부정문 남용 금지.** "다음 중 옳지 *않은* 것은?"은 전체의 20%를 넘지 않는다. 넘으면 독해력 시험이 된다. 7. **한 문항에 두 가지를 묻지 않는다.** 복합 질문은 무엇을 몰라서 틀렸는지 알 수 없게 만든다. 8. **트리비아 금지.** 외워야만 알 수 있고 몰라도 일에 지장 없는 것(정확한 기본 TTL 초 값 등)은 내지 않는다. **판단을 묻는다.** -9. **발문이 답을 진단해 주지 않는다.** 이것이 실측된 최다 결함이다(1차 블라인드 감사 77문항 중 24건). - 상황을 줄 때 **증상만** 서술하고 원인·기준·전략을 발문에 쓰지 말 것. 발문이 원인을 말해 버리면 +9. **발문이 답을 진단해 주지 않는다.** 이것이 실측에서 가장 많이 발견된 결함이다(1차 블라인드 감사 77문항 중 24건). + 상황을 제시할 때는 **증상만** 서술하고 원인·기준·전략을 발문에 쓰지 않는다. 발문이 원인을 말해 버리면 정답은 그것을 되풀이하는 보기가 되고, 풀이자는 의미를 몰라도 **발문과 어휘가 겹치는 보기**를 고르면 된다. - 나쁜 예: "쿼리 파라미터 **순서와 대소문자가 달라 같은 이미지가 중복 캐시되고 있다.** 해법은?" - → 정답 "URL을 정규화한다"는 발문의 진단을 그대로 돌려준다. + → 정답 "URL을 정규화한다"는 발문에 적힌 진단을 그대로 되풀이한다. - 좋은 예: "같은 이미지의 CDN 히트율이 40%대에 머문다. 접근 로그를 보니 동일 이미지에 대한 URL이 수십 종이다. 원인은?" - - 자기 점검: **발문에서 정답을 가린 채, 정답과 어휘가 가장 많이 겹치는 보기를 골라 보라.** 그게 정답이면 실패다. + - 자기 점검: **발문에서 정답을 가린 채, 발문과 어휘가 가장 많이 겹치는 보기를 골라 보라.** 그 보기가 정답이면 실패다. 10. **구체성 격차 금지.** 정답에만 수치·고유명사·메커니즘 서술이 붙고 오답은 뭉뚱그려져 있으면, - 길이 제한(1.4배)을 지켜도 "자세한 쪽이 정답"으로 풀린다. **네 보기의 서술 밀도를 맞춘다** — - 정답이 메커니즘을 말하면 오답도 (틀린) 메커니즘을 말해야 한다. + 길이 제한(1.4배)을 지켜도 "자세한 쪽이 정답"이라는 요령으로 풀린다. **네 보기의 서술 밀도를 맞춘다.** + 즉 정답이 메커니즘을 말하면 오답도 (틀린) 메커니즘을 말해야 한다. 11. **결함을 묻는 발문은 오답도 결함을 서술해야 한다.** 이 발문 형태("무엇이 깨지는가 / 구조적 - 결함은? / 실질적 문제는?")는 그 자체로 위험하다. 오답을 쓰기 쉬운 쪽이 **처방**("~하면 해결된다"), - **방어**("~를 켜면 관리가 된다"), **부정**("아무 문제 없다")이고, 셋 중 어느 쪽이든 넣으면 + 결함은? / 실질적 문제는?")는 그 자체로 위험하다. 오답으로 쓰기 쉬운 형태가 **처방**("~하면 해결된다"), + **방어**("~를 켜면 관리가 된다"), **부정**("아무 문제 없다")인데, 셋 중 어느 쪽이든 넣으면 *결함을 서술한 보기가 정답 하나뿐*이 되어 내용을 몰라도 문형만으로 답이 특정된다. 3라운드 감사에서 반복 지목된 유형 중 가장 많았던 것이 이것이다. - - **부정형은 절대 금지** — 발문과 정면으로 모순되어 읽자마자 소거된다. 이 절반은 게이트가 검사한다. + - **부정형은 절대 금지한다.** 발문과 정면으로 모순되어 읽자마자 소거되기 때문이다. 이 절반은 게이트가 검사한다. - **처방형·방어형도 쓰지 않는다.** 오답 셋을 모두 *다른 결함* 서술로 쓴다. - 그러면 "그 결함도 실제로 참"이 되어 정답이 둘 이상으로 읽히기 쉽다. 발문에 **"가장 근본적으로"** - 같은 한정을 넣어 부차적으로 참인 결함이 정답과 경쟁하되 밀리게 한다. + 같은 한정을 넣어서, 부차적으로 참인 결함이 정답과 경쟁은 하되 정답에는 밀리게 한다. - 나쁜 예: 결함=A / 오답="B를 켜면 해결된다", "비용은 무시할 만하다", "문제되지 않는다" - 좋은 예: 결함=A(전면적) / 오답=B·C·D(각각 실재하지만 부차적이거나, 그럴듯하지만 거짓인 결함) - `node tools/validate.mjs --defect-stems`가 이 형태의 발문을 모아 준다. 게이트는 결함 서술 여부를 - 판정하지 않는다 — **대상만 지목하고, 판정은 사람이 한다.** + 판정하지 않는다. **대상만 지목하고, 판정은 사람이 한다.** --- @@ -153,7 +153,7 @@ **좋은 예 — 판단** > 이미지 CDN에서 오리진 부하가 특정 시각마다 튄다. TTL이 만료되는 순간 같은 객체에 대한 요청이 한꺼번에 오리진으로 몰리는 것이 원인으로 확인됐다. 코드 변경 없이 헤더만으로 완화하려면? -두 번째가 좋은 이유: 오답이 자연스럽게 경쟁한다. `stale-if-error`(인접), TTL 상향(one-step-short — 문제를 미루기만 함), `no-cache` 추가(inverted — 악화시킴)가 전부 그럴듯하다. +두 번째가 좋은 이유는 오답이 자연스럽게 경쟁한다는 점이다. `stale-if-error`(인접), TTL 상향(one-step-short: 문제를 미루기만 함), `no-cache` 추가(inverted: 오히려 악화시킴)가 전부 그럴듯하다. **형태 권장** - 상황을 2~3문장으로 준 뒤 "무엇을 하겠는가 / 원인은 무엇인가 / 무엇이 깨지는가"를 묻는다. @@ -170,7 +170,7 @@ | 2 | 한 번 데어봤거나 문서를 읽었어야 안다 | 45~65% | | 3 | 설계 결정을 직접 내려봤어야 안다 | 25~45% | -정답률이 90%를 넘으면 오답이 죽은 것이고, 25% 미만(무작위 수준)이면 문항이 모호하거나 트리비아다. 둘 다 교체 대상이다. +정답률이 90%를 넘으면 오답이 죽은 보기가 된 것이고, 25% 미만(무작위 수준)이면 문항이 모호하거나 트리비아다. 둘 다 교체 대상이다. --- @@ -185,37 +185,37 @@ - 정답률이 지나치게 높으면 오답이 약하다. - **"지식 없이 요령만으로 답을 고를 수 있었나"** 를 함께 묻는다. "그렇다"면 그 문항은 재작성한다. - 판정은 그대로 수용하지 말고 두 부류로 나눈다. **어휘 반복·문형·길이·구체성으로 풀렸다면 진짜 결함**이고, - **"보기가 발문과 모순되어 소거됐다"는 판정은 그 모순을 알아채는 데 도메인 지식이 필요한지 되물어야 한다** — - 필요하다면 그것은 정상적인 문항이다. 1차 감사에서 40건이 지목됐고 이 분류로 16건이 남았다. + **"보기가 발문과 모순되어 소거됐다"는 판정은 그 모순을 알아채는 데 도메인 지식이 필요한지 되물어야 한다.** + 도메인 지식이 필요하다면 그것은 정상적인 문항이다. 1차 감사에서 40건이 지목됐고 이 분류로 16건이 남았다. - **한 라운드의 판정은 재현되지 않는다. 3라운드를 돌리고 2회 이상 지목된 것만 고친다.** 실측(2026-08-27, 같은 89문항·같은 기준·같은 모델로 3회): 지목률이 51.9% → 5.6% → 13.5%로 단조적이지 않았고, **2차와 3차의 자카드 유사도는 0.13**이었다. 즉 두 라운드가 거의 다른 문항을 - 지목했다. 2회 이상 지목은 12건, 정확히 1회만 지목은 32건 — **반복 지목만이 신호이고 나머지는 - 노이즈다.** 단일 라운드를 근거로 고치면 노이즈를 쫓게 되고, 그 라운드를 다시 돌려 0건을 + 지목했다. 2회 이상 지목은 12건, 정확히 1회만 지목은 32건이었다. **반복 지목만이 신호이고 나머지는 + 노이즈다.** 단일 라운드를 근거로 고치면 노이즈를 따라 고치게 되고, 그 라운드를 다시 돌려 0건을 만들려는 시도는 수렴하지 않는다. 라운드를 **병렬로 3개** 돌려라. 순차로 돌리면 앞 라운드의 - 수정이 다음 라운드의 입력을 바꿔 비교가 성립하지 않는다. + 수정이 다음 라운드의 입력을 바꾸기 때문에 비교가 성립하지 않는다. - **다른 문항 집합에서 한 번 더 재현됐다.** 신규 60문항에 같은 절차를 돌린 결과 지목 수가 3 / 1 / 12건, 라운드 쌍의 자카드가 0.00 · 0.08 · 0.15였다(2026-08-27). 2회 이상 지목은 - 6건, 1회만 지목은 10건. 첫 측정(89문항, 자카드 0.13)과 같은 결론이므로 이 규약은 - 한 번의 우연이 아니다. 참고로 세 라운드 모두 60문항을 **100% 정답**으로 풀었다 — - 정답률은 이 감사의 판정 축이 아니다. 요령으로 풀렸는지가 축이다. + 6건, 1회만 지목은 10건이었다. 첫 측정(89문항, 자카드 0.13)과 같은 결론이므로 이 규약은 + 한 번의 우연에서 나온 것이 아니다. 참고로 세 라운드 모두 60문항을 **100% 정답**으로 풀었다. + 즉 정답률은 이 감사의 판정 기준이 아니다. 요령으로 풀렸는지가 판정 기준이다. -**"정답이 둘 이상"을 발문 조건으로 닫으면 그 보기가 죽은 보기가 된다.** 3차 감사에서 실측된 +**"정답이 둘 이상"이라는 결함을 발문에 조건을 추가해서 해소하면 그 보기가 죽은 보기가 된다.** 3차 감사에서 실측된 자기 유발 결함이다. 2차에서 "보기 두 개가 모두 참"이라는 지적을 받고 발문에 조건을 넣어 경쟁 보기를 배제했는데(예: 발문에 "브라우저 지원 비율은 이미 확인했다"를 추가), 그 조건이 해당 보기를 *읽자마자 소거되는 보기*로 바꿨다. 두 결함은 상호 배타적이다. **조건을 넣지 말고 보기 자체를 다른 오개념으로 교체하라.** 판정 기준은 이렇게 갈린다. 경쟁 보기가 **거짓인데 발문의 전제 때문에 참처럼 읽히는 것**이면 조건 추가가 옳다(그 보기는 여전히 그럴듯한 오개념으로 남는다). 경쟁 보기가 **그냥 참**이면 -조건을 넣어도 참은 참이므로 닫히지 않고, 교체만이 수정이다. 실측 사례: +조건을 넣어도 참은 참이므로 결함이 해소되지 않고, 교체만이 수정이다. 실측 사례: `cdn-purge-all-risk-001`의 경쟁 보기 둘(무효화 전파 창, purge가 브라우저 캐시에 닿지 않음)은 -둘 다 사실이었고, "가장 근본적인"이라는 한정만이 순위를 매기고 있었다 — 오답 셋을 모두 +둘 다 사실이었고, "가장 근본적인"이라는 한정만이 순위를 매기고 있었다. 그래서 오답 셋을 모두 *거짓인* 결함 서술로 교체했다. -**`why`가 자기 보기와 다른 이야기를 하는 것은 기계로 잡히지 않는다.** 편집 중 문장이 뒤섞여 +**`why`가 자기 보기와 다른 이야기를 하는 결함은 기계로 검출되지 않는다.** 편집 중에 문장이 뒤섞이면서 생기고, 화면에서는 정상으로 보인다(실측 4건: 오답 텍스트는 "계산이 수 초 걸려 병목"인데 -`why`는 "단일 지표는 게이팅에 약하다"를 말하고 있었다). 어휘 겹침으로 근사해 596개 보기 -전부에 재 봤지만 겹침 0인 보기가 42%였고 지목된 4건은 그 한가운데였다 — 오답의 `why`는 -원래 보기를 되풀이하지 않고 그 오해를 설명하는 글이므로 겹침이 낮은 것이 정상이다. +`why`는 "단일 지표는 게이팅에 약하다"를 말하고 있었다). 어휘 겹침으로 근사해서 596개 보기 +전부를 측정해 봤지만 겹침 0인 보기가 42%였고, 지목된 4건도 그 42% 한가운데에 있었다. 오답의 `why`는 +원래 보기를 되풀이하지 않고 그 오해를 설명하는 글이므로, 겹침이 낮은 것이 정상이다. **§2.3의 한 줄 테스트를 보기 하나하나에 소리 내어 적용하는 것이 유일한 검사다.** 3. **근거 확인** — `source`를 실제로 열어 진술과 일치하는지 본다. 시점 의존 수치(벤더 기본값, 지원 현황)는 문항으로 내지 않거나, 확인 날짜를 함께 적는다. @@ -224,23 +224,23 @@ 이 게이트를 만든 날 46개 인용 중 **9개가 틀려 있었다**: Vary를 RFC 9110 §12.5.5가 아니라 §12.5.1(실제 제목 "Accept")로 적은 문항 세 건, If-Range를 §13.1.4(실제 "If-Unmodified-Since")로 적은 한 건, stale-if-error를 RFC 5861 §4가 아니라 §3(실제는 stale-while-revalidate)로 적은 한 건, - 그 밖에 부모 절을 자식 이름으로 부른 것들. 전부 사람의 눈이 아니라 목차 대조로 발견됐다. - - **잘 알려진 출처를 걸어 두었는데 그 문서가 주장을 지지하지 않는 것이 가장 비싼 결함이다.** - 클릭 한 번으로 확인되고, 하나가 들키면 나머지 인용 전체의 신뢰가 같이 떨어진다. + 그 밖에도 부모 절을 자식 절의 이름으로 적은 사례들이 있었다. 전부 사람의 눈이 아니라 목차 대조로 발견됐다. + - **잘 알려진 출처를 링크로 걸어 두었는데 그 문서가 주장을 뒷받침하지 않는 것이 가장 손실이 큰 결함이다.** + 클릭 한 번으로 확인되고, 하나가 틀린 것으로 드러나면 나머지 인용 전체의 신뢰가 같이 떨어진다. 같은 라운드에서 이 부류로 13건을 지웠다(예: AWS 개념인 Origin Shield를 GCP 발표 자료로 인용, - 배포 카나리 사실을 CDN 페일오버 근거로 인용). **인용을 지우는 비용은 거의 0이다** — - 벤더 문서가 이미 진짜 근거였다. 확신이 없으면 링크 없이 텍스트 근거만 둔다. + 배포 카나리 사실을 CDN 페일오버 근거로 인용). **인용을 지우는 비용은 거의 0이다.** + 벤더 문서가 이미 진짜 근거였기 때문이다. 확신이 없으면 링크 없이 텍스트 근거만 둔다. --- -## 7. 출시 후 — 데이터로 고친다 +## 7. 출시 후: 데이터로 고친다 기능하지 않는 오답은 데이터로만 확인된다. - 문항별 **보기 선택 분포**를 익명 집계한다. - **선택률 5% 미만인 오답은 죽은 보기다.** 원장에서 다른 오개념을 꺼내 교체한다. - 정답률이 §5의 목표 범위를 벗어난 문항은 난이도를 재분류하거나 재작성한다. -- **확신도 × 정답 여부** 교차표를 본다. "확신했는데 틀린" 비율이 높은 문항이 가장 가치 있는 문항이다. 그 주제를 더 파서 문항을 늘린다. +- **확신도 × 정답 여부** 교차표를 본다. "확신했는데 틀린" 비율이 높은 문항이 가장 가치 있는 문항이다. 그 주제를 더 깊이 다뤄서 문항을 늘린다. --- diff --git a/README.md b/README.md index cfcefd3..bfe71a5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # cachehit -> **당신의 캐시 히트율은?** — CDN·캐싱·이미지/동영상 서빙 파이프라인 퀴즈 +> **당신의 캐시 히트율은?** CDN·캐싱·이미지/동영상 서빙 파이프라인 퀴즈 -매일 쓰지만 정확히는 모르는 것들을 물어봅니다. `no-cache`가 실제로 무슨 뜻인지, 304가 왜 공짜가 아닌지, 코덱과 컨테이너가 어떻게 다른지. +매일 쓰지만 정확히는 모르는 것들을 물어봅니다. 예를 들어 `no-cache`가 실제로 무슨 뜻인지, 304가 왜 공짜가 아닌지, 코덱과 컨테이너가 어떻게 다른지를 묻습니다. **→ [퀴즈 풀어보기](https://midagedev.github.io/cachehit)** @@ -12,51 +12,51 @@ 대부분의 객관식 퀴즈는 학습 도구가 아니라 소거법 연습입니다. 오답 보기가 허술하면 내용을 몰라도 "제일 길고 그럴듯한 것"을 골라 맞힐 수 있기 때문입니다. -cachehit은 그 지점을 설계로 막았습니다. +cachehit은 그 문제를 설계 단계에서 막았습니다. -**모든 오답은 실제 오개념에서 가져옵니다.** 즉석에서 지어낸 보기는 쓰지 않습니다. 주제별 [오개념 원장](data/misconceptions/)을 먼저 만들고, 문항의 오답은 거기서 꺼내 씁니다. 각 오답에는 어떤 유형의 오해인지 태그가 붙습니다 — 인접 개념 치환, 방향 반전, 과잉 일반화, 한 단계 부족한 답, 벤더 혼동, 시대 지난 정답, 그럴듯한 수치. +**모든 오답은 실제 오개념에서 가져옵니다.** 즉석에서 지어낸 보기는 쓰지 않습니다. 주제별 [오개념 원장](data/misconceptions/)을 먼저 만들고, 문항의 오답은 거기서 가져와 씁니다. 각 오답에는 어떤 유형의 오해인지 태그가 붙습니다: 인접 개념 치환, 방향 반전, 과잉 일반화, 한 단계 부족한 답, 벤더 혼동, 시대 지난 정답, 그럴듯한 수치. **핵심 게이트는 하나입니다.** *"이 보기를 고른 사람은 무엇을 오해한 것인가"를 한 문장으로 쓸 수 없으면 그 보기는 버립니다.* 통과하면 그 문장이 곧 오답 해설이 됩니다. -**근거는 실제로 열리는 링크입니다.** 각 문항의 `source` 텍스트는 `data/sources.json` 레지스트리와 RFC 표기 규칙을 거쳐 원문 링크가 됩니다. `tools/check-links.mjs`가 그 URL을 전부 눌러 봅니다 — **죽은 링크는 화면에서 지어낸 근거와 구분되지 않기 때문**입니다. 봇을 막는 사이트(Medium·ISO)의 403은 실패로 세지 않고, 연결 오류는 `curl`로 한 번 더 확인합니다(측정해 보니 `itu.int`와 `ijg.org`는 `curl` 200에 Node `fetch` ETIMEDOUT이었습니다 — fetch만 믿었으면 멀쩡한 링크 둘을 지웠을 겁니다). +**근거는 실제로 열리는 링크입니다.** 각 문항의 `source` 텍스트는 `data/sources.json` 레지스트리와 RFC 표기 규칙을 거쳐 원문 링크가 됩니다. `tools/check-links.mjs`가 그 URL에 전부 실제로 요청을 보내 확인합니다. **죽은 링크는 화면에서 지어낸 근거와 구분되지 않기 때문**입니다. 봇을 막는 사이트(Medium·ISO)의 403은 실패로 세지 않고, 연결 오류는 `curl`로 한 번 더 확인합니다(측정해 보니 `itu.int`와 `ijg.org`는 `curl`에서는 200인데 Node `fetch`에서는 ETIMEDOUT이었습니다. fetch만 믿었으면 멀쩡한 링크 둘을 지웠을 겁니다). -**링크가 열리는 것과 그 문서가 주장을 지지하는 것은 다릅니다.** 후자가 훨씬 비싼 결함입니다 — 클릭 한 번으로 들키고, 하나가 들키면 나머지 인용 전체의 신뢰가 같이 떨어집니다. 그래서 인용을 두 가지로 검사합니다. 하나는 `tools/check-rfc-sections.mjs`로, RFC 절 번호와 괄호에 적은 이름을 **원문 목차와 대조**합니다. 만든 날 46개 인용 중 **9개가 틀려 있었습니다**: `Vary`를 RFC 9110 §12.5.5가 아니라 §12.5.1(실제 제목 "Accept")로 적은 문항 세 건, `If-Range`를 §13.1.4(실제 "If-Unmodified-Since")로, `stale-if-error`를 RFC 5861 §4가 아니라 §3(실제는 `stale-while-revalidate`)로 적은 것 등입니다. 다른 하나는 사람이 하는 대조인데, 이쪽으로 **잘 알려진 출처를 걸어 두었지만 그 문서가 주장을 지지하지 않는 인용 13건**을 지웠습니다(AWS 개념인 Origin Shield를 GCP 발표 자료로 인용한 것, 배포 카나리 사실을 CDN 페일오버 근거로 인용한 것 등). 지우는 비용은 거의 0이었습니다 — 벤더 문서가 이미 진짜 근거였습니다. +**링크가 열리는 것과 그 문서가 주장을 뒷받침하는 것은 다릅니다.** 후자가 훨씬 치명적인 결함입니다. 클릭 한 번으로 드러나고, 하나가 드러나면 나머지 인용 전체의 신뢰가 같이 떨어집니다. 그래서 인용을 두 가지 방법으로 검사합니다. 하나는 `tools/check-rfc-sections.mjs`로, RFC 절 번호와 괄호에 적은 이름을 **원문 목차와 대조**합니다. 이 검사를 만든 당일에 46개 인용 중 **9개가 틀려 있었습니다**: `Vary`를 RFC 9110 §12.5.5가 아니라 §12.5.1(실제 제목 "Accept")로 적은 문항 세 건, `If-Range`를 §13.1.4(실제 "If-Unmodified-Since")로, `stale-if-error`를 RFC 5861 §4가 아니라 §3(실제는 `stale-while-revalidate`)로 적은 것 등입니다. 다른 하나는 사람이 하는 대조인데, 이 검사로 **잘 알려진 출처를 인용해 두었지만 그 문서가 주장을 뒷받침하지 않는 인용 13건**을 지웠습니다(AWS 개념인 Origin Shield를 GCP 발표 자료로 인용한 것, 배포 카나리 사실을 CDN 페일오버 근거로 인용한 것 등). 지우는 비용은 거의 들지 않았습니다. 벤더 문서가 이미 진짜 근거였기 때문입니다. -**기계가 검사합니다.** `tools/validate.mjs`가 길이 편향(정답이 오답 평균의 1.4배 초과), "위의 모든 것"류 보기, 문법 단서, 절대 표현 편중, 정답 위치 편중, 부정형 발문 과다, 오답 유형 단일화, 그리고 발문이 결함을 전제하는데 "문제 없다"고 답하는 죽은 보기를 자동으로 잡아냅니다. 통과하지 못하면 빌드되지 않습니다. +**기계가 검사합니다.** `tools/validate.mjs`가 길이 편향(정답이 오답 평균의 1.4배 초과), "위의 모든 것"류 보기, 문법 단서, 절대 표현 편중, 정답 위치 편중, 부정형 발문 과다, 오답 유형 단일화, 그리고 발문이 결함을 전제하는데 "문제 없다"고 답해 읽자마자 소거되는 보기를 자동으로 잡아냅니다. 통과하지 못하면 빌드되지 않습니다. -**기계로 안 잡히는 것은 블라인드 감사로 잡습니다.** 정답과 해설을 제거한 세트를 만들어(`tools/blind.mjs`) 다른 모델에게 풀리고, 문항마다 **"지식 없이 요령만으로 답을 고를 수 있었나"**를 묻습니다. 1차 감사에서 77문항 중 40건이 지목됐고, 그중 진짜 결함은 16건이었습니다 — **"보기가 발문과 모순되어 소거됐다"는 지적은 그 모순을 알아채는 데 도메인 지식이 필요하면 결함이 아니기 때문**입니다. +**기계로 안 잡히는 것은 블라인드 감사로 잡습니다.** 정답과 해설을 제거한 세트를 만들어(`tools/blind.mjs`) 다른 모델에게 풀게 하고, 문항마다 **"지식 없이 요령만으로 답을 고를 수 있었나"**를 묻습니다. 1차 감사에서 77문항 중 40건이 지목됐고, 그중 진짜 결함은 16건이었습니다. **"보기가 발문과 모순되어 소거됐다"는 지적은 그 모순을 알아채는 데 도메인 지식이 필요하다면 결함이 아니기 때문**입니다. -가장 많이 나온 결함은 **발문이 답을 진단해 주는 것**이었습니다. "순서와 대소문자가 달라 중복 캐시되고 있다. 해법은?"이라고 물으면 정답은 발문을 되풀이하는 보기가 되고, 내용을 몰라도 발문과 어휘가 겹치는 보기를 고르면 맞습니다. 지금은 증상만 서술합니다 — "히트율이 60%에서 40%로 떨어졌다. 로그에 `w=640&q=75` 와 `Q=75&w=640` 이 함께 잡힌다." +가장 많이 나온 결함은 **발문이 답을 진단해 주는 것**이었습니다. "순서와 대소문자가 달라 중복 캐시되고 있다. 해법은?"이라고 물으면 정답은 발문을 되풀이하는 보기가 되고, 내용을 몰라도 발문과 어휘가 겹치는 보기를 고르면 맞습니다. 지금은 "히트율이 60%에서 40%로 떨어졌다. 로그에 `w=640&q=75` 와 `Q=75&w=640` 이 함께 잡힌다."처럼 증상만 서술합니다. -**감사 자체가 재현되지 않는다는 것도 측정했습니다.** 같은 89문항을 같은 기준·같은 모델로 3번 감사했더니 지목률이 51.9% → 5.6% → 13.5%로 나왔고, **2차와 3차가 지목한 문항의 자카드 유사도는 0.13**이었습니다. 두 라운드가 거의 다른 문항을 지목한 셈입니다. 2회 이상 지목된 것은 12건, 정확히 1회만 지목된 것은 32건 — 그래서 **반복 지목만 신호로 취급하고, 1회 지목은 고치지 않습니다.** 한 라운드를 근거로 고치고 그 라운드를 다시 돌려 0건을 만들려는 시도는 수렴하지 않습니다. 지금 규약은 "라운드 3개를 병렬로 돌리고 2회 이상 지목된 것만 고친다"입니다. 문항을 60개 늘린 뒤 같은 절차를 그 60문항에 다시 돌렸을 때도 지목 수가 3 / 1 / 12건, 라운드 쌍의 자카드가 0.00 · 0.08 · 0.15로 같은 결론이 나왔습니다 — 한 번의 우연이 아닙니다. 그 세 라운드는 60문항을 모두 **100% 맞혔는데도** 6건을 반복 지목했습니다. 정답률은 이 감사의 축이 아니라는 뜻입니다. +**감사 자체가 재현되지 않는다는 것도 측정했습니다.** 같은 89문항을 같은 기준·같은 모델로 3번 감사했더니 지목률이 51.9% → 5.6% → 13.5%로 나왔고, **2차와 3차가 지목한 문항의 자카드 유사도는 0.13**이었습니다. 두 라운드가 서로 거의 다른 문항을 지목한 셈입니다. 2회 이상 지목된 것은 12건, 정확히 1회만 지목된 것은 32건이었습니다. 그래서 **반복 지목만 신호로 취급하고, 1회 지목은 고치지 않습니다.** 한 라운드를 근거로 고치고 그 라운드를 다시 돌려 0건을 만들려는 시도는 수렴하지 않습니다. 지금 규약은 "라운드 3개를 병렬로 돌리고 2회 이상 지목된 것만 고친다"입니다. 문항을 60개 늘린 뒤 같은 절차를 그 60문항에 다시 돌렸을 때도 지목 수가 3 / 1 / 12건, 라운드 쌍의 자카드가 0.00 · 0.08 · 0.15로 같은 결론이 나왔습니다. 한 번의 우연이 아니라는 뜻입니다. 그 세 라운드는 60문항을 모두 **100% 맞혔는데도** 6건을 반복 지목했습니다. 정답률은 이 감사가 측정하는 지표가 아니라는 뜻입니다. -그 과정에서 **서로를 되살리는 결함 한 쌍**도 발견했습니다. "정답이 둘 이상으로 읽힌다"는 지적을 발문에 조건을 넣어 닫으면(예: "브라우저 지원 비율은 이미 확인했다"), 그 조건이 경쟁 보기를 *읽자마자 소거되는 죽은 보기*로 바꿉니다. 두 결함은 상호 배타적이라, 조건을 넣는 대신 보기 자체를 다른 오개념으로 교체해야 했습니다. 어느 쪽인지 가르는 기준은 경쟁 보기가 **거짓인데 참처럼 읽히는지**(조건 추가), 아니면 **그냥 참인지**(교체)입니다 — 후자에는 조건을 넣어도 참이 거짓이 되지 않습니다. 전체 purge를 묻는 문항이 그랬습니다. 경쟁 보기 둘(무효화 전파가 끝나기 전의 창, purge가 브라우저 캐시에 닿지 않음)이 둘 다 사실이었고 "가장 근본적인"이라는 한정만이 순위를 매기고 있어서, 오답 셋을 모두 *거짓인* 결함 서술로 바꿨습니다. +그 과정에서 **한쪽을 고치면 다른 쪽이 다시 생기는 결함 한 쌍**도 발견했습니다. "정답이 둘 이상으로 읽힌다"는 지적을 발문에 조건을 넣어 해결하면(예: "브라우저 지원 비율은 이미 확인했다"), 그 조건이 경쟁 보기를 *읽자마자 소거되는 보기*로 바꿉니다. 두 결함은 상호 배타적이라, 조건을 넣는 대신 보기 자체를 다른 오개념으로 교체해야 했습니다. 어느 쪽인지 가르는 기준은 경쟁 보기가 **거짓인데 참처럼 읽히는지**(조건 추가), 아니면 **그냥 참인지**(교체)입니다. 후자는 조건을 넣어도 참이 거짓이 되지 않기 때문입니다. 전체 purge를 묻는 문항이 그랬습니다. 경쟁 보기 둘(무효화 전파가 끝나기 전의 시간 창, purge가 브라우저 캐시에 닿지 않음)이 둘 다 사실이었고 "가장 근본적인"이라는 한정만이 순위를 매기고 있어서, 오답 셋을 모두 *거짓인* 결함 서술로 바꿨습니다. -반복 지목의 최다 유형은 **결함을 묻는 발문에 결함을 말한 보기가 정답 하나뿐**인 것이었습니다. "무엇이 깨지는가?"라고 물으면 오답은 처방("~하면 해결된다")이나 방어("~를 켜면 관리가 된다")로 쓰기 쉬운데, 그러면 내용을 몰라도 문형만 보고 답이 특정됩니다. 지금은 오답 셋을 모두 *다른 결함* 서술로 쓰고, 그것들이 실제로 참일 때는 발문에 "가장 근본적으로"를 넣어 경쟁하되 밀리게 합니다. `node tools/validate.mjs --defect-stems`가 이 형태의 발문을 모아 주지만, 게이트는 결함 서술 여부를 판정하지 않습니다 — 대상만 지목하고 판정은 사람이 합니다. +반복 지목에서 가장 많았던 유형은 **결함을 묻는 발문에서 결함을 서술한 보기가 정답 하나뿐**인 것이었습니다. "무엇이 깨지는가?"라고 물으면 오답은 처방("~하면 해결된다")이나 방어("~를 켜면 관리가 된다")로 쓰기 쉬운데, 그러면 내용을 몰라도 문형만 보고 답이 특정됩니다. 지금은 오답 셋을 모두 *다른 결함* 서술로 쓰고, 그것들이 실제로 참일 때는 발문에 "가장 근본적으로"를 넣어 정답과 경쟁하되 밀리게 합니다. `node tools/validate.mjs --defect-stems`가 이 형태의 발문을 모아 주지만, 게이트는 결함 서술 여부까지 판정하지는 않습니다. 게이트는 대상만 지목하고 판정은 사람이 합니다. -**측정되지 않는 것은 측정되지 않는다고 적었습니다.** 이 두 결함(발문 어휘 반복, 구체성 격차)을 기계로 검사하려고 구현해 지목군 12건과 대조군 8건의 분포를 비교했지만, 두 군이 전혀 분리되지 않았습니다(격차 최대 +1, 대조군에도 +1). 토큰 겹침으로는 의미적 반복을 잡을 수 없다는 뜻이라 **검사를 제거했습니다** — 무력한 게이트는 "통과"를 "결함 없음"으로 잘못 읽히게 만들기 때문입니다. 실측 기록은 `tools/validate.mjs` 상단에 남아 있습니다. +**측정되지 않는 것은 측정되지 않는다고 적었습니다.** 이 두 결함(발문 어휘 반복, 구체성 격차)을 검사하는 코드를 구현해 지목군 12건과 대조군 8건의 분포를 비교했지만, 두 군이 전혀 분리되지 않았습니다(격차 최대 +1, 대조군에도 +1). 토큰 겹침으로는 의미적 반복을 잡을 수 없다는 뜻이라 **검사를 제거했습니다.** 무력한 게이트는 "통과"를 "결함 없음"으로 잘못 읽히게 만들기 때문입니다. 실측 기록은 `tools/validate.mjs` 상단에 남아 있습니다. -세 번째 축도 같았습니다. 오답의 해설(`why`)이 자기 보기가 아니라 **다른 보기 이야기를 하고 있는** 결함이 4건 발견됐는데(오답 텍스트는 "계산이 수 초 걸려 병목"인데 해설은 "단일 지표는 게이팅에 약하다"를 말하고 있었습니다), 어휘 겹침으로 596개 보기 전부를 재 보니 겹침 0인 보기가 42%였고 지목된 4건은 그 한가운데였습니다. 오답의 해설은 원래 보기를 되풀이하지 않고 **그 오해를 설명하는 글**이니 겹침이 낮은 것이 정상입니다 — 이 축에는 신호가 없습니다. 그래서 게이트를 만들지 않았습니다. +세 번째로 검토한 항목도 결과가 같았습니다. 오답의 해설(`why`)이 자기 보기가 아니라 **다른 보기 이야기를 하고 있는** 결함이 4건 발견됐는데(오답 텍스트는 "계산이 수 초 걸려 병목"인데 해설은 "단일 지표는 게이팅에 약하다"를 말하고 있었습니다), 어휘 겹침으로 596개 보기 전부를 측정해 보니 겹침 0인 보기가 42%였고 지목된 4건도 그 분포의 한가운데에 있었습니다. 오답의 해설은 원래 보기를 되풀이하지 않고 **그 오해를 설명하는 글**이니 겹침이 낮은 것이 정상입니다. 즉 이 지표에는 신호가 없습니다. 그래서 게이트를 만들지 않았습니다. ## 학습 설계 | 장치 | 근거 | |---|---| -| 4지선다 | 단답형과 최종 파지 차이가 작다 (Smith & Karpicke, 4개 실험 종합 d=0.07). 형식보다 인출 성공률이 중요하다 | +| 4지선다 | 단답형과 비교해도 최종 파지 차이가 작다 (Smith & Karpicke, 4개 실험 종합 d=0.07). 형식보다 인출 성공률이 중요하다 | | 보기 4개 (오답 3개) | 기능하는 오답이 2~3개일 때 난이도와 변별력이 가장 좋다 | | **확신도 입력** | 확신했는데 틀린 오류가 가장 강하게 교정된다 (hypercorrection). 결과 화면에서 그 문항을 따로 모아 보여준다 | -| **"모르겠음"이 아니라 "찍었어요"** | 건너뛰기 버튼은 인출 시도를 없앤다. 실패한 인출도 이후 학습을 향상시키고, 즉시 교정 피드백이 있으면 추측이 오답을 심는 위험은 거의 사라진다. 그래서 답은 반드시 고르게 하고 찍었음을 신고받는다 — 결과에서 **찍어서 맞은 문항**을 따로 모으고, 점수도 그것을 뺀 값을 나란히 보여준다 | +| **"모르겠음"이 아니라 "찍었어요"** | 건너뛰기 버튼은 인출 시도를 없앤다. 실패한 인출도 이후 학습을 향상시키고, 즉시 교정 피드백이 있으면 추측 때문에 오답이 기억에 남을 위험은 거의 사라진다. 그래서 답은 반드시 고르게 하고 찍었음을 신고받는다. 결과에서 **찍어서 맞은 문항**을 따로 모으고, 점수도 그것을 뺀 값을 나란히 보여준다 | | **즉시 해설 + 오답별 해설** | 교정 피드백은 오류 직후에 줄 때 효과가 가장 크다. 정답 이유뿐 아니라 *당신이 고른 답이 왜 틀렸는지*를 보여준다 | | **약한 문항 재도전** | 피드백 직후 한 번 더 인출하지 않으면 교정된 오류가 되돌아온다. 대상은 틀린 문항 + 찍어서 맞은 문항 | -| **10문항 / 20문항 선택** | 한 라운드를 끝까지 마치는 것이 중요하다. 기본은 10문항 — 짧게 끝내고 한 번 더 하는 편이, 긴 라운드를 중간에 놓는 것보다 낫다 | -| **원문 링크** | 해설에서 바로 원문으로 넘어갈 수 있어야 퀴즈가 학습의 입구가 된다. 89문항 중 88문항에 링크가 붙는다 (나머지 하나는 근거가 유료 ISO 표준이라 공개 링크가 없다) | +| **10문항 / 20문항 선택** | 한 라운드를 끝까지 마치는 것이 중요하다. 기본은 10문항이다. 짧게 끝내고 한 번 더 하는 편이, 긴 라운드를 중간에 그만두는 것보다 낫다 | +| **원문 링크** | 해설에서 바로 원문으로 넘어갈 수 있어야 퀴즈가 학습의 입구가 된다. 149문항 중 147문항에 링크가 붙는다 (나머지 둘은 근거가 유료 ISO 표준이라 공개 링크가 없다) | | **문항 복사** | 채점된 문항을 보기·정답·오답 해설·원문 링크까지 마크다운으로 복사한다. 마지막 줄에 무엇을 물어볼지까지 적혀 있어, Claude 같은 도구에 붙여넣으면 그 자리에서 더 파고들 수 있다 | 자세한 규약: [AUTHORING.md](AUTHORING.md) ## 다루는 범위 -현재 89문항. +현재 149문항입니다. - **HTTP 캐싱** — `Cache-Control` 디렉티브, 조건부 요청, `Vary`, 휴리스틱 신선도, 캐시 스탬피드 - **CDN** — 캐시 키 설계, tiered caching / origin shield, 히트율과 비용, Signed URL, 멀티 CDN @@ -82,11 +82,11 @@ node tools/validate.mjs --topic cdn # 주제별로 나눠 쓸 때는 자기 node tools/build.mjs ``` -게이트는 통과의 절반입니다. 발문·보기를 고쳤다면 **§3-9(발문이 답을 진단해 주지 않는다)를 스스로 점검**하세요 — 정답을 모르는 상태를 가정하고 발문과 어휘가 가장 많이 겹치는 보기를 골라 봤을 때 그게 정답이면 아직 실패입니다. 이 축은 기계가 잡지 못합니다. +게이트 통과는 필요한 검증의 절반일 뿐입니다. 발문·보기를 고쳤다면 **§3-9(발문이 답을 진단해 주지 않는다)를 스스로 점검**하세요. 정답을 모르는 상태를 가정하고 발문과 어휘가 가장 많이 겹치는 보기를 골라 봤을 때 그게 정답이면 아직 실패입니다. 이 결함은 기계가 잡지 못합니다. -PR에 **각 오답이 어느 오개념에서 왔는지** 한 줄씩 적어주세요. 설명하지 못하는 오답이 있는 PR은 병합하지 않습니다. +PR에 **각 오답이 어느 오개념에서 왔는지** 한 줄씩 적어 주세요. 출처를 설명하지 못하는 오답이 있는 PR은 병합하지 않습니다. -문항 오류나 더 나은 오답 아이디어는 [이슈](https://github.com/midagedev/cachehit/issues)로 알려주세요. +문항 오류나 더 나은 오답 아이디어는 [이슈](https://github.com/midagedev/cachehit/issues)로 알려 주세요. ## 로컬 실행 @@ -98,7 +98,7 @@ node tools/check-links.mjs # 근거 URL이 전부 살아 있는지 (네트워 python3 -m http.server 8000 ``` -퀴즈 흐름 회귀 테스트(선택 — 20문항을 자동으로 풀어 확신도 집계와 결과 화면을 검증): +퀴즈 흐름 회귀 테스트(선택 사항, 20문항을 자동으로 풀어 확신도 집계와 결과 화면을 검증): ```bash npm i -D playwright && npx playwright install chromium diff --git a/assets/app.js b/assets/app.js index 0298e74..4ece2ef 100644 --- a/assets/app.js +++ b/assets/app.js @@ -21,7 +21,7 @@ const TOPIC_LABEL = { } const GRADES = [ - { min: 90, name: 'ORIGIN SHIELD', line: '오리진까지 갈 일이 거의 없군요. 이 정도면 설계를 맡깁니다.' }, + { min: 90, name: 'ORIGIN SHIELD', line: '오리진까지 갈 일이 거의 없군요. 이 정도면 설계를 맡겨도 되겠습니다.' }, { min: 75, name: 'CACHE HIT', line: '대부분 엣지에서 끝냅니다. 실무에서 바로 통하는 수준이에요.' }, { min: 55, name: 'STALE WHILE REVALIDATE', line: '일단 응답은 나갑니다. 뒤에서 조용히 갱신할 부분이 남았네요.' }, { min: 35, name: 'TTL EXPIRED', line: '알던 것들이 조금씩 만료됐습니다. 재검증할 때가 됐어요.' }, @@ -224,7 +224,7 @@ function grade(confidence) { bad: { sure: '틀렸습니다확신했던 문항입니다. 이런 문항이 가장 오래 기억에 남습니다.', unsure: '틀렸습니다', - guess: '틀렸습니다모르는 자리를 찾았습니다. 찍은 것을 정직하게 눌러 두면 결과에서 따로 모아 드립니다.', + guess: '틀렸습니다모르는 부분을 하나 찾았습니다. 찍었다고 정직하게 표시해 두면 결과에서 따로 모아 드립니다.', }, } const verdict = $('#verdict') @@ -339,8 +339,8 @@ function renderResult() { $('#result-rate').innerHTML = `${rate}%` $('#result-grade').textContent = g.name $('#result-line').textContent = lucky - ? `${total}문항 중 ${hits}문항 — 찍어서 맞은 ${lucky}문항을 빼면 ${solidRate}% · ${g.line}` - : `${total}문항 중 ${hits}문항 — ${g.line}` + ? `${total}문항 중 ${hits}문항을 맞혔습니다. 찍어서 맞은 ${lucky}문항을 빼면 ${solidRate}%입니다. ${g.line}` + : `${total}문항 중 ${hits}문항을 맞혔습니다. ${g.line}` // 이전 최고 기록 (재도전 라운드는 기록하지 않는다) if (!state.isRetry) { @@ -413,8 +413,8 @@ function renderResult() { : `틀린 문항 ${weak.length}개 다시 풀기` const text = lucky - ? `나의 캐시 히트율은 ${rate}% — ${g.name}\n찍어서 맞은 걸 빼면 ${solidRate}%\nCDN·이미지·동영상 서빙 퀴즈` - : `나의 캐시 히트율은 ${rate}% — ${g.name}\nCDN·이미지·동영상 서빙 퀴즈` + ? `나의 캐시 히트율은 ${rate}% (${g.name})\n찍어서 맞은 것을 빼면 ${solidRate}%\nCDN·이미지·동영상 서빙 퀴즈` + : `나의 캐시 히트율은 ${rate}% (${g.name})\nCDN·이미지·동영상 서빙 퀴즈` $('#btn-tweet').href = `https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(SITE_URL)}` @@ -462,7 +462,7 @@ function drawCard() { x.fillStyle = '#6f7787' x.font = `400 28px ${F}` x.fillText(lucky - ? `${total}문항 중 ${hits}문항 정답 · 찍은 것 빼면 ${solidRate}%` + ? `${total}문항 중 ${hits}문항 정답 · 찍은 것을 빼면 ${solidRate}%` : `${total}문항 중 ${hits}문항 정답`, 80, 500) // 영역별 미니 바 diff --git a/data/questions/caching.json b/data/questions/caching.json index a645f12..a684152 100644 --- a/data/questions/caching.json +++ b/data/questions/caching.json @@ -58,7 +58,7 @@ "why": "집계 방식은 CDN마다 다르지만, 지표를 의심하기 전에 304 자체가 왕복이라는 점이 먼저 설명한다." } ], - "explanation": "\"캐시 히트\"라는 말이 두 가지를 덮고 있는 것이 함정이다. 저장된 응답을 재검증 없이 바로 쓰는 것과, 재검증해서 304를 받고 쓰는 것은 비용이 다르다. 전자는 0 RTT, 후자는 1 RTT다. 신선도(max-age)를 충분히 주면 재검증 자체가 사라지고, 변경 가능성이 있는 리소스는 immutable + 버전드 URL로 바꾸면 왕복을 없앨 수 있다.", + "explanation": "\"캐시 히트\"라는 말이 서로 다른 두 경우를 뭉뚱그려 가리키는 것이 함정이다. 저장된 응답을 재검증 없이 바로 쓰는 것과, 재검증해서 304를 받고 쓰는 것은 비용이 다르다. 전자는 0 RTT, 후자는 1 RTT다. 신선도(max-age)를 충분히 주면 재검증 자체가 사라지고, 변경 가능성이 있는 리소스는 immutable + 버전드 URL로 바꾸면 왕복을 없앨 수 있다.", "source": "RFC 9111 §4.3 (Validation), RFC 9110 §15.4.5 (304)" }, { @@ -89,7 +89,7 @@ "why": "모든 요청을 오리진으로 보내게 되어 문제를 악화시킨다." } ], - "explanation": "캐시 스탬피드는 만료 시각이 한 점에 모여 있어서 생긴다. 해법은 세 갈래다. 만료 순간을 흩는 것(TTL jitter), 동시 미스를 하나로 합치는 것(request coalescing / collapsed forwarding), 만료 직후에도 낡은 것을 내보내는 것(stale-while-revalidate). 헤더만으로 가능한 건 세 번째다. TTL을 늘리는 건 문제를 미루는 것이지 없애는 게 아니다.", + "explanation": "캐시 스탬피드는 만료 시각이 한 점에 모여 있어서 생긴다. 해법은 세 가지다. 만료 시각을 분산시키는 것(TTL jitter), 동시 미스를 하나로 합치는 것(request coalescing / collapsed forwarding), 만료 직후에도 낡은 것을 내보내는 것(stale-while-revalidate). 헤더만으로 가능한 건 세 번째다. TTL을 늘리는 건 문제를 미루는 것이지 없애는 게 아니다.", "source": "RFC 5861 (stale-while-revalidate, stale-if-error)" }, { @@ -97,7 +97,7 @@ "topic": "caching", "tags": ["s-maxage", "shared-cache", "tiering"], "difficulty": 2, - "question": "엣지에는 하루 동안 캐시시키고 브라우저에는 1분만 캐시시키고 싶다. 표준 Cache-Control 지시자만 쓴다면 어떤 조합인가?", + "question": "엣지에는 하루 동안 캐시되게 하고 브라우저에는 1분만 캐시되게 하고 싶다. 표준 Cache-Control 지시자만 쓴다면 어떤 조합인가?", "options": [ { "text": "max-age=60, s-maxage=86400", @@ -143,7 +143,7 @@ { "text": "Vary: Accept 는 브라우저 캐시에 적용되지 않아 잘못된 포맷이 재사용된다", "distractorType": "one-step-short", - "why": "Vary는 공유 캐시와 브라우저 캐시 양쪽에 적용된다. 위험이 클라이언트 쪽에 있다고 보아 히트율 분화라는 본론을 놓친 것이다." + "why": "Vary는 공유 캐시와 브라우저 캐시 양쪽에 적용된다. 위험이 클라이언트 쪽에 있다고 보아 히트율 분화라는 핵심을 놓친 것이다." }, { "text": "Vary를 쓰면 조건부 요청이 전부 무효화된다", @@ -151,7 +151,7 @@ "why": "Vary와 ETag 기반 재검증은 함께 동작한다. 무효화되지 않는다." } ], - "explanation": "협상 자체가 나쁜 게 아니라 협상 축의 카디널리티가 문제다. Accept는 값의 종류가 사실상 무한에 가깝다. 그래서 실무에서는 세 갈래로 피한다. 서버가 지원 여부를 판단해 쿼리 파라미터로 정규화하거나(당근이 2019년에 택한 방식), 엣지에서 Accept를 webp/avif/jpeg 같은 소수 버킷으로 접거나, 클라이언트가 확장자로 명시하게 한다.", + "explanation": "협상 자체가 나쁜 게 아니라 협상 축의 카디널리티가 문제다. Accept는 값의 종류가 사실상 무한에 가깝다. 그래서 실무에서는 세 가지 방식으로 피한다. 서버가 지원 여부를 판단해 쿼리 파라미터로 정규화하거나(당근이 2019년에 택한 방식), 엣지에서 Accept를 webp/avif/jpeg 같은 소수 버킷으로 묶거나, 클라이언트가 확장자로 명시하게 한다.", "source": "RFC 9110 §12.5.5 (Vary), 당근 테크블로그 「Lambda@Edge 실시간 이미지 리사이즈」(2019)" }, { @@ -174,7 +174,7 @@ { "text": "Age 헤더가 없어 캐시가 응답을 신선하다고 판단한 것이다", "distractorType": "plausible-number", - "why": "Age는 캐시에 머문 시간을 알리는 응답 헤더로, 없다고 신선 판정의 근거가 되지 않는다. 신선도는 수명 계산의 결과다." + "why": "Age는 캐시에 머문 시간을 알리는 응답 헤더라서, 없다는 사실이 신선 판정의 근거가 되지는 않는다. 신선도는 수명 계산의 결과다." }, { "text": "Expires가 없으면 캐시가 무한 TTL로 간주한다", @@ -195,12 +195,12 @@ { "text": "서버 ETag가 W/ 접두어 없는 strong으로 바뀌어 값이 달라졌기 때문이다", "distractorType": "adjacent", - "why": "값이 어긋나서 전체를 보내는 것은 If-Range의 정상 동작이다. 다만 여기서 막힌 곳은 값의 비교가 아니라 그 앞이다 — weak 검증자는 비교 대상으로 받아들여지지도 않는다." + "why": "값이 어긋나서 전체를 보내는 것은 If-Range의 정상 동작이다. 다만 여기서 막힌 지점은 값의 비교가 아니라 그 이전 단계다. weak 검증자는 비교 대상으로 받아들여지지도 않는다." }, { "text": "요청한 범위가 서버의 최소 슬라이스 단위보다 작아 전체로 회귀하기 때문이다", "distractorType": "plausible-number", - "why": "그런 최소 단위 규격은 없다. 200 회귀는 검증자 조건이 맞지 않아서다." + "why": "그런 최소 단위 규격은 없다. 전체를 200으로 내주는 것은 검증자 조건이 맞지 않아서다." }, { "text": "If-Range가 받는 검증자는 strong ETag와 HTTP 날짜뿐이기 때문이다", @@ -213,7 +213,7 @@ "why": "If-Range는 단독으로 쓰는 조건부 범위 요청 장치다. 세트여야 한다는 규칙은 없다." } ], - "explanation": "이어 받은 구간과 새로 받은 구간을 합치려면 둘이 같은 바이트여야 한다. weak ETag는 의미상 같음까지만 보장하므로 If-Range는 strong ETag나 HTTP 날짜만 받고, weak가 오면 서버는 안전한 쪽으로 돌아가 전체 표현을 내준다. 이어받기 대상에는 strong 검증자를 쓰거나, 내용이 바뀌면 URL이 바뀌는 구조로 검증 수요 자체를 없애는 것이 정석이다. 바이트가 다른 조각끼리 섞이는 사고는 이 축을 흐린 채 부분 요청을 설계할 때 터진다.", + "explanation": "이어받은 구간과 새로 받은 구간을 합치려면 둘이 같은 바이트여야 한다. weak ETag는 의미상 같음까지만 보장하므로 If-Range는 strong ETag나 HTTP 날짜만 받고, weak가 오면 서버는 안전한 쪽으로 돌아가 전체 표현을 내준다. 이어받기 대상에는 strong 검증자를 쓰거나, 내용이 바뀌면 URL이 바뀌는 구조로 검증 수요 자체를 없애는 것이 정석이다. 바이트가 다른 조각끼리 섞이는 사고는 이 조건을 살피지 않고 부분 요청을 설계할 때 일어난다.", "source": "RFC 9110 §13.1.5 (If-Range), §8.8 (Validator Fields)" }, { @@ -231,7 +231,7 @@ { "text": "must-revalidate가 있으면 오류 시에도 stale 응답 제공이 금지되기 때문이다", "correct": true, - "why": "must-revalidate는 재검증 성공 없이는 stale을 재사용하지 못하게 하는 강한 제약이라 stale-if-error의 구명 동작을 덮어버린다." + "why": "must-revalidate는 재검증 성공 없이는 stale을 재사용하지 못하게 하는 강한 제약이라, 오류 시 stale을 내주는 stale-if-error의 동작을 무효로 만든다." }, { "text": "stale-while-revalidate가 함께 있어야 장애 시에도 stale이 풀리기 때문이다", @@ -244,7 +244,7 @@ "why": "벤더 동작을 의심하기 전에 표준 규칙상 조합이 이미 막혀 있다. 원인은 정책이 아니라 지시자 충돌이다." } ], - "explanation": "지시자는 많이 넣는다고 겹치는 게 아니다. must-revalidate는 검증 없는 stale 재사용을 금지하는 상한선이라, 오류 때 낡은 값을 내주는 stale-if-error와는 목적이 정면으로 충돌하고 금지 쪽이 이긴다. 장애 시 무정전이 목표라면 must-revalidate를 빼야 한다. 반대로 오래된 값이 사고가 되는 결제·재고 응답이라면 must-revalidate가 맞고, 그 경우 장애 시 에러가 나가는 것을 받아들이고 다른 완충 설계를 해야 한다.", + "explanation": "지시자는 여러 개 넣는다고 효과가 합쳐지는 것이 아니다. must-revalidate는 검증 없는 stale 재사용을 금지하는 상한선이라, 오류 때 낡은 값을 내주는 stale-if-error와는 목적이 정면으로 충돌하고 금지 쪽이 이긴다. 장애 중에도 중단 없이 응답하는 것이 목표라면 must-revalidate를 빼야 한다. 반대로 오래된 값이 사고가 되는 결제·재고 응답이라면 must-revalidate가 맞고, 그 경우 장애 시 에러가 나가는 것을 받아들이고 다른 완충 설계를 해야 한다.", "source": "RFC 9111 §5.2.2.2 (must-revalidate), RFC 5861 §4 (stale-if-error)" }, { @@ -286,27 +286,27 @@ "question": "오리진이 max-age=3600 을 준 응답이 중간 캐시를 거쳐 브라우저에 도착했다. 응답의 Age 헤더는 2400이었다. 브라우저 입장에서 이 응답의 남은 신선도는?", "options": [ { - "text": "3600초다 — 브라우저에 도착한 시점에 수명이 새로 시작되기 때문이다", + "text": "3600초다. 브라우저에 도착한 시점에 수명이 새로 시작되기 때문이다", "distractorType": "one-step-short", "why": "나이가 홉마다 초기화된다고 믿은 것이다. 나이는 오리진으로부터 흐른 시간을 그대로 이어받는다." }, { - "text": "2400초다 — Age 값이 남은 신선도를 뜻하기 때문이다", + "text": "2400초다. Age 값이 남은 신선도를 뜻하기 때문이다", "distractorType": "plausible-number", "why": "이미 경과한 나이와 앞으로 남은 신선도를 바꿔 읽은 것이다. Age는 수명에서 빼야 할 값이다." }, { - "text": "0초다 — Age가 실린 응답은 이미 재검증 대상이기 때문이다", + "text": "0초다. Age가 실린 응답은 이미 재검증 대상이기 때문이다", "distractorType": "overgeneralized", "why": "Age가 0보다 크다고 만료가 아니다. 판정은 Age를 수명과 비교한 결과로만 난다." }, { - "text": "1200초다 — Age는 오리진에서 경과한 나이를 실어 오기 때문이다", + "text": "1200초다. Age는 오리진에서 경과한 나이를 실어 오기 때문이다", "correct": true, - "why": "3600에서 이미 경과한 2400을 빼면 1200이 남는다. 나이 계산은 절대 시계가 아니라 전달받은 나이에 자기 체류분을 더하는 구조다." + "why": "3600에서 이미 경과한 2400을 빼면 1200이 남는다. 나이 계산은 절대 시각 비교가 아니라, 전달받은 나이에 자신이 보관한 시간을 더하는 구조다." } ], - "explanation": "캐시의 나이는 오리진 응답이 태어난 순간부터 흐른 시간이고 Age 헤더가 그 값을 다음 계층으로 전달한다. 하위 캐시는 전달받은 Age에 자기 체류분만 더해 판정하므로 3600 − 2400 = 1200초가 남은 것이다. 이 구조 덕분에 중간 계층이 여러 개여도 신선도 계산이 어긋나지 않고, 서버 시계가 틀어져도 응답에 실린 시간 정보의 합산으로 판정이 유지된다. 운영에서는 Age와 TTL만 있으면 이 노드에서 곧 재검증이 나갈지를 진단할 수 있어 자주 읽게 되는 헤더다.", + "explanation": "캐시의 나이는 오리진 응답이 생성된 순간부터 흐른 시간이고 Age 헤더가 그 값을 다음 계층으로 전달한다. 하위 캐시는 전달받은 Age에 자신이 보관한 시간만 더해 판정하므로 3600 − 2400 = 1200초가 남은 것이다. 이 구조 덕분에 중간 계층이 여러 개여도 신선도 계산이 어긋나지 않고, 서버 시계가 틀어져도 응답에 실린 시간 정보의 합산으로 판정이 유지된다. 운영에서는 Age와 TTL만 있으면 이 노드에서 곧 재검증이 나갈지를 진단할 수 있어 자주 읽게 되는 헤더다.", "source": "RFC 9111 §4.2.3 (Calculating Age), §4.2.1 (Calculating Freshness Lifetime)" }, { @@ -324,7 +324,7 @@ { "text": "요청에 stale-while-revalidate 를 붙여 백그라운드 갱신으로 돌리는 것", "distractorType": "adjacent", - "why": "stale-while-revalidate는 응답 방향 지시고 만료 직후의 갱신 평탄화가 목적이라 오프라인 실패 제어와 다른 문제를 푼다." + "why": "stale-while-revalidate는 응답 방향 지시고 만료 직후의 갱신 요청을 고르게 만드는 것이 목적이라, 오프라인 실패 제어와는 다른 문제를 푼다." }, { "text": "요청에 Cache-Control: only-if-cached 를 붙이는 것", @@ -345,12 +345,12 @@ "topic": "caching", "tags": ["invalidation", "versioned-url", "immutable"], "difficulty": 2, - "question": "배포 때마다 CDN 캐시를 purge하는 운영을 이어오고 있다. 멀티 CDN 이관 검토에서 purge 호출 N벌과 전파 확인 부담이 논의됐다. 이 부담을 구조적으로 줄이는 방향은?", + "question": "배포 때마다 CDN 캐시를 purge하는 운영을 이어오고 있다. 멀티 CDN 이관 검토에서 CDN마다 purge를 따로 호출하고 전파를 확인해야 하는 부담이 논의됐다. 이 부담을 구조적으로 줄이는 방향은?", "options": [ { "text": "콘텐츠가 바뀌면 URL이 바뀌게 하고 TTL을 1년까지 올리는 것", "correct": true, - "why": "바뀐 내용은 새 URL이 되므로 purge가 아니라 참조 전환으로 갱신이 끝난다. 무효화라는 상태ful 운영 자체가 사라진다." + "why": "바뀐 내용은 새 URL이 되므로 purge가 아니라 참조 전환으로 갱신이 끝난다. 무효화라는 stateful 운영 자체가 사라진다." }, { "text": "TTL을 짧게 낮춰 purge가 필요한 국면 자체를 줄이는 것", @@ -360,7 +360,7 @@ { "text": "TTL을 크게 늘리기만 하면 히트율과 무효화 문제가 함께 풀리는 것", "distractorType": "overgeneralized", - "why": "이름이 같은 채 내용이 바뀌는 콘텐츠에 긴 TTL은 오히려 위험하다. 히트율 상한을 가두는 것은 별개의 축이다." + "why": "이름이 같은 채 내용이 바뀌는 콘텐츠에 긴 TTL은 오히려 위험하다. 히트율의 상한을 정하는 요인은 TTL과는 별개의 문제다." }, { "text": "s-maxage를 써서 브라우저만 짧게 가져가 purge 대상을 없애는 것", @@ -368,7 +368,7 @@ "why": "s-maxage는 공유 캐시용 값이라 방향이 반대고, purge 대상인 엣지 캐시는 그대로 남는다." } ], - "explanation": "이름이 같은 채 내용이 바뀌는 순간 무효화라는 상태ful 운영이 태어난다. 내용 해시를 파일명에 넣으면 바뀐 내용은 새 URL이 되고 참조하는 문서만 갱신하면 된다. 이 구조에서는 TTL을 1년까지 줄 수 있다. immutable(RFC 8246)을 얹으면 리로드 재검증까지 줄지만, 이 지시를 기본 지원하는 주요 브라우저가 사실상 없어 실효는 제한적이다. HTML처럼 이름을 못 바꾸는 자원만 no-cache로 재검증을 맡기는 2층 구조가 정석이고, 이것이 번들러 해시 파일명이 업계 표준이 된 이유다.", + "explanation": "이름이 같은 채 내용이 바뀌는 순간 무효화라는 stateful 운영이 생긴다. 내용 해시를 파일명에 넣으면 바뀐 내용은 새 URL이 되고 참조하는 문서만 갱신하면 된다. 이 구조에서는 TTL을 1년까지 줄 수 있다. immutable(RFC 8246)을 얹으면 리로드 재검증까지 줄지만, 이 지시를 기본 지원하는 주요 브라우저가 사실상 없어 실효는 제한적이다. HTML처럼 이름을 못 바꾸는 자원만 no-cache로 재검증을 맡기는 2층 구조가 정석이고, 이것이 번들러 해시 파일명이 업계 표준이 된 이유다.", "source": "RFC 8246 (HTTP Immutable Responses), Google Cloud CDN·AWS CloudFront 공식 무효화 API 문서(벤더별 단위·전파 상이)" }, { @@ -430,7 +430,7 @@ "why": "저장 회피는 크기와 무관하다. 원인은 용량이 아니라 캐시 구현의 기본 정책이다." } ], - "explanation": "공유 캐시 입장에서 쿠키가 얹힌 응답은 개인화된 것이다. 표준은 이것을 금지하지 않는다 — RFC 9111 §7.3은 Set-Cookie가 캐싱을 억제하지 않는다고 명시한다. 저장을 피하는 것은 구현의 기본값이며(nginx는 Set-Cookie가 있으면 캐시하지 않고 proxy_ignore_headers로만 해제된다), 결과적으로 히트율이 바닥난다. 벤더마다 기본값이 다르니 우리 앞단의 동작을 문서로 확인해야 한다. 해법은 헤더를 더 붙이는 것이 아니라 캐시 대상 경로에서 쿠키를 아예 내리지 않는 구조다. 세션 발급을 별도 경로로 옮기거나 엣지에서 쿠키 축을 분리하는 식이다. 쿠키를 캐시 키에 넣는 것도 값이 사용자별로 갈라지는 최악의 선택이라 피해야 한다.", + "explanation": "공유 캐시 입장에서 쿠키가 얹힌 응답은 개인화된 것이다. 표준이 이것을 금지하는 것은 아니다. RFC 9111 §7.3은 Set-Cookie가 캐싱을 억제하지 않는다고 명시한다. 저장을 피하는 것은 구현의 기본값이며(nginx는 Set-Cookie가 있으면 캐시하지 않고 proxy_ignore_headers로만 해제된다), 결과적으로 히트율이 0 가까이 떨어진다. 벤더마다 기본값이 다르니 우리 앞단의 동작을 문서로 확인해야 한다. 해법은 헤더를 더 붙이는 것이 아니라 캐시 대상 경로에서 쿠키를 아예 내리지 않는 구조다. 세션 발급을 별도 경로로 옮기거나 엣지에서 쿠키 축을 분리하는 식이다. 쿠키를 캐시 키에 넣는 것도 값이 사용자별로 갈라지는 최악의 선택이라 피해야 한다.", "source": "nginx 공식 문서 ngx_http_proxy_module(Set-Cookie가 있으면 응답을 캐시하지 않는다); RFC 9111 §7.3 (Caching of Sensitive Information — 표준은 Set-Cookie로 캐싱을 금지하지 않는다)" }, { @@ -443,7 +443,7 @@ { "text": "두 서버의 ETag가 strong과 weak으로 종류가 엇갈려 매칭이 안 되기 때문이다", "distractorType": "adjacent", - "why": "불일치의 원인은 검증자 종류가 아니라 값 자체가 서버마다 다른 것이다. 종류 혼동으로 진단을 돌렸다." + "why": "불일치의 원인은 검증자 종류가 아니라 값 자체가 서버마다 다른 것이다. 종류의 문제로 잘못 진단한 것이다." }, { "text": "검증자가 인스턴스마다 다르게 생성되어 조건부 요청이 매번 어긋나기 때문이다", @@ -474,7 +474,7 @@ { "text": "Vary를 붙이면 헤더 축만큼 키가 늘어나 압축 응답에서는 피할 수 없기 때문이다", "distractorType": "one-step-short", - "why": "압축 응답에는 Vary: Accept-Encoding이 필요한 올바른 설정이다. 문제는 축 추가 자체이기보다 값이 접히지 않은 것이다." + "why": "압축 응답에는 Vary: Accept-Encoding이 필요한 올바른 설정이다. 문제는 축이 늘어난 것 자체가 아니라 값이 정규화되지 않은 것이다." }, { "text": "CDN이 Vary를 무시해 서로 다른 인코딩 응답이 하나로 섞이기 때문이다", @@ -484,7 +484,7 @@ { "text": "CDN이 Accept-Encoding 원문을 정규화하지 않은 채 캐시 키에 넣고 있기 때문이다", "correct": true, - "why": "원문 문자열은 사실상 유니크하다. 지원 인코딩 집합으로 접지 않으면 같은 내용의 변형이 문자열 수만큼 쌓인다." + "why": "원문 문자열은 사실상 요청마다 다르다. 지원 인코딩 집합으로 정규화하지 않으면 같은 내용의 변형이 문자열 수만큼 쌓인다." }, { "text": "q값 0.9와 1.0이 서로 다른 압축 결과물을 만들어 내기 때문이다", @@ -492,7 +492,7 @@ "why": "변형의 수는 인코딩 종류의 수지 q값 조합의 수가 아니다. 같은 인코딩이면 바이트도 같다." } ], - "explanation": "압축된 응답은 요청한 인코딩에 따라 바이트가 달라지므로 Vary: Accept-Encoding은 필요하고 흔한 올바른 설정이다. 문제는 Accept-Encoding 원문이 q값과 순서, 나열까지 브라우저마다 달라 사실상 유니크하다는 점이다. 지원 인코딩 집합으로 접어 키에 넣는 정규화를 CDN이 해 주는지가 히트율을 가른다. 대상 CDN의 정규화 동작을 확인하는 것이 이 설정의 마지막 단계다.", + "explanation": "압축된 응답은 요청한 인코딩에 따라 바이트가 달라지므로 Vary: Accept-Encoding은 필요하고 흔한 올바른 설정이다. 문제는 Accept-Encoding 원문이 q값과 순서, 나열까지 브라우저마다 달라 사실상 전부 제각각이라는 점이다. 지원 인코딩 집합으로 정규화해 키에 넣는 처리를 CDN이 해 주는지가 히트율을 가른다. 대상 CDN의 정규화 동작을 확인하는 것이 이 설정의 마지막 단계다.", "source": "RFC 9110 §12.5.5 (Vary)" }, { @@ -500,30 +500,30 @@ "topic": "caching", "tags": ["ttl", "eviction", "hit-rate"], "difficulty": 3, - "question": "정적 객체의 TTL을 1시간에서 하루로, 다시 일주일로 올렸다. 히트율은 하루 구간까지 오르다가 그 뒤로는 일주일을 줘도 제자리다. 신규 업로드는 거의 없고 요청 대상 객체 집합도 고정되어 있다. 이 구간의 히트율을 가두고 있는 것은?", + "question": "정적 객체의 TTL을 1시간에서 하루로, 다시 일주일로 올렸다. 히트율은 하루 구간까지 오르다가 그 뒤로는 일주일을 줘도 제자리다. 신규 업로드는 거의 없고 요청 대상 객체 집합도 고정되어 있다. 이 구간에서 히트율이 더 오르지 못하게 막는 것은?", "options": [ { - "text": "최초 요청이다 — 남은 미스는 전부 처음 보는 객체의 콜드 미스라 늘어나지 않는 것이다", + "text": "최초 요청이다. 남은 미스는 전부 처음 보는 객체의 콜드 미스라 늘어나지 않는 것이다", "distractorType": "one-step-short", "why": "롱테일은 한 번 저장돼도 밀려났다 다시 요청되는 반복 미스를 낸다. 처음 보는 요청만 남은 것이 아니다." }, { - "text": "축출이다 — 용량이 롱테일 워킹셋보다 작아 꼬리 객체가 밀려나며 반복 미스되는 것이다", + "text": "축출이다. 용량이 롱테일 워킹셋보다 작아 꼬리 객체가 밀려나며 반복 미스되는 것이다", "correct": true, - "why": "TTL은 신선 축의 이야기일 뿐 저장이 유지됨을 보증하지 않는다. 히트율의 상한은 요청 분포와 캐시 용량에서 나온다." + "why": "TTL은 신선도를 정하는 값일 뿐 저장이 유지된다고 보증하지 않는다. 히트율의 상한은 요청 분포와 캐시 용량에서 나온다." }, { "text": "브라우저 캐시가 같은 헤더로 길게 잡혀 엣지 도달 자체를 줄여버리는 것이다", "distractorType": "adjacent", - "why": "히트율의 분자분모는 엣지 기준인데 브라우저 축을 섞었다. 미스의 원인은 엣지 내부에 있다." + "why": "히트율의 분자와 분모는 엣지 기준인데 브라우저 캐시 이야기를 끌어온 것이다. 미스의 원인은 엣지 내부에 있다." }, { - "text": "캐시 불가 응답이다 — 남은 미스 대상이 no-store 응답이라 애초에 저장이 안 되는 것이다", + "text": "캐시 불가 응답이다. 남은 미스 대상이 no-store 응답이라 애초에 저장이 안 되는 것이다", "distractorType": "overgeneralized", "why": "정적 객체 대부분이 저장 불가라는 전제는 성립하지 않는다. 남은 미스의 실체는 축출이다." } ], - "explanation": "TTL을 올리면 신선 기간이 늘어나지만, 저장이 유지되는지는 별개다. 히트율의 상한은 요청 분포와 캐시 용량의 관계에서 나온다. 롱테일 전체를 담으려면 워킹셋보다 큰 용량이 필요한데 모자라면 꼬리 객체는 들어왔다 밀려나기를 반복하며 TTL이 남아 있어도 미스가 난다. 이 지점부터는 TTL을 더 올리는 대신 키 정규화로 같은 객체를 합치거나 용량과 축출 정책을 보는 것이 맞는 축이다. 콜드 미스와 축출 미스는 로그를 나눠 봐야 구분된다.", + "explanation": "TTL을 올리면 신선 기간이 늘어나지만, 저장이 유지되는지는 별개다. 히트율의 상한은 요청 분포와 캐시 용량의 관계에서 나온다. 롱테일 전체를 담으려면 워킹셋보다 큰 용량이 필요한데 모자라면 꼬리 객체는 들어왔다 밀려나기를 반복하며 TTL이 남아 있어도 미스가 난다. 이 지점부터는 TTL을 더 올리는 대신 키 정규화로 같은 객체를 합치거나 용량과 축출 정책을 살펴보는 것이 맞다. 콜드 미스와 축출 미스는 로그를 나눠 봐야 구분된다.", "source": "AWS CloudFront·Google Cloud CDN 공식 문서(캐시 용량·축출 정책은 TTL과 무관하게 객체를 밀어냄), RFC 9111 §4.2 (신선도는 재사용 조건이지 저장 보증이 아님)" }, { @@ -554,7 +554,7 @@ "why": "키 문제처럼 보이지만 저장 자체가 규범으로 막힌 것이 먼저고, 인증 값을 키에 넣는 방식도 개인화 캐시의 해법이 아니다." } ], - "explanation": "공유 캐시가 인증 문맥의 응답을 다른 사용자에게 재사용하는 일을 막으려고 표준은 Authorization이 붙은 요청의 응답을 기본 저장 금지로 둔다. 예외는 응답이 public, s-maxage, must-revalidate 중 하나를 명시할 때뿐인데, 개인화 응답에 이를 붙이면 다른 사용자에게 재사용되는 역방향 사고가 된다. 캐시 헤더가 없다고 휴리스틱이 적용될 여지도 이 저장 금지가 우선해 가로막힌다. 인가와 캐싱을 함께 쓰려면 재사용 가능한 축과 개인 축을 나누고 인가를 URL 서명이나 별도 경로로 옮기는 것이 구조적 해법이다.", + "explanation": "공유 캐시가 인증 문맥의 응답을 다른 사용자에게 재사용하는 일을 막으려고 표준은 Authorization이 붙은 요청의 응답을 기본 저장 금지로 둔다. 예외는 응답이 public, s-maxage, must-revalidate 중 하나를 명시할 때뿐인데, 개인화 응답에 이를 붙이면 다른 사용자에게 재사용되는 정반대 방향의 사고가 된다. 캐시 헤더가 없어도 휴리스틱 신선도가 적용될 여지는 없다. 이 저장 금지 규칙이 우선하기 때문이다. 인가와 캐싱을 함께 쓰려면 재사용 가능한 축과 개인 축을 나누고 인가를 URL 서명이나 별도 경로로 옮기는 것이 구조적 해법이다.", "source": "RFC 9111 §3 (Storing Responses in Caches — Authorization 저장 규칙)" }, { @@ -585,7 +585,7 @@ "why": "특정 벤더의 오래된 동작을 일반 규칙처럼 믿은 것이다. 그런 충돌 규칙은 규격에 없다." } ], - "explanation": "Pragma는 요청 방향으로만 의미가 정의된 HTTP/1.0 잔재고, 응답에 실어도 동작은 구현 마음이다. Cache-Control이 자리 잡은 지금은 1.0 클라이언트가 실제로 존재하는 경로가 확인된 경우에만 남길 가치가 있다. 이중 지시의 실제 비용은 헤더 증가가 아니라 '이 헤더가 뭘 하는지'에 대한 팀의 오해가 누적되는 것이다 — no-cache와 no-store조차 섞어 쓰는 팀이라면 더 그렇다.", + "explanation": "Pragma는 요청 방향으로만 의미가 정의된 HTTP/1.0 잔재고, 응답에 실어도 어떻게 동작할지는 구현에 달려 있다. Cache-Control이 자리 잡은 지금은 1.0 클라이언트가 실제로 존재하는 경로가 확인된 경우에만 남길 가치가 있다. 이중 지시의 실제 비용은 헤더 증가가 아니라 '이 헤더가 뭘 하는지'에 대한 팀의 오해가 누적되는 것이다. no-cache와 no-store조차 섞어 쓰는 팀이라면 더 그렇다.", "source": "RFC 9111 §5.4 (Pragma), MDN Web Docs (Pragma)" }, { @@ -616,7 +616,7 @@ "why": "존재하지 않는 절충 규칙이다. 수명 계산은 우선순위에 따라 한 값을 고를 뿐이다." } ], - "explanation": "신선 수명 계산의 순서는 정해져 있다 — 공유 캐시면 s-maxage, 그다음 max-age, 둘 다 없어야 Expires에서 Date를 뺀 값이 쓰인다. 이 우선순위는 실무에서 자주 걸린다. 중간 장비가 Expires만 다시 쓰는 구성은 아무 효과가 없고, 헤더를 정리할 때도 어느 쪽이 지배하는지 알아야 잘라낼 값을 고를 수 있다. '짧은 쪽이 안전'은 그럴듯하지만 규격이 아니라서, 그렇게 구현된 일부 장비와 규격 준수 캐시가 어긋나는 원인이 되기도 한다.", + "explanation": "신선 수명 계산의 순서는 정해져 있다. 공유 캐시면 s-maxage, 그다음 max-age, 둘 다 없어야 Expires에서 Date를 뺀 값이 쓰인다. 이 우선순위는 실무에서 자주 문제가 된다. 중간 장비가 Expires만 다시 쓰는 구성은 아무 효과가 없고, 헤더를 정리할 때도 어느 쪽이 지배하는지 알아야 잘라낼 값을 고를 수 있다. '짧은 쪽이 안전'은 그럴듯하지만 규격이 아니라서, 그렇게 구현된 일부 장비와 규격 준수 캐시가 어긋나는 원인이 되기도 한다.", "source": "RFC 9111 §4.2.1 (Calculating Freshness Lifetime), §5.3 (Expires)" }, { @@ -627,27 +627,27 @@ "question": "캐시된 문서의 재검증 요청에 If-None-Match: \"v2\" 와 If-Modified-Since: (어제 09:00) 이 함께 실렸다. 오리진의 현재 상태는 ETag \"v3\", 마지막 수정은 그저께다. 규격대로 서버가 내놓을 응답은?", "options": [ { - "text": "304 — 마지막 수정 시각이 조건보다 이전이라 변경이 없었다는 조건이 성립한다", + "text": "304: 마지막 수정 시각이 조건보다 이전이라 변경이 없었다는 조건이 성립한다", "distractorType": "overgeneralized", "why": "두 검증자를 병렬로 평가한다고 믿은 것이다. 규격은 검증자가 있으면 날짜 조건을 아예 보지 않는다." }, { - "text": "412 — 검증자와 날짜 조건이 서로 어긋나 전제조건 실패로 응답된다", + "text": "412: 검증자와 날짜 조건이 서로 어긋나 전제조건 실패로 응답된다", "distractorType": "adjacent", "why": "412는 상태 변경 메서드에서 If-Match 계열이 실패할 때의 응답이다. GET 재검증 실패의 모양새가 아니다." }, { - "text": "304 — 마지막 수정 시각이 검증자보다 신뢰할 수 있는 정보라 날짜 쪽이 우선한다", + "text": "304: 마지막 수정 시각이 검증자보다 신뢰할 수 있는 정보라 날짜 쪽이 우선한다", "distractorType": "inverted", "why": "우선순위의 방향을 뒤집었다. 검증자가 있는 이상 날짜 조건은 평가에서 밀려난다." }, { - "text": "200 — 검증자 불일치로 조건이 실패해 본문을 내주고, If-Modified-Since는 평가에서 빠진다", + "text": "200: 검증자 불일치로 조건이 실패해 본문을 내주고, If-Modified-Since는 평가에서 빠진다", "correct": true, "why": "If-None-Match가 있으면 If-Modified-Since는 무시된다. 검증자 불일치가 조건 실패를 확정하므로 본문이 다시 나간다." } ], - "explanation": "조건부 검증자의 우선순위는 규격이 정한다 — If-None-Match가 있으면 If-Modified-Since는 무시된다. ETag가 우선하는 까닭은 날짜 조건이 같은 초 안의 수정을 구분 못 하는 데 비해 검증자는 그 모호함을 덮기 때문이다. 조건 실패의 응답은 메서드에 따라 갈린다. GET/HEAD 재검증이면 200 본문이 다시 가고, 상태 변경 메서드의 조건 실패는 412다. 캐시가 두 헤더를 '보험 삼아' 같이 싣는 일은 흔해서, 이 우선순위를 모르면 '수정한 적 없는데 200이 돌아온다'는 오진이 팀에 생긴다.", + "explanation": "조건부 검증자의 우선순위는 규격이 정한다. If-None-Match가 있으면 If-Modified-Since는 무시된다. ETag가 우선하는 까닭은 날짜 조건이 같은 초 안의 수정을 구분 못 하는 데 비해 검증자는 그 모호함을 해소하기 때문이다. 조건 실패의 응답은 메서드에 따라 갈린다. GET/HEAD 재검증이면 200 본문이 다시 가고, 상태 변경 메서드의 조건 실패는 412다. 캐시가 두 헤더를 '보험 삼아' 같이 싣는 일은 흔해서, 이 우선순위를 모르면 '수정한 적 없는데 200이 돌아온다'는 오진이 팀에 생긴다.", "source": "RFC 9110 §13.1.2 (If-None-Match), §13.2.2 (Precedence of Preconditions), MDN Web Docs (If-None-Match)" }, { @@ -678,7 +678,7 @@ "why": "신선 수명은 max-age가 정한다. immutable은 수명을 늘리는 지시가 아니라 재검증 습관을 줄이는 지시다." } ], - "explanation": "immutable은 RFC 8246(Standards Track, 2017)으로 표준화된 지시지만 구현은 선택이고, 효과도 한정적이다 — fresh 기간에 사용자가 리로드를 해도 재검증을 생략하게 하는 것뿐이다. 표준 문서가 있다는 것이 곧 동작 보장은 아니다: 이 지시를 기본 지원하는 주요 브라우저가 사실상 없다. 해시 파일명과 1년 max-age 조합의 설계 자체는 여전히 옳지만, '표준 보장'이라고 적어 두면 지원하지 않는 브라우저에서 기대가 어긋났을 때 원인 진단이 늦어진다. 신선 수명을 정하는 것은 어디까지나 max-age라는 점도 함께 못박아 둔다. 문서에는 '지원하는 브라우저에서 리로드 재검증을 줄여 주는 표준 지시' 정도로 정확하게 쓰는 것이 관리 가능하다.", + "explanation": "immutable은 RFC 8246(Standards Track, 2017)으로 표준화된 지시지만 구현은 선택이고, 효과도 한정적이다. fresh 기간에 사용자가 리로드를 해도 재검증을 생략하게 하는 것뿐이다. 표준 문서가 있다는 것이 곧 동작 보장은 아니다: 이 지시를 기본 지원하는 주요 브라우저가 사실상 없다. 해시 파일명과 1년 max-age 조합의 설계 자체는 여전히 옳지만, '표준 보장'이라고 적어 두면 지원하지 않는 브라우저에서 기대가 어긋났을 때 원인 진단이 늦어진다. 신선 수명을 정하는 것은 어디까지나 max-age라는 점도 함께 못박아 둔다. 문서에는 '지원하는 브라우저에서 리로드 재검증을 줄여 주는 표준 지시' 정도로 정확하게 써 두는 것이 나중에 관리하기 좋다.", "source": "RFC 8246 (HTTP Immutable Responses), RFC 9111 §5.2.2.1 (max-age)" }, { @@ -696,7 +696,7 @@ { "text": "무효화 대상 지정이 캐시 키 정규화 규칙과 어긋나 일부 URL 변형만 지워졌다", "distractorType": "adjacent", - "why": "purge가 변형을 놓치는 실재하는 사고 유형이지만, 이 증상은 변형이 아니라 리전을 따라 남아 있다." + "why": "purge가 URL 변형을 놓치는 일은 실제로 있는 사고 유형이지만, 이 증상은 변형 단위가 아니라 리전 단위로 남아 있다." }, { "text": "이 CDN은 200을 반환하고도 purge를 수행하지 않는 알려진 결함이 있다", @@ -709,7 +709,7 @@ "why": "purge는 즉시 제거고 TTL은 제거와 무관한 신선도 축이다. 두 축을 뒤집어 붙였다." } ], - "explanation": "purge API의 성공 응답은 '접수됨'이지 '전역 완료'가 아니다. 무효화는 수백 개 POP에 비동기로 퍼지고 전파 창이 수 분 생긴다. 이 창이 사고가 되는 조직은 긴급성 있는 자원을 purge 의존 설계에 올려둔 곳이다 — 내용이 바뀌면 URL이 바뀌는 버전 체계로 옮기면 수정·롤백이 참조 전환만으로 끝나고 무효화라는 상태ful 운영 자체가 사라진다. 멀티 CDN이면 벤더마다 API·대상 단위·전파 SLA가 달라 이 창은 더 커지고 관리 지점도 N벌이 된다.", + "explanation": "purge API의 성공 응답은 '접수됨'이지 '전역 완료'가 아니다. 무효화는 수백 개 POP에 비동기로 퍼지고 전파 창이 수 분 생긴다. 이 창이 사고가 되는 조직은 긴급성 있는 자원을 purge에 의존하는 설계에 맡겨 둔 곳이다. 내용이 바뀌면 URL이 바뀌는 버전 체계로 옮기면 수정·롤백이 참조 전환만으로 끝나고 무효화라는 stateful 운영 자체가 사라진다. 멀티 CDN이면 벤더마다 API·대상 단위·전파 SLA가 달라 이 창은 더 커지고 관리 지점도 N벌이 된다.", "source": "AWS CloudFront·Google Cloud CDN 공식 무효화 API 문서 (전파 지연·비동기 처리)" }, { @@ -727,7 +727,7 @@ { "text": "206 응답은 불완전한 부분 응답이라 캐시 원칙상 저장 대상이 아니다", "distractorType": "adjacent", - "why": "부분 응답도 저장하고 결합하도록 허용된다. 저장 불가 규칙을 잘못된 자리로 끌어왔다." + "why": "부분 응답도 저장하고 결합하도록 허용된다. 저장 불가 규칙을 엉뚱한 곳에 적용한 것이다." }, { "text": "미스 때 오리진에 요청 범위를 그대로 프록시하는 것이 규격상 의무다", @@ -787,22 +787,22 @@ "why": "캐시가 다른 UA의 응답을 재사용하게 되어 잘못된 마크업이 나갈 수 있다. 원인을 고친 게 아니라 감춘 것이다." }, { - "text": "TTL을 크게 올려 늘어난 각 UA 변형이라도 오래 살게 둔다", + "text": "TTL을 크게 올려 늘어난 각 UA 변형이라도 오래 남게 둔다", "distractorType": "overgeneralized", - "why": "변형 수 자체는 줄지 않는다. 키 폭발을 상환할 뿐이고 변경 반영 지연까지 커진다." + "why": "변형 수 자체는 줄지 않는다. 키 폭발의 비용을 뒤로 미룰 뿐이고 변경 반영 지연까지 커진다." }, { - "text": "주요 CDN은 UA를 알아서 접어 주니 현재 설정을 유지한다", + "text": "주요 CDN은 UA를 알아서 정규화해 주니 현재 설정을 유지한다", "distractorType": "vendor-mixup", - "why": "정규화 여부는 제품마다 다르고 기본 동작으로 가정할 수 없다. 확인 전의 방치는 선택지가 아니다." + "why": "정규화 여부는 제품마다 다르고 기본 동작으로 가정할 수 없다. 확인하지 않은 채 그대로 두는 것은 선택지가 아니다." }, { - "text": "UA를 소수 버킷으로 접어 주거나, 협상 축을 별도 URL로 분리한다", + "text": "UA를 소수 버킷으로 정규화하거나, 협상 축을 별도 URL로 분리한다", "correct": true, - "why": "UA 원문은 사실상 요청별 유니크라 버킷 없이는 키가 폭발한다. 축을 식별 가능한 소수로 접는 것이 구조적 해법이다." + "why": "UA 원문은 사실상 요청마다 달라 버킷으로 묶지 않으면 키가 폭발한다. 축을 식별 가능한 소수로 정규화하는 것이 구조적 해법이다." } ], - "explanation": "UA 문자열은 브라우저 빌드·버전·디바이스·패치 수준까지 갈라져 사실상 무한에 가깝고, 원문이 키에 들어가면 요청 단위로 캐시가 갈라진다. 실무의 해법은 축을 접는 것이다 — 엣지에서 모바일/데스크톱 같은 소수 버킷으로 정규화하거나, m.서브도메인이나 별도 경로처럼 URL 축으로 아예 분리하는 쪽이다. Vary에서 UA를 빼는 것은 다른 UA에게 잘못된 변형을 내주는 다른 결함을 여는 것이므로 답이 아니다. 협상 결과가 두세 세트뿐이라면 버킷 수도 그만큼으로 끝난다.", + "explanation": "UA 문자열은 브라우저 빌드·버전·디바이스·패치 수준까지 갈라져 사실상 무한에 가깝고, 원문이 키에 들어가면 요청 단위로 캐시가 갈라진다. 실무의 해법은 축을 소수로 줄이는 것이다. 엣지에서 모바일/데스크톱 같은 소수 버킷으로 정규화하거나, m.서브도메인이나 별도 경로처럼 URL 축으로 아예 분리하는 쪽이다. Vary에서 UA를 빼는 것은 다른 UA에게 잘못된 변형을 내주는 다른 결함을 만드는 것이므로 답이 아니다. 협상 결과가 두세 세트뿐이라면 버킷 수도 그만큼으로 끝난다.", "source": "RFC 9110 §12.5.5 (Vary), MDN Web Docs (Browser detection using the user agent)" }, { @@ -813,27 +813,27 @@ "question": "이미지 API에서 엣지에는 만료된 응답을 절대 내주지 않으면서 신선한 동안의 엣지 히트는 그대로 두고, 브라우저는 네트워크가 끊긴 동안 자기 캐시를 계속 쓸 수 있게 하려 한다. 이 요구를 만족시키는 표준 지시는?", "options": [ { - "text": "proxy-revalidate — 공유 캐시에만 만료 후 재검증을 강제한다", + "text": "proxy-revalidate: 공유 캐시에만 만료 후 재검증을 강제한다", "correct": true, "why": "재검증 상한이 엣지에만 닿는다. 브라우저의 stale 사용 정책은 규격상 그대로 남는다." }, { - "text": "must-revalidate — 공유 캐시에만 걸리는 상한이라 브라우저의 stale 사용은 남는다", + "text": "must-revalidate: 공유 캐시에만 걸리는 상한이라 브라우저의 stale 사용은 남는다", "distractorType": "adjacent", - "why": "must-revalidate는 계층을 가리지 않는다. 공유 캐시 한정판인 proxy-revalidate가 따로 있는 이유가 그 차이다." + "why": "must-revalidate는 계층을 가리지 않는다. 공유 캐시 전용인 proxy-revalidate가 따로 있는 이유가 그 차이다." }, { - "text": "no-cache — 만료 후에만 재검증을 요구하므로 신선한 동안의 엣지 히트는 남는다", + "text": "no-cache: 만료 후에만 재검증을 요구하므로 신선한 동안의 엣지 히트는 남는다", "distractorType": "inverted", "why": "no-cache는 만료 여부와 무관하게 재사용 전 재검증을 요구한다. 신선한 동안의 히트가 남지 않는다." }, { - "text": "s-maxage=0 — 공유 캐시의 만료 후 재검증만 강제하고 신선 기간은 max-age가 정한다", + "text": "s-maxage=0: 공유 캐시의 만료 후 재검증만 강제하고 신선 기간은 max-age가 정한다", "distractorType": "one-step-short", "why": "s-maxage=0은 공유 캐시의 신선 기간을 0으로 만든다. s-maxage가 있으면 max-age는 공유 캐시에 닿지 않는다." } ], - "explanation": "proxy-revalidate는 must-revalidate의 공유 캐시 한정판이다. 만료 후 재검증 강제가 엣지에만 닿아서, 브라우저의 오프라인 재사용이나 이동 사이의 관대한 stale 사용은 그대로 남는다. 이런 계층 비대칭 요구는 실제로 자주 나온다 — 가격이나 재고처럼 엣지가 엄격해야 하는 축과 사용자의 최근 화면처럼 느긋해도 되는 축이 같은 서비스 안에 공존한다. s-maxage=0도 엣지 stale을 막지만 신선 기간을 희생하므로 오리진 검증이 매 요청으로 늘어나는 반대 극단이다.", + "explanation": "proxy-revalidate는 must-revalidate를 공유 캐시로 한정한 지시다. 만료 후 재검증 강제가 엣지에만 닿아서, 브라우저의 오프라인 재사용이나 이동 사이의 관대한 stale 사용은 그대로 남는다. 이런 계층 비대칭 요구는 실제로 자주 나온다. 가격이나 재고처럼 엣지가 엄격해야 하는 축과 사용자의 최근 화면처럼 느긋해도 되는 축이 같은 서비스 안에 공존한다. s-maxage=0도 엣지 stale을 막지만 신선 기간을 희생하므로 오리진 검증이 매 요청으로 늘어나는 반대 극단이다.", "source": "RFC 9111 §5.2.2.8 (proxy-revalidate), §5.2.2.2 (must-revalidate)" }, { @@ -841,7 +841,7 @@ "topic": "caching", "tags": ["stampede", "request-collapsing", "origin-shield"], "difficulty": 2, - "question": "인기 객체 하나의 TTL이 막 지났다. 같은 객체로 200건의 요청이 엣지에 거의 동시에 들어왔다. 이 CDN에는 request collapsing이 켜져 있다. 이 요청들의 운명은?", + "question": "인기 객체 하나의 TTL이 막 지났다. 같은 객체로 200건의 요청이 엣지에 거의 동시에 들어왔다. 이 CDN에는 request collapsing이 켜져 있다. 이 요청들은 어떻게 처리되는가?", "options": [ { "text": "요청 각자가 저장된 낡은 응답을 즉시 받고 갱신은 뒤에서 진행된다", @@ -851,7 +851,7 @@ { "text": "만료 시각에 무작위 지터가 부여돼 재검증이 순차적으로 분산된다", "distractorType": "adjacent", - "why": "만료 시각을 흩는 것은 TTL 지터의 동작이다. 이미 들어온 요청을 합치는 것과 다른 층이다." + "why": "만료 시각을 분산시키는 것은 TTL 지터의 동작이다. 이미 들어온 요청을 합치는 것과 다른 층이다." }, { "text": "동시 미스는 설정과 무관하게 전부 오리진으로 그대로 나간다", @@ -864,7 +864,7 @@ "why": "동시 요청이 하나로 병합되어 오리진에는 재검증 한 건만 나간다. 오리진 보호가 이 기능의 목적이다." } ], - "explanation": "스탬피드 완화책은 셋이고 각자 바꾸는 대상이 다르다 — collapsing은 동시 요청을 하나로 합치고, TTL 지터는 만료 시각을 흩으며, stale-while-revalidate는 재검증이 돌아올 동안 낡은 것을 내준다. collapsing의 대가는 첫 결과가 돌아올 때까지 뒤 요청의 지연이 늘어나는 것이다. 오리진 입장의 스파이크 높이와 사용자 입장의 꼬리 지연이 오가는 트레이드오프다. 기능 이름과 기본값은 벤더마다 다르므로(Fastly의 request collapsing, CloudFront의 Origin Shield 등) 도입 전에 우리 CDN의 동작 문서를 확인하는 것이 전제다.", + "explanation": "스탬피드 완화책은 세 가지고 각자 바꾸는 대상이 다르다. collapsing은 동시 요청을 하나로 합치고, TTL 지터는 만료 시각을 분산시키며, stale-while-revalidate는 재검증이 돌아올 동안 낡은 것을 내준다. collapsing의 대가는 첫 결과가 돌아올 때까지 뒤 요청의 지연이 늘어나는 것이다. 오리진 입장의 스파이크 높이와 사용자 입장의 꼬리 지연을 맞바꾸는 트레이드오프다. 기능 이름과 기본값은 벤더마다 다르므로(Fastly의 request collapsing, CloudFront의 Origin Shield 등) 도입 전에 우리 CDN의 동작 문서를 확인하는 것이 전제다.", "source": "Fastly 공식 문서 (Request Collapsing), AWS CloudFront 공식 문서 (Origin Shield)" }, { @@ -872,30 +872,30 @@ "topic": "caching", "tags": ["no-store", "private", "shared-cache"], "difficulty": 1, - "question": "1회용 인증 링크를 담은 짧은 응답을 설계 중이다. 요구는 두 가지다 — 공유 캐시에 남지 않을 것, 그리고 공용 PC의 브라우저 캐시에도 남지 않을 것. Cache-Control 한 줄로 옳은 선택은?", + "question": "1회용 인증 링크를 담은 짧은 응답을 설계 중이다. 요구는 두 가지다. 공유 캐시에 남지 않아야 하고, 공용 PC의 브라우저 캐시에도 남지 않아야 한다. Cache-Control 한 줄로 옳은 선택은?", "options": [ { - "text": "private — 공유 캐시 저장만 막는다", + "text": "private: 공유 캐시 저장만 막는다", "distractorType": "one-step-short", "why": "공유 캐시 저장은 막지만 브라우저 캐시에는 디스크로 남을 수 있다. 두 요구 중 하나만 만족한다." }, { - "text": "no-store — 모든 캐시에서 저장을 금지한다", + "text": "no-store: 모든 캐시에서 저장을 금지한다", "correct": true, "why": "저장 자체를 금지하는 유일한 표준 지시다. 공유/개인 캐시 구분이 없다." }, { - "text": "no-cache — 재사용 전 재검증을 강제한다", + "text": "no-cache: 재사용 전 재검증을 강제한다", "distractorType": "adjacent", "why": "저장은 일어나고 재사용 전에 검증만 간다. 저장 금지와 다른 지시다." }, { - "text": "private, no-cache — 저장과 재사용을 동시에 막는 조합이다", + "text": "private, no-cache: 저장과 재사용을 동시에 막는 조합이다", "distractorType": "overgeneralized", "why": "no-cache는 저장을 막지 않으므로 조합해도 브라우저에는 남는다. no-store의 효과로 승격되지 않는다." } ], - "explanation": "private은 '한 사람의 응답' 표시일 뿐 브라우저 저장을 막지 않고, no-cache는 저장 후 재검증을 조건으로 삼을 뿐이다. 어디에도 남기지 않으려면 no-store다. 다만 no-store는 캐시의 힘을 완전히 버리는 선택이므로, 매번 재검증으로 충분한 경우에는 no-cache가 304 절약을 유지하는 더 나은 균형이 될 수 있다. 설계의 첫 단계는 요구가 '재사용 금지'인지 '저장 금지'인지부터 가르는 것이다. 공용 기기의 잔존까지 걱정하는 1회용 링크라면 답은 후자다.", + "explanation": "private은 '한 사람의 응답' 표시일 뿐 브라우저 저장을 막지 않고, no-cache는 저장 후 재검증을 조건으로 삼을 뿐이다. 어디에도 남기지 않으려면 no-store다. 다만 no-store는 캐시의 이점을 완전히 포기하는 선택이므로, 매번 재검증으로 충분한 경우에는 no-cache가 304 절약을 유지하는 더 나은 균형이 될 수 있다. 설계의 첫 단계는 요구가 '재사용 금지'인지 '저장 금지'인지부터 구분하는 것이다. 공용 기기에 남는 것까지 걱정해야 하는 1회용 링크라면 답은 후자다.", "source": "RFC 9111 §5.2.2.5 (no-store), §5.2.2.4 (no-cache), §5.2.2.7 (private)" }, { @@ -906,7 +906,7 @@ "question": "1년째 수정이 없던 안내 페이지에 문구를 하나 고쳤다. 이 페이지에는 Cache-Control·Expires가 없고 Last-Modified만 내려온다. 배포 2주 뒤에도 일부 사용자에게 옛 문구가 나오고 있다. 가장 그럴듯한 설명은?", "options": [ { - "text": "옛 사본은 배포 전 Last-Modified 기준의 휴리스틱 수명을 안고 있어 아직 신선하다", + "text": "옛 사본은 배포 전 Last-Modified 기준의 휴리스틱 수명을 그대로 가지고 있어 아직 신선하다", "correct": true, "why": "명시 신선도가 없으면 캐시가 수정 이력 구간의 비율로 수명을 추정한다. 1년짜리 이력이면 수 주짜리 수명이 나온다." }, @@ -926,7 +926,7 @@ "why": "오래 남아 있는 것과 신선하게 재사용되는 것은 다른 축이다. 축출은 오히려 다시 받아오게 만든다." } ], - "explanation": "명시 신선도가 없으면 캐시는 휴리스틱으로 수명을 추정하고, 전통적인 기준은 Last-Modified 이후 경과 구간의 일부(10% 관행)다. 수정 이력이 1년이면 추정 수명이 수 주가 되고, 핵심은 이미 퍼진 옛 사본이 배포 전 기준의 그 긴 수명을 그대로 가진 채 남아 있다는 점이다 — 수정 반영이 밀리는 창은 여기서 온다. 근본 대책은 이력 길이에 수명을 묶지 않는 것이다. 캐시돼도 되는 페이지에는 명시 max-age를, 즉시 반영이 필요하면 no-cache로 재검증에 맡기면 추정에 의존하는 구간이 사라진다.", + "explanation": "명시 신선도가 없으면 캐시는 휴리스틱으로 수명을 추정하고, 전통적인 기준은 Last-Modified 이후 경과 구간의 일부(10% 관행)다. 수정 이력이 1년이면 추정 수명이 수 주가 되고, 핵심은 이미 퍼진 옛 사본이 배포 전 기준의 그 긴 수명을 그대로 가진 채 남아 있다는 점이다. 수정 반영이 늦어지는 창은 여기서 생긴다. 근본 대책은 이력 길이에 수명을 묶지 않는 것이다. 캐시돼도 되는 페이지에는 명시 max-age를, 즉시 반영이 필요하면 no-cache로 재검증에 맡기면 추정에 의존하는 구간이 사라진다.", "source": "RFC 9111 §4.2.2 (Calculating Heuristic Freshness)" } ] diff --git a/data/questions/cdn.json b/data/questions/cdn.json index 5424cac..4642bcb 100644 --- a/data/questions/cdn.json +++ b/data/questions/cdn.json @@ -9,7 +9,7 @@ { "text": "CDN이 오리진 응답을 알아서 압축해서 전송량을 줄여 주기 때문이다", "distractorType": "adjacent", - "why": "본문 재압축은 CDN의 기본 동작이 아니다. 캐시 외 이득의 원리를 본문 가공으로 짚은 인접 개념 치이반이다." + "why": "본문 재압축은 CDN의 기본 동작이 아니다. 캐시 외 이득의 원리를 본문 가공으로 잘못 짚은 인접 개념 치환이다." }, { "text": "TCP·TLS 핸드셰이크와 전송 램프업이 유저 가까운 PoP에서 끝나기 때문이다", @@ -19,15 +19,15 @@ { "text": "TLS 세션 재개로 오리진과 맺은 연결이 재사용되어 왕복이 짧아지기 때문이다", "distractorType": "adjacent", - "why": "짧아지는 구간은 유저와 엣지 사이지 CDN과 오리진 사이가 아니다. 이득이 나는 위치를 오리진 쪽으로 옮겨 읽었다." + "why": "짧아지는 구간은 유저와 엣지 사이지 CDN과 오리진 사이가 아니다. 이득이 발생하는 위치를 오리진 쪽으로 잘못 짚었다." }, { "text": "CDN이 오리진 응답을 예측해 미리 받아 두는 프리페치가 작동하기 때문이다", "distractorType": "overgeneralized", - "why": "저장이 안 되는 인증 응답은 예측 선반입의 대상도 아니다. 캐시형 이득을 저장 불가 트래픽으로 확장한 과잉 일반화다." + "why": "저장이 안 되는 인증 응답은 프리페치의 대상도 아니다. 캐시로 얻는 이득을 저장 불가능한 트래픽까지 확장한 과잉 일반화다." } ], - "explanation": "CDN의 이득은 두 개다. 캐시 히트(RTT 0 서빙)와 종료점 이동(TCP/TLS 핸드셰이크와 혼잡 제어 램프업이 유저 근처 PoP에서 끝남)이다. 미스가 나면 전자만 사라지고 후자는 그대로 남는다. 그래서 캐시 불가능한 API 트래픽에도 CDN을 붙이는 구성이 성립하고, 오리진 fetch는 CDN 백본에서 벌어져 유저에게는 첫 바이트부터 순차 전달되는 경우가 일반적이다. \"CDN = 캐시\"라는 등식 하나로 두 이득을 통째로 놓치게 된다.", + "explanation": "CDN의 이득은 두 개다. 캐시 히트(RTT 0 서빙)와 종료점 이동(TCP/TLS 핸드셰이크와 혼잡 제어 램프업이 유저 근처 PoP에서 끝남)이다. 미스가 나면 전자만 사라지고 후자는 그대로 남는다. 그래서 캐시 불가능한 API 트래픽에도 CDN을 붙이는 구성이 성립하고, 오리진 fetch는 CDN 백본에서 일어나고 유저에게는 첫 바이트부터 순차 전달되는 경우가 일반적이다. \"CDN = 캐시\"라는 등식으로만 이해하면 종료점 이동이라는 두 번째 이득을 통째로 놓치게 된다.", "source": "AWS CloudFront Developer Guide — 동적 콘텐츠 서빙(TLS 종료·연결 재사용)" }, { @@ -38,27 +38,27 @@ "question": "엣지 캐시 미스율이 10%이고 origin shield 히트율이 80%다. 오리진에 실제로 도달하는 요청 비율은?", "options": [ { - "text": "20% — 1 − 0.8", + "text": "20%: 1 − 0.8", "distractorType": "one-step-short", - "why": "1 − 0.8 은 실드의 미스율이지 오리진 도달률이 아니다. 엣지 미스율 10% 를 곱하는 단계를 건너뛰었다." + "why": "1 − 0.8 은 실드의 미스율이지 오리진 도달률이 아니다. 엣지 미스율 10%를 곱하는 단계를 건너뛰었다." }, { - "text": "8% — 10% × 0.8", + "text": "8%: 10% × 0.8", "distractorType": "plausible-number", "why": "10% × 0.8 은 실드가 흡수한 몫이다. 흡수량과 관통량을 뒤집어 계산했다." }, { - "text": "2% — 10% × (1 − 0.8)", + "text": "2%: 10% × (1 − 0.8)", "correct": true, "why": "엣지 미스가 실드를 거치고 실드에서 남은 20%만 오리진까지 간다. 계층의 이득은 이렇게 곱으로 쌓인다." }, { - "text": "10% — 실드 히트율은 오리진 도달률과 무관하다", + "text": "10%: 실드 히트율은 오리진 도달률과 무관하다", "distractorType": "inverted", "why": "실드가 있으면 엣지 미스가 오리진으로 직행하지 않는다. 실드 무용론은 키 규칙 불일치로 히트율이 깨진 사례와 혼동한 것이다." } ], - "explanation": "origin shield의 정량 효과는 '오리진 도달률 = 엣지 미스율 × (1 − 실드 히트율)' 하나로 계산된다. 실드가 없으면 각 PoP의 콜드 미스가 오리진을 직접 때려 객체당 최대 PoP 수만큼 중복 fetch가 일어나고, 실드가 있으면 곱셈(N배)이 덧셈(1배)으로 바뀐다. 단 이 식의 전제는 엣지와 실드의 캐시 키 정규화 규칙이 같다는 것 — 규칙이 어긋나면 실드 히트율 자체가 깨져 계층 이득이 소멸한다. 실드는 만능 스위치가 아니라 히트율의 함수다.", + "explanation": "origin shield의 정량 효과는 '오리진 도달률 = 엣지 미스율 × (1 − 실드 히트율)' 하나로 계산된다. 실드가 없으면 각 PoP의 콜드 미스가 오리진을 직접 때려 객체당 최대 PoP 수만큼 중복 fetch가 일어나고, 실드가 있으면 곱셈(N배)이 덧셈(1배)으로 바뀐다. 단 이 식의 전제는 엣지와 실드의 캐시 키 정규화 규칙이 같다는 것이다. 규칙이 어긋나면 실드 히트율 자체가 깨져 계층 이득이 사라진다. 실드는 만능 스위치가 아니라 히트율의 함수다.", "source": "AWS CloudFront Developer Guide — Origin Shield(오리진 요청 감소)" }, { @@ -71,17 +71,17 @@ { "text": "서명이 없다고 판단한 CDN이 검증을 건너뛰고 오리진까지 전달한다", "correct": true, - "why": "서명 관련 쿼리 파라미터가 없으면 '서명된 URL이 아니네' 하고 바이패스한다. 응답이 캐시될 수도 있다." + "why": "서명 관련 쿼리 파라미터가 없으면 서명된 URL이 아니라고 판단해 검증을 건너뛴다. 그 응답이 캐시될 수도 있다." }, { "text": "Cloud CDN이 서명 파라미터 부재를 확인하고 403 Forbidden으로 거절한다", "distractorType": "vendor-mixup", - "why": "서명 검증이 전 요청에 적용되는 다른 벤더의 동작이나 상식적 기대를 Cloud CDN에 이식한 것이다." + "why": "서명 검증이 전 요청에 적용되는 다른 벤더의 동작이나 상식적인 기대를 Cloud CDN에 그대로 대입한 것이다." }, { "text": "CDN은 통과시키지만 오리진이 서명 부재를 보고 거절한다", "distractorType": "one-step-short", - "why": "오리진에 도달하는 URL은 어차피 서명이 벗겨진 모습이라 정상 요청과 구분되지 않는다. 오리진 검증은 헤더의 원본 URL로 해야 한다." + "why": "오리진에 도달하는 URL은 어차피 서명 파라미터가 제거된 상태라 정상 요청과 구분되지 않는다. 오리진 검증은 헤더에 담긴 원본 URL로 해야 한다." }, { "text": "서명이 없으면 공개 콘텐츠 요청으로 간주해 캐시에 있을 때만 응답한다", @@ -102,12 +102,12 @@ { "text": "CDN의 시계와 오리진의 시계가 어긋나 만료 판정이 틀릴 수 있어서", "distractorType": "plausible-number", - "why": "시계 오차는 검증 일반의 리스크지만 이 권장의 본체가 아니다. 검증이 아예 생략되는 경로가 존재한다는 것이 핵심이다." + "why": "시계 오차는 검증 일반의 리스크지만 이 권장의 핵심 근거가 아니다. 검증이 아예 생략되는 경로가 존재한다는 것이 핵심이다." }, { "text": "서명 키를 CDN과 오리진이 서로 다르게 가져야 해서", "distractorType": "adjacent", - "why": "키 분리·로테이션은 설계 화제일 뿐 재검증 권장과는 무관한 인접 주제다." + "why": "키 분리·로테이션은 별개의 설계 주제일 뿐 재검증 권장과는 무관한 인접 주제다." }, { "text": "CDN 검증은 캐시 미스일 때만 수행되어 히트 요청이 무검증이라서", @@ -143,15 +143,15 @@ { "text": "X-Client-Request-URL 헤더에 담긴 원본 URL로 검증한다", "correct": true, - "why": "CDN은 서명 파라미터를 지운 URL을 오리진에 보내고 원본은 이 헤더에 담아 전달한다. 오리진 검증의 진실 원본은 헤더다." + "why": "CDN은 서명 파라미터를 지운 URL을 오리진에 보내고 원본 URL은 이 헤더에 담아 전달한다. 오리진 검증의 기준은 이 헤더다." }, { "text": "라우팅 규칙에서 서명 파라미터를 다시 붙여 오리진에 전달한다", "distractorType": "vendor-mixup", - "why": "서명 파라미터가 LB의 호스트·경로 규칙과 충돌하는 것을 예방하려고 지워 주는 설계다. 재삽입은 그 취지를 되돌린다." + "why": "서명 파라미터가 LB의 호스트·경로 규칙과 충돌하는 것을 예방하려고 지워 주는 설계다. 재삽입은 그 취지에 어긋난다." } ], - "explanation": "Cloud CDN은 오리진에 요청을 보낼 때 서명 관련 쿼리 파라미터를 제거한다. 이유는 오리진 앞의 Cloud Load Balancing이 호스트·경로 규칙에 쿼리 파라미터를 포함할 수 있어(공식 문서의 A/B 테스팅 예제) 라우팅 충돌을 예방하는 방어적 설계다. 그 결과 정상 검증된 요청과 파라미터를 지운 악의 요청이 오리진에서 똑같은 모습으로 도착해 구분할 수 없으므로, 오리진은 X-Client-Request-URL 헤더에 담겨 온 원본 URL로 시그니처를 재검증해야 한다. 경유층이 요청을 다시 쓴다는 선택지를 상정하지 않으면 오리진 검증 설계 자체가 성립하지 않는다.", + "explanation": "Cloud CDN은 오리진에 요청을 보낼 때 서명 관련 쿼리 파라미터를 제거한다. 이유는 오리진 앞의 Cloud Load Balancing이 호스트·경로 규칙에 쿼리 파라미터를 포함할 수 있어(공식 문서의 A/B 테스팅 예제) 라우팅 충돌을 예방하는 방어적 설계다. 그 결과 정상적으로 검증된 요청과 파라미터를 지운 악의적 요청이 오리진에는 똑같은 형태로 도착해 구분할 수 없으므로, 오리진은 X-Client-Request-URL 헤더에 담겨 온 원본 URL로 시그니처를 재검증해야 한다. 경유 계층이 요청을 다시 쓸 수 있다는 가능성을 고려하지 않으면 오리진 검증 설계 자체가 성립하지 않는다.", "source": "Google Cloud CDN 공식 문서 — Use signed URLs(서명 컴포넌트는 오리진 요청 전에 URL에서 제거되고, 원본 서명 URL은 x-client-request-url 요청 헤더로 전달된다), 당근 2025 GCP 밋업 발표 「대규모 이미지 서비스 with Cloud CDN, Cloud Run, Cloud Storage」" }, { @@ -164,7 +164,7 @@ { "text": "3%p가량 개선된 수준이라 오리진 부하는 거의 비슷하다", "distractorType": "plausible-number", - "why": "히트율의 표면 차이(3%p)를 오리진 축에 그대로 이식한 것이다. 비용은 h가 아니라 1−h에 비례한다." + "why": "히트율의 표면 차이(3%p)를 오리진 축에 그대로 적용한 것이다. 비용은 h가 아니라 1−h에 비례한다." }, { "text": "미스율이 5%에서 2%로 줄어 오리진 요청은 60% 감소한다", @@ -200,7 +200,7 @@ { "text": "히트율 지표가 잘못 집계되고 있으므로 로그를 먼저 의심한다", "distractorType": "overgeneralized", - "why": "'히트율이 오르면 요금도 준다'는 전제가 틀렸다. 지표가 아니라 청구 구조를 먼저 본다." + "why": "'히트율이 오르면 요금도 준다'는 전제가 틀렸다. 지표가 아니라 청구 구조를 먼저 확인해야 한다." }, { "text": "히트가 늘면서 캐시 응답의 전송량이 늘어 요금이 오른 것이다", @@ -213,7 +213,7 @@ "why": "축출은 히트율 자체에 영향을 주는 인접 요인이지만 요금 불변을 설명하지 못한다. 청구 축이 다르다." } ], - "explanation": "비용 모델은 대략 '요청 수 × 요청 단가 + 전송 바이트 × egress 단가 + 미스율 × (오리진 컴퓨트+fetch 비용)'이다. 히트율 h는 셋째 항에만 들어가므로, 히트율을 올려도 CDN 청구(첫째·둘째 항)는 전송 구조가 그대로인 한 그대로다. \"히트율을 올렸는데 CDN 요금이 안 줄었다\"는 이상 징후가 아니라 구조의 정상이다. 진짜로 CDN 요금을 줄이려면 전송 바이트 자체(포맷·해상도)나 요청 수 구조를 건드려야 한다.", + "explanation": "비용 모델은 대략 '요청 수 × 요청 단가 + 전송 바이트 × egress 단가 + 미스율 × (오리진 컴퓨트+fetch 비용)'이다. 히트율 h는 셋째 항에만 들어가므로, 히트율을 올려도 CDN 청구(첫째·둘째 항)는 전송 구조가 바뀌지 않는 한 그대로다. \"히트율을 올렸는데 CDN 요금이 안 줄었다\"는 이상 징후가 아니라 구조상 정상이다. 진짜로 CDN 요금을 줄이려면 전송 바이트 자체(포맷·해상도)나 요청 수 구조를 건드려야 한다.", "source": "AWS CloudFront·Google Cloud CDN 공식 요금 문서(데이터 전송·요청 단위 과금)" }, { @@ -224,22 +224,22 @@ "question": "이미지 CDN이다. 객체당 평균 8KB, 하루 요청 20억 건. 비용 구조에서 가장 먼저 봐야 할 축은?", "options": [ { - "text": "전송량 — 하루 16TB를 내보내니 바이트 단가가 청구를 지배한다", + "text": "전송량: 하루 16TB를 내보내니 바이트 단가가 청구를 지배한다", "distractorType": "one-step-short", - "why": "전송량은 과금 축이 맞지만 이 트래픽에서는 지배축이 못 된다. 크기 직관이 건당 과금이 쌓이는 축을 가렸다." + "why": "전송량도 과금 축이 맞지만 이 트래픽에서는 지배 축이 못 된다. 객체가 작다는 직관 때문에 건당 과금이 쌓이는 축을 놓친 것이다." }, { - "text": "히트율 — 캐시가 잘 되면 요청 수 자체가 줄어든다", + "text": "히트율: 캐시가 잘 되면 요청 수 자체가 줄어든다", "distractorType": "inverted", "why": "캐시는 요청 수를 줄이지 않는다. 히트율이 줄이는 것은 오리진 도달뿐이다." }, { - "text": "오리진 egress — 미스가 나면 원본에서 나가는 요금이 청구를 지배한다", + "text": "오리진 egress: 미스가 나면 원본에서 나가는 요금이 청구를 지배한다", "distractorType": "adjacent", "why": "오리진 egress도 실제 비용 항목이지만 히트율이 높은 이미지 서빙에서는 미스분에만 발생한다. 20억 건 전체에 곱해지는 축이 아니다." }, { - "text": "요청 단가 — 객체가 가벼워도 건당 과금은 요청 수에 그대로 곱해진다", + "text": "요청 단가: 객체가 가벼워도 건당 과금은 요청 수에 그대로 곱해진다", "correct": true, "why": "전송량이 작아도 요청 20억 건의 단가는 그대로 곱해진다. 이 트래픽의 비용 축은 요청 수다." } @@ -255,7 +255,7 @@ "question": "원본은 GCS에 있고 CDN만 AWS CloudFront로 구성하려 한다. 비용 검토에서 반드시 포함해야 할 항목은?", "options": [ { - "text": "없다 — CDN 요금표와 스토리지 요금표만 보면 된다", + "text": "없다. CDN 요금표와 스토리지 요금표만 보면 된다", "distractorType": "overgeneralized", "why": "원본→CDN 구간이 빠진다. 미스가 발생할 때마다 GCS에서 인터넷으로 나가는 egress가 과금된다." }, @@ -270,12 +270,12 @@ "why": "크로스 클라우드 조합에서는 미스 fetch가 인터넷 egress로 과금된다. 같은 클라우드면 내부 처리되는 구간이다." }, { - "text": "CloudFront 캐시 미스율 예산 — 히트율만 높게 잡으면 된다", + "text": "CloudFront 캐시 미스율 예산: 히트율만 높게 잡으면 된다", "distractorType": "one-step-short", "why": "히트율 관리도 필요하지만 미스가 남는 이상 원본 구간 과금은 계속된다. 항목 자체를 누락했다." } ], - "explanation": "원본과 CDN이 클라우드를 달리하면 미스 fetch가 인터넷 egress로 과금된다. 같은 클라우드 안이라면 이 구간이 내부 처리되거나 무료가 되는 경우가 많다 — 당근이 Cloud Run과 Cloud Storage 사이 트래픽이 무료라 밝힌 것, 그리고 원본이 GCP로 이사하면서 CDN도 자연스럽게 GCP가 된 이유가 정확히 이 비용 논리다. 미스율에 비례하는 비용은 오리진 fetch와 오리진 egress까지 포함하므로, 원본 위치 선정은 CDN 선택과 함께 계산되어야 한다.", + "explanation": "원본과 CDN이 클라우드를 달리하면 미스 fetch가 인터넷 egress로 과금된다. 같은 클라우드 안이라면 이 구간이 내부 처리되거나 무료가 되는 경우가 많다. 당근이 Cloud Run과 Cloud Storage 사이 트래픽이 무료라고 밝힌 것, 그리고 원본이 GCP로 이전하면서 CDN도 자연스럽게 GCP가 된 이유가 정확히 이 비용 논리다. 미스율에 비례하는 비용은 오리진 fetch와 오리진 egress까지 포함하므로, 원본 위치 선정은 CDN 선택과 함께 계산되어야 한다.", "source": "Google Cloud Storage 공식 요금 문서(인터넷 egress), 당근 2025 GCP 밋업 발표(Cloud Run↔GCS 무료 트래픽)" }, { @@ -283,12 +283,12 @@ "topic": "cdn", "tags": ["multi-cdn", "cache-fragmentation", "operations"], "difficulty": 2, - "question": "가용성을 이유로 멀티 CDN 도입을 검토 중이다. 함께 사야 할 비용 중 구조적으로 가장 큰 것은?", + "question": "가용성을 이유로 멀티 CDN 도입을 검토 중이다. 함께 치러야 할 비용 중 구조적으로 가장 큰 것은?", "options": [ { "text": "캐시가 벤더별로 쪼개져 어느 쪽도 히트율이 오르지 않는 것", "correct": true, - "why": "벤더마다 캐시가 따로 쌓여 콜드 미스가 재발하고 무효화 API·로그 필드·서명 방식이 갈라진다. 보험의 보험료다." + "why": "벤더마다 캐시가 따로 쌓여 콜드 미스가 재발하고 무효화 API·로그 필드·서명 방식이 갈라진다. 가용성을 얻는 대신 치르는 대가다." }, { "text": "물량 커밋이 두 벤더로 나뉘어도 각각 할인 구간에 들어가 단가가 내려가는 것", @@ -303,10 +303,10 @@ { "text": "DNS 레코드와 TLS 인증서를 벤더마다 관리해야 하는 것", "distractorType": "adjacent", - "why": "DNS는 진입 비용일 뿐이다. 캐시 분할과 일관성 전선이라는 본체를 못 보았다." + "why": "DNS와 인증서 관리는 진입 비용일 뿐이다. 캐시 분할과 일관성 관리라는 더 큰 비용을 놓쳤다." } ], - "explanation": "멀티 CDN은 무료 보험이 아니라 히트율과 운영 단순성으로 사는 보험이다. 각 벤더가 자기 캐시를 따로 채우므로 전체 히트율은 벤더별 히트율의 가중합이 되고, 트래픽을 옮기는 순간 콜드 미스가 재발한다. 여기에 무효화 API·서명 방식·로그 필드가 벤더마다 달라 일관성을 위한 별도 설계(이벤트 소싱 무효화, 로그 정규화 스키마)가 필요하다. 히트율이 이미 98%+이거나 무효화 정확성이 생명인 서비스에서는 이 대가가 이득을 넘을 수 있다 — 도입은 계산된 교환이어야 한다.", + "explanation": "멀티 CDN은 공짜 보험이 아니라 히트율과 운영 단순성을 대가로 치르는 보험이다. 각 벤더가 자기 캐시를 따로 채우므로 전체 히트율은 벤더별 히트율의 가중합이 되고, 트래픽을 옮기는 순간 콜드 미스가 재발한다. 여기에 무효화 API·서명 방식·로그 필드가 벤더마다 달라 일관성을 위한 별도 설계(이벤트 소싱 무효화, 로그 정규화 스키마)가 필요하다. 히트율이 이미 98% 이상이거나 무효화 정확성이 중요한 서비스에서는 이 대가가 이득을 넘을 수 있다. 따라서 도입은 득실을 계산한 뒤의 선택이어야 한다.", "source": "Google Cloud CDN·AWS CloudFront 공식 무효화 API 문서(벤더별 단위·전파 상이)" }, { @@ -360,15 +360,15 @@ { "text": "예비 CDN 계약의 최소 사용량 약정 때문에 평시에도 비용이 나가는 것이다", "distractorType": "one-step-short", - "why": "약정이 붙는 계약도 있지만 그것은 조건 협상의 문제다. 이 계획이 구조적으로 사는 것은 비용이 아니라 검증되지 않은 전환 경로다." + "why": "약정이 붙는 계약도 있지만 그것은 조건 협상의 문제다. 이 계획이 구조적으로 떠안는 것은 비용이 아니라 검증되지 않은 전환 경로다." }, { "text": "평시에 예비 CDN으로 트래픽을 조금 흘리면 주력 히트율이 떨어지는 것이다", "distractorType": "inverted", - "why": "방향을 뒤집었다. 소량 상시 분배는 히트율을 조금 내주고 전환 경로의 검증을 사는 거래이며, 이 계획의 결함을 닫는 표준 처방이다." + "why": "방향을 뒤집었다. 소량 상시 분배는 히트율을 조금 양보하고 전환 경로의 검증을 얻는 교환이며, 이 계획의 결함을 해소하는 표준 처방이다." } ], - "explanation": "페일오버는 실전이 처음이 아니어야 한다. 평시 100/0 배분은 예비 CDN의 캐시를 항상 콜드로 두고, 전환 절차·계약 커밋·모니터링 경로도 검증되지 않은 채 둔다. 그래서 평시에도 제2벤더에 소량 트래픽을 흘려 두는 웜 스탠바이가 정석이다 — 캐시가 데워지고, 런북이 상시 리허설되며, 인기 객체의 콜드 기간이 압축된다. '장애 때만 쓰는 벤더'는 장애 때 쓸 수 없는 벤더가 되기 쉽다.", + "explanation": "페일오버는 실전이 처음이 아니어야 한다. 평시 100/0 배분은 예비 CDN의 캐시를 항상 콜드로 두고, 전환 절차·계약 커밋·모니터링 경로도 검증되지 않은 채 둔다. 그래서 평시에도 제2벤더에 소량 트래픽을 흘려 두는 웜 스탠바이가 정석이다. 캐시가 데워지고, 런북이 상시 리허설되며, 인기 객체의 콜드 기간이 짧아진다. '장애 때만 쓰는 벤더'는 장애 때 쓸 수 없는 벤더가 되기 쉽다.", "source": "AWS Route 53 헬스체크·DNS 페일오버 공식 문서(웜 스탠바이 구성)" }, { @@ -379,14 +379,14 @@ "question": "DNS 기반 분배 멀티 CDN에서 벤더 A 장애를 감지하고 즉시 가중치를 반전했다. 사용자 트래픽이 B로 넘어가는 속도를 지배하는 것은?", "options": [ { - "text": "감지 알고리즘의 속도 — 감지만 빠르면 전환도 빨라진다", + "text": "감지 알고리즘의 속도: 감지만 빠르면 전환도 빨라진다", "distractorType": "overgeneralized", "why": "감지와 전환은 별개 축이다. 감지된 뒤에도 DNS 응답이 퍼지는 속도가 상한이다." }, { - "text": "헬스체크 주기 — 프로브 간격이 곧 전환 지연이다", + "text": "헬스체크 주기: 프로브 간격이 곧 전환 지연이다", "distractorType": "adjacent", - "why": "헬스체크는 감지 축이다. 감지된 뒤의 트래픽 수렴은 다른 메커니즘에 묶인다." + "why": "헬스체크는 감지 축이다. 감지된 뒤의 트래픽 수렴은 다른 메커니즘에 달려 있다." }, { "text": "DNS 레코드의 TTL과 리졸버 캐시가 상한이다", @@ -399,7 +399,7 @@ "why": "DNS 기반 구성에는 그런 중간 계층이 없다. 프록시 계층 구성과 혼동했다." } ], - "explanation": "DNS 기반 분배의 제어 루프는 분 단위다. 권한 DNS의 응답을 바꿔도 재귀 리졸버와 OS·브라우저 캐시가 TTL만큼 옛 값을 붙잡고 있어, 실제 사용자 트래픽의 수렴은 TTL + 캐시 초과분까지 늘어진다. TTL을 낮추면 페일오버는 빨라지지만 DNS 조회량과 권한 DNS 의존이 커진다 — 페일오버 속도와 DNS 비용의 트레이드오프다. 즉시 전환이 요구사항이면 분배 방식 자체를 클라이언트 사이드로 바꿔야 한다.", + "explanation": "DNS 기반 분배의 제어 루프는 분 단위다. 권한 DNS의 응답을 바꿔도 재귀 리졸버와 OS·브라우저 캐시가 TTL만큼 옛 값을 붙잡고 있어, 실제 사용자 트래픽의 수렴은 TTL + 캐시 초과분까지 늘어진다. TTL을 낮추면 페일오버는 빨라지지만 DNS 조회량과 권한 DNS 의존이 커진다. 즉 페일오버 속도와 DNS 비용의 트레이드오프다. 즉시 전환이 요구사항이면 분배 방식 자체를 클라이언트 사이드로 바꿔야 한다.", "source": "RFC 1035 §4.1.3(TTL — 리소스 레코드를 캐시할 수 있는 시간), AWS Route 53 DNS 페일오버 공식 문서" }, { @@ -410,12 +410,12 @@ "question": "동영상 서비스가 두 CDN을 운영한다. 한 벤더에 장애가 나면 재생 중인 사용자가 버퍼를 소진하기 전에 다른 벤더로 옮겨야 한다는 요구사항이다. 맞는 분배 방식은?", "options": [ { - "text": "DNS 기반 가중치 — 벤더 CNAME 응답을 바꿔 주면 충분하다", + "text": "DNS 기반 가중치: 벤더 CNAME 응답을 바꿔 주면 충분하다", "distractorType": "overgeneralized", "why": "DNS는 TTL만큼 지연된다. 요청 단위 전환 요구와 맞지 않는다." }, { - "text": "CNAME 체이닝 — 앞단 프록시 계층이 업스트림 벤더를 골라 준다", + "text": "CNAME 체이닝: 앞단 프록시 계층이 업스트림 벤더를 골라 준다", "distractorType": "adjacent", "why": "즉시성은 있지만 그 계층이 새로운 단일 장애점·지연 홉이 된다. 요구사항의 주체(플레이어)와 맞지 않는다." }, @@ -425,12 +425,12 @@ "why": "GSLB도 결국 DNS 응답을 바꾸는 방식이라 TTL 상한은 그대로다." }, { - "text": "클라이언트 사이드 선택 — 플레이어가 매니페스트의 세그먼트 호스트를 바꾼다", + "text": "클라이언트 사이드 선택: 플레이어가 매니페스트의 세그먼트 호스트를 바꾼다", "correct": true, "why": "ABR 플레이어는 어차피 매니페스트를 해석해 세그먼트를 고른다. 호스트만 바꾸면 DNS 없이 요청 단위 전환이 된다." } ], - "explanation": "전환 지연 요구가 '요청 단위'이면 클라이언트가 선택권을 가져야 한다. ABR 스트리밍은 플레이어가 매니페스트를 해석해 세그먼트를 고르는 구조라, 세그먼트 호스트만 벤더별로 바꿔 주면 DNS 개입 없이 다음 요청부터 전환이 가능하다. 여기에 타임아웃·재버퍼링 같은 실측(RUM) 신호를 얹어 벤더 성능을 관측하고 반영할 수도 있다. 웹 브라우저에는 이런 훅을 걸 곳이 적어 DNS 기반이 현실적이다 — 플랫폼별로 정답이 다르다는 것 자체가 분배 방식 설계의 내용이다.", + "explanation": "전환 지연 요구가 '요청 단위'이면 클라이언트가 선택권을 가져야 한다. ABR 스트리밍은 플레이어가 매니페스트를 해석해 세그먼트를 고르는 구조라, 세그먼트 호스트만 벤더별로 바꿔 주면 DNS 개입 없이 다음 요청부터 전환이 가능하다. 여기에 타임아웃·재버퍼링 같은 실측(RUM) 신호를 더해 벤더 성능을 관측하고 반영할 수도 있다. 웹 브라우저에는 이런 훅을 걸 곳이 적어 DNS 기반이 현실적이다. 플랫폼별로 정답이 다르다는 것 자체가 분배 방식 설계에서 고려해야 할 지점이다.", "source": "RFC 8216(HTTP Live Streaming — 매니페스트 기반 세그먼트 URL 선택)" }, { @@ -458,10 +458,10 @@ { "text": "한국 리전 오리진 서버의 리소스 알럿을 새로 단다", "distractorType": "adjacent", - "why": "끊김의 원인이 엣지 국부에 있으면 오리진 지표에는 나타나지 않는다. 감지 계층을 오리진으로 옮겨 짚은 착오다." + "why": "실패의 원인이 엣지 쪽 국부 장애에 있으면 오리진 지표에는 나타나지 않는다. 감지할 계층을 오리진으로 잘못 짚은 것이다." } ], - "explanation": "실제 CDN 장애의 모습은 '벤더 전체 다운'이 아니라 특정 리전·PoP·경로만 나쁜 부분 장애다. 전역 집계에 섞이면 이탈이 희석되어 임계를 넘지 않고, 벤더 statuspage도 늦고 불완전할 수 있다. 그래서 지역×캐시상태×오류코드 차원을 로그에 남기고 쪼개서 보는 자체 계측이 원본이며, 임계는 절대값보다 동일 시간대 베이스라인 대비 이탈로 잡는 것이 노이즈에 강하다. 당근이 LB 로그를 BigQuery에 쌓고 서비스별 대시보드로 보는 것도 같은 구조 — 관측의 차원 설계가 감지 능력의 상한을 정한다.", + "explanation": "실제 CDN 장애의 모습은 '벤더 전체 다운'이 아니라 특정 리전·PoP·경로만 나쁜 부분 장애다. 전역 집계에 섞이면 이탈이 희석되어 임계를 넘지 않고, 벤더 statuspage도 늦고 불완전할 수 있다. 그래서 지역×캐시상태×오류코드 차원을 로그에 남기고 쪼개서 보는 자체 계측이 원본이며, 임계는 절대값보다 동일 시간대 베이스라인 대비 이탈로 잡는 것이 노이즈에 강하다. 당근이 LB 로그를 BigQuery에 쌓고 서비스별 대시보드로 보는 것도 같은 구조다. 관측 차원의 설계가 감지 능력의 상한을 정한다.", "source": "당근 2025 GCP 밋업 발표(LB 로그→BigQuery→대시보드, 지역·서비스별 관측)" }, { @@ -484,7 +484,7 @@ { "text": "실사용자 지표가 노이즈라서 합성 점수가 진짜 성능이다", "distractorType": "inverted", - "why": "RUM은 편차가 크지만 진짜 경험이다. 합성을 진실로 삼는 순간 측정 지점 편향을 못 본다." + "why": "RUM은 편차가 크지만 실제 사용자의 경험이다. 합성 점수를 기준으로 삼는 순간 측정 지점의 편향을 보지 못한다." }, { "text": "신규 벤더의 TLS 인증서 검증이 느려 첫 연결이 느린 것이다", @@ -492,7 +492,7 @@ "why": "핸드셰이크 축의 가능성은 있으나 p95 전반 악화의 주된 설명으로는 근거가 없다." } ], - "explanation": "합성 모니터링은 내가 통제하는 소수 뷰포인트·조건에서 재현성 있게 재는 도구다. 그 강점이 곧 한계라, 실사용자의 지역·ISP·디바이스 분포를 대표하지 못한다. 벤더 비교의 기준값은 RUM에 두되 CDN 식별 태그·지역 층화·동시간대 비교를 갖춰야 공정하다. 합성은 회귀 감지와 페일오버 판정 입력으로, RUM은 사용자 경험의 진실로 — 역할 분담을 정해 두지 않으면 '점수로 갈았는데 실제는 나빠졌다'는 함정에 빠진다.", + "explanation": "합성 모니터링은 내가 통제하는 소수 뷰포인트·조건에서 재현성 있게 재는 도구다. 그 강점이 곧 한계라, 실사용자의 지역·ISP·디바이스 분포를 대표하지 못한다. 벤더 비교의 기준값은 RUM에 두되 CDN 식별 태그·지역 층화·동시간대 비교를 갖춰야 공정하다. 합성은 회귀 감지와 페일오버 판정의 입력으로, RUM은 사용자 경험의 기준으로 역할을 나눠 두지 않으면 '점수만 보고 교체했는데 실제 경험은 나빠졌다'는 함정에 빠진다.", "source": "Google Cloud Synthetic Monitoring 공식 문서(지정된 위치에서 수행)" }, { @@ -503,19 +503,19 @@ "question": "CDN 벤더 장애 시 자동 페일오버를 도입하려 한다. 자동화가 만드는 가장 큰 리스크는?", "options": [ { - "text": "자동화 스크립트의 버그 — 사람이 했으면 안 생겼을 오류", + "text": "자동화 스크립트의 버그: 사람이 했으면 안 생겼을 오류", "distractorType": "adjacent", "why": "버그는 자동·수동 모두에 있다. 자동화 고유의 리스크는 판단의 결여에서 나온다." }, { "text": "페일오버 대상 벤더의 단가가 평시보다 비싸 전환할 때마다 비용이 튀는 것", "distractorType": "one-step-short", - "why": "전환 비용이 튀는 것은 청구서에서 끝나는 문제다. 자동화가 새로 만드는 리스크는 그보다 가용성 쪽에 있고, 거기까지 한 걸음이 남았다." + "why": "전환할 때 비용이 튀는 것은 청구서 안에서 끝나는 문제다. 자동화가 새로 만드는 리스크는 그보다 가용성 쪽에 있는데, 거기까지 짚지 못했다." }, { "text": "계측 오류·국부 장애를 전체 장애로 읽고 정상 벤더를 버리는 것", "correct": true, - "why": "자동 판정은 신호 품질에 묶인다. 다수결·지속 조건·점진 램프·원상복구로 블래스트 레디어스를 제한한다." + "why": "자동 판정의 품질은 신호 품질에 좌우된다. 다수결·지속 조건·점진 램프·원상복구로 영향 범위를 제한해야 한다." }, { "text": "알럿이 자주 울려 on-call의 대응 감각이 무뎌지는 것", @@ -523,7 +523,7 @@ "why": "알럿 피로는 실재하는 운영 문제지만 페일오버 자동화의 고유 리스크는 아니다." } ], - "explanation": "자동 전환은 복구 시간을 줄이지만 판단을 내리는 주체가 신호 품질에 종속된다. 계측 오류나 특정 리전의 국부 장애를 전체 장애로 읽으면 살아 있는 벤더를 버리게 되고, 전환 자체가 콜드 캐시 폭주라는 새 사고를 만들 수 있다. 실무 타협은 짧은 윈도의 다수결·지속 조건, 점진 램프, 자동 원상복구로 블래스트 레디어스를 제한하는 것이다. 조직의 운영 성숙도에 맞춰 '자동 판정 + 램프'와 '자동 알럿 + 수동 런북' 사이를 고르는 것이 설계자의 답이다.", + "explanation": "자동 전환은 복구 시간을 줄이지만 판단을 내리는 주체가 신호 품질에 종속된다. 계측 오류나 특정 리전의 국부 장애를 전체 장애로 읽으면 살아 있는 벤더를 버리게 되고, 전환 자체가 콜드 캐시 폭주라는 새 사고를 만들 수 있다. 실무의 타협안은 짧은 윈도의 다수결·지속 조건, 점진 램프, 자동 원상복구로 영향 범위(blast radius)를 제한하는 것이다. 조직의 운영 성숙도에 맞춰 '자동 판정 + 램프'와 '자동 알럿 + 수동 런북' 사이에서 고르는 것이 설계자의 몫이다.", "source": "AWS Route 53 헬스체크·DNS 페일오버 공식 문서" }, { @@ -546,7 +546,7 @@ { "text": "쿼리가 키에 있으면 전부 히트되어 오리진이 쉴 수 있어서", "distractorType": "inverted", - "why": "쿼리 축이 늘수록 키는 잘게 쪼개진다. 히트가 아니라 분열의 방향이다." + "why": "쿼리 축이 늘수록 키는 잘게 쪼개진다. 히트율이 오르는 것이 아니라 캐시가 분열하는 방향이다." }, { "text": "파라미터가 변형을 정의해 값이 바뀌면 미스→오리진이 동적 생성하는 것이 의도라서", @@ -554,7 +554,7 @@ "why": "키 공간 = 변형 공간이 성립한다. w=300&f=webp는 곧 '300px WebP 변형'이라는 비즈니스 객체다." } ], - "explanation": "캐시 키 설계의 원칙은 '키 공간 = 실제 변형 공간'이다. 리사이즈 파라미터가 변형을 정의하는 구조에서는 쿼리 전체 키가 오히려 정합적이다 — 값이 바뀌면 미스가 나고, 오리진(Cloud Run)이 그 변형을 동적으로 만들어 채운 뒤 캐시되는 것이 의도한 동작이다(당근의 기본 키 선택). 남는 리스크는 변형이 아닌 파라미터(utm·추적 코드)가 키에 섞이는 것이고, 그 완화는 화이트리스트다. '쿼리는 캐시의 적'이라는 경구는 트래킹 파라미터 맥락에서만 참이지 변형 파라미터 맥락에서는 거짓이다.", + "explanation": "캐시 키 설계의 원칙은 '키 공간 = 실제 변형 공간'이다. 리사이즈 파라미터가 변형을 정의하는 구조에서는 쿼리 전체 키가 오히려 정합적이다. 값이 바뀌면 미스가 나고, 오리진(Cloud Run)이 그 변형을 동적으로 만들어 채운 뒤 캐시되는 것이 의도한 동작이다(당근의 기본 키 선택). 남는 리스크는 변형이 아닌 파라미터(utm·추적 코드)가 키에 섞이는 것이고, 그 완화는 화이트리스트다. '쿼리는 캐시의 적'이라는 경구는 트래킹 파라미터 맥락에서만 참이지 변형 파라미터 맥락에서는 거짓이다.", "source": "당근 2025 GCP 밋업 발표(기본 캐시 키 — 쿼리 매개변수가 바뀌면 미스, Cloud Run 동적 리사이즈)" }, { @@ -567,12 +567,12 @@ { "text": "TTL이 짧아 같은 변형의 엔트리가 만료를 반복해 재검증이 늘어난 것이다", "distractorType": "adjacent", - "why": "만료로 인한 재검증은 같은 문자열 안의 이야기다. 신선도 축을 카디널리티 축에 겹쳐 읽었다." + "why": "만료로 인한 재검증은 같은 문자열의 엔트리 안에서 일어나는 일이다. 신선도 축을 카디널리티 축과 혼동했다." }, { "text": "요청이 서로 다른 PoP로 흩어져 캐시가 아직 각 PoP에 퍼지지 않은 것이다", "distractorType": "one-step-short", - "why": "콜드 PoP의 중복 fetch는 실재하는 현상이지만 같은 쌍이 이미지마다 반복되는 규칙성은 못 만든다." + "why": "콜드 PoP의 중복 fetch는 실재하는 현상이지만 같은 쌍이 이미지마다 반복되는 규칙성은 만들지 못한다." }, { "text": "쿼리 문자열이 하나라도 다르면 CloudFront는 다른 키로 캐싱하기 때문이다", @@ -596,12 +596,12 @@ "question": "히트율 88% 이미지 CDN의 개선 예산이 한 가지뿐이다. 어디에 쓰는 것이 가장 큰 효과를 내나?", "options": [ { - "text": "축출 정책 교체 — LRU에서 LFU로 전환하는 작업", + "text": "축출 정책 교체: LRU에서 LFU로 전환하는 작업", "distractorType": "one-step-short", "why": "같은 용량에서 정책 간 차이는 한 자릿수 % 포인트다. 유의미하지만 가장 큰 축이 아니다." }, { - "text": "캐시 키 정규화 — 파라미터 화이트리스트 적용 작업", + "text": "캐시 키 정규화: 파라미터 화이트리스트 적용 작업", "correct": true, "why": "가짜 키 축(utm·순서·쿠키)이 만드는 분열은 두 자릿수 % 포인트를 움직인다. 키 위생이 먼저다." }, @@ -616,7 +616,7 @@ "why": "변경되는 객체에서 무효화 문제가 커지고 만료 집중 구조도 그대로다. 정규화와는 다른 축이다." } ], - "explanation": "같은 용량에서 축출 정책 간 히트율 차이는 보통 한 자릿수 % 포인트지만, 키 정규화·화이트리스트는 두 자릿수 % 포인트를 움직인다. 트래킹 파라미터·순서 차이·쿠키 같은 가짜 축은 실제 변형이 아닌 엔트리를 무한히 만들어 트래픽을 분열시키기 때문이다. 용량 증설은 롱테일 특성상 수익이 체감되고, TTL 상향은 무효화 문제를 다른 곳으로 미룰 뿐이다. 개선 순서는 키 위생 → 용량·계층 → 정책 튜닝이며, 원인 규명 없는 파라미터 조정은 이 우선순위를 흐린다.", + "explanation": "같은 용량에서 축출 정책 간 히트율 차이는 보통 한 자릿수 % 포인트지만, 키 정규화·화이트리스트는 두 자릿수 % 포인트를 움직인다. 트래킹 파라미터·순서 차이·쿠키 같은 가짜 축은 실제 변형이 아닌 엔트리를 무한히 만들어 트래픽을 분열시키기 때문이다. 용량 증설은 롱테일 특성상 효과가 갈수록 줄어들고, TTL 상향은 무효화 문제를 다른 곳으로 미룰 뿐이다. 개선 순서는 키 위생 → 용량·계층 → 정책 튜닝이며, 원인 규명 없는 파라미터 조정은 이 우선순위를 흐린다.", "source": "당근 테크블로그(2019) — 쿼리 순서·대소문자 캐시 분열 실측, AWS CloudFront 캐시 정책 공식 문서" }, { @@ -629,7 +629,7 @@ { "text": "Cloud Run을 백엔드 서비스로 LB에 직접 등록하면 된다", "distractorType": "vendor-mixup", - "why": "Cloud Run은 LB 백엔드로 직접 붙지 못한다. 다른 클라우드의 인스턴스 그룹 감각을 이식한 것이다." + "why": "Cloud Run은 LB 백엔드로 직접 붙지 못한다. 다른 클라우드에서 인스턴스 그룹을 등록하던 방식을 그대로 가져온 것이다." }, { "text": "Cloud Run URL로 리다이렉트하도록 LB 규칙을 만들면 된다", @@ -639,7 +639,7 @@ { "text": "Serverless NEG로 Cloud Run을 LB 백엔드에 매핑하면 된다", "correct": true, - "why": "캐싱 가능 콘텐츠를 원본으로 내리려면 부하분산기가 필요하고 Cloud Run은 서버리스 NEG로 매핑된다 — 공식 권장이다." + "why": "캐싱 가능한 콘텐츠를 Cloud Run 원본에서 서빙하려면 부하분산기가 필요하고, Cloud Run은 서버리스 NEG로 매핑된다. 공식 권장 구성이다." }, { "text": "Cloud Run 인그레스를 내부 전용으로 바꿔 직접 이으면 된다", @@ -647,7 +647,7 @@ "why": "인그레스 정책은 접근 경계의 문제다. LB·NEG 없이는 CDN의 오리진이 될 수 없다." } ], - "explanation": "GCP에서 캐싱 가능한 콘텐츠를 Cloud Run으로 서빙하려면 Cloud CDN → Cloud Load Balancing(L7) → Serverless NEG → Cloud Run의 연결이 공식 권장 구성이다. Cloud Run은 LB 백엔드로 직접 등록되지 않고 반드시 서버리스 NEG로 매핑되어야 하며, TLS 인증서는 Certificate Manager가 프론트엔드에 주입한다. 이 그림은 그냥 외울 게 아니라 '오리진이 컴퓨트일 때 검증 지점이 어디로 가는가'의 사례로 소화할 것 — 앞의 서명 검증 문제들이 정확히 이 구조 위에서 터진다.", + "explanation": "GCP에서 캐싱 가능한 콘텐츠를 Cloud Run으로 서빙하려면 Cloud CDN → Cloud Load Balancing(L7) → Serverless NEG → Cloud Run의 연결이 공식 권장 구성이다. Cloud Run은 LB 백엔드로 직접 등록되지 않고 반드시 서버리스 NEG로 매핑되어야 하며, TLS 인증서는 Certificate Manager가 프론트엔드에 주입한다. 이 구성은 단순히 외울 것이 아니라 '오리진이 컴퓨트일 때 검증 지점이 어디로 가는가'를 보여주는 사례로 이해해야 한다. 앞의 서명 검증 문제들이 정확히 이 구조 위에서 발생한다.", "source": "Google Cloud 공식 문서 — Cloud Run 캐싱 가능한 콘텐츠 서빙(부하분산기 + 서버리스 NEG 매핑), 당근 2025 GCP 밋업 발표" }, { @@ -670,7 +670,7 @@ { "text": "Vary는 응답 부가정보일 뿐 캐싱 동작을 바꾸지 않고, 미들웨어가 응답 본문을 수정해 무효화가 일어난 것이다", "distractorType": "inverted", - "why": "Vary는 부가정보가 아니라 캐시 키 정의의 일부다. 원인을 본문 변경으로 옮겨 놓았다." + "why": "Vary는 부가정보가 아니라 캐시 키 정의의 일부다. 원인을 본문 변경 쪽으로 잘못 짚었다." }, { "text": "쿠키가 붙은 요청은 개인화 트래픽으로 분류되어 CDN이 캐시를 우회시키는 것이다", @@ -678,7 +678,7 @@ "why": "쿠키가 있으면 무조건 우회하는 건 특정 구성·벤더의 동작이다. 여기선 캐시는 되지만 방문자 수만큼 쪼개진다." } ], - "explanation": "Vary는 캐시 키에 사실상 축을 추가한다. Vary: Accept-Encoding이라면 축의 값이 gzip/br/identity 몇 개로 끝나지만, Vary: Cookie는 값이 사용자 수만큼 있어 같은 이미지가 방문자별로 따로 캐시된다. 히트율 89%가 40%대로 무너지는 전형적인 패턴이고, 캐시 키 위생 검토의 첫 확인 항목이 'Vary에 뭐가 붙었나'다. 미들웨어가 넣는 실험·개인화 헤더는 값의 종류가 통제 불가능하므로, 이미지처럼 정적 자원에는 애초에 붙지 않아야 한다.", + "explanation": "Vary는 캐시 키에 사실상 축을 추가한다. Vary: Accept-Encoding이라면 축의 값이 gzip/br/identity 몇 개로 끝나지만, Vary: Cookie는 값이 사용자 수만큼 있어 같은 이미지가 방문자별로 따로 캐시된다. 히트율 89%가 40%대로 무너지는 전형적인 패턴이고, 캐시 키 위생 검토에서 가장 먼저 확인할 항목이 'Vary에 무엇이 붙었는가'이다. 미들웨어가 넣는 실험·개인화 헤더는 값의 종류가 통제 불가능하므로, 이미지처럼 정적 자원에는 애초에 붙지 않아야 한다.", "source": "RFC 9111 §4.1(Calculating Cache Keys with the Vary Header Field)" }, { @@ -691,12 +691,12 @@ { "text": "미스가 고화질 대용량 세그먼트에 몰려 있어, 바이트 기준 미스 비중이 요청 기준 1%보다 훨씬 크다", "correct": true, - "why": "히트율의 분모는 요청 수다. 오리진 트래픽은 바이트 축이라 희소한 미스의 '무게'가 청구를 지배한다." + "why": "히트율의 분모는 요청 수다. 오리진 트래픽은 바이트 축이라 드문 미스가 차지하는 바이트 비중이 청구를 지배한다." }, { "text": "요청의 99%가 캐시에서 나오니 오리진 egress도 총량의 1% 수준이어야 하고, 3배라는 수치는 계량 오류다", "distractorType": "plausible-number", - "why": "요청 축의 비율을 바이트 축으로 그대로 이식했다. 미스가 큰 객체에 몰리면 바이트 비중은 수 배가 된다." + "why": "요청 축의 비율을 바이트 축에 그대로 적용했다. 미스가 큰 객체에 몰리면 바이트 비중은 수 배가 된다." }, { "text": "만료된 세그먼트 재검증이 조건부 요청을 계속 만들어 오리진 왕복이 늘어난 것이다", @@ -709,7 +709,7 @@ "why": "신규 콘텐츠의 콜드 미스는 실재하는 요인이지만 예산 3배를 한 달 유지하는 구조적 설명이 되지 못한다." } ], - "explanation": "캐시 히트율은 요청 축 지표고, 오리진 egress는 바이트 축 현상이다. 두 축은 미스의 분포가 어디에 무게를 두느냐에 따라 크게 벌어진다 — 4K 세그먼트 하나의 미스는 썸네일 수백 개의 미스와 같은 바이트다. 요청 히트율 99%는 미스된 1%가 대용량 콘텐츠에 몰려 있으면 오리진 트래픽 관점에서 훨씬 나쁜 숫자다. 그래서 VOD·대용량 배포에서는 요청 히트율과 바이트 히트율을 나눠 보는 게 기본이고, 예산은 미스 바이트 분포 기준으로 잡아야 한다.", + "explanation": "캐시 히트율은 요청 축 지표고, 오리진 egress는 바이트 축 현상이다. 두 축은 미스가 어떤 객체에 몰리느냐에 따라 크게 벌어진다. 4K 세그먼트 하나의 미스는 썸네일 수백 개의 미스와 같은 바이트다. 요청 히트율 99%는 미스된 1%가 대용량 콘텐츠에 몰려 있으면 오리진 트래픽 관점에서 훨씬 나쁜 숫자다. 그래서 VOD·대용량 배포에서는 요청 히트율과 바이트 히트율을 나눠 보는 게 기본이고, 예산은 미스 바이트 분포 기준으로 잡아야 한다.", "source": "AWS CloudFront 공식 문서 — 모니터링 및 메트릭(캐시 히트율은 요청 기준 지표)" }, { @@ -717,7 +717,7 @@ "topic": "cdn", "tags": ["observability", "logging", "sampling"], "difficulty": 2, - "question": "드물게 터지는 5xx 계열 오류의 실제 발생 규모를 잡고 싶다. 표준 액세스 로그로 세는 운영 대시보드에서 가장 먼저 확인할 일은?", + "question": "드물게 발생하는 5xx 계열 오류의 실제 규모를 파악하려 한다. 표준 액세스 로그로 세는 운영 대시보드에서 가장 먼저 확인할 일은?", "options": [ { "text": "로그 전달 지연을 흡수하도록 집계 창을 하루 미뤄 반영한다", @@ -727,20 +727,20 @@ { "text": "오리진에 도달한 요청만 남도록 로그 필터를 좁혀 잡음을 없앤다", "distractorType": "inverted", - "why": "엣지 액세스 로그의 기록 단위를 오리진으로 옮겨 읽었다. 엣지에서 난 5xx가 오히려 사라진다." + "why": "엣지 액세스 로그가 기록하는 지점을 오리진으로 잘못 읽었다. 엣지에서 난 5xx가 오히려 집계에서 사라진다." }, { "text": "표준 로그의 샘플링 정책을 확인하고, 필요하면 전량 스트리밍 로그로 옮긴다", "correct": true, - "why": "샘플에서 희귀 이벤트는 놓치거나 과소계상된다. 집계의 전제인 기록 정책을 먼저 검증해야 한다." + "why": "샘플에서 희귀 이벤트는 누락되거나 실제보다 적게 집계된다. 집계의 전제인 기록 정책을 먼저 검증해야 한다." }, { "text": "로그 설정에서 전체 요청 기록 토글을 켜면 표준 로그도 전량 기록이 된다", "distractorType": "vendor-mixup", - "why": "표준 로그에는 그 토글이 없다. 전량을 원하면 상품(실시간 로그) 자체를 바꿔야 한다. 다른 설정을 이식한 믿음이다." + "why": "표준 로그에는 그 토글이 없다. 전량 기록을 원하면 상품(실시간 로그) 자체를 바꿔야 한다. 다른 상품의 설정을 그대로 가져온 오해다." } ], - "explanation": "많은 CDN의 표준 액세스 로그는 전수 기록을 보장하지 않는다. 베스트 에포트로 전달되고 샘플링될 수 있어, 발생 빈도가 낮은 이벤트는 표본에서 계통적으로 과소 계상된다. 희귀 오류·보안 이벤트처럼 꼬리를 세는 계측이라면 로그 상품의 기록 정책부터 확인하고, 필요하면 샘플링 비율을 정할 수 있는 실시간(스트리밍) 로그로 올려야 한다. '로그 = 전수 조사'라는 등식이 대시보드의 정밀도를 결정하는 숨은 전제다.", + "explanation": "많은 CDN의 표준 액세스 로그는 전수 기록을 보장하지 않는다. 베스트 에포트로 전달되고 샘플링될 수 있어, 발생 빈도가 낮은 이벤트는 표본에서 계통적으로 실제보다 적게 집계된다. 희귀 오류나 보안 이벤트처럼 드물게 발생하는 사건을 세는 계측이라면 로그 상품의 기록 정책부터 확인하고, 필요하면 샘플링 비율을 정할 수 있는 실시간(스트리밍) 로그로 올려야 한다. '로그 = 전수 조사'라는 등식이 대시보드의 정밀도를 결정하는 숨은 전제다.", "source": "AWS CloudFront 공식 문서 — 액세스 로그(전체 요청 기록 미보장) 및 실시간 로그(샘플링 비율 설정)" }, { @@ -753,12 +753,12 @@ { "text": "시간이 지나는 동안 캐시가 꽉 차 엔트리가 밀려나 다시 받아온 것이다", "distractorType": "adjacent", - "why": "밀려남은 축출(eviction)의 이야기다. Expired는 존재하되 신선도가 지난 상태다." + "why": "엔트리가 밀려나는 것은 축출(eviction)에 해당한다. Expired는 엔트리가 존재하되 신선도가 지난 상태다." }, { "text": "그 URL은 캐시된 적 없이 매번 오리진까지 간 것이다", "distractorType": "inverted", - "why": "매번 오리진 직행은 Miss의 의미다. Expired는 저장 이력을 전제하는 상태다." + "why": "매번 오리진까지 가는 것은 Miss가 뜻하는 상태다. Expired는 저장된 이력을 전제한다." }, { "text": "캐시에 있던 엔트리의 TTL이 지나 오리진 재검증을 다녀온 것이다", @@ -771,7 +771,7 @@ "why": "무효화는 운영자가 CDN API로 호출하는 것이지 오리진이 자동으로 시그널을 보내는 게 아니다." } ], - "explanation": "캐시 상태 값은 각각 다른 사건을 가리킨다. Miss는 저장된 적 없는 첫 요청, Expired는 저장돼 있던 엔트리의 TTL이 지나 재검증을 다녀왔다는 뜻이고, 둘 다 캐시가 정상 동작한다는 증거다. 밀려남은 용량 문제(축출), 지워짐은 운영자 호출(무효화)의 영역이라 상태 표기로 구분된다. 상태 헤더를 읽을 줄 알면 배포 직후 '캐시가 깨졌다'는 오진을 내리지 않고, TTL과 재검증 주기가 의도대로 걸려 있는지도 확인할 수 있다.", + "explanation": "캐시 상태 값은 각각 다른 사건을 가리킨다. Miss는 저장된 적 없는 첫 요청, Expired는 저장돼 있던 엔트리의 TTL이 지나 재검증을 다녀왔다는 뜻이고, 둘 다 캐시가 정상 동작한다는 증거다. 엔트리가 밀려나는 것은 용량 문제(축출)이고, 지워지는 것은 운영자가 호출하는 무효화의 영역이라 상태 표기로 구분된다. 상태 헤더를 읽을 줄 알면 배포 직후 '캐시가 깨졌다'는 오진을 내리지 않고, TTL과 재검증 주기가 의도대로 걸려 있는지도 확인할 수 있다.", "source": "Cloudflare 개발자 문서 — 캐시 상태 응답 헤더 값 정의(EXPIRED 등), RFC 9111 §4(신선도 및 재검증)" }, { @@ -802,7 +802,7 @@ "why": "Vary를 존중하는 캐시는 축마다 엔트리를 나눈다. 단일 표현 정책은 다른 설정의 동작이다." } ], - "explanation": "Vary: Accept-Encoding은 '이 객체는 인코딩별로 다른 변형이며, 요청의 Accept-Encoding 값이 캐시 키에 들어간다'는 선언이다. 그래서 br 요청은 gzip 엔트리에 히트하지 못하고 미스가 나며, 오리진이 br 표현을 내려주면 그것이 별도 엔트리로 쌓인다. 히트율 관점에서 이 축은 gzip/br/identity 정도로 값이 한정되어 Vary: Cookie 같은 폭발은 없지만, 변형별로 웜업이 필요하다는 점은 그대로다. 즉석 재압축을 해 주는 CDN 기능도 있지만 그것은 캐시 동작이 아니라 별도 설정이며, 기본 가정은 '표현은 오리진이 정의한다'다.", + "explanation": "Vary: Accept-Encoding은 '이 객체는 인코딩별로 다른 변형이며, 요청의 Accept-Encoding 값이 캐시 키에 들어간다'는 선언이다. 그래서 br 요청은 gzip 엔트리에 히트하지 못하고 미스가 나며, 오리진이 br 표현을 내려주면 그것이 별도 엔트리로 쌓인다. 히트율 관점에서 이 축은 gzip/br/identity 정도로 값이 한정되어 Vary: Cookie 같은 폭발은 없지만, 변형별로 웜업이 필요하다는 점은 그대로다. 즉석 재압축을 해 주는 CDN 기능도 있지만 그것은 캐시 동작이 아니라 별도 설정이며, 기본 가정은 '표현은 오리진이 정의한다'는 것이다.", "source": "RFC 9110 §12.5.3(Accept-Encoding), RFC 9111 §4.1(Vary와 캐시 키)" }, { @@ -820,7 +820,7 @@ { "text": "유저의 h3 연결을 오리진까지 이어주려 했으나 오리진이 지원하지 않아 그 구간만 폴백된 것이다", "distractorType": "one-step-short", - "why": "오리진이 h3를 못 쓰는 건 사실일 수 있지만 연결은 애초에 분리돼 있다. 하나의 연결이 강등되는 그림이 아니다." + "why": "오리진이 h3를 못 쓰는 것은 사실일 수 있지만 연결은 애초에 분리돼 있다. 하나의 연결이 강등되는 구조가 아니다." }, { "text": "협상된 프로토콜이 유저부터 오리진까지 그대로 이어지는 것이고, 로그 표기 오류일 뿐이다", @@ -830,10 +830,10 @@ { "text": "h3가 아직 실험 단계라 중간 장비들이 자동으로 구버전으로 강등하는 것이다", "distractorType": "outdated", - "why": "HTTP/3는 RFC 9114로 표준화됐다. 실험 프로토콜 시절의 감각이 남은 것이다." + "why": "HTTP/3는 RFC 9114로 표준화됐다. 실험 프로토콜이던 시절의 인식이 남은 것이다." } ], - "explanation": "CDN은 유저 세션의 종료점이다. 유저가 h3로 붙어도 CDN은 오리진과 자신이 관리하는 별도 커넥션 풀(h1.1/h2)로 요청하므로, 오리진 로그에 남는 프로토콜은 그 구간의 협상 결과다. 이 구조는 결함이 아니라 CDN의 설계다 — 오리진 연결을 재사용해 핸드셰이크를 아끼고, 유저 구간은 PoP에서 끊어 혼잡 제어를 유저 가까이서 시작한다. 그래서 '앞단 프로토콜'은 유저 경험 축이고, '오리진 프로토콜'은 커넥션 관리 축으로 따로 다뤄야 한다.", + "explanation": "CDN은 유저 세션의 종료점이다. 유저가 h3로 붙어도 CDN은 오리진과 자신이 관리하는 별도 커넥션 풀(h1.1/h2)로 요청하므로, 오리진 로그에 남는 프로토콜은 그 구간의 협상 결과다. 이 구조는 결함이 아니라 CDN의 설계다. 오리진 연결을 재사용해 핸드셰이크를 아끼고, 유저 구간은 PoP에서 끊어 혼잡 제어를 유저 가까이에서 시작한다. 그래서 '앞단 프로토콜'은 유저 경험 축이고, '오리진 프로토콜'은 커넥션 관리 축으로 따로 다뤄야 한다.", "source": "AWS CloudFront 개발자 안내서 — 동적 콘텐츠 서빙(엣지 연결 종료·오리진 연결 최적화), RFC 9114(HTTP/3)" }, { @@ -854,17 +854,17 @@ "why": "샘플링은 무작위라 특정 순간에 딱 1건만 남기지 않는다. 병합을 관측 왜곡으로 해석한 것이다." }, { - "text": "CDN이 인기 객체를 미리 예측해 선반입해 둔 덕분에 오리진이 쉴 수 있었던 것이다", + "text": "CDN이 인기 객체를 미리 예측해 프리페치해 둔 덕분에 오리진이 쉴 수 있었던 것이다", "distractorType": "overgeneralized", - "why": "선반입은 이 순간의 설명이 아니다. 첫 요청만 오리진에 가고 나머지는 그 응답을 기다렸다 받는다." + "why": "프리페치는 이 상황의 설명이 아니다. 첫 요청만 오리진에 가고 나머지는 그 응답을 기다렸다 받는다." }, { - "text": "동일 키 동시 미스 병합 — 첫 요청만 오리진으로 가고 나머지는 그 응답을 기다려 받는 것이다", + "text": "동일 키 동시 미스 병합: 첫 요청만 오리진으로 가고 나머지는 그 응답을 기다려 받는 것이다", "correct": true, "why": "request collapsing이다. 만료 순간의 동시 재검증 폭주를 오리진에 그대로 전달하지 않는 표준 방어다." } ], - "explanation": "캐시가 같은 키에 대한 동시 미스를 하나로 병합하는 것은 널리 쓰이는 방어다. nginx의 proxy_cache_lock처럼 잠금으로 구현되기도 하고 CDN마다 request collapsing이라 부르기도 한다. 첫 요청이 오리진에서 응답을 받아 오는 동안 나머지 요청은 대기하고, 응답이 캐시에 들어오면 그대로 히트로 서빙된다. TTL 만료 순간과 무효화 직후에 인기 객체로 요청이 몰리는 상황에서 오리진을 지키는 첫 번째 안전장치다. 다만 병합은 동시에 몰린 요청에만 작동하므로, 각기 다른 시간에 흩어져 도착하는 미스까지 막아주지는 않는다 — 그건 TTL과 계층의 몫이다.", + "explanation": "캐시가 같은 키에 대한 동시 미스를 하나로 병합하는 것은 널리 쓰이는 방어다. nginx의 proxy_cache_lock처럼 잠금으로 구현되기도 하고 CDN마다 request collapsing이라 부르기도 한다. 첫 요청이 오리진에서 응답을 받아 오는 동안 나머지 요청은 대기하고, 응답이 캐시에 들어오면 그대로 히트로 서빙된다. TTL 만료 순간과 무효화 직후에 인기 객체로 요청이 몰리는 상황에서 오리진을 지키는 첫 번째 안전장치다. 다만 병합은 동시에 몰린 요청에만 작동하므로, 각기 다른 시간에 흩어져 도착하는 미스까지 막아주지는 않는다. 그것은 TTL과 계층의 몫이다.", "source": "nginx 공식 문서 — proxy_cache_lock(같은 키의 동시 요청은 1건만 프록시), AWS CloudFront 공식 문서 — Origin Shield(동시 요청 병합)" }, { @@ -892,10 +892,10 @@ { "text": "실드 히트율은 별도 콘솔 지표로 집계되는 벤더 설정이라 현재 값은 그대로 믿으면 안 되는 것이다", "distractorType": "vendor-mixup", - "why": "지표 혼합을 의심하는 그림은 다른 벤더의 콘솔 체계를 이식한 것이다. 지표는 정상이고 값이 낮은 것이 본체다." + "why": "지표를 의심하는 해석은 다른 벤더의 콘솔 체계를 가져온 것이다. 지표는 정상이고 값이 낮다는 사실 자체가 문제다." } ], - "explanation": "origin shield의 절감은 '오리진 도달률 = 엣지 미스율 × (1 − 실드 히트율)' 하나로 계산된다. 엣지 미스율 8%에서 실드 히트율 5%라면 절감은 거의 0이고, 이 실드 히트율이 깨지는 대표 원인이 두 계층의 캐시 키 규칙 불일치다 — 엣지가 계산한 키와 실드가 계산한 키가 어긋나면 같은 객체가 실드에서 계속 다른 엔트리로 들어가 히트가 성립하지 않는다. 실드는 스위치가 아니라 키 위생의 연장이고, 켜기만 해서 절감이 보장되지 않는 이유가 여기에 있다. 계층을 늘리기 전에 '두 층이 같은 키를 계산하는가'부터 확인해야 한다.", + "explanation": "origin shield의 절감은 '오리진 도달률 = 엣지 미스율 × (1 − 실드 히트율)' 하나로 계산된다. 엣지 미스율 8%에서 실드 히트율 5%라면 절감은 거의 0이고, 이 실드 히트율이 깨지는 대표 원인이 두 계층의 캐시 키 규칙 불일치다. 엣지가 계산한 키와 실드가 계산한 키가 어긋나면 같은 객체가 실드에서 계속 다른 엔트리로 들어가 히트가 성립하지 않는다. 실드는 스위치가 아니라 키 위생의 연장이고, 켜기만 해서 절감이 보장되지 않는 이유가 여기에 있다. 계층을 늘리기 전에 '두 층이 같은 키를 계산하는가'부터 확인해야 한다.", "source": "AWS CloudFront 공식 문서 — Origin Shield(캐시 키 정규화 규칙이 계층 간 히트율을 결정)" }, { @@ -926,7 +926,7 @@ "why": "TTL 시계는 오리진 장애와 무관하게 흐른다. 만료를 무릅쓰고 내려주도록 '설정된' 동작이다." } ], - "explanation": "stale-if-error(또는 벤더의 serve-stale 설정)은 재검증하러 간 요청이 5xx 등으로 실패했을 때, 만료된 캐시 응답을 지정한 시간만큼 계속 내려주는 지시자다. 오리진 장애가 곧 서비스 장애로 번지는 것을 캐시 계층에서 끊는 표준 완화 수단이고, 무효화·배포 지연 창을 흡수하는 용도로도 쓰인다. 주의할 구분은 두 가지다 — stale-while-revalidate는 재검증 '동안' 잠시 오래된 것을 주는 것이고, 기본 동작은 오류를 그대로 전파한다는 것. '만료 = 못 쓴다'가 아니라 '만료 = 설정이 허용하면 쓸 수 있다'가 이 기능의 세계관이다.", + "explanation": "stale-if-error(또는 벤더의 serve-stale 설정)은 재검증하러 간 요청이 5xx 등으로 실패했을 때, 만료된 캐시 응답을 지정한 시간만큼 계속 내려주는 지시자다. 오리진 장애가 곧 서비스 장애로 번지는 것을 캐시 계층에서 끊는 표준 완화 수단이고, 무효화·배포 지연 창을 흡수하는 용도로도 쓰인다. 주의할 구분은 두 가지다. stale-while-revalidate는 재검증이 진행되는 '동안' 잠시 오래된 것을 주는 것이고, 기본 동작은 오류를 그대로 전파한다는 것이다. '만료 = 못 쓴다'가 아니라 '만료 = 설정이 허용하면 쓸 수 있다'가 이 기능이 전제하는 원칙이다.", "source": "RFC 5861(stale-if-error), Fastly 개발자 문서 — stale 콘텐츠 서빙(stale_if_error 설정)" }, { @@ -954,10 +954,10 @@ { "text": "무효화 호출은 실패해도 알려주지 않으므로 완료 판정에 재호출 확인 단계를 더해야 한다", "distractorType": "one-step-short", - "why": "재시도 검토도 필요하지만 지금 관찰되는 위험은 실패가 아니라 전파 지연이다. 병목을 앞당겨 짚었다." + "why": "재시도 검토도 필요하지만 지금 관찰되는 위험은 실패가 아니라 전파 지연이다. 위험 지점을 잘못 짚었다." } ], - "explanation": "무효화 API의 200은 '요청을 접수했다'는 응답이지 '전 세계 PoP에서 사라졌다'는 보증이 아니다. 실제 제거는 비동기로 전파되어 완료까지 시간이 걸리고, 그 창 동안 캐시 히트는 옛 응답을 계속 내준다. 그래서 실무 교리는 무효화를 비상 수단으로 쓰고 상시 정합성은 '짧은 TTL + 내용이 바뀌면 URL이 바뀌는 버전 체계'로 확보하는 것이다. 배포 완료 판정을 무효화 접수 시점에 두려면, 그 뒤에 따르는 전파 지연 창을 수용 가능한 이탈로 명시해야 한다.", + "explanation": "무효화 API의 200은 '요청을 접수했다'는 응답이지 '전 세계 PoP에서 사라졌다'는 보증이 아니다. 실제 제거는 비동기로 전파되어 완료까지 시간이 걸리고, 그 창 동안 캐시 히트는 옛 응답을 계속 내준다. 그래서 실무 원칙은 무효화를 비상 수단으로 쓰고 상시 정합성은 '짧은 TTL + 내용이 바뀌면 URL이 바뀌는 버전 체계'로 확보하는 것이다. 배포 완료 판정을 무효화 접수 시점에 두려면, 그 뒤에 따르는 전파 지연 창을 수용 가능한 이탈로 명시해야 한다.", "source": "AWS CloudFront 공식 문서 — 파일 무효화(전파 시간·비동기 처리)" }, { @@ -985,10 +985,10 @@ { "text": "무효화가 진행되는 동안 해당 경로의 요청이 일시적으로 5xx로 떨어지는 것이다", "distractorType": "adjacent", - "why": "무효화 중에도 요청은 정상 처리된다 — 미스로 돌아 오리진을 다녀올 뿐이다. 실패로 바뀌는 구간은 없다." + "why": "무효화 중에도 요청은 정상 처리된다. 미스로 처리되어 오리진을 다녀올 뿐이다. 실패로 바뀌는 구간은 없다." } ], - "explanation": "전체 무효화는 웜 캐시 전체를 버린다. 배포 직후 모든 객체가 콜드 상태에서 출발해 인기 콘텐츠의 미스가 오리진에 동시에 몰리고, 트래픽이 클 때는 이것 자체가 장애가 된다. 무효화의 설계된 용도는 변경된 객체만 선별적으로 지우는 비상 수단이고, 상시 정합성은 짧은 TTL과 내용이 바뀌면 URL이 바뀌는 버전 체계로 확보하는 게 교리다 — CloudFront 문서도 되도록 무효화 대신 버전이 바뀐 파일명 사용을 권고한다. 무효화 자체가 오리진으로 요청을 쏘거나 브라우저 사본까지 지우거나 요청을 실패시키지는 않는다. 비용은 전부 그 뒤의 콜드 미스로 나타난다.", + "explanation": "전체 무효화는 웜 캐시 전체를 버린다. 배포 직후 모든 객체가 콜드 상태에서 출발해 인기 콘텐츠의 미스가 오리진에 동시에 몰리고, 트래픽이 클 때는 이것 자체가 장애가 된다. 무효화의 설계된 용도는 변경된 객체만 선별적으로 지우는 비상 수단이고, 상시 정합성은 짧은 TTL과 내용이 바뀌면 URL이 바뀌는 버전 체계로 확보하는 것이 원칙이다. CloudFront 문서도 되도록 무효화 대신 버전이 바뀐 파일명 사용을 권고한다. 무효화 자체가 오리진으로 요청을 쏘거나 브라우저 사본까지 지우거나 요청을 실패시키지는 않는다. 비용은 전부 그 뒤의 콜드 미스로 나타난다.", "source": "AWS CloudFront 공식 문서 — 파일 무효화(버전이 바뀐 파일명 사용 권고)" }, { @@ -1019,7 +1019,7 @@ "why": "총량이 같아도 단가 높은 리전의 비중이 늘면 청구는 커진다. 로그의 믹스 변화가 바로 그 신호다." } ], - "explanation": "주요 CDN의 전송 단가는 리전 스키마로 쪼개져 있고, 남미·아프리카 등은 북미·유럽보다 비싸다 — CloudFront 기준 $0.110 대 $0.085로 약 1.3배이고 GCP는 격차가 더 크다. 단가 격차가 상한이므로 총량이 같을 때 청구가 그 배수를 넘을 수는 없다. 그래서 같은 트래픽 총량이어도 발신 리전의 믹스가 바뀌면 청구가 크게 달라진다 — 바이럴 확산으로 특정 지역 트래픽이 뛰는 커뮤니티 서비스에서 자주 보이는 패턴이다. 비용 추정은 '기준 리전 단가 × 총량'이 아니라 '리전별 단가 × 리전별 트래픽'으로 계산해야 하고, 다국적 서비스라면 예산을 리전 믹스 시나리오로 감싸야 한다. 요금표의 리전 구분은 벤더마다 스키마가 다르다는 점도 비교 검토 때 함께 봐야 한다.", + "explanation": "주요 CDN의 전송 단가는 리전별로 쪼개져 있고, 남미·아프리카 등은 북미·유럽보다 비싸다. CloudFront 기준으로 $0.110 대 $0.085, 약 1.3배이고 GCP는 격차가 더 크다. 단가 격차가 상한이므로 총량이 같을 때 청구가 그 배수를 넘을 수는 없다. 그래서 같은 트래픽 총량이어도 발신 리전의 믹스가 바뀌면 청구가 크게 달라진다. 바이럴 확산으로 특정 지역의 트래픽이 뛰는 커뮤니티 서비스에서 자주 보이는 패턴이다. 비용 추정은 '기준 리전 단가 × 총량'이 아니라 '리전별 단가 × 리전별 트래픽'으로 계산해야 하고, 다국적 서비스라면 예산을 리전 믹스 시나리오별로 세워야 한다. 요금표의 리전 구분 방식이 벤더마다 다르다는 점도 비교 검토 때 함께 봐야 한다.", "source": "AWS CloudFront 공식 요금표(리전별 데이터 전송 단가), Google Cloud CDN 가격 문서(리전별 차등)" } ] diff --git a/data/questions/image.json b/data/questions/image.json index 8d16791..b754ec8 100644 --- a/data/questions/image.json +++ b/data/questions/image.json @@ -19,15 +19,15 @@ { "text": "인코더마다 quality 척도가 달라 지각 품질 지표로 다시 비교해야 한다", "correct": true, - "why": "비교의 정석은 지각 품질을 고정하고 그 지점의 바이트를 재는 것이다. 숫자 동일성은 품질 동일성이 아니다." + "why": "비교의 정석은 지각 품질을 고정하고 그 지점의 바이트를 재는 것이다. 숫자가 같다고 품질이 같은 것은 아니다." }, { "text": "WebP는 JPEG보다 작게 나오므로 용량만 확인하면 된다", "distractorType": "overgeneralized", - "why": "콘텐츠와 품질점에 따라 편차가 있고 '같은 품질' 전제 없이는 비교가 성립하지 않는다." + "why": "콘텐츠와 품질 수준에 따라 편차가 있고 '같은 품질'이라는 전제 없이는 비교가 성립하지 않는다." } ], - "explanation": "quality는 인코더가 양자화 정책을 스케일하는 관례 값이지 표준 단위가 아니다. 그래서 포맷은 물론 같은 포맷에서 인코더를 바꾸기만 해도 같은 숫자의 결과가 달라진다. 포맷 비교의 정석은 지각 품질 지표로 품질을 고정하고 그 지점의 바이트를 재는 것이다. WebP 전환 보고서가 '같은 75'에 기대고 있다면 그 비교는 처음부터 성립하지 않았다.", + "explanation": "quality는 인코더가 양자화 정책을 스케일하는 관례 값이지 표준 단위가 아니다. 그래서 포맷이 다르면 물론이고 같은 포맷에서 인코더만 바꿔도 같은 숫자에서 다른 결과가 나온다. 포맷 비교의 정석은 지각 품질 지표로 품질을 고정하고 그 지점의 바이트를 재는 것이다. WebP 전환 보고서가 '같은 75'라는 숫자를 근거로 삼고 있다면 그 비교는 처음부터 성립하지 않았다.", "source": "Google cwebp 문서(-q 파라미터), IJG libjpeg 문서 — quality는 인코더 도구의 관례 파라미터" }, { @@ -40,7 +40,7 @@ { "text": "리사이즈 커널의 링잉 아티팩트다. Lanczos3를 bicubic으로 바꾼다", "distractorType": "adjacent", - "why": "커널 아티팩트는 경계의 진동이지 색 번짐이 아니다. 축소 품질의 축과 색상의 축을 섞었다." + "why": "커널 아티팩트는 경계의 진동이지 색 번짐이 아니다. 축소 품질 문제와 색 문제를 혼동한 것이다." }, { "text": "사진용 기본값인 색차 서브샘플링(4:2:0)을 텍스트 이미지에 적용한 것이다", @@ -89,7 +89,7 @@ "why": "뷰어 보정은 원본 보기에만 작동한다. 태그 없이 나가는 파생물에서는 그대로 재발한다." } ], - "explanation": "스마트폰 카메라는 센서 방향 그대로 픽셀을 저장하고 촬영 방향을 EXIF orientation 태그(값 1~8)로 기록한다. 뷰어는 이 태그를 읽어 회전해 보여주므로 원본은 정상으로 보인다. 그런데 파생물을 만들 때 태그를 버리고 픽셀만 리사이즈하면 누운 썸네일이 나온다. 해결은 변환 전에 태그를 픽셀에 굽는 것(bake)이고, 원본은 태그째 보관하며 파생물에만 굽는다. '리사이즈 후 이미지가 누웠다'는 장애의 1순위 원인으로 두는 것이 맞다.", + "explanation": "스마트폰 카메라는 센서 방향 그대로 픽셀을 저장하고 촬영 방향을 EXIF orientation 태그(값 1~8)로 기록한다. 뷰어는 이 태그를 읽어 회전해 보여주므로 원본은 정상으로 보인다. 그런데 파생물을 만들 때 태그를 버리고 픽셀만 리사이즈하면 누운 썸네일이 나온다. 해결은 변환 전에 태그를 픽셀에 굽는 것(bake)이고, 원본은 태그째 보관하며 파생물에만 굽는다. '리사이즈 후 이미지가 누웠다'는 장애가 생기면 가장 먼저 의심할 원인으로 두는 것이 맞다.", "source": "EXIF 사양 CIPA DC-008(Orientation tag)" }, { @@ -105,9 +105,9 @@ "why": "보존은 광색역을 명시적으로 지원할 때의 유효 경로지만, 프로파일을 이해하지 못하는 클라이언트에서는 여전히 색이 틀어진다." }, { - "text": "지금처럼 벗기는 게 맞고 빛바램은 오래된 브라우저의 버그다", + "text": "지금처럼 제거하는 게 맞고 빛바램은 오래된 브라우저의 버그다", "distractorType": "inverted", - "why": "변환 없이 태그만 벗기면 P3 픽셀이 sRGB로 해석돼 채도가 낮아지는 예측된 결과다. 버그가 아니다." + "why": "변환 없이 태그만 제거하면 P3 픽셀이 sRGB로 해석되어 채도가 낮아진다. 예측할 수 있는 결과이지 버그가 아니다." }, { "text": "P3를 sRGB로 변환한 뒤 태그를 최소화한다. 순서가 핵심이다", @@ -120,7 +120,7 @@ "why": "비트 깊이는 표현 단계 수지 색역 자체가 아니다. 색 공간 변환 문제를 해결하지 못한다." } ], - "explanation": "색 공간 태그는 픽셀 값을 어떤 절대 색으로 해석할지의 약속이다. P3 원본에서 변환 없이 태그만 벗기면 웹은 그 픽셀을 sRGB로 읽어 채도가 낮아 보인다. 웹 서빙용 파생물의 기본 정책은 sRGB로 변환한 뒤 태그를 최소화하는 것이고, 이 순서가 바뀌면 손실은 되돌릴 수 없다. 프로파일을 원본 그대로 보존하는 경로는 광색역 지원을 명시적으로 제공할 때 별도 파라미터로 연다.", + "explanation": "색 공간 태그는 픽셀 값을 어떤 절대 색으로 해석할지의 약속이다. P3 원본에서 변환 없이 태그만 제거하면 웹은 그 픽셀을 sRGB로 읽어 채도가 낮아 보인다. 웹 서빙용 파생물의 기본 정책은 sRGB로 변환한 뒤 태그를 최소화하는 것이고, 이 순서가 바뀌면 손실은 되돌릴 수 없다. 프로파일을 원본 그대로 보존하는 경로는 광색역 지원을 명시적으로 제공할 때 별도 파라미터로 연다.", "source": "ICC 사양(ICC.1), IEC 61966-2-1(sRGB)" }, { @@ -133,7 +133,7 @@ { "text": "캐시 키 설정에서 파라미터 이름의 대소문자를 무시하도록 켠다", "distractorType": "one-step-short", - "why": "로그에 보이는 차이의 절반만 닫는다. 대소문자를 맞춰도 파라미터 순서와 생략된 기본값은 여전히 별개 키로 갈린다." + "why": "로그에 보이는 차이의 절반만 해결한다. 대소문자를 맞춰도 파라미터 순서와 생략된 기본값은 여전히 별개 키로 갈린다." }, { "text": "변형마다 서브도메인을 배정해 캐시를 나눠 담는다", @@ -148,10 +148,10 @@ { "text": "요청을 표준형 URL로 재작성하는 계층을 CDN 앞단에 둔다", "correct": true, - "why": "CDN은 문자열이 다르면 다른 키로 본다. 표준형을 정하고 그 변환을 한 곳에서 강제해야 분열이 닫힌다." + "why": "CDN은 문자열이 다르면 다른 키로 본다. 표준형을 정하고 그 변환을 한 곳에서 강제해야 캐시 키가 갈라지는 문제가 사라진다." } ], - "explanation": "CDN의 캐시 키는 URL 문자열에 충실하다. CloudFront는 쿼리 파라미터의 순서와 대소문자가 다르면 별도로 캐싱하고, Cloud CDN도 쿼리가 조금이라도 다르면 miss다. 그래서 정규화 — 순서 고정, 소문자 통일, 기본값 생략 — 는 URL을 생성하는 서비스의 책임이 된다. 정규 표준 URL을 한 번 만들어 캐시 키·서명·로그가 전부 그 문자열만 보게 하면 이 클래스의 히트율 누수는 사라진다.", + "explanation": "CDN의 캐시 키는 URL 문자열에 충실하다. CloudFront는 쿼리 파라미터의 순서와 대소문자가 다르면 별도로 캐싱하고, Cloud CDN도 쿼리가 조금이라도 다르면 miss다. 그래서 순서 고정, 소문자 통일, 기본값 생략 같은 정규화는 URL을 생성하는 서비스의 책임이 된다. 정규 표준 URL을 한 번 만들어 캐시 키·서명·로그가 전부 그 문자열만 보게 하면 이 클래스의 히트율 누수는 사라진다.", "source": "당근 테크블로그 「AWS Lambda@Edge에서 실시간 이미지 리사이즈 & WebP 형식으로 변환」(2019) — 파라미터 순서·대소문자별 캐싱 경고" }, { @@ -182,7 +182,7 @@ "why": "완화일 뿐, 허용 집합을 정의하지 않으면 변형은 계속 늘어난다." } ], - "explanation": "검증 없는 임의 크기 수용은 두 가지 폭발을 만든다. 캐시 변형이 무한히 늘어나 히트율이 무너지고, 임의 크기 변환의 반복 요청이 변환 CPU를 태운다. 방어선은 이산 화이트리스트다. 서비스가 실제로 쓰는 크기 집합만 허용하고 밖의 값은 400으로 거부한다. 클램프는 요청과 다른 콘텐츠를 조용히 서빙하므로 거부가 원칙이며, 프론트엔드가 요구하는 규격 집합과 서버의 화이트리스트가 일치해야 캐시도 공유된다.", + "explanation": "검증 없는 임의 크기 수용은 두 가지 폭발을 만든다. 캐시 변형이 무한히 늘어나 히트율이 무너지고, 임의 크기 변환을 반복시키는 요청이 변환 CPU를 소모시킨다. 방어선은 이산 화이트리스트다. 서비스가 실제로 쓰는 크기 집합만 허용하고 밖의 값은 400으로 거부한다. 클램프는 요청과 다른 콘텐츠를 조용히 서빙하므로 거부가 원칙이며, 프론트엔드가 요구하는 규격 집합과 서버의 화이트리스트가 일치해야 캐시도 공유된다.", "source": "Cloudflare Images 공개 문서(Variants — 변형은 고정 집합으로 정의)" }, { @@ -190,7 +190,7 @@ "topic": "image", "tags": ["decompression-bomb", "memory", "decode"], "difficulty": 2, - "question": "변환 워커가 2MB짜리 PNG 업로드 하나를 처리하다 OOM으로 죽었다. 업로드에는 10MB 파일 크기 상한이 이미 걸려 있었다. 예산 변수는?", + "question": "변환 워커가 2MB짜리 PNG 업로드 하나를 처리하다 OOM으로 죽었다. 업로드에는 10MB 파일 크기 상한이 이미 걸려 있었다. 실제로 상한을 걸어야 할 변수는 무엇인가?", "options": [ { "text": "파일 크기 상한을 더 낮춰야 한다", @@ -213,7 +213,7 @@ "why": "JPEG도 픽셀 폭탄이 가능하다. 포맷 차단이 아니라 픽셀 수 상한이 방어선이다." } ], - "explanation": "decompression bomb의 예산 변수는 파일의 메가바이트가 아니라 디코드 후 픽셀 수다. RGBA 기준 1억 픽셀은 약 400MB라 2MB짜리 PNG 하나가 워커를 죽일 수 있다. 방어는 본문을 디코드하기 전에 헤더만 읽어 w×h 상한을 검사하는 것, 그리고 디코드를 격리된 워커에서 수행하는 것이다. 이미지 디코더는 신뢰할 수 없는 입력을 파싱하는 오래된 네이티브 코드라 공격면 취급이 원칙이다.", + "explanation": "decompression bomb의 예산 변수는 파일의 메가바이트가 아니라 디코드 후 픽셀 수다. RGBA 기준 1억 픽셀은 약 400MB라 2MB짜리 PNG 하나가 워커를 죽일 수 있다. 방어는 본문을 디코드하기 전에 헤더만 읽어 w×h 상한을 검사하는 것, 그리고 디코드를 격리된 워커에서 수행하는 것이다. 이미지 디코더는 신뢰할 수 없는 입력을 파싱하는 오래된 네이티브 코드라 공격면으로 취급하는 것이 원칙이다.", "source": "ImageMagick policy.xml 공식 문서(width/height/area 상한), NVD CVE-2023-4863(libwebp 디코더 취약점)" }, { @@ -226,17 +226,17 @@ { "text": "AVIF가 애니메이션을 지원하지 않아 GIF 대체 경로가 막힌다는 우려", "distractorType": "outdated", - "why": "지원은 Accept 헤더 분포로 확인하는 축이고 이미 높다. 지원 논점으로 다른 축을 덮고 있다." + "why": "지원 여부는 Accept 헤더 분포로 확인하는 문제이고 이미 충분함을 확인했다. 지원 논점으로 정작 검토해야 할 비용 문제를 가리고 있다." }, { "text": "AVIF 디코딩은 가벼우니 클라이언트 비용은 안 봐도 된다는 믿음", "distractorType": "inverted", - "why": "AVIF 디코드는 JPEG보다 비싸다. 저가 기기의 배터리·스크롤 성능에 영향한다." + "why": "AVIF 디코드는 JPEG보다 비싸다. 저가 기기의 배터리와 스크롤 성능에 영향을 준다." }, { "text": "인코딩 CPU 비용이 miss 지연과 변환 단가에 미치는 영향", "correct": true, - "why": "고품질 AVIF 인코딩은 크게 느려 miss p95와 비용 구조를 바꾼다. 이중 전략 검토로 이어진다." + "why": "고품질 AVIF 인코딩은 훨씬 느려서 miss p95와 비용 구조를 바꾼다. 그래서 이중 전략 검토로 이어진다." }, { "text": "전송 바이트가 줄면 총비용도 내려간다는 가정", @@ -244,7 +244,7 @@ "why": "대역폭 절감과 신규 변환 CPU 비용은 다른 축이라 상쇄 계산이 필요하다." } ], - "explanation": "포맷 선택은 압축 효율 이전에 인코딩 CPU 비용을 어느 계층에서 누가 지불할지의 아키텍처 문제다. 고품질 AVIF 인코딩은 WebP보다 크게 느려 on-the-fly의 miss 지연과 변환 단가를 바꾼다. 그래서 실무는 인기 콘텐츠는 사전 생성, 롱테일은 WebP/JPEG로 즉시 응답하는 이중 전략이나 빠른 인코더 백엔드와의 품질 트레이드오프로 간다. 디코드 비용도 클라이언트가 내므로 저가 기기 배터리·스크롤 성능까지가 검토 범위다.", + "explanation": "포맷 선택은 압축 효율 이전에 인코딩 CPU 비용을 어느 계층에서 누가 지불할지를 정하는 아키텍처 문제다. 고품질 AVIF 인코딩은 WebP보다 크게 느려 on-the-fly의 miss 지연과 변환 단가를 바꾼다. 그래서 실무는 인기 콘텐츠는 사전 생성, 롱테일은 WebP/JPEG로 즉시 응답하는 이중 전략이나 빠른 인코더 백엔드와의 품질 트레이드오프로 간다. 디코드 비용도 클라이언트가 내므로 저가 기기 배터리·스크롤 성능까지가 검토 범위다.", "source": "libavif 공개 문서(speed 파라미터), 당근 테크블로그(2019) — 변환 비용 실측(평균 0.492초, 월 $2,400)" }, { @@ -255,7 +255,7 @@ "question": "같은 URL에서 AVIF/WebP/JPEG를 클라이언트별로 주고 싶다. Vary: Accept 는 공유 캐시의 변종을 폭발시킨다. 클라이언트가 실제로 지원하는 포맷을 정확히 반영하면서 히트율까지 지키는 실무적 방식은?", "options": [ { - "text": "Accept를 포맷 소수값으로 정규화해 URL 파라미터로 굽는다", + "text": "Accept를 소수의 포맷 값으로 정규화해 URL 파라미터에 넣는다", "correct": true, "why": "캐시 키의 카디널리티가 포맷 수(2~3)로 고정된다. URL 생성 시점에 정규화하는 방식이 실증돼 있다." }, @@ -265,17 +265,17 @@ "why": "변종 수가 줄지 않는다. 신선도는 카디널리티 문제의 해법이 아니다." }, { - "text": "User-Agent로 판별해 클라이언트별 URL을 본문에 심는다", + "text": "User-Agent로 판별해 클라이언트별 URL을 본문에 넣는다", "distractorType": "adjacent", "why": "UA 문자열은 Accept보다 카디널리티가 더 크고 불안정하다. 협상 축을 잘못 골랐다." }, { "text": "협상을 포기하고 모든 클라이언트에 JPEG를 준다", "distractorType": "overgeneralized", - "why": "회피는 히트율을 지키지만 압축 효율을 버린다. 문제의 해결이 아니다." + "why": "회피는 히트율을 지키지만 압축 효율을 버린다. 문제를 해결한 것이 아니다." } ], - "explanation": "Vary: Accept의 문제는 논리가 아니라 카디널리티다. Accept 문자열은 브라우저 빌드 수준으로 다양해 같은 이미지가 수십 개 변종으로 쪼개진다. 해법은 협상 축을 정규화하는 것이다. URL을 만드는 시점에 서버가 지원 여부를 판단해 쿼리 파라미터로 굽거나, 엣지에서 Accept를 webp/avif/jpeg 버킷으로 접는다. 어느 쪽이든 캐시 키의 카디널리티가 포맷 수로 고정되고, 캐시는 불변 URL만 보게 된다.", + "explanation": "Vary: Accept의 문제는 논리가 아니라 카디널리티다. Accept 문자열은 브라우저 빌드 수준으로 다양해 같은 이미지가 수십 개 변종으로 쪼개진다. 해법은 협상 축을 정규화하는 것이다. URL을 만드는 시점에 서버가 지원 여부를 판단해 쿼리 파라미터에 반영하거나, 엣지에서 Accept를 webp/avif/jpeg 세 갈래로 묶는다. 어느 쪽이든 캐시 키의 카디널리티가 포맷 수로 고정되고, 캐시는 불변 URL만 보게 된다.", "source": "RFC 9110 §12.5.5(Vary), 당근 테크블로그(2019) — 쿼리 파라미터 정규화 방식 채택" }, { @@ -288,7 +288,7 @@ { "text": "VMAF는 0~10 점수라 95 같은 임계가 잘못됐다", "distractorType": "plausible-number", - "why": "VMAF는 대략 0~100 스케일이다. 스케일 논점이지 이 리뷰의 본질이 아니다." + "why": "VMAF는 대략 0~100 스케일이다. 게다가 스케일은 이 리뷰의 본질도 아니다." }, { "text": "VMAF는 PSNR보다 지각 상관이 낮아 게이트 지표로 부적절하다", @@ -298,15 +298,15 @@ { "text": "VMAF 계산이 이미지마다 수 초 걸려 게이트가 파이프라인 병목이 된다", "distractorType": "adjacent", - "why": "계산 비용은 실재하지만 표본 축소·캐시로 다룰 운영 문제다. 지표가 이 대상에 맞는지와는 다른 축이다." + "why": "계산 비용은 실재하지만 표본 축소나 캐시로 다룰 운영 문제다. 지표가 이 대상에 맞는지와는 별개의 문제다." }, { "text": "VMAF는 모션 특징을 학습한 비디오용 지표라 정지 이미지에 부적합하다", "correct": true, - "why": "이미지 회귀 게이트엔 저렴한 PSNR/SSIM 감시선과 지각 지표의 조합이 맞다." + "why": "이미지 회귀 게이트에는 계산이 저렴한 PSNR/SSIM과 지각 지표를 조합하는 구성이 맞다." } ], - "explanation": "VMAF는 모션 특징을 포함해 비디오용으로 학습된 지표라 정지 이미지의 품질 임계로 쓸 수 없다. 이미지 회귀 게이트의 정석은 저렴한 PSNR/SSIM을 매 커밋의 감시선으로 걸고, 여기서 튀면 지각 지표로 정밀 분석하는 이층 구조다. 그리고 어떤 단일 지표도 '사람 눈에 같다'의 증명이 아니므로 지표는 둘 이상 쓰고 최종 판단은 사람 스팟 체크로 남긴다.", + "explanation": "VMAF는 모션 특징을 포함해 비디오용으로 학습된 지표라 정지 이미지의 품질 임계로 쓸 수 없다. 이미지 회귀 게이트의 정석은 계산이 저렴한 PSNR/SSIM을 매 커밋에 검사하는 1차 지표로 걸고, 여기서 이상값이 나오면 지각 지표로 정밀 분석하는 이층 구조다. 그리고 어떤 단일 지표도 '사람 눈에 같다'의 증명이 아니므로 지표는 둘 이상 쓰고 최종 판단은 사람 스팟 체크로 남긴다.", "source": "Netflix VMAF 공개 문서·저장소(비디오용 지표), Wang 외(2004) SSIM 논문" }, { @@ -317,27 +317,27 @@ "question": "UGC 이미지 서비스: 하루 업로드 50만 장, 하루 이미지 요청 2억 건, 사용 규격 4종에 2종 추가 예정. 사전 생성과 on-the-fly 중 어느 쪽이 유리한가?", "options": [ { - "text": "사전 생성 — 응답이 즉시라 규격이 늘어도 유리하다", + "text": "사전 생성: 응답이 즉시라 규격이 늘어도 유리하다", "distractorType": "overgeneralized", "why": "규격 추가마다 기존 전량 재생성이 필요하다. 읽기 대 쓰기 비율을 보지 않았다." }, { - "text": "on-the-fly — 변환 비용은 첫 요청자만 내고, 파생물을 저장할 필요가 없다", + "text": "on-the-fly: 변환 비용은 첫 요청자만 내고, 파생물을 저장할 필요가 없다", "correct": true, "why": "파생물 저장이 0이고 규격 변경이 즉시 반영된다. miss는 첫 요청자만 지불한다." }, { - "text": "사전 생성 — miss 지연이 없으니 사용자 경험에서 앞선다", + "text": "사전 생성: miss 지연이 없으니 사용자 경험에서 앞선다", "distractorType": "one-step-short", "why": "첫 응답 축만 보면 맞지만 저장·재생성 비용과 규격 유연성을 버린 것이다." }, { - "text": "사전 생성 — 2억 건의 요청을 실시간으로 변환하면 컴퓨트가 따라가지 못하니 미리 만들어 두는 것이다", + "text": "사전 생성: 2억 건의 요청을 실시간으로 변환하면 컴퓨트가 따라가지 못하니 미리 만들어 두는 것이다", "distractorType": "overgeneralized", - "why": "변환은 첫 miss에만 일어나고 이후 캐시가 흡수한다. 전 요청이 변환기를 지난다고 상상한 것이다." + "why": "변환은 첫 miss에만 일어나고 이후 캐시가 흡수한다. 모든 요청이 변환기를 거친다고 잘못 전제한 것이다." } ], - "explanation": "두 아키텍처는 비용을 지불하는 시점이 다르다. 사전 생성은 쓰기 시점에 규격 수만큼 미리 지불하고 읽기를 싸게 만들고, on-the-fly는 첫 miss마다 지불하고 쓰기를 가볍게 만든다. 읽기가 쓰기보다 400배 많고 규격이 늘어날 예정이면 on-the-fly가 파생물 저장 0과 규격 변경 즉시 반영으로 이긴다. miss는 첫 요청자만 지불하고 이후 캐시가 흡수한다는 구조가 이 계산의 전제다.", + "explanation": "두 아키텍처는 비용을 지불하는 시점이 다르다. 사전 생성은 쓰기 시점에 규격 수만큼 미리 지불하고 읽기를 싸게 만들고, on-the-fly는 첫 miss마다 지불하고 쓰기를 가볍게 만든다. 읽기가 쓰기보다 400배 많고 규격이 늘어날 예정이면, 파생물을 저장하지 않고 규격 변경이 즉시 반영되는 on-the-fly가 유리하다. miss는 첫 요청자만 지불하고 이후 캐시가 흡수한다는 구조가 이 계산의 전제다.", "source": "당근 테크블로그(2019) — 하루 50만 업로드 vs 2억 요청, 사전 생성 방식의 4가지 문제" }, { @@ -365,10 +365,10 @@ { "text": "파일 크기 상한이다. 상한 이하면 GIF를 유지하고 넘으면 비디오로 보낸다", "distractorType": "one-step-short", - "why": "용량 상한은 결과를 재는 자이지 포맷을 가르는 축이 아니다. 같은 용량에서도 짧은 루프는 이미지 계열이, 긴 루프는 비디오가 이긴다." + "why": "용량 상한은 결과를 측정하는 기준이지 포맷을 고르는 기준이 아니다. 같은 용량에서도 짧은 루프는 이미지 계열이, 긴 루프는 비디오가 이긴다." } ], - "explanation": "GIF의 문제는 두 겹이다. 256색 팔레트 제한은 그라데이션에서 밴딩을 만들고, 프레임 독립 압축은 길이에 비례해 비효율이 쌓인다. 대체재 선택의 기준은 루프 길이다. 짧은 스티커성 루프는 animated WebP/AVIF가 같은 품질에 수 분의 1 크기고, 수 초를 넘는 부드러운 루프는 이전 프레임과의 차이만 부호화하는 비디오가 이긴다. '이미지 포맷'과 '비디오'의 경계를 루프 길이로 긋는 것이 설계 판단이다.", + "explanation": "GIF의 문제는 두 가지다. 256색 팔레트 제한은 그라데이션에서 밴딩을 만들고, 프레임 독립 압축은 길이에 비례해 비효율이 쌓인다. 대체재 선택의 기준은 루프 길이다. 짧은 스티커성 루프는 animated WebP/AVIF가 같은 품질에 수 분의 1 크기고, 수 초를 넘는 부드러운 루프는 이전 프레임과의 차이만 부호화하는 비디오가 이긴다. '이미지 포맷'과 '비디오'의 경계를 루프 길이로 긋는 것이 설계 판단이다.", "source": "GIF89a 사양(256색 팔레트), RFC 9649(WebP 애니메이션)" }, { @@ -386,12 +386,12 @@ { "text": "CDN에 서명 없는 요청을 차단하는 규칙을 추가한다", "distractorType": "one-step-short", - "why": "완화는 되지만 우회 가능한 필터다. 계층 분리라는 구조적 닫힘이 없다." + "why": "완화는 되지만 우회할 수 있는 필터다. 계층을 분리해 구조적으로 막는 해법이 아니다." }, { "text": "CDN이 검증을 이미 했으니 오리진은 응답 속도만 보면 된다", "distractorType": "inverted", - "why": "반대다. CDN을 통과했다고 오리진 권한까지 증명되지 않는다." + "why": "반대다. CDN을 통과했다고 해서 오리진에서 확인할 권한까지 증명된 것은 아니다." }, { "text": "서명 만료 시간을 짧게 줄여 노출 창을 최소화한다", @@ -399,7 +399,7 @@ "why": "만료는 통과된 요청의 수명만 줄인다. 우회 자체를 막지 못한다." } ], - "explanation": "Cloud CDN은 서명 관련 쿼리 파라미터가 없으면 '서명된 URL이 아니네' 하고 검증 없이 통과시키며, 오리진으로 보낼 때는 서명 파라미터를 떼 버린다. 그래서 오리진 입장에서 정상 서명 요청과 서명을 지운 요청이 똑같이 보인다. 해법은 CDN이 원본 URL을 별도 헤더로 전달하게 하고 오리진 앱이 시그니처를 재검증하는 것이다. 'CDN 서명 = 캐시 보호, 애플리케이션 재검증 = 권한 보호'라는 계층 분리로 일반화된다.", + "explanation": "Cloud CDN은 서명 관련 쿼리 파라미터가 없으면 서명 검증 대상이 아니라고 판단해 검증 없이 통과시키며, 오리진으로 보낼 때는 서명 파라미터를 제거한다. 그래서 오리진 입장에서 정상 서명 요청과 서명을 지운 요청이 똑같이 보인다. 해법은 CDN이 원본 URL을 별도 헤더로 전달하게 하고 오리진 앱이 시그니처를 재검증하는 것이다. 'CDN 서명 = 캐시 보호, 애플리케이션 재검증 = 권한 보호'라는 계층 분리로 일반화된다.", "source": "당근 「대규모 이미지 서비스 with Cloud CDN, Cloud Run, Cloud Storage」(2025 GCP 밋업 공개 발표) — X-Client-Request-URL 기반 시그니처 재검증" }, { @@ -412,7 +412,7 @@ { "text": "WebP의 주류 브라우저 기본 지원 여부", "distractorType": "outdated", - "why": "시점 의존 사실이라 기억이나 낡은 인용으로는 판단할 수 없다. 근거축이 아니다." + "why": "지원 여부는 시점에 따라 달라지는 사실이라 기억이나 낡은 인용으로는 판단할 수 없다. 그 자체로는 판단 근거가 되지 못한다." }, { "text": "포털·경쟁사가 WebP를 쓰고 있다는 사실", @@ -430,7 +430,7 @@ "why": "사용자 분포가 유일한 판단 근거다. 실측 위에서 전환 범위를 정한다." } ], - "explanation": "브라우저 지원률은 시점 의존 사실이라 기억이나 낡은 인용으로 판단할 수 없다. 근거는 자사 트래픽의 Accept 헤더 분포다. 당근도 2019년에 클라이언트의 80% 이상이 WebP를 지원하는 Android이라는 자체 분포 실측을 근거로 전환을 결정했다. 벤치마크로 효과를 확인하고, 자사 분포를 재고, 단계적으로 롤아웃하는 것이 정석 순서다.", + "explanation": "브라우저 지원률은 시점 의존 사실이라 기억이나 낡은 인용으로 판단할 수 없다. 근거는 자사 트래픽의 Accept 헤더 분포다. 당근도 2019년에 클라이언트의 80% 이상이 WebP를 지원하는 Android라는 자체 분포 실측을 근거로 전환을 결정했다. 벤치마크로 효과를 확인하고, 자사 분포를 측정하고, 단계적으로 롤아웃하는 것이 정석 순서다.", "source": "당근 테크블로그(2019) — 클라이언트 80% 이상이 WebP 지원 Android이라는 자체 분포 기반 결정" }, { @@ -453,12 +453,12 @@ { "text": "검증 코드를 엣지와 오리진에 복제해 이중으로 돌린다", "distractorType": "adjacent", - "why": "같은 코드가 두 계층에서 각자 진화해 어긋난다. 단일 소유자가 아니다." + "why": "같은 코드가 두 계층에서 따로 수정되다 어긋난다. 검증 로직을 책임지는 주체가 둘이 되기 때문이다." }, { "text": "이미지 서빙과 비즈니스 로직은 분리하니 현행을 유지한다", "distractorType": "one-step-short", - "why": "서명 재검증은 서빙 경로 자체의 요구다. 분리로 안 되는 것을 분리로 치웠다." + "why": "서명 재검증은 서빙 경로 자체에 붙는 요구다. 분리할 수 없는 것을 분리 논리로 회피한 것이다." } ], "explanation": "엣지 변환은 POP에서 변환해 오리진 왕복이 없지만 런타임 제약을 안는다. 본문 크기 상한, 단일 언어, 제한된 API가 그것이다. 요구가 서명 재검증·권한 확인 같은 비즈니스 로직을 포함하는 순간 풀 런타임이 필요해지고, 변환을 오리진 앱으로 옮기는 것이 구조적 답이다. 엣지는 캐시 계층으로 남는다. 실제로 이 요구 사항이 이미지 서비스를 엣지 함수에서 오리진 앱으로 옮긴 사례의 핵심이었다.", @@ -484,15 +484,15 @@ { "text": "quality 75는 대다수 콘텐츠에 이미 과잉이라 바이트만 낭비된다", "distractorType": "inverted", - "why": "방향을 뒤집었다. 낭비도 함께 일어나지만 CS 문의를 만드는 쪽은 반대편 — 텍스트·평면 일러스트에서의 과압축이다." + "why": "방향을 뒤집었다. 낭비도 함께 일어나지만 CS 문의를 일으키는 쪽은 반대편인 텍스트·평면 일러스트의 과압축이다." }, { "text": "클래스별 결과 편차가 커서 한쪽은 과압축, 반대쪽은 과잉 품질이 된다", "correct": true, - "why": "콘텐츠 클래스별 품질 계단을 정하거나 목표 지표 방식이 해법의 방향이다." + "why": "콘텐츠 클래스별로 품질 값을 달리 정하거나 목표 지표를 두는 방식이 해법의 방향이다." } ], - "explanation": "고정 quality는 품질 정책을 포기한 것이다. 노이즈 사진은 같은 값에서도 바이트가 크고 디테일이 먼저 무너지며, 플랫 그래픽은 과잉 품질로 낭비가 된다. 규모가 커지면 콘텐츠 클래스별로 오프라인 실험으로 품질 계단을 정하는 캘리브레이션, 또는 지각 지표를 목표로 이미지별로 탐색하는 방식으로 간다. 평균 바이트가 목표치 안이어도 분포의 최악이 무너지면 그게 사용자 항의로 나타난다.", + "explanation": "고정 quality는 품질 정책을 포기한 것이다. 노이즈 사진은 같은 값에서도 바이트가 크고 디테일이 먼저 무너지며, 플랫 그래픽은 과잉 품질로 낭비가 된다. 규모가 커지면 콘텐츠 클래스별로 오프라인 실험을 거쳐 품질 값을 정하는 캘리브레이션, 또는 지각 지표를 목표로 이미지별로 탐색하는 방식으로 간다. 평균 바이트가 목표치 안에 있어도 분포의 최악 구간이 무너지면 그것이 사용자 항의로 나타난다.", "source": "Google Research Guetzli 발표(2017) — 지각 지표를 목표로 인코딩 파라미터를 탐색하는 접근" }, { @@ -518,12 +518,12 @@ "why": "progressive는 바이트를 크게 줄이지도 늘리지도 않는다. 15% 절감은 지어낸 수치고 주효과는 체감이다." }, { - "text": "JPEG는 progressive로 켜고 PNG 인터레이스는 끈다 — PNG의 용량은 포맷 교체로 푼다", + "text": "JPEG는 progressive로 켜고 PNG 인터레이스는 끈다. PNG 용량은 포맷 교체로 해결한다", "correct": true, "why": "progressive JPEG는 바이트 손해가 거의 없지만 PNG의 Adam7은 크기 손해가 커서 권장되지 않는다." } ], - "explanation": "progressive JPEG는 스캔을 여러 번 나눠 담아 저해상도 전체가 먼저 뜨게 한다. 바이트 관점에서 대체로 중립적이라 '공짜 체감 개선'으로 불린다. PNG의 인터레이스(Adam7)는 이름이 같은 다른 트레이드오프다 — 필터가 예측에 쓰는 이전 패스 데이터가 멀어져 압축 효율이 나빠지고 크기가 오히려 커지는 경우가 많아 권장되지 않는다. PNG를 작게 만드는 길은 인터레이스가 아니라 무손실 WebP/AVIF 같은 포맷 교체다. 같은 단어 '인터레이스'가 포맷마다 다른 비용 구조를 감춘다.", + "explanation": "progressive JPEG는 스캔을 여러 번 나눠 담아 저해상도 전체가 먼저 뜨게 한다. 바이트 관점에서 대체로 중립적이라 '공짜 체감 개선'으로 불린다. PNG의 인터레이스(Adam7)는 이름만 같은 다른 트레이드오프다. 필터가 예측에 쓰는 이전 패스 데이터가 멀어져 압축 효율이 나빠지고, 크기가 오히려 커지는 경우가 많아 권장되지 않는다. PNG를 작게 만드는 길은 인터레이스가 아니라 무손실 WebP/AVIF 같은 포맷 교체다. '인터레이스'라는 같은 단어가 포맷마다 전혀 다른 비용 구조를 가리키는 셈이다.", "source": "ITU-T T.81(JPEG — progressive DCT 모드), ISO/IEC 15948(PNG — Adam7 인터레이스)" }, { @@ -534,12 +534,12 @@ "question": "원본이 1200px인 아바타에 대해 클라이언트 팀이 w=4096 변형을 요청하고 있다. 변환 API의 올바른 대응은?", "options": [ { - "text": "4096 변형을 만들지 않고 원본 소스를 응답한다 — 업스케일은 품질 이득 없이 비용만 만든다", + "text": "4096 변형을 만들지 않고 원본을 응답한다. 업스케일은 품질 이득 없이 비용만 늘린다", "correct": true, "why": "업스케일은 새 디테일을 만들지 못한다. '그 이상 없음'을 원본 응답으로 표현하는 것이 이미지 API의 정석이다." }, { - "text": "요청값을 그대로 만들어 준다 — 고해상도 디스플레이 사용자를 위한 친절함이다", + "text": "요청값을 그대로 만들어 준다. 고해상도 디스플레이 사용자를 위한 친절함이다", "distractorType": "one-step-short", "why": "'요청한 값을 준다'가 서비스 품질처럼 읽히지만 실제로는 뭉개진 픽셀과 변환 비용·캐시 변형만 늘어난다." }, @@ -565,27 +565,27 @@ "question": "체크무늬 셔츠 상품의 썸네일에 원본 사진에는 없는 지그재그 줄무늬가 나타난다. 가장 유력한 원인은?", "options": [ { - "text": "JPEG 압축 아티팩트다 — 품질값을 올리면 사라진다", + "text": "JPEG 압축 아티팩트다. 품질값을 올리면 사라진다", "distractorType": "adjacent", - "why": "압축 아티팩트는 8×8 블록 얼룩 형태지 규칙적인 줄무늬가 아니다. 압축의 축과 리샘플링의 축을 섞었다." + "why": "압축 아티팩트는 8×8 블록 얼룩 형태지 규칙적인 줄무늬가 아니다. 압축 문제와 리샘플링 문제를 혼동했다." }, { - "text": "축소할 때 고주파를 미리 제거하지 않은 리샘플링 — aliasing이다", + "text": "축소할 때 고주파를 미리 제거하지 않은 리샘플링, 즉 aliasing이다", "correct": true, "why": "축소는 샘플링 레이트를 낮추는 연산이라 사전 필터 없이 픽셀을 건너뛰면 남은 고주파가 낮은 주파수처럼 접혀 보인다." }, { - "text": "촬영 원본 해상도가 낮아서 생긴다 — 더 큰 원본을 받아 다시 만들면 된다", + "text": "촬영 원본 해상도가 낮아서 생긴다. 더 큰 원본을 받아 다시 만들면 된다", "distractorType": "inverted", "why": "방향이 반대다. 모아레는 고주파 정보가 있는 원본을 제대로 필터링하지 않고 축소할 때 생긴다." }, { - "text": "썸네일 포맷 문제다 — WebP로 내보내면 이런 무늬가 없어진다", + "text": "썸네일 포맷 문제다. WebP로 내보내면 이런 무늬가 없어진다", "distractorType": "overgeneralized", "why": "모아레는 인코딩 이전의 리샘플링 단계 결함이다. 같은 축소기라면 포맷을 바꿔도 그대로 재현된다." } ], - "explanation": "썸네일의 지그재그 무늬 정체는 대부분 aliasing이다. 축소는 샘플링 레이트를 낮추는 연산인데, 낮추기 전에 표현 못 할 고주파를 걸러내지 않으면 그 에너지가 낮은 주파수 대역으로 접히면서 줄무늬로 보인다. 체크무늬·벽돌 담·섬세한 격자가 단골 피해자고 '원본이 원래 그런가 보다'로 넘어가는 경우가 많다. 제대로 된 축소는 적분 기반·사전 필터 방식이며 사진 축소의 관례 커널은 Lanczos3다. 커널 선택은 취향이 아니라 품질 정책이다.", + "explanation": "썸네일의 지그재그 무늬 정체는 대부분 aliasing이다. 축소는 샘플링 레이트를 낮추는 연산인데, 낮추기 전에 표현 못 할 고주파를 걸러내지 않으면 그 에너지가 낮은 주파수 대역으로 접히면서 줄무늬로 보인다. 체크무늬·벽돌 담·섬세한 격자에서 자주 생기고, '원본이 원래 그런가 보다' 하고 넘어가는 경우가 많다. 제대로 된 축소는 적분 기반·사전 필터 방식이며 사진 축소의 관례 커널은 Lanczos3다. 커널 선택은 취향이 아니라 품질 정책이다.", "source": "sharp 공개 문서(resize — kernel, 이미지 축소용 기본 lanczos3)" }, { @@ -596,27 +596,27 @@ "question": "피드 이미지에 블러 자리표시자(blurhash)를 적용했다. 로딩 체감은 좋아졌다는 평가인데 레이아웃 점프(CLS) 지표는 그대로다. 이유는?", "options": [ { - "text": "자리표시자의 블러 강도가 약해서다 — 강도를 올리면 점프도 잡힌다", + "text": "자리표시자의 블러 강도가 약해서다. 강도를 올리면 점프도 잡힌다", "distractorType": "one-step-short", "why": "블러는 '무언가 있다'는 시각 피드백일 뿐 공간 예약과는 다른 축이다. 강도는 점프에 영향하지 않는다." }, { "text": "자리표시자가 본체보다 99% 작아 그만큼 로딩이 앞당겨지는데 측정 오차로 안 보이는 것이다", "distractorType": "plausible-number", - "why": "자리표시자는 본체 로딩을 빠르게 하지 않는다. 속도를 만드는 계층이 아니라 체감을 채우는 계층이다." + "why": "자리표시자는 본체 로딩을 빠르게 하지 않는다. 로딩 속도를 높이는 계층이 아니라 체감을 채우는 계층이다." }, { "text": "점프는 웹폰트 스왑이 만드는 것이므로 font-display 설정만 고치면 된다", "distractorType": "adjacent", - "why": "폰트 스왑도 CLS 원인이지만 이 화면의 점프는 이미지 영역에서 일어나고 있다. 축을 옮긴 진단이다." + "why": "폰트 스왑도 CLS 원인이지만 이 화면의 점프는 이미지 영역에서 일어나고 있다. 원인을 엉뚱한 곳에서 찾은 진단이다." }, { - "text": "자리표시자는 공간을 예약하지 않는다 — 점프는 종횡비 예약(width/height)으로 잡는다", + "text": "자리표시자는 공간을 예약하지 않는다. 점프는 종횡비 예약(width/height)으로 잡는다", "correct": true, "why": "CLS의 근본 해결은 컨테이너가 종횡비를 미리 갖는 것이고, 자리표시자는 그 위에 얹히는 체감 계층이다." } ], - "explanation": "CLS는 늦게 도착한 콘텐츠가 이미 그린 레이아웃을 밀어내는 현상이라, 해결은 이미지 도착 전에 공간을 점유시키는 것이다. width/height 속성이나 aspect-ratio CSS로 컨테이너에 종횡비를 예약하면 점프가 사라진다. LQIP·BlurHash·dominant color는 그 위에 시각적 연속성을 얹는 계층이지 공간을 만들지도 속도를 만들지도 않는다. 순서가 지켜지면 블러가 뜨는 동안에도 레이아웃은 이미 확정되어 있다.", + "explanation": "CLS는 늦게 도착한 콘텐츠가 이미 그린 레이아웃을 밀어내는 현상이라, 해결은 이미지 도착 전에 공간을 점유시키는 것이다. width/height 속성이나 aspect-ratio CSS로 컨테이너에 종횡비를 예약하면 점프가 사라진다. LQIP·BlurHash·dominant color는 그 위에 시각적 연속성을 얹는 계층이지, 공간을 예약하지도 로딩을 앞당기지도 않는다. 순서가 지켜지면 블러가 뜨는 동안에도 레이아웃은 이미 확정되어 있다.", "source": "web.dev «Optimize Cumulative Layout Shift»(이미지 치수 예약), BlurHash 공식 문서" }, { @@ -627,9 +627,9 @@ "question": "저장 비용 절감 회의에서 '필요한 파생물을 모두 만들었으니 원본은 지우고, 파생물은 지금처럼 메타데이터 포함해 그대로 서빙하자'는 제안이 나왔다. 올바른 판단은?", "options": [ { - "text": "방향을 뒤집는다 — 원본은 무손실로 보존하고 파생물에서 EXIF를 스트립해 서빙한다", + "text": "방향을 뒤집는다. 원본은 무손실로 보존하고 파생물에서 EXIF를 스트립해 서빙한다", "correct": true, - "why": "원본은 다음 규격·정책 변경 때 재변환의 유일한 원천이고 파생물의 메타데이터는 개인정보 유출면이다. 제안과 정확히 반대로 가야 한다." + "why": "원본은 다음 규격·정책 변경 때 재변환의 유일한 원천이고 파생물의 메타데이터는 개인정보가 유출되는 경로다. 제안과 정확히 반대로 가야 한다." }, { "text": "제안대로 원본을 지우되, 파생물 응답에서 GPS 좌표만 골라 지운다", @@ -647,7 +647,7 @@ "why": "GPS 좌표와 임베디드 썸네일을 포함한 EXIF는 KB급이 될 수 있고, 크기와 무관하게 위치 정보 노출은 그 자체로 사고다." } ], - "explanation": "원본 보존과 파생물 스트리핑은 한 쌍의 정책이다. 원본은 다음 규격·포맷·정책 변경 때 재변환의 유일한 원천이라 메타데이터 포함 무손실로 남기고, 서빙되는 파생물에서는 EXIF를 스트립한다 — 용량보다 GPS 좌표 등 개인정보 노출 방지가 본체다. 오브젝트 스토리지는 업로드된 바이트를 수정하지 않으므로 '올려 두면 정리된다'는 믿음은 성립하지 않는다. 방향이 반대인 이 제안은 저장 비용은 아끼고 재변환 자산과 사용자 신뢰를 태운다.", + "explanation": "원본 보존과 파생물 스트리핑은 한 쌍의 정책이다. 원본은 다음 규격·포맷·정책 변경 때 재변환의 유일한 원천이라 메타데이터 포함 무손실로 남기고, 서빙되는 파생물에서는 EXIF를 스트립한다. 용량 절감보다 GPS 좌표 등 개인정보 노출 방지가 목적의 본체다. 오브젝트 스토리지는 업로드된 바이트를 수정하지 않으므로 '올려 두면 정리된다'는 믿음은 성립하지 않는다. 방향이 반대인 이 제안은 저장 비용은 아끼지만 재변환의 원천과 사용자 신뢰를 잃게 만든다.", "source": "CIPA DC-008(EXIF 사양 — GPS IFD), Amazon S3 개발자 안내(객체는 업로드된 그대로 저장)" }, { @@ -658,7 +658,7 @@ "question": "흰 배경 텍스트 스크린샷의 가장자리 번짐 CS가 이어지자 회의에서 'JPEG의 색 압축 때문이니 이미지를 전부 손실 WebP로 전환하자'는 제안이 나왔다. 이 제안을 평가하면?", "options": [ { - "text": "전환에 찬성한다 — 전환하면서 quality를 90으로 올리면 색 경계도 선명해진다", + "text": "전환에 찬성한다. 전환하면서 quality를 90으로 올리면 색 경계도 선명해진다", "distractorType": "one-step-short", "why": "quality 상향은 완화일 뿐 색차 해상도를 절반으로 저장하는 구조 자체는 그대로 남는다." }, @@ -668,17 +668,17 @@ "why": "무손실 WebP는 사진 계열에서 손실 JPEG보다 크기가 크다. 텍스트에 맞는 정책과 사진의 바이트 비교를 한 문장에 섞었다." }, { - "text": "손실 WebP도 색차 4:2:0으로 부호화한다 — 번짐은 그대로, 텍스트류는 4:4:4·무손실로 갈라야 한다", + "text": "손실 WebP도 색차 4:2:0으로 부호화한다. 번짐은 그대로라 텍스트류는 4:4:4나 무손실로 갈라야 한다", "correct": true, - "why": "크로마 서브샘플링은 특정 인코더 옵션이 아니라 손실 부호화의 일반 구조다. 해결 축은 포맷이 아니라 콘텐츠 클래스별 색차 정책이다." + "why": "크로마 서브샘플링은 특정 인코더 옵션이 아니라 손실 부호화의 일반 구조다. 해결은 포맷 전환이 아니라 콘텐츠 클래스별 색차 정책에 있다." }, { "text": "4:2:0은 JPEG 고유의 옵션이라 포맷을 바꾸는 것만으로 이 문제류는 전부 사라진다", "distractorType": "overgeneralized", - "why": "서브샘플링은 색 공간 압축의 일반이다. 손실 WebP도 같은 축을 쓴다는 사실을 놓친 것이다." + "why": "서브샘플링은 색 공간 압축에서 일반적으로 쓰이는 구조다. 손실 WebP도 같은 방식을 쓴다는 사실을 놓친 것이다." } ], - "explanation": "크로마 서브샘플링은 JPEG의 전유물이 아니라 색 공간 압축의 일반이다. 손실 WebP도 Y'CbCr 4:2:0으로 부호화하므로 '포맷을 바꾸면 색 축이 사라진다'는 전제부터 성립하지 않는다. 번짐을 잡는 정책 축은 콘텐츠 클래스별 색차 결정이다 — 사진은 4:2:0, 텍스트·UI·다이어그램은 4:4:4 또는 무손실. 포맷 전환은 같은 지각 품질에서의 바이트 비교로 별도로 평가할 문제지 이 번짐의 해법이 아니다.", + "explanation": "크로마 서브샘플링은 JPEG의 전유물이 아니라 색 공간 압축에서 일반적으로 쓰이는 구조다. 손실 WebP도 Y'CbCr 4:2:0으로 부호화하므로 '포맷을 바꾸면 색 축이 사라진다'는 전제부터 성립하지 않는다. 번짐을 잡는 정책은 콘텐츠 클래스별 색차 결정이다. 사진은 4:2:0으로, 텍스트·UI·다이어그램은 4:4:4 또는 무손실로 간다. 포맷 전환은 같은 지각 품질에서의 바이트 비교로 별도로 평가할 문제지 이 번짐의 해법이 아니다.", "source": "RFC 6386 §2(VP8은 8비트 YUV 4:2:0 전용 — 손실 WebP의 부호화)" }, { @@ -694,7 +694,7 @@ "why": "소스 선택은 전적으로 브라우저가 하고 CDN은 브라우저가 요청한 URL만 응답한다. 선택 주체를 뒤집었다." }, { - "text": "sizes가 실제 렌더 폭보다 크게 선언돼 있다(예: 100vw 고정) — 브라우저는 이 값으로 소스를 계산한다", + "text": "sizes가 실제 렌더 폭보다 크게 선언돼 있다(예: 100vw 고정). 브라우저는 이 값으로 소스를 계산한다", "correct": true, "why": "sizes는 힌트가 아니라 선택 계산의 입력이다. 크게 쓰면 그만큼 큰 소스를 내려받는다." }, @@ -704,12 +704,12 @@ "why": "브라우저는 sizes×DPR로 계산한 값에 맞는 소스를 고른다. '항상 최대'는 선택 규칙에 없다." }, { - "text": "sizes가 없어도 브라우저가 DOM에서 렌더 크기를 읽어 알아서 고른다 — 원인은 다른 곳에 있다", + "text": "sizes가 없어도 브라우저가 DOM에서 렌더 크기를 읽어 알아서 고른다. 원인은 다른 곳에 있다", "distractorType": "one-step-short", "why": "sizes가 없으면 뷰포트 폭을 가정한다. 렌더된 DOM 크기를 다시 보고 소스를 갈아타지는 않는다." } ], - "explanation": "srcset과 w 디스크립터 조합에서 브라우저는 'sizes에 적힌 렌더 폭 × 기기 픽셀 밀도'로 필요한 픽셀 수를 계산하고 그에 맞는 소스를 고른다. 이 계산은 문서 파싱 시점에 sizes 선언으로 끝나며 렌더 후 실제 DOM 크기와 무관하다. 그래서 카드 이미지인데 100vw로 두는 식의 sizes 부정확은 좁은 화면에서도 큰 소스를 내려받는 과다 전송으로 나타난다. sizes는 참고 정보가 아니라 브라우저가 사용하는 계산 입력이다.", + "explanation": "srcset과 w 디스크립터 조합에서 브라우저는 'sizes에 적힌 렌더 폭 × 기기 픽셀 밀도'로 필요한 픽셀 수를 계산하고 그에 맞는 소스를 고른다. 이 계산은 문서 파싱 시점에 sizes 선언으로 끝나며 렌더 후 실제 DOM 크기와 무관하다. 그래서 카드 이미지인데 sizes를 100vw로 두는 식으로 부정확하게 선언하면, 좁은 화면에서도 큰 소스를 내려받는 과다 전송이 일어난다. sizes는 참고 정보가 아니라 브라우저가 사용하는 계산 입력이다.", "source": "HTML 표준(img 요소 — sizes 속성), MDN «Responsive images»" }, { @@ -720,9 +720,9 @@ "question": "메인 배너를 모바일에서는 인물 클로즈업, 데스크톱에서는 전체 구도로 보여주고 싶다. 적절한 구현은?", "options": [ { - "text": "두 구도를 srcset의 w 디스크립터로 등록한다 — 브라우저가 화면 크기에 맞게 골라 간다", + "text": "두 구도를 srcset의 w 디스크립터로 등록한다. 브라우저가 화면 크기에 맞게 골라 쓴다", "distractorType": "adjacent", - "why": "srcset은 같은 내용의 다른 해상도를 고르는 축이다. 구도가 다른 이미지를 섞으면 해상도 변형으로 오해해 예측과 다르게 고른다." + "why": "srcset은 같은 내용의 다른 해상도를 고르는 장치다. 구도가 다른 이미지를 섞으면 브라우저가 해상도 변형으로 오해해 예측과 다르게 고른다." }, { "text": "picture 요소는 아직 브라우저 지원이 성숙하지 않아 JS 폴백 라이브러리를 함께 써야 한다", @@ -732,7 +732,7 @@ { "text": "데스크톱 구도 하나를 내려받게 하고 모바일에서는 CSS object-position으로 초점을 옮겨 보여준다", "distractorType": "one-step-short", - "why": "화면에서는 그렇게 보여도 모바일이 데스크톱 크기 이미지를 통째로 내려받는다. 크롭 흉내가 아니라 전송 낭비다." + "why": "화면에서는 크롭처럼 보여도 모바일이 데스크톱 크기 이미지를 통째로 내려받는다. 겉보기만 크롭이지 실제로는 전송 낭비다." }, { "text": "picture 요소의 source에 media 조건으로 구도별 소스를 지정한다", @@ -740,7 +740,7 @@ "why": "내용이 다른 이미지를 뷰포트별로 갈라 내는 것이 picture+source media의 용도다." } ], - "explanation": "반응형 이미지에는 두 가지 다른 문제가 있다. 같은 내용의 해상도 분배(srcset)와 내용 자체가 다른 구도 분배다. 후자는 어느 쪽이 좋은지 브라우저가 판단할 수 없으므로 마크업이 명시적으로 조건을 갈라야 하고, 그 용도가 picture 요소의 source media다. 크롭을 CSS로 흉내 내면 모바일에 불필요하게 큰 소스를 전송하게 되어 반응형의 본래 목적과 어긋난다. picture는 주류 전면 지원이 오래 된 표준 요소라 별도 폴백은 필요 없다.", + "explanation": "반응형 이미지에는 두 가지 다른 문제가 있다. 같은 내용의 해상도 분배(srcset)와 내용 자체가 다른 구도 분배다. 후자는 어느 쪽이 좋은지 브라우저가 판단할 수 없으므로 마크업이 명시적으로 조건을 갈라야 하고, 그 용도가 picture 요소의 source media다. 크롭을 CSS로 흉내 내면 모바일에 불필요하게 큰 소스를 전송하게 되어 반응형의 본래 목적과 어긋난다. picture는 주류 브라우저에서 전면 지원된 지 오래된 표준 요소라 별도 폴백은 필요 없다.", "source": "HTML 표준(picture 요소), MDN «Using the picture element»(아트 디렉션)" }, { @@ -758,7 +758,7 @@ { "text": "화면에서는 img 태그로만 삽입하면 스크립트가 실행되지 않으니, 업로드 검증 없이 허용해도 된다", "distractorType": "one-step-short", - "why": "img 맥락의 미실행은 사실이지만 URL 직접 접근 같은 문서 맥락이 남아 있다. 한 맥락의 안전성으로 전체를 닫았다." + "why": "img 맥락에서 실행되지 않는 것은 사실이지만 URL 직접 접근 같은 문서 맥락이 남아 있다. 한 맥락의 안전성으로 전체가 안전하다고 결론 내렸다." }, { "text": "SVG는 실행 코드를 담을 수 없는 벡터 포맷이라 이미지 확장자 화이트리스트에 넣는 것으로 충분하다", @@ -768,10 +768,10 @@ { "text": "응답에서 script 태그 문자열만 걸러내는 필터를 붙인다", "distractorType": "one-step-short", - "why": "이벤트 핸들러·foreignObject·외부 리소스 참조 같은 우회 경로가 많다. 태그 이름 블랙리스트는 소독이 아니다." + "why": "이벤트 핸들러·foreignObject·외부 리소스 참조 같은 우회 경로가 많다. 태그 이름 블랙리스트는 새니타이즈가 되지 못한다." } ], - "explanation": "SVG는 이미지이면서 동시에 XML 문서다. img로 로드되면 스크립트가 실행되지 않지만 그 URL을 직접 열면 문서로 취급되어 포함된 스크립트가 실행될 수 있다. 사용자 업로드를 받는 순간 이는 '이미지 확장자 하나 늘리기'가 아니라 신뢰할 수 없는 문서 입력을 받는 문제가 된다. 대응은 이중이다 — 업로드 경로에서 허용 요소만 남기는 새니타이즈·재인코딩, 그리고 실행 맥락을 끊는 서빙 도메인 분리. 'img로만 쓰면 안전하다'는 통제는 한 렌더링 지점에만 성립한다.", + "explanation": "SVG는 이미지이면서 동시에 XML 문서다. img로 로드되면 스크립트가 실행되지 않지만 그 URL을 직접 열면 문서로 취급되어 포함된 스크립트가 실행될 수 있다. 사용자 업로드를 받는 순간 이는 '이미지 확장자 하나 늘리기'가 아니라 신뢰할 수 없는 문서 입력을 받는 문제가 된다. 대응은 이중이다. 업로드 경로에서 허용 요소만 남기는 새니타이즈·재인코딩이 하나이고, 실행 맥락을 끊는 서빙 도메인 분리가 다른 하나다. 'img로만 쓰면 안전하다'는 통제는 한 렌더링 지점에만 성립한다.", "source": "MDN «SVG as an image»(이미지 맥락의 제한과 문서 맥락의 차이 — 직접 열면 스크립트 실행)" }, { @@ -782,17 +782,17 @@ "question": "마케팅 페이지의 3초 루프 배너가 GIF 4MB라 모바일 트래픽 상위 1위다. 비디오 교체 제안에 '모바일 브라우저에서는 재생이 안 될 것'이라는 반대가 나왔다. 이 반대를 평가하면?", "options": [ { - "text": "반대가 맞다 — iOS Safari는 playsinline 옵션조차 무시하므로 iOS에서는 GIF가 유일한 선택이다", + "text": "반대가 맞다. iOS Safari는 playsinline 옵션조차 무시하므로 iOS에서는 GIF가 유일한 선택이다", "distractorType": "vendor-mixup", "why": "iOS Safari는 음소거·인라인 비디오의 제스처 없는 재생을 허용한다. iOS 정책을 반대 방향으로 기억하고 있다." }, { - "text": "대체로 틀렸다 — 무음·인라인 비디오는 제스처 없이 재생된다. 단 iOS 저전력 모드에서는 그것도 차단되므로 정지 프레임 폴백을 함께 둔다", + "text": "대체로 틀렸다. 무음·인라인 비디오는 제스처 없이 재생된다. 단 iOS 저전력 모드에서는 그것도 차단되므로 정지 프레임 폴백을 함께 둔다", "correct": true, "why": "autoplay 제한의 본체는 소리이므로 무음 루프는 허용 범주에 있다. 다만 iOS는 저전력 모드에서 무음 비디오의 자동재생까지 끄므로, 그 구간에 무엇이 보일지를 정해 둬야 한다." }, { - "text": "반대는 틀렸다 — 무음에 인라인을 붙이면 모바일 어디서나 제스처 없이 재생되므로 폴백은 불필요하다", + "text": "반대는 틀렸다. 무음에 인라인을 붙이면 모바일 어디서나 제스처 없이 재생되므로 폴백은 불필요하다", "distractorType": "one-step-short", "why": "차단 대상이 소리라는 것은 맞지만 예외가 하나 더 있다. iOS 저전력 모드에서는 무음 비디오의 자동재생도 꺼진다." }, @@ -802,7 +802,7 @@ "why": "비디오도 HTTP 캐시 대상이다. 미디어 종류와 캐시 가능성은 연결되어 있지 않다." } ], - "explanation": "모바일 autoplay 정책의 설계는 「소리 없는 재생은 허용한다」는 방향이다. 음소거(muted)에 playsinline을 붙인 비디오는 iOS Safari를 포함한 주류 모바일 브라우저에서 제스처 없이 재생되므로, 「모바일은 GIF여야 한다」는 반대는 대체로 옛 기억이다. 그런데 예외가 하나 더 있다 — iOS는 저전력 모드에서 무음 비디오의 자동재생까지 차단한다(WebKit 168985). 배터리가 적은 사용자에게는 배너가 정지 상태로 보인다는 뜻이라, poster 프레임을 첫 프레임으로 지정하거나 재생 어포던스를 두는 폴백이 교체 작업의 일부다. 애니메이션 GIF는 이미지라 이 정책 밖에 있어 저전력 모드에서도 움직인다 — 그래서 반대가 완전히 틀린 것은 아니다. 그 차이를 알고 폴백을 설계하면 프레임 압축의 이득은 그대로 가져간다.", + "explanation": "모바일 autoplay 정책의 설계는 '소리 없는 재생은 허용한다'는 방향이다. 음소거(muted)에 playsinline을 붙인 비디오는 iOS Safari를 포함한 주류 모바일 브라우저에서 제스처 없이 재생되므로, 「모바일은 GIF여야 한다」는 반대는 대체로 옛 기억이다. 그런데 예외가 하나 더 있다. iOS는 저전력 모드에서 무음 비디오의 자동재생까지 차단한다(WebKit 168985). 배터리가 적은 사용자에게는 배너가 정지 상태로 보인다는 뜻이라, poster 프레임을 첫 프레임으로 지정하거나 재생 어포던스를 두는 폴백이 교체 작업의 일부다. 애니메이션 GIF는 이미지라 이 정책 밖에 있어 저전력 모드에서도 움직인다. 그래서 반대가 완전히 틀린 것은 아니다. 그 차이를 알고 폴백을 설계하면 프레임 압축의 이득은 그대로 가져갈 수 있다.", "source": "WebKit 공식 블로그 «New