docs(TechLog): 주제 7~10 을 다시 쓴다

주소가 게시 시점에 굳어 저장되는 구조, 축 링크를 두 번 옮긴 순서, 한글 slug 가
간헐적으로 보인 두 가지 어긋남을 표로 갈랐다. 화면이 실패를 없음으로 그릴 때 작성
도구에서 왜 더 오래 숨는지, Promise.all 이 거절과 던짐에서 다른 경로를 타는 이유를
채웠다. CSS module 이 왜 전역 규칙에 닿지 않는지, 403 과 404 가 원인을 어떻게
좁혔는지도 적었다.

link-audit.py 를 감사 Case 의 evidence 로 걸어 배정한 증거 하나를 메웠다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 19:06:20 +09:00
co-authored by Claude Opus 5
parent 193da20d09
commit b1653dbba8
15 changed files with 204 additions and 92 deletions
@@ -60,23 +60,31 @@ tech-log-design-package : 71bab4c · b93d62a
<!-- body:start -->
## 앵커로 옮겼더니 자기 자신을 가리켰
## 주소만 바뀌고 화면은 그대로였
주제 화면의 네 줄은 링크로 그려져 있었는데 눌러도 아무 일이 없었다.
처음에 축의 주소를 `/topics/{주제}/{축}` 이라 적어 두었는데 그런 화면이 없었다. 그래서 축의 주소를 주제 화면 안의 앵커로 바꿨다.
주제 화면에서 그 링크를 누르면 주소에 앵커가 붙는다. 화면은 이미 그 주제 화면이므로 아무것도 바뀌지 않는다.
주제 화면에서 그 링크를 누르면 주소에 앵커가 붙는다. 화면은 이미 그 주제 화면이므로 라우터가 다시 그릴 것이 없고, 앵커가 가리키는 element 도 없으니 스크롤도 일어나지 않는다.
## 축에 자기 화면을 줬다
목록 조회에 축 필터를 더하고 `record_variant` 로 거른다. 축 slug 는 주제 안에서만 유일하므로 주제까지 맞춰야 하고, 주제를 빼면 다른 주제의 같은 이름 축까지 걸린다.
목록 조회에 축 필터를 더하고 기록이 어느 축에 걸리는지를 담은 표로 거른다.
축 slug 는 주제 안에서만 유일하므로 조회에서 주제까지 함께 맞춘다. 주제를 빼면 다른 주제의 같은 이름 축이 함께 걸린다 — 인증 경계 주제의 `bff` 와 다른 주제의 `bff` 가 구분되지 않는다.
## 배포 순서로 만든 2차 사고
> **이 건에서 제가 만든 2차 사고:** 축 화면을 만들고 **백엔드를 프론트보다 먼저 배포했습니다.** nginx 설정은 라우트 계약에서 생성되므로, 프론트가 배포되기 전까지 `/topics/x/y` 는 404 입니다. 서버는 이미 그 주소를 내보내고 있었고, 사용자는 네 링크가 전부 404 인 화면을 봤습니다. **순서가 있습니다 — 새 라우트는 프론트가 먼저입니다.**
고치기 전과 고친 뒤가 사용자에게는 같아 보였다. 앞에서는 눌러도 아무 일이 없었고, 뒤에서는 눌러서 404 를 만났다.
## 주제가 없는 기록
같은 시기에 주제 없이 게시된 기록이 이름 없는 주제 링크를 달고 있었다. 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다였다. 프로젝트 조각은 처음부터 조건부였는데 주제 쪽만 아니었다.
같은 시기에 주제 없이 게시된 기록이 이름 없는 주제 링크를 달고 있었다. 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다였다.
프로젝트 조각은 처음부터 조건부였다. 주제 쪽만 조건이 없었고, 그 차이가 두 화면에서 같은 모양으로 나타났다.
## 확인하지 못한 것
@@ -64,23 +64,31 @@ tech-log-frontend : 5cffe30 · 7093d84
## 간헐적으로 보인 이유
slug 생성이 `[a-z0-9]` 만 남기고 나머지를 버렸다. 한글 이름은 통째로 사라지므로 이름에 영문이 얼마나 섞였는지에 따라 결과가 갈린다.
slug 생성이 영문 소문자와 숫자만 남기고 나머지를 버렸다. 한글 이름은 통째로 사라지므로, 결과가 이름에 영문이 얼마나 섞였는지에 따라 갈린다.
영문이 하나도 없으면 빈 문자열이 되어 폼이 요청 전에 거절한다. 영문이 앞에 붙어 있으면 그 부분만 남으므로 뒤가 다른 두 이름이 같은 slug 가 된다.
| 이름 | 옛 규칙이 만든 slug | 무엇이 일어났나 |
|---|---|---|
| `인증` | (빈 문자열) | 폼이 요청 전에 거절 |
| `Redis 캐시` | `redis` | 만들어짐 |
| `Redis 클러스터` | `redis` | 두 번째가 충돌 |
`Redis 캐시``Redis 클러스터` 가 둘 다 `redis` 였다. 두 번째를 만들 때 충돌이 났고, 사용자에게는 「가끔 안 된다」로 보였다.
사용자는 둘 다 만났다. 어느 쪽도 「한글이 버려졌다」로 보이지 않고, 하나는 폼 오류로 하나는 중복 오류로 나타난다.
> 규칙은 간헐적이었던 적이 없다. **보이지 않았을 뿐이다** — slug 생성이 `[a-z0-9]` 만 남기고 나머지를 버려서, 한글 이름은 아무것도 기여하지 못했다.
## 산술 분해로 로마자를 만든다
한글 음절은 초성·중성·종성이 정해진 순서로 조합된 코드다. 음절 코드에서 세 값을 산술로 분해할 수 있으므로 변환표가 필요 없고 결과가 결정적이다.
한글 음절은 초성·중성·종성이 정해진 순서로 조합된 코드다. 음절 코드에서 세 값을 산술로 분해할 수 있으므로 변환표가 필요 없고, 같은 입력에 늘 같은 결과가 나온다.
`백엔드 아키텍처``baekendeu-akitekcheo`
```text
백엔드 아키텍처 → baekendeu-akitekcheo
```
## 음운 변화 규칙을 뺀 이유
국어의 로마자 표기법에는 자모 대응 외에 음운 변화 규칙이 있다. 그것을 넣지 않았다.
slug 는 읽는 것이지 발음하는 것이 아니다. 음운 변화를 적용하면 같은 이름이 앞뒤 글자에 따라 다른 slug 가 되고, 그러면 같은 이름을 두 번 만들 때 결과가 갈린다.
slug 는 읽는 것이지 발음하는 것이 아니다. 음운 변화를 적용하면 같은 글자가 앞뒤에 무엇이 오느냐에 따라 다르게 옮겨지고, 그러면 이름의 일부만 바뀌어도 앞쪽 slug 가 달라진다. 결정적이지 않은 slug 는 주소로 쓸 수 없다.
## 확인하지 못한 것
@@ -14,6 +14,7 @@ evidence:
- ../../../final/evidence/raw/db/decision-path-after-v15.txt
- ../../../final/evidence/raw/api/decision-anchor-fixed.txt
- ../../../final/evidence/raw/audit/dead-link-sweep.txt
- ../../../final/evidence/raw/audit/link-audit.py
sourceRevision: tech-log@2026-09-02
source:
- final/document.md#§9.2
@@ -78,23 +79,34 @@ tech-log-frontend : fe6b56a
:::evidence key="decision-path-404" alt="계약·게시 시점 경로 생성·저장 테이블·조회 시점 경로 생성·방문자·공개 라우트 여섯 참가자 사이의 순서도" caption=" " zoom="true"
:::
계약은 결정의 공개 주소가 앵커라고 규정한다. 게시 시점의 `PublicPaths.forKind` 는 그 대신 경로를 만들어 `public_resource_projection` 에 저장한다. 조회 시점의 `PublicSql.pathOf` 가 저장된 주소를 읽 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 목록 하나뿐이라 맞는 라우트가 없다.
계약은 결정의 공개 주소가 목록 위의 앵커라고 규정한다. 게시 시점의 경로 생성기는 그 대신 목록 아래에 slug 를 붙인 경로를 만들어 공개 투영에 저장한다. 조회 시점의 다른 생성기가 저장된 주소를 읽 방문자에게 링크로 내보낸다. 방문자가 그 주소를 요청하면 공개 라우트에는 목록 하나뿐이라 맞는 라우트가 없다.
## 계약은 이미 맞게 적혀 있었다
계약은 고칠 것이 없었다. 공개 주소가 앵커라는 것이 이미 계약에 있었고, 만드는 쪽 두 곳이 그것을 따르지 않았다.
공개 주소가 `#{slug}` 앵커라는 것이 계약에 이미 있었고, 만드는 쪽 두 곳이 그것을 따르지 않았다.
## 화면 코드에는 흔적이 없다
이 부류는 저장소를 훑어서는 나오지 않는다. 주소는 게시할 때 서버가 만들어 데이터베이스에 저장한 문자열이고, 화면은 그것을 받아 그대로 링크로 그린다. 코드에 `to=``href=` 리터럴로 남아 있지 않다.
감사 스크립트가 그 이유를 적어 두었다.
> 주소는 게시 시점에 서버가 만들어 DB(`public_resource_projection.navigation_path`)에 저장한 문자열이다. 그래서 저장소 안의 `to=` / `href=` 리터럴만 훑는 감사로는 잡히지 않는다 — 실제로 결정 링크가 그렇게 숨어 있었다.
## 저장된 행까지 고쳐야 한다
주소가 게시 시점에 굳어져 저장되므로 코드만 고치면 이미 게시된 링크는 깨진 채 남는다. V15 마이그레이션에서 저장된 행 고쳤다.
주소가 게시 시점에 굳어므로 코드만 고치면 이미 게시된 링크는 깨진 채 남는다. 마이그레이션에서 저장된 행을 함께 고쳤다.
`public_route.slug` 손봤다. 마지막 슬래시 뒤를 자르면 앵커가 붙은 주소에서 `decisions#slug` 전체가 slug 로 저장된다. 앵커가 있으면 그 뒤를 조각으로 읽게 했다.
공개 라우트의 slug 를 읽는 규칙도 함께 손봤다. 마지막 슬래시 뒤를 자르는 방식이면 앵커가 붙은 주소에서 `decisions#slug` 전체가 slug 로 저장된다. 앵커가 있으면 그 뒤를 조각으로 읽게 했다.
목록 항목이 앵커를 달 수 있도록 계약에 slug 를 더하고, 화면이 그 slug 를 element id 로 달고 앵커로 들어오면 데이터를 받아 그린 뒤 스크롤하게 했다.
## 두 겹 가드
`PublicPathsTest` 는 백엔드에서 종류마다 만들어 낸 경로가 실제 공개 라우트 패턴에 맞는지 본다.
| 어디 | 무엇을 보나 | 이 부류가 또 나면 |
|---|---|---|
| 백엔드 `PublicPathsTest` | 종류마다 만들어 낸 경로가 실제 공개 라우트 패턴에 맞는가 | 만드는 쪽에서 멈춘다 |
| 프론트 `resolvesToPublicRoute` | 서버가 준 주소가 라우트 표에 있는가 | 링크로 그리지 않아 방문자가 404 를 만나지 않는다 |
`resolvesToPublicRoute` 는 프론트에서 라우트 계약이 준 표에 서버가 준 주소를 맞춰 보고, 맞는 라우트가 없으면 링크로 그리지 않는다. 이 부류가 또 생겨도 방문자가 404 를 만나지는 않는다. 틀린 주소가 만들어지는 것 자체는 백엔드 쪽 검사가 잡는다.
만드는 쪽에만 두면 이미 저장된 주소를 못 잡고, 그리는 쪽에만 두면 링크가 안 그려지는 것으로 끝나 원인이 남는다.
## 배포 뒤 전수 감사
@@ -103,6 +115,8 @@ tech-log-frontend : fe6b56a
:::evidence key="dead-link-sweep" alt="서버가 내보내는 주소 35개를 전수로 훑은 감사 출력" caption=" " zoom="false"
:::
같은 방식으로 다시 검사하는 스크립트를 증거와 함께 남겼다. 다음에 라우트를 더하면 그 스크립트를 다시 돌린다.
## 확인하지 못한 것
이 감사는 서버가 내보내는 주소만 본다. 본문 안에 작성자가 손으로 쓴 링크는 대상이 아니다.
@@ -76,23 +76,33 @@ tech-log-frontend : 344dadb · 805d400 · 8c5dbe1
}
```
`a` 구역 안의 모든 링크를 잡는다. 제목 안에 링크가 있으면 그 링크도 격자가 되고 첫 칸의 200px 에 갇힌다. 행을 위한 padding 까지 물려 h2 높이가 199px 이 됐다.
선택자가 구역 클래스 아래의 `a` 이므로 그 구역 안의 모든 링크가 걸린다. 제목 안에 링크가 있으면 그 링크도 격자가 되고 첫 칸의 200px 에 갇힌다.
제목은 한 줄로 들어갈 길이였는데 200px 안에서 두 줄로 접혔고, 행을 위한 위아래 padding 까지 물려 h2 높이가 199px 이 됐다. 같은 이유로 주제 화면의 안내 문단도 행의 크기와 색으로 덮여 있었다.
사용자가 원인을 정확히 짚어 주었다 — 「디자인이 안 된 게 아니라 CSS 선택자가 새고 있습니다」.
## 아홉 구역에 같은 모양이 있었다
같은 형태가 다른 구역 아홉 곳에도 있었다. 선택자 51개를 `li > a` 처럼 그 배치를 쓰는 요소까지 좁혔다.
`li > a` 는 목록 항목의 바로 아래 링크만 잡는다. 제목 안의 링크는 목록 항목의 자식이 아니므로 걸리지 않는다.
## CSS 만으로 막지 않았다
선택자를 좁혀도 같은 구조가 다시 생기면 다시 샌다. 제목 안에 링크를 두지 않도록 구조를 바꿨고, 주제로 가는 길은 아래 한 줄이 맡는다.
선택자를 좁히는 것은 같은 구조가 다시 생기면 다시 샌다. 제목 안에 링크를 두지 않도록 구조를 바꿨고, 주제로 가는 길은 아래 한 줄이 맡는다.
## 전역 규칙이 닿지 않는 화면
버튼에서 상자를 걷어내는 변경이 앱 전역 규칙만 고쳤다. 게시 기록·게시 흐름·워크플로 게이트는 CSS module 을 쓰므로 그 규칙이 닿지 않아, 다른 화면에서 상자를 걷어낸 뒤에도 「Snapshot 보기」·「게시 취소」·「적용」만 테두리와 파란 채움으로 남았다. 한 화면 안에서 두 언어가 섞여 더 눈에 띄었다.
버튼에서 상자를 걷어내는 변경이 앱 전역 규칙만 고쳤다. 게시 기록·게시 흐름·워크플로 게이트는 CSS module 을 쓰므로 그 규칙이 닿지 않는다 — module 은 클래스 이름을 빌드 때 바꿔 전역 선택자와 겹치지 않게 만드는 방식이라, 전역에서 그 클래스를 가리킬 방법이 없다.
다른 화면에서 상자를 걷어낸 뒤에도 「Snapshot 보기」·「게시 취소」·「적용」 세 버튼만 테두리와 파란 채움으로 남았다. 한 화면 안에서 두 언어가 섞여 더 눈에 띄었다.
## 검사
`section-selector-scope.test.ts``.클래스 태그` 모양에 배치 속성(`display: grid|flex`, `grid-template-columns`, `padding`)이 걸려 있으면 멈춘다. 이미 좁혀 둔 곳은 `SETTLED` 로 명시한다. 색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다.
`section-selector-scope.test.ts``.클래스 태그` 모양에 배치 속성(`display: grid|flex`, `grid-template-columns`, `padding`)이 걸려 있으면 멈춘다. 이미 좁혀 둔 곳은 `SETTLED` 로 명시한다.
색이나 글꼴만 거는 규칙은 대상이 아니다. 새어도 티가 나지 않으므로 검사에 넣으면 결과가 늘 빨갛고, 그러면 곧 읽히지 않는다.
## 확인하지 못한 것
@@ -59,17 +59,26 @@ tech-log-frontend : 68538f2
> 규칙이 없었던 게 아니라 **절반만 있었다.** 정본은 `.section-heading-row h2` 인데 그 안에 들어가지 않는 두 구역이 **크기만 각자 적어 두어 굵기를 아무도 정하지 않았고**, 그래서 기본값 400 으로 떨어졌다.
크기를 각자 적어 두었기 때문에 「규칙이 없다」로 보이지 않는다.
크기를 각자 적어 두었기 때문에 「규칙이 없다」로 보이지 않는다. 선언이 있고 그 선언이 절반만 덮는다.
| | 정본 | 문제의 두 구역 |
|---|---|---|
| 크기 | 30px | 26px (각자 선언) |
| 굵기 | 650 | 400 (아무도 선언하지 않음) |
같은 화면에 나란히 서 있는 구역들이라 이 둘만 급이 낮아 보였다.
## 처음에 잘못 판단한 것
> **이때 제가 저지른 판단 오류:** 처음에 grid/columns 만 측정하고 "정상"이라고 답했습니다. 사용자가 다시 지적한 뒤 **전체 페이지 스크린샷**을 찍어서야 26px/400 을 봤습니다. **프록시 지표가 아니라 보이는 것을 측정해야 합니다.**
격자와 열은 정상이었다. 어긋난 것은 글자 크기와 굵기였고, 격자를 재서는 그 값이 나오지 않는다.
격자와 열은 정상이었다. 보고된 증상은 글자 크기와 굵기였는데 다른 축을 재고 답했다.
## 검사
`section-heading-rank.test.ts` 가 나란히 서는 구역 제목들이 정본과 같은 `font-size``font-weight` 를 쓰는지 CSS 를 파싱해 확인한다. 굵기를 빼 보고 실제로 멈추는 것을 확인했다.
`section-heading-rank.test.ts` 가 나란히 서는 구역 제목들이 정본과 같은 `font-size``font-weight` 를 쓰는지 CSS 를 파싱해 확인한다.
굵기를 빼 보고 실제로 멈추는 것을 확인한 뒤 커밋했다. 확인하지 않았다면 그 구역들이 원래 정본을 쓰고 있어서 통과한 것인지 검사가 그 구역을 안 보는 것인지 구별되지 않는다.
## 확인하지 못한 것
@@ -36,19 +36,21 @@ source:
### 1. 보고된 증상과 같은 축의 값을 잰다
「제목이 작아 보인다」는 글자 크기와 굵기다. 격자와 열은 다른 축이다.
「제목이 작아 보인다」는 글자 크기와 굵기다. 격자와 열은 다른 축이라, 그것이 정상이라는 답으로는 보고한 사람이 본 것을 설명하지 못한다.
목록 간격을 바운딩 박스로만 재서 엉뚱한 구역을 결함으로 지목한 적도 있다. 박스가 겹치는 것과 눈에 보이는 간격이 다르기 때문이다.
### 2. 촬영을 스크립트로 고정한다
손으로 찍으면 뷰포트와 축소 배율이 매번 달라진다.
손으로 찍으면 뷰포트와 축소 배율이 매번 달라진다. 브라우저 세션이 리셋되면 창은 약 877px 로 돌아가는데 이 사이트의 분기는 1179·1050·900·767 이라, 방문자 대부분이 보지 않는 배치를 놓고 디자인을 논하게 된다.
### 3. 폭을 고정해 여러 개를 돌고, 폭마다 측정도 남긴다
### 3. 폭을 고정해 여러 개를 돌고 폭마다 측정도 함께 남긴다
스크린샷만 남기면 나중에 그 값이 얼마였는지 다시 잴 수 없다.
스크린샷만 남기면 나중에 그 값이 얼마였는지 다시 잴 수 없다. 폭마다 계산된 스타일을 함께 적어 두면 다음에 무엇이 달라졌는지 대조할 수 있다.
### 4. 전체 페이지를 한 장으로 찍지 않는다
축소되어 글자 크기가 실제와 달라진다. 화면 높이만큼 잘라 찍는다.
축소되어 글자 크기가 실제와 달라진다. 1425x4466 이 638x2000 으로 들어오면 17px 글자가 7~8px 이 되고, 그 그림에서는 크기 문제를 볼 수 없다. 화면 높이만큼 잘라 찍는다.
## 적용 조건
@@ -60,15 +62,11 @@ source:
## 예외
- 측정 대상이 좌표나 간격 자체라면 바운딩 박스는 프록시가 아니라 대상이다.
- CSS 선언이 정본과 같은지를 보는 검사는 렌더 결과를 재지 않는다. 그 검사가 덮는 범위를 알고 쓰면 된다.
## 예시
- 격자와 열만 재고 「정상」이라 답했다. 사용자가 다시 지적한 뒤 전체 페이지 스크린샷을 찍어서야 26px 과 400 을 봤다.
- 목록 간격을 바운딩 박스로만 재서 「판단 기준」을 결함으로 잘못 지목한 적이 있다.
- 브라우저 세션이 리셋되면 창이 약 877px 로 돌아가는데 이 사이트의 분기는 1179·1050·900·767 이라, 877px 에서 본 배치는 방문자 대부분이 보는 배치가 아니다.
- 전체 페이지를 한 장으로 찍으면 1425x4466 이 638x2000 으로 들어와 17px 글자가 7~8px 이 된다.
- 격자와 열만 재고 「정상」이라 답했다. 사용자가 다시 지적한 뒤 전체 페이지 스크린샷을 찍어서야 26px 과 400 을 봤다
- 목록 간격을 바운딩 박스로만 재서 엉뚱한 구역을 결함으로 지목한 적이 있다
- 브라우저 세션이 리셋되면 창이 약 877px 로 돌아가는데 이 사이트의 분기는 1179·1050·900·767 이다
- 전체 페이지를 한 장으로 찍으면 1425x4466 이 638x2000 으로 들어와 17px 글자가 7~8px 이 된다
@@ -27,25 +27,29 @@ source:
## 목적
「디자인이 안 된 것처럼 보인다」는 보고의 원인이 되는 선택자 누출을 막는다. 이 부류는 규칙 자체는 맞고 걸리는 대상이 넓다.
「디자인이 안 된 것처럼 보인다」는 보고의 원인이 되는 선택자 누출을 막는다.
이 부류는 규칙 자체는 맞고 걸리는 대상이 넓다. 그래서 규칙을 읽어서는 틀린 것을 찾을 수 없고, 그려진 결과를 봐야 한다.
## 규칙
### 1. 배치 속성은 그 배치를 쓰는 요소까지 좁혀 적는다
`display: grid|flex`, `grid-template-columns`, `padding` 을 구역 클래스 아래 태그 선택자로 걸지 않는다.
`display: grid|flex`·`grid-template-columns`·`padding` 을 구역 클래스 아래 태그 선택자로 걸지 않는다.
행을 위한 격자를 구역 전체에 걸면 제목 안의 링크도 그 격자가 된다. 첫 칸 너비에 갇혀 접히고 행용 padding 까지 물린다.
### 2. CSS 만으로 막지 않고 구조도 본다
선택자를 좁히면 같은 구조가 다시 생겼을 때 다시 샌다. 제목 안에 링크를 두지 않는 편이 낫다.
선택자를 좁히면 같은 구조가 다시 생겼을 때 다시 샌다. 제목 안에 링크를 두지 않는 편이 낫고, 주제로 가는 길은 별도의 한 줄이 맡는다.
### 3. 이미 좁혀 둔 곳은 검사에서 명시적으로 뺀다
예외를 적어 두지 않으면 검사 결과가 늘 빨갛고 곧 읽히지 않는다.
예외를 적어 두지 않으면 검사 결과가 늘 빨갛고 곧 읽히지 않는다. 뺀 것이 코드에 이름으로 남아 다음 사람이 세어 볼 수 있어야 한다.
### 4. 전역 규칙과 CSS module 이 섞인 화면을 따로 센다
전역 규칙을 고쳐도 module 을 쓰는 화면에는 닿지 않는다. 한 화면 안에서 두 언어가 섞이면 더 눈에 띈다.
module 은 클래스 이름을 빌드 때 바꿔 전역 선택자와 겹치지 않게 만든다. 그래서 전역 규칙을 고쳐도 module 을 쓰는 화면에는 닿지 않고, 한 화면 안에서 두 언어가 섞이면 더 눈에 띈다.
## 적용 조건
@@ -56,14 +60,11 @@ source:
## 예외
- 색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다.
- 색이나 글꼴만 거는 규칙은 새어도 티가 나지 않으므로 대상이 아니다. 검사에 넣으면 결과가 늘 빨갛다.
- 구역 안의 모든 같은 태그가 실제로 같은 배치를 써야 하는 화면이라면 좁히지 않아도 된다. 그때는 그 의도를 검사의 예외 목록에 적는다.
## 예시
- 비교 행을 위한 격자가 제목 안의 링크까지 잡아 제목이 200px 칸에 갇혔다.
- 같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤다.
`section-selector-scope.test.ts``.클래스 태그` 모양에 배치 속성이 걸려 있으면 멈춘다.
- 비교 행을 위한 격자가 제목 안의 링크까지 잡아 제목이 200px 칸에 갇히고 h2 높이가 199px 이 됐다
- 같은 모양이 다른 구역 아홉 곳에도 있어 선택자 51개를 고쳤다
- 전역 규칙만 고쳐서 CSS module 을 쓰는 세 버튼만 테두리와 파란 채움으로 남았다
@@ -61,23 +61,29 @@ tech-log-backend : 911e8ba — 없던 두 컨트롤러를 구현
편집기는 질문 목록을 받아 고를 수 있게 그린다. 목록이 비면 「이 프로젝트에 열린 질문이 없습니다」를 적는다.
요청이 실패했을 때도 빈 배열이 되고 있었다.
요청이 실패했을 때도 빈 배열이 되고 있었다. 그래서 「없다」와 「못 읽었다」가 같은 화면이 됐다.
서버는 404 를 주고 있었다. 그 두 목록 조회에 컨트롤러가 없었고, 계약에는 선언돼 있어 프론트가 그것을 믿고 불렀다.
## 작성자가 무엇으로 읽었나
작성자는 자기가 쓴 것과 화면을 대조한다. 화면이 「없습니다」라고 하면 아직 안 썼거나 게시하지 않았다고 읽는다.
이 경우에는 넷이 있었고 공개 사이트에도 나오고 있었다. 편집기만 못 읽고 있었다.
이 경우에는 넷이 있었고 공개 사이트에도 나오고 있었다. 두 화면이 같은 데이터베이스를 보는데 한쪽만 비어 있었으므로, 공개 사이트를 함께 보지 않으면 알아챌 방법이 없었다.
작성 도구에서 이 부류가 특히 오래 숨는다. 읽기 전용 화면이면 방문자가 「데이터가 없나 보다」로 넘어가지만, 작성 도구에서는 그 오독이 곧바로 작업 판단이 된다 — 없다고 읽으면 다시 쓰게 된다.
## 못 읽었다고 적는다
> 거짓말을 하느니 못 읽었다고 말한다.
요청이 실패하면 실패했다고 적는다. 0건은 0건이라고 적는다.
요청이 실패하면 실패했다고 적 0건은 0건이라고 적는다. 이 둘을 구분할 수 있어야 작성자가 다음에 무엇을 할지 정한다 — 실패면 다시 부르거나 서버를 보고, 0건이면 쓰면 된다.
## 같은 판단을 다른 화면에
주제 탭에도 같은 판단을 적용했다. 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그려지고, 못 받은 탭에는 못 받았다고 적는다.
주제 탭에도 같은 판단을 적용했다. 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그고, 못 받은 탭에는 못 받았다고 적는다.
탭 줄이 남는 것이 중요하다. 탭 줄까지 사라지면 그 주제가 없는 것처럼 보이고, 그러면 못 읽은 범위가 화면에서 더 넓어진다.
## 확인하지 못한 것
@@ -58,19 +58,27 @@ tech-log-frontend : 6e784ed · fd73bc8 · 3bb724b
## 묶어 읽으면 한쪽이 전체를 끌고 내려간다
편집기가 질문 목록과 결정 목록을 하나로 묶어 기다리고 있었다. 결정 쪽 연산에 컨트롤러가 없어 404 가 났고, 화면은 두 목록을 다 못 받은 것으로 그렸다.
편집기가 질문 목록과 결정 목록을 하나로 묶어 기다리고 있었다. 결정 쪽 연산에 컨트롤러가 없어 404 가 났고, 화면은 두 목록을 다 못 받은 것으로 그렸다.
질문 목록은 정상적으로 오고 있었다. 둘을 따로 읽도록 갈랐다.
질문 목록은 정상적으로 오고 있었다. 묶어 기다리면 하나라도 거절되는 순간 전체가 거절되므로, 성공한 응답이 있어도 쓸 수 없다.
## 동기적으로 던지면 거절 처리를 지나지 않는다
둘을 따로 읽도록 갈랐다. 실패한 목록에만 못 받았다고 적고 나머지는 그대로 그린다.
## 거절과 던짐이 다른 경로를 탄다
같은 코드에서 더 미묘한 변종이 났다.
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
배열을 만드는 표현식 안에서 던지면 그 표현식이 완성되지 않으므로 거절 처리기 붙을 대상이 없다.
배열 리터럴 안에서 함수를 부르는 시점은 `Promise.all` 에 배열이 넘어가기 전이다. 그 호출이 동기적으로 던지면 배열이 완성되지 않으므로 `Promise.all` 이 시작조차 하지 않고, 거기에 붙인 거절 처리기 붙을 대상이 없다.
거절만 잡는 처리로는 부족하다. 던지는 경로도 함께 잡아야 한 칸의 실패가 화면 전체로 번지지 않는다.
## 탭에도 같은 판단을
탭 줄은 목록 하나로 그리고 상세는 고른 탭만 받는다. 상세 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
탭 줄은 목록 호출 하나가 주는 전부이고 상세는 고른 탭만 그때 받는다. 상세 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
이 구조에서는 실패가 번질 수 있는 범위가 처음부터 한 탭으로 좁다. 상세를 미리 다 받아 두는 구조였다면 하나가 실패할 때 어디까지 그릴지를 다시 정해야 했다.
## 확인하지 못한 것
@@ -62,20 +62,30 @@ tech-log-design-package : 76a7ccb
## null 을 돌려주고 걸러 내면 흔적이 없다
매퍼가 아는 종류가 아니면 `null` 을 돌려준다. 호출부는 그 목록에서 `null` 을 걸러 낸다.
매퍼가 아는 종류가 아니면 `null` 을 돌려주고, 호출부는 그 목록에서 `null` 을 걸러 낸다.
이 조합에서는 오류가 나지 않고 빈 줄도 생기지 않는다. 목록의 길이만 줄어든다.
| 무엇이 남나 | 있음 |
|---|---|
| 오류 로그 | x |
| 화면의 빈 줄 | x |
| 목록 길이의 변화 | o |
목록에 몇 개가 있어야 하는지 아는 사람만 알아챌 수 있고, 공개 화면에서는 그것을 아는 사람이 작성자뿐이다.
## 응답 모양이 다른 목록에 다른 매퍼를 썼다
프로젝트 기록 목록과 탐색의 지식 목록은 응답 모양이 다르다. 프로젝트 쪽은 관계 항목을 그대로 실어 요약도 주제도 게시일도 없었고, 지식 목록은 처음부터 그 칸들을 갖고 있었다.
모양이 다른데 매퍼를 공유하면서 종류 판정까지 그쪽 것을 따랐다.
모양이 다른데 매퍼를 공유하면서 종류 판정까지 그쪽 것을 따랐다. 지식 목록의 매퍼는 CASE 와 REFERENCE 만 다루면 되는 화면을 위해 만들어졌으므로, 질문과 개념을 받으면 `null` 을 돌려준다.
## 고친 것
응답 모양에 맞는 매퍼를 쓰고 모든 종류를 싣게 했다. 목록 항목에 요약과 주제와 게시일을 더해 「제목만 있고 가운뎃점만 남은」 줄을 없앴다.
이 목록은 계약도 함께 고쳐야 했다. 관계 항목을 그대로 싣던 구조라 요약·주제·게시일을 담을 칸이 응답에 없었다.
## 확인하지 못한 것
`null` 을 돌려주고 호출부가 거르는 매퍼가 다른 목록에도 남아 있는지는 세지 않았다.
@@ -29,23 +29,27 @@ source:
매번 우는 검사가 읽히지 않게 되는 것을 막는다.
> 매번 늑대를 외치는 검사는 읽히지 않게 되고, 진짜 실패가 그 옆에 눈에 띄지 않은 채 앉아 있게 된다.
## 규칙
### 1. 기대된 실패는 조건을 적어 뺀다
미리보기가 아직 없는 문서는 현재 미리보기를 물으면 404 를 답하고, 화면은 그것을 「미리보기를 만드세요」로 바꾼다. 이런 응답은 실패가 아니다.
미리보기가 아직 없는 문서는 현재 미리보기를 물으면 404 를 답하고, 화면은 그것을 「미리보기를 만드세요」로 바꾼다. 이 404 는 정상 동작이 오류 상태 코드로 나타난 것이지 실패가 아니다.
로그인 전 세션 탐침의 401 도 같은 부류다. 어느 쪽도 사람이 고칠 것이 없다.
### 2. 뺀 나머지는 전부 실패시킨다
조건에 걸리지 않는 4xx 와 5xx 는 모두 스윕을 실패시킨다.
조건에 걸리지 않는 4xx 와 5xx 는 모두 스윕을 실패시킨다. 「이건 아마 괜찮을 것」이라는 판단을 스윕 안에 두지 않는다.
### 3. 조건을 적을 수 없으면 빼지 않는다
조건 없이 빼면 진짜 실패도 같이 빠진다.
조건 없이 빼면 진짜 실패도 같이 빠진다. 어떤 요청이 왜 그 응답을 받는지 한 줄로 적을 수 없으면 그것은 아직 기대된 실패가 아니다.
### 4. 뺀 조건을 사람이 읽을 수 있는 곳에 남긴다
왜 그 응답이 기대된 것인지 적혀 있지 않으면 다음 사람이 조건을 넓힌다.
왜 그 응답이 기대된 것인지 적혀 있지 않으면 다음 사람이 조건을 넓힌다. 비슷한 404 가 하나 더 나왔을 때 같은 조건에 넣을지 판단할 근거가 없기 때문이다.
## 적용 조건
@@ -56,11 +60,10 @@ source:
## 예외
- 기계가 판정하고 사람이 결과를 읽지 않는 검사라면 빨간 줄이 쌓여도 무뎌지지 않는다. 그래도 통과 기준은 정해야 한다.
- 그 응답이 정말 고쳐야 하는 것이면 조건으로 빼지 말고 고친다. 뺄지 고칠지는 사람이 고칠 것이 있는가로 가른다.
## 예시
- 미리보기가 없는 문서의 404 를 스윕이 실패로 셌다. 건강한 배포 아래에 매번 같은 빨간 줄이 남았다.
> 매번 늑대를 외치는 검사는 읽히지 않게 되고, 진짜 실패가 그 옆에 눈에 띄지 않은 채 앉아 있게 된다.
- 로그인 전 세션 탐침의 401 도 같은 부류라 같은 조건으로 제외하고, 나머지 4xx·5xx 는 전부 스윕을 실패시킨다.
- 미리보기가 없는 문서의 404 를 스윕이 실패로 셌다. 건강한 배포 아래에 매번 같은 빨간 줄이 남았다
- 로그인 전 세션 탐침의 401 도 같은 부류라 같은 조건으로 제외했다
- 나머지 4xx·5xx 는 전부 스윕을 실패시킨다
@@ -28,7 +28,9 @@ source:
## 목적
작성자가 「아직 안 썼다」와 「못 읽었다」를 구분할 수 있게 한다. 이 둘이 같은 화면이면 작성자는 다음에 무엇을 할지 정할 수 없다.
작성자가 「아직 안 썼다」와 「못 읽었다」를 구분할 수 있게 한다.
이 둘이 같은 화면이면 작성자는 다음에 무엇을 할지 정할 수 없다. 실패면 다시 부르거나 서버를 봐야 하고 0건이면 쓰면 되는데, 화면이 「없습니다」 하나로 답하면 뒤쪽으로 읽고 다시 쓰게 된다.
## 규칙
@@ -36,17 +38,25 @@ source:
빈 배열로 삼키지 않는다. 0건과 실패는 다른 문구를 쓴다.
목록 응답을 받아 그리는 코드에서 실패 경로가 빈 배열을 만들면, 그 아래의 「비어 있으면 이 문구」 분기가 두 경우를 같은 화면으로 만든다. 실패를 빈 값으로 접는 지점을 없애야 한다.
### 2. 한 칸의 실패가 옆 칸을 끌고 내려가지 않게 한다
여러 목록을 하나로 묶어 기다리면 한쪽의 실패가 전체를 실패로 만든다. 따로 읽고 실패한 목록에만 적는다.
여러 목록을 하나로 묶어 기다리면 하나라도 거절되는 순간 전체가 거절된다. 성공한 응답이 있어도 쓸 수 없다.
따로 읽고 실패한 목록에만 적는다. 화면에 여러 묶음이 있으면 실패가 번질 수 있는 범위를 그 묶음 하나로 좁힌다.
### 3. 거절만 잡는 처리로는 부족하다
호출이 동기적으로 던지면 그 처리기를 지나지 않는다. 던지는 경로도 잡는다.
호출이 동기적으로 던지면 그 처리기를 지나지 않는다. 배열 리터럴 안에서 부르는 함수가 던지면 배열이 완성되지 않으므로, 거기에 붙인 거절 처리기도 붙을 대상이 없다.
던지는 경로도 함께 잡아야 한 칸의 실패가 화면 전체로 번지지 않는다.
### 4. 항목을 걸러 낼 때 걸러 낸 것을 세어 둔다
매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 거르면, 목록이 한 줄 짧아지는 것 말고는 흔적이 없다.
매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 그것을 거르면, 오류도 빈 줄도 남지 않고 목록 길이만 줄어든다.
목록에 몇 개가 있어야 하는지 아는 사람만 알아챌 수 있다. 공개 화면에서는 그것을 아는 사람이 작성자뿐이다.
## 적용 조건
@@ -58,13 +68,11 @@ source:
## 예외
- 정말로 0건인 것과 못 읽은 것을 구분할 수 없는 화면이라면 그 구분을 먼저 만든다. 구분 없이 문구만 바꾸면 0건이 실패로 읽힌다.
- 읽는 사람이 그 데이터를 만들지 않는 화면 — 공개 조회 — 에서는 실패를 화면 전체의 오류로 다뤄도 된다.
- 읽는 사람이 그 데이터를 만들지 않는 화면에서는 실패를 화면 전체의 오류로 다뤄도 된다. 방문자에게는 어느 목록이 실패했는지가 할 일을 바꾸지 않는다.
## 예시
- 「이 프로젝트에 열린 질문이 없습니다」가 적혀 있는 동안 그 프로젝트에는 질문이 넷 있었고 공개 사이트에도 나오고 있었다.
- 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
- 매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 걸러 내, 질문과 개념이 목록에서 사라졌다.
- 「이 프로젝트에 열린 질문이 없습니다」가 적혀 있는 동안 그 프로젝트에는 질문이 넷 있었고 공개 사이트에도 나오고 있었다
- 결정 목록만 404 인데 함께 묶어 읽은 질문 목록까지 「불러오지 못했습니다」가 됐다
- 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다
- 매퍼가 모르는 종류에 빈 값을 돌려주고 호출부가 걸러 내, 질문과 개념이 목록에서 사라졌다
@@ -61,7 +61,13 @@ Dockerfile 이 그 경고를 문자 그대로 적어 두고 있는데도 빠뜨
인자를 넘기지 않으면 기본값이 들어간다. 그 기본값은 존재하지 않는 주소다.
## 요구하는 인자 전부
## 빌드도 배포도 성공한다
빌드는 인자가 없어도 성공한다. 이미지도 정상적으로 올라오고 파드도 healthy 다.
드러나는 곳은 배포본이 첫 요청을 낼 때다. 브라우저가 존재하지 않는 호스트로 요청을 보내고, 화면은 데이터를 받지 못한다.
Dockerfile 이 그 경고를 문자 그대로 적어 두고 있었는데도 빠뜨렸다.
```text
APP_PROFILE=production
@@ -76,8 +82,10 @@ SOURCE_DATE_EPOCH
`kubectl rollout undo` 로 이전 리비전으로 되돌린 뒤 인자를 넣어 다시 빌드하고 다시 올렸다.
되돌리는 것이 빨랐던 이유는 이전 이미지가 클러스터에 아직 있어서다. 레지스트리가 없어도 롤아웃 이력은 남으므로, 이미지를 다시 import 하지 않고 태그만 되돌리면 된다.
## 확인하지 못한 것
빌드가 이 인자를 요구하도록 막지 않았다. 빠뜨리면 여전히 빌드는 성공하고 배포본만 틀린다. 인자 목록을 적어 두고 메모리에 한 건 남긴 것으로 그쳤다 — `techlog-deploy-runtime-api-base.md`.
빌드가 이 인자를 요구하도록 막지 않았다. 빠뜨리면 여전히 빌드는 성공하고 배포본만 틀린다. 지금 남은 것은 인자 목록을 적어 둔 것과 메모리 하나다.
<!-- body:end -->
@@ -62,17 +62,28 @@ tech-log-frontend : 83409be
헬스 판정은 헬스 엔드포인트가 응답하는지를 본다. nginx 프로세스가 살아 있고 그 경로를 돌려주면 통과한다.
SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다.
SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다. 그래서 파드는 healthy 로 올라오고 사이트만 안 된다.
## 빌드가 쓴 권한
빌드가 그 파일을 0600 으로 쓴다. 파일을 만든 사용자만 읽을 수 있고, nginx 를 돌리는 사용자는 다른 사용자다.
빌드가 그 설정 파일을 0600 으로 쓴다. 파일을 만든 사용자만 읽을 수 있고, nginx 를 돌리는 사용자는 다른 사용자다.
증상이 404 가 아니라 403 이라는 것이 원인을 좁혔다. 404 면 파일이 없는 것이고 403 이면 파일은 있는데 읽지 못하는 것이므로, 이미지에 파일이 들어갔는지부터 확인할 필요가 없었다.
이미지가 권한을 정규화하도록 고쳤다.
## 브라우저가 묻는 주소
같은 배포에서 favicon 도 404 였다. `index.html``public/favicon.svg` 를 참조한 적이 없다. 파일은 이미지에 들어 있었고 nginx 도 서빙했지만, 브라우저는 참조가 없으면 `/favicon.ico` 를 묻는다. 그 이름의 파일이 없어 404 를 받고 기본 아이콘으로 떨어졌다.
같은 배포에서 favicon 도 404 였는데 원인이 달랐다. `index.html``public/favicon.svg` 를 참조한 적이 없다.
파일은 이미지에 들어 있었고 nginx 도 서빙했다. 브라우저는 참조가 없으면 `/favicon.ico` 를 묻고, 그 이름의 파일이 없어 404 를 받고 기본 아이콘으로 떨어졌다.
| | 파일이 이미지에 있나 | nginx 가 서빙하나 | 무엇이 문제였나 |
|---|---|---|---|
| 설정 파일 | o | o | 권한이 0600 이라 읽지 못함 |
| favicon | o | o | 아무도 그 이름을 요청하지 않음 |
둘 다 「파일이 없다」가 아니었고, 그래서 이미지를 열어 파일을 찾는 방식으로는 둘 다 정상으로 보였다.
## 확인하지 못한 것
@@ -37,15 +37,13 @@ source:
→ kube-system 의 containerd import Job → kubectl set image
```
공개 Hub 는 소스가 들어간 이미지라 쓸 수 없다. k3s 의 containerd 소켓은 root 전용이라 사용자 셸에서 닿지 않는다. 그래서 클러스터 안에 일회성 Job 을 띄워 tar 를 import 한다.
## 배포 단위가 하나다
공개 Hub 는 소스가 들어간 이미지라 쓸 수 없다. k3s 의 containerd 소켓은 root 전용이라 사용자 셸에서 닿지 않는다. 그래서 클러스터 안에 일회성 Job 을 띄워 tar 를 import 한다 — Job 은 클러스터 권한으로 도므로 그 소켓에 닿는다.
배포 단위는 `hyeonworks.com` 하나이고 서브도메인을 쓰지 않는다. 공개는 `/`, API 는 `/api` 다.
## 무엇이 이미지 안에서 굳는
## 빌드 인자는 이미지에 굳는
빌드 인자는 이미지에 굳는다. 런타임 환경 변수가 아니므로 배포한 뒤에 바꿀 수 고, 잘못 넣으면 다시 빌드해서 다시 올려야 한다.
이 경로에서 설정은 두 가지 방식으로 들어간다. 런타임 환경 변수 배포한 뒤에 바꿀 수 고, 빌드 인자는 이미지에 굳어 다시 빌드해야 바뀐다.
프론트 이미지가 요구하는 인자는 이만큼이다.
@@ -58,14 +56,26 @@ CI_RUNNER_IMAGE=node@sha256:… ← 반드시 @sha256 다이
SOURCE_DATE_EPOCH
```
넘기지 않으면 기본값이 굳는다. 빌드는 성공하고 이미지도 정상적으로 올라오므로, 잘못된 값은 배포본이 요청을 내보낼 때 처음 드러난다.
백엔드 이미지는 Dockerfile 이 `src/` 아래에 있고 `RELEASE_VERSION`·`BUILD_VERSION`·`GIT_SHA`·`SOURCE_URL` 을 받는다. 태그는 짧은 SHA 일곱 자이고 배포된 것과 맞춰야 한다.
## 빌드 산출물에 커밋 해시가 들어간다
백엔드 빌드 산출물 이름에 커밋 해시가 들어간다. 작업 트리가 더러우면 해시가 달라져 stale 산출물 검사가 멈춘다. 커밋한 뒤에 빌드를 돌려야 한다.
백엔드 빌드 산출물 이름에 커밋 해시가 들어간다. 작업 트리가 더러우면 해시가 달라져 stale 산출물 검사가 멈추므로, 커밋한 뒤에 빌드를 돌려야 한다.
이 순서는 산출물이 어느 커밋에서 나왔는지를 이름으로 추적하려고 둔 것이다. 그래서 순서를 지키지 않으면 검사가 통과하지 않고, 검사를 끄면 추적이 사라진다.
## 이 경로가 늦게 알려 주는 것
컨테이너 안의 파일 권한, nginx 가 서빙하기로 한 파일 목록, 빌드에 굳은 주소는 전부 이 단계 뒤에 드러난다.
이미지가 healthy 로 올라오는 것과 사이트가 동작하는 것은 다르다.
| 무엇이 | 언제 드러나나 |
|---|---|
| 컨테이너 안의 파일 권한 | 그 파일을 처음 요청할 때 |
| nginx 가 서빙하기로 한 파일 목록 | 목록에 없는 파일을 요청할 때 |
| 빌드에 굳은 주소 | 배포본이 그 주소로 요청을 낼 때 |
셋 다 헬스 판정을 지난다. 헬스 엔드포인트는 그 파일도 그 목록도 그 주소도 쓰지 않기 때문이다.
<!-- body:end -->