docs(TechLog): 얇은 Case 열 편과 Question 셋을 저장소 실물로 채운다

SSOT 를 저장소에서 확인해 더 보강하고 그것으로 다시 썼다.

  §7.2   참조 검사 SQL 을 문자열로 조립하는 실제 코드 — 컴파일러가 표 이름도
         컬럼 이름도 보지 않는다는 것이 그 모양에서 드러난다
  §11.2  section-heading-rank 가 미디어 쿼리 값을 먼저 걷어내는 이유(테스트 주석)
  §16.1  질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다는 대조

Case 열 편과 Question 셋을 다시 썼다. Question 은 사실·가정·미지수·제약을 갈라
채우고 선택지마다 무엇을 감수하는지 적었다 — 오류 코드를 나누면 계약과 반입한 두
저장소가 함께 움직인다는 것처럼.

SSOT 62,643 → 68,319 자. 검사 넷 전부 통과한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 19:18:37 +09:00
co-authored by Claude Opus 5
parent 6917ce2420
commit fd221353a3
15 changed files with 162 additions and 50 deletions
+22
View File
@@ -548,6 +548,19 @@ refresh 에 실패해 **파드가 CrashLoopBackOff** 로 들어갔습니다.
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
그 어댑터는 SQL 을 문자열로 이어 붙여 만듭니다. 컴파일러가 확인하는 것은 이 식이 문자열이라는
것까지이고, 표 이름도 컬럼 이름도 실행해야 검증됩니다.
```java
"SELECT EXISTS ("
+ " SELECT 1 FROM document_relation WHERE target_document_id = :id"
+ " UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id"
+ ")"
```
**진짜 실패는 이 SQL 이 한 번도 실행된 적이 없다는 것이었습니다.** 표준 `check`
Testcontainers 를 띄우지 않으므로 **persistence SQL 은 한 번도 실행되지 않은 채 빌드가
통과합니다.** 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못합니다.
@@ -915,6 +928,11 @@ Studio 에 주제 설명을 쓸 칸조차 없었기 때문입니다.
`font-size`/`font-weight` 를 쓰는지 CSS 를 파싱해 확인합니다. 굵기를 빼 보고 실제로 멈추는
것을 확인했습니다.
이 검사는 미디어 쿼리 안의 값을 먼저 걷어냅니다. 그 이유가 테스트 안에 적혀 있습니다:
> 좁은 화면용 값은 뺀다. 화면 폭마다 급이 달라지는 것은 의도이고, 여기서 보려는 것은 같은
> 폭에서 나란히 설 때의 급이다.
> **이때 제가 저지른 판단 오류:** 처음에 grid/columns 만 측정하고 "정상"이라고 답했습니다.
> 사용자가 다시 지적한 뒤 **전체 페이지 스크린샷**을 찍어서야 26px/400 을 봤습니다.
> **프록시 지표가 아니라 보이는 것을 측정해야 합니다.**
@@ -1358,6 +1376,10 @@ UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id
UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
```
같은 어댑터의 질문 삭제는 참조가 둘뿐이라 같은 문제가 덜합니다 — `project_question_link`
`home_focus_config.open_question_id` 입니다. 다섯을 하나로 묶은 쪽에서만 어느 것이 막았는지가
사라집니다.
실제 사례: 「DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1」(CASE, DRAFT/PRIVATE)을 지우려는데
관계를 다 지워도 삭제가 안 됐습니다. 남아 있던 것은 `project_document_link` 의 **PRIMARY 링크
1행**(프로젝트 「Liner N + 1문제」)이었습니다. **프로젝트 연결은 「관계」 편집기가 아니라 문서의
@@ -68,6 +68,12 @@ tech-log-design-package : 71bab4c · b93d62a
주제 화면에서 그 링크를 누르면 주소에 앵커가 붙는다. 화면은 이미 그 주제 화면이므로 라우터가 다시 그릴 것이 없고, 앵커가 가리키는 element 도 없으니 스크롤도 일어나지 않는다.
## 앵커로 옮긴 것이 왜 나쁘지 않았나
앵커는 결정에는 맞는 선택이다. 결정은 프로젝트 화면 안에서만 읽히므로 자기 화면이 필요 없고, 목록 위의 앵커로 충분하다.
축은 다르다. 축을 고르면 그 축에 걸린 기록만 보여야 하는데, 앵커는 화면을 바꾸지 않고 위치만 옮긴다. 같은 표기를 쓰지만 두 경우가 요구하는 것이 다르다.
## 축에 자기 화면을 줬다
목록 조회에 축 필터를 더하고 기록이 어느 축에 걸리는지를 담은 표로 거른다.
@@ -84,7 +90,7 @@ tech-log-design-package : 71bab4c · b93d62a
같은 시기에 주제 없이 게시된 기록이 이름 없는 주제 링크를 달고 있었다. 문서 머리말의 breadcrumb 과 탐색의 「주제 없음」 묶음 둘 다였다.
프로젝트 조각은 처음부터 조건부였다. 주제 쪽만 조건이 없었고, 그 차이가 두 화면에서 같은 모양으로 나타났다.
프로젝트 조각은 처음부터 조건부였다. 주제 쪽만 조건이 없었고, 그 차이가 두 화면에서 같은 모양으로 나타났다 — 값이 없을 때 그 조각을 그리지 않는 판단을 한쪽에만 넣었다.
## 확인하지 못한 것
@@ -30,19 +30,21 @@ source:
## 사실
홈의 비교 구역에서 한 줄은 축 하나다. 기록을 스무 개 붙여도 줄 수는 늘지 않는다.
홈의 비교 구역에서 한 줄은 축 하나다. 기록을 스무 개 붙여도 줄 수는 그대로다.
줄에 보이는 결론 문장은 축에 손으로 쓴 글이다. 기록을 붙여도 그 문장은 누가 고치기 전까지 바뀌지 않는다.
줄에 보이는 결론 문장은 축에 손으로 쓴 글이다. 기록을 붙여도 그 문장은 누가 고치기 전까지 그대로다.
주제 화면에는 축마다 「기록 N」이 붙는다. 홈에는 그 수가 없다.
그래서 기록 1개짜리 축과 20개짜리 축이 홈에서 똑같아 보인다.
홈이 처음 보내는 요청은 목록 하나와 주제 하나로 고정돼 있다. 상세는 고른 탭만 그때 받아 캐시한다.
주제의 논지와 축의 결론은 2026-09-01 에 AI 가 써서 DB 에 직접 넣은 초안이다. 사용자 검토 대상이고 아직 검토되지 않았다.
## 가정
결론을 사람이 쓰게 한 것이 의도라고 보고 있다. 요약은 「무엇인가」이고 결론은 「무엇을 알게 됐나」라서 기록의 요약 첫 줄을 잘라 쓰면 안 된다고 판단했지만, 그 판단이 지금도 맞는지 다시 보지 않았다.
결론을 사람이 쓰게 한 것이 의도라고 보고 있다. 요약은 「무엇인가」이고 결론은 「무엇을 알게 됐나」라서 기록의 요약 첫 줄을 잘라 쓰면 안 된다고 판단했는데, 그 판단이 지금도 맞는지 다시 보지 않았다.
기록 수는 유도할 수 있다고 보고 있다. 주제 화면이 이미 그 수를 그리므로 같은 값을 홈에 붙이면 된다고 짐작하지만, 홈의 목록 호출이 그 수를 싣는지 확인하지 않았다.
@@ -50,7 +52,7 @@ source:
홈에 기록 수를 붙이면 축에 기록을 더했을 때 화면이 달라지는가. 달라진다면 결론 문장이 낡았다는 것도 같은 화면에서 드러나는가.
결론을 사람이 갱신해야 한다는 것을 화면이 말해야 하는가. 말한다면 어디에 말해야 읽히는가.
결론을 사람이 갱신해야 한다는 것을 화면이 말해야 하는가. 말한다면 어디에 말해야 읽히는가 — 공개 화면에 적으면 독자가 그것을 읽고, Studio 에만 적으면 작성자만 읽는다.
DB 에 직접 넣은 초안을 누가 언제 검토하는가. 검토 전까지 그 문장을 공개 화면에 그대로 둘지.
@@ -63,10 +65,10 @@ DB 에 직접 넣은 초안을 누가 언제 검토하는가. 검토 전까지
## 선택지
**홈 비교표에 기록 수를 붙인다**
목록 호출이 이미 그 수를 실을 수 있으면 요청이 늘지 않는다. 결론 문장은 여전히 사람이 쓴다.
목록 호출이 이미 그 수를 실을 수 있으면 요청이 늘지 않는다. 결론 문장은 그대로 사람이 쓴다. 기록이 늘면 수가 바뀌므로 결론만 낡은 상태가 화면에 드러난다.
**결론 문장이 마지막으로 고쳐진 때를 함께 보인다**
기록이 그 뒤에 늘었으면 낡았다는 것이 드러난다. 화면에 날짜가 하나 더 늘어난다.
기록이 그 뒤에 늘었으면 낡았다는 것이 드러난다. 화면에 날짜가 하나 더 늘고, 독자에게는 필요 없는 값이 보인다.
**결론을 쓰지 않은 축은 결론 줄을 비운다**
쓰지 않은 것과 낡은 것을 구분한다. 지금은 초안이 들어 있어 둘이 같아 보인다.
@@ -66,7 +66,7 @@ tech-log-frontend : 68538f2
| 크기 | 30px | 26px (각자 선언) |
| 굵기 | 650 | 400 (아무도 선언하지 않음) |
같은 화면에 나란히 서 있는 구역들이라 이 둘만 급이 낮아 보였다.
같은 화면에 나란히 서 있는 구역들이라 이 둘만 급이 낮아 보였다. 「구조별로 알게 된 것」이 형제 구역보다 작고 가늘게 나왔고, 보고는 「디자인이 안 됐다」로 왔다.
## 처음에 잘못 판단한 것
@@ -74,9 +74,15 @@ tech-log-frontend : 68538f2
격자와 열은 정상이었다. 보고된 증상은 글자 크기와 굵기였는데 다른 축을 재고 답했다.
## 검사
## 검사가 무엇을 보고 무엇을 빼나
`section-heading-rank.test.ts` 가 나란히 서는 구역 제목들이 정본과 같은 `font-size``font-weight` 를 쓰는지 CSS 를 파싱해 확인한다.
`section-heading-rank.test.ts` globals.css 를 읽어 나란히 서는 구역 제목들이 정본과 같은 `font-size``font-weight` 를 쓰는지 확인한다.
이 검사는 미디어 쿼리 안의 값을 먼저 걷어낸다. 그 이유가 테스트 안에 적혀 있다.
> 좁은 화면용 값은 뺀다. 화면 폭마다 급이 달라지는 것은 의도이고, 여기서 보려는 것은 같은 폭에서 나란히 설 때의 급이다.
걷어내지 않으면 좁은 화면에서 일부러 급을 낮춘 선언이 전부 위반으로 나온다. 그러면 결과가 늘 빨갛고 곧 읽히지 않는다.
굵기를 빼 보고 실제로 멈추는 것을 확인한 뒤 커밋했다. 확인하지 않았다면 그 구역들이 원래 정본을 쓰고 있어서 통과한 것인지 검사가 그 구역을 안 보는 것인지 구별되지 않는다.
@@ -61,7 +61,7 @@ tech-log-backend : 911e8ba — 없던 두 컨트롤러를 구현
편집기는 질문 목록을 받아 고를 수 있게 그린다. 목록이 비면 「이 프로젝트에 열린 질문이 없습니다」를 적는다.
요청이 실패했을 때도 빈 배열이 되고 있었다. 그래서 「없다」와 「못 읽었다」가 같은 화면이 됐다.
요청이 실패했을 때도 빈 배열이 되고 있었다. 실패 경로가 빈 값을 만들고, 그 아래의 「비어 있으면 이 문구」 분기가 두 경우를 같은 화면으로 만든다.
서버는 404 를 주고 있었다. 그 두 목록 조회에 컨트롤러가 없었고, 계약에는 선언돼 있어 프론트가 그것을 믿고 불렀다.
@@ -69,9 +69,9 @@ tech-log-backend : 911e8ba — 없던 두 컨트롤러를 구현
작성자는 자기가 쓴 것과 화면을 대조한다. 화면이 「없습니다」라고 하면 아직 안 썼거나 게시하지 않았다고 읽는다.
이 경우에는 넷이 있었고 공개 사이트에도 나오고 있었다. 두 화면이 같은 데이터베이스를 보는데 한쪽만 비어 있었으므로, 공개 사이트를 함께 보지 않으면 알아챌 방법이 없었다.
이 경우에는 넷이 있었고 공개 사이트에도 나오고 있었다. 두 화면이 같은 데이터베이스를 보는데 한쪽만 비어 있었으므로, 공개 사이트를 함께 열어 보지 않으면 알아챌 방법이 없었다.
작성 도구에서 이 부류가 특히 오래 숨는다. 읽기 전용 화면이면 방문자가 「데이터가 없나 보다」로 넘어가지만, 작성 도구에서는 그 오독이 곧바로 작업 판단이 된다 — 없다고 읽으면 다시 쓰게 된다.
읽기 전용 화면이면 방문자가 「데이터가 없나 보다」로 넘어간다. 작성 도구에서는 그 오독이 곧바로 작업 판단이 된다 — 없다고 읽으면 다시 쓰게 된다.
## 못 읽었다고 적는다
@@ -79,11 +79,13 @@ tech-log-backend : 911e8ba — 없던 두 컨트롤러를 구현
요청이 실패하면 실패했다고 적고 0건은 0건이라고 적는다. 이 둘을 구분할 수 있어야 작성자가 다음에 무엇을 할지 정한다 — 실패면 다시 부르거나 서버를 보고, 0건이면 쓰면 된다.
실패를 빈 값으로 접는 지점을 없애는 것이 고치는 방법이다. 문구만 바꾸면 그 지점이 그대로여서 다음 화면에서 같은 일이 난다.
## 같은 판단을 다른 화면에
주제 탭에도 같은 판단을 적용했다. 탭 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
탭 줄이 남는 것이 중요하다. 탭 줄까지 사라지면 그 주제가 없는 것처럼 보이고, 그러면 못 읽은 범위가 화면에서 더 넓어진다.
탭 줄이 남는 것이 중요하다. 탭 줄까지 사라지면 그 주제가 없는 것처럼 보이고, 못 읽은 범위가 화면에서 더 넓어진다.
## 확인하지 못한 것
@@ -70,10 +70,16 @@ tech-log-frontend : 6e784ed · fd73bc8 · 3bb724b
> `Promise.all([gateway.foo()])` 은 foo 가 **거절하는 것만** 잡는다. 호출이 **동기적으로 던지면** 배열을 만드는 중에 터져 rejection handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
배열 리터럴 안에서 함수를 부르는 시점은 `Promise.all` 배열이 넘어가기 전이다. 그 호출이 동기적으로 던지면 배열이 완성되지 않으므로 `Promise.all` 이 시작조차 하지 않고, 거기에 붙인 거절 처리기도 붙을 대상이 없다.
배열 리터럴 안에서 함수를 부르는 시점은 그 배열이 `Promise.all` 에 넘어가기 전이다. 그 호출이 동기적으로 던지면 배열이 완성되지 않으므로 `Promise.all` 이 시작조차 하지 않고, 거기에 붙인 거절 처리기도 붙을 대상이 없다.
거절만 잡는 처리로는 부족하다. 던지는 경로도 함께 잡아야 한 칸의 실패가 화면 전체로 번지지 않는다.
## 왜 동기적으로 던질 수 있나
게이트웨이 호출이 비동기 함수여도 그 안의 첫 줄이 동기적으로 실행된다. 인자를 검증하거나 연산을 고르는 코드가 거기 있고, 등록되지 않은 연산을 고르면 거기서 바로 던진다.
그래서 「비동기 함수를 불렀으니 거절로 온다」는 전제가 성립하지 않는다.
## 탭에도 같은 판단을
탭 줄은 목록 호출 하나가 주는 전부이고 상세는 고른 탭만 그때 받는다. 상세 하나를 못 받아도 탭 줄과 나머지 탭은 그대로 그리고, 못 받은 탭에는 못 받았다고 적는다.
@@ -62,7 +62,7 @@ tech-log-design-package : 76a7ccb
## null 을 돌려주고 걸러 내면 흔적이 없다
매퍼가 아는 종류가 아니면 `null` 을 돌려주고, 호출부는 그 목록에서 `null` 을 걸러 낸다.
매퍼가 아는 종류가 아니면 빈 값을 돌려주고, 호출부는 그 목록에서 빈 값을 걸러 낸다.
이 조합에서는 오류가 나지 않고 빈 줄도 생기지 않는다. 목록의 길이만 줄어든다.
@@ -72,22 +72,27 @@ tech-log-design-package : 76a7ccb
| 화면의 빈 줄 | x |
| 목록 길이의 변화 | o |
목록에 몇 개가 있어야 하는지 아는 사람만 알아챌 수 있고, 공개 화면에서는 그것을 아는 사람이 작성자뿐이다.
목록에 몇 개가 있어야 하는지 아는 사람만 알아챌 수 있다. 공개 화면에서는 그것을 아는 사람이 작성자뿐이고, 작성자는 그 목록을 자기 기록의 전부로 읽는다.
## 응답 모양이 다른 목록에 다른 매퍼를 썼다
프로젝트 기록 목록과 탐색의 지식 목록은 응답 모양이 다르다. 프로젝트 쪽은 관계 항목을 그대로 실어 요약도 주제도 게시일도 없었고, 지식 목록은 처음부터 그 칸들을 갖고 있었다.
프로젝트 기록 목록과 탐색의 지식 목록은 응답 모양이 다르다.
모양이 다른데 매퍼를 공유하면서 종류 판정까지 그쪽 것을 따랐다. 지식 목록의 매퍼는 CASE 와 REFERENCE 만 다루면 되는 화면을 위해 만들어졌으므로, 질문과 개념을 받으면 `null` 을 돌려준다.
| | 프로젝트 기록 목록 | 탐색의 지식 목록 |
|---|---|---|
| 무엇을 실었나 | 관계 항목 그대로 | 요약·주제·게시일까지 |
| 아는 종류 | (매퍼를 빌림) | CASE · REFERENCE |
## 고친 것
모양이 다른데 매퍼를 공유하면서 종류 판정까지 그쪽 것을 따랐다. 지식 목록의 매퍼는 CASE 와 REFERENCE 만 다루면 되는 화면을 위해 만들어졌으므로, 질문과 개념을 받으면 빈 값을 돌려준다.
응답 모양에 맞는 매퍼를 쓰고 모든 종류를 싣게 했다. 목록 항목에 요약과 주제와 게시일을 더해 「제목만 있고 가운뎃점만 남은」 줄을 없앴다.
## 두 가지를 함께 고쳐야 했다
이 목록은 계약도 함께 고쳐야 했다. 관계 항목을 그대로 싣던 구조라 요약·주제·게시일을 담을 칸이 응답에 없었다.
응답 모양에 맞는 매퍼를 쓰고 모든 종류를 싣게 했다. 그것만으로는 줄이 「제목만 있고 가운뎃점만 남은」 모양이었다.
목록 항목이 관계 항목을 그대로 싣던 구조라 요약도 주제도 게시일도 담을 칸이 응답에 없었기 때문이다. 계약에 그 칸들을 더하고 백엔드가 그것을 채우게 한 뒤에야 줄이 완성됐다.
## 확인하지 못한 것
`null` 을 돌려주고 호출부가 거르는 매퍼가 다른 목록에도 남아 있는지는 세지 않았다.
빈 값을 돌려주고 호출부가 거르는 매퍼가 다른 목록에도 남아 있는지는 세지 않았다.
<!-- body:end -->
@@ -30,11 +30,15 @@ CI 게이트가 설치된 라우트마다 수동 접근성 증거 파일을 하
FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고, 그 집합이 정확히 일치하지 않으면 거절한다.
정확한 일치를 요구하는 이유는 빠뜨림이 통과가 되지 않게 하려는 것이다. 파일이 더 많아도 더 적어도 거절한다.
`artifacts/tests/a11y-manual/*.md` 는 전부 `pending-manual-review` 다.
`review:a11y-manual` 스크립트는 그래서 실패하는 것이 지금 정상이다.
`review:a11y-manual` 스크립트는 그래서 실패하는 것이 지금 정상이다.
라우트를 더할 때마다 이 증거 개수도 늘어, 커밋 넷에서 111 → 117 이 됐다.
라우트를 더할 때마다 이 증거 개수가 함께 움직였고, 커밋 넷에서 111 → 117 로 늘었다.
게이트가 세는 것은 파일의 존재이고 그 안의 서명 상태는 보지 않는다.
## 가정
@@ -46,13 +50,13 @@ FE-GATE-009 는 설치된 라우트마다 증거 파일 하나를 요구하고,
게이트가 서명 여부까지 보게 하면 지금 몇 개의 라우트가 막히는가.
서명을 요구하지 않기로 한다면 이 게이트가 파일 개수를 세는 것이 무엇을 막는가.
서명을 요구하지 않기로 한다면 이 게이트가 파일 개수를 세는 것이 무엇을 막는가. 파일이 있고 내용이 비어 있으면 지금도 통과한다.
라우트 하나를 사람이 실제로 검토하는 데 얼마가 드는가.
라우트 하나를 사람이 실제로 검토하는 데 얼마가 드는가. 그 비용을 모르면 어느 쪽도 고를 수 없다.
## 제약
FE-GATE-009 는 라우트 집합과 증거 집합이 정확히 일치하기를 요구한다. 이 규칙은 바꾸지 않는다.
FE-GATE-009 는 라우트 집합과 증거 집합이 정확히 일치하기를 요구한다. 이 규칙은 바꾸지 않는다 — 빠뜨림이 통과가 되면 게이트가 아니다.
수동 접근성 증거는 사람이 만든다. 자동 검사로 대신하지 않는다.
@@ -65,7 +69,7 @@ FE-GATE-009 는 라우트 집합과 증거 집합이 정확히 일치하기를
게이트가 세는 것이 무엇인지 분명해진다. 수동 검토를 하지 않기로 하는 결정이므로 그 결과를 따로 적어야 한다.
**게이트는 그대로 두고 서명 현황을 별도로 보고한다**
배포는 막지 않고 서명되지 않은 라우트 수를 드러낸다. 막지 않는 지표가 읽히지 않게 되는 것을 감수한다.
배포는 막지 않고 서명되지 않은 라우트 수를 드러낸다. 막지 않는 지표가 읽히지 않게 되는 것을 감수한다 — 이 저장소에서 매번 우는 검사가 읽히지 않게 된 사례가 이미 있다.
## 다음 검증
@@ -86,9 +86,19 @@ QUESTION → 열린 질문
두 안이 말하는 것은 같다. 「직접 해보니」와 「검증 기록」은 둘 다 그 글이 재현한 결과라고 말한다. 갈린 것은 이 사이트의 다른 글들이 쓰는 어조와 맞느냐다.
## 이름 표가 하나였기 때문에 두 번 바꿀 수 있었다
이름을 바꾸는 작업이 한 파일을 고치는 일이 됐다. 표가 화면마다 복사돼 있던 때였다면 두 번 바꾸는 동안 여섯 벌이 두 번씩 갈렸을 것이다.
같은 파일에 표가 하나 더 있다. 다섯 종류를 셋으로 접어 지식의 상태로 만드는 표다 — 확인한 것, 정리한 것, 아직 모르는 것.
> 미해결이 이 기록의 가장 정직한 신호인데 다섯 종류가 같은 회색 11px 로 나오면 그것이 가장 안 보인다.
이름을 바꿔도 이 표는 그대로였다. 표시 이름과 지식 상태가 다른 축이라 따로 두었기 때문이다.
## 계약의 kind 는 그대로 뒀다
바꾼 것은 화면에 보이는 이름이다. 계약의 `RecordKind` 다섯 그대로이고 주소도 그대로다.
바꾼 것은 화면에 보이는 이름이다. 계약의 종류 값은 다섯 그대로이고 주소도 그대로다.
표시 이름과 계약 값을 갈라 두었기 때문에 두 번 바꾸면서 계약을 한 번도 건드리지 않았다. 계약을 바꿨다면 반입한 두 저장소가 함께 움직여야 했고, 이미 게시된 주소도 함께 흔들렸을 것이다.
@@ -29,9 +29,13 @@ source:
## 사실
작업본 삭제 실패는 다섯 가지 이유가 전부 같은 한 문장으로 나다 — `another record still links to this one; unlink it first`.
작업본 삭제 실패는 다섯 참조 중 무엇이 막았든 같은 오류 코드로 나다 — `DOCUMENT_IN_USE`.
실제로 막는 것은 다섯 참조 중 하나다.
클라이언트에 나가는 문구는 코드마다 하나로 고정돼 있다. 그 코드의 문구는 「이 기록을 참조하는 곳이 있어 삭제할 수 없습니다」이다.
고정 문구를 두는 이유는 예외의 원문 메시지에 저장소 제약 이름이나 SQL 조각이 섞일 수 있어서다. 그 판단은 서버 쪽 클래스의 javadoc 에 적혀 있다.
참조 검사는 다섯 표를 하나의 존재 검사로 묶는다.
```sql
SELECT 1 FROM document_relation WHERE target_document_id = :id
@@ -41,47 +45,54 @@ UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id
UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id
```
실제 사례는 「DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1」이다. 관계를 다 지워도 삭제가 안 됐고, 남아 있던 것은 프로젝트 「Liner N + 1문제」로 가는 링크 한 행이었다.
같은 어댑터의 질문 삭제는 참조가 둘뿐이라 같은 문제가 덜하다 — `project_question_link``home_focus_config.open_question_id` 다.
프로젝트 연결은 「관계」 편집기가 아니라 문서의 Project 필드다. 관계를 아무리 지워도 그 행은 남는다.
문구가 `another record` 라고 하니 관계를 찾아 지우게 되는데, 정작 막는 것은 record 가 아니라 프로젝트다. 문구가 잘못된 것을 가리키고 있다.
실제 사례에서 관계를 다 지워도 삭제가 안 됐다. 남아 있던 것은 프로젝트 링크 한 행이었고, 그 링크는 「관계」 편집기가 아니라 문서의 Project 필드가 만든다.
사용자는 Project 필드를 「미지정」으로 바꾸고 저장한 뒤 삭제했다.
## 가정
문구가 `another record` 라고 말하므로 사용자가 관계를 먼저 찾는다고 보고 있다. 실제 사례가 하나이고, 다른 사용자가 같은 순서로 움직이는지는 확인하지 않았다.
문구가 another record」라고 하니 사용자가 관계를 먼저 찾는다고 보고 있다. 실제 사례가 하나이고, 다른 사용자가 같은 순서로 움직이는지는 확인하지 않았다.
다섯 참조를 종류별로 갈라도 성능이 문제가 되지 않는다고 보고 있다. 다섯 개의 존재 검사를 따로 돌리는 비용은 재지 않았다.
다섯 참조를 종류별로 갈라도 응답 시간이 문제가 되지 않는다고 보고 있다. 하나의 존재 검사를 다섯 개로 나누는 비용은 재지 않았다.
질문 삭제처럼 참조가 둘뿐이면 이 문제가 덜하다고 보고 있다. 질문 삭제에서 사용자가 실제로 헤맨 기록은 없다.
## 미지수
무엇이 막는지 말하면서 내부 테이블 이름을 노출하지 않는 문구가 무엇인가.
무엇이 막는지 말하면서 내부 이름을 노출하지 않는 문구가 무엇인가.
사용자가 고칠 수 있는 의 이름으로 옮기면 다섯 참조가 몇 가지로 줄어드는가. 문서의 Project 필드와 주제의 대표 기록은 서로 다른 화면이다.
사용자가 고칠 수 있는 화면의 이름으로 옮기면 다섯 참조가 몇 가지로 줄어드는가. 문서의 Project 필드와 주제의 대표 기록은 서로 다른 화면이고, 관계 편집기는 또 다른 화면이다.
코드를 다섯으로 나누면 계약의 오류 코드 열거형도 함께 늘어난다. 그것을 반입하는 두 저장소가 그 값을 알아야 하는데, 그 비용이 얼마인가.
## 제약
클라이언트에 내보내는 메시지에 내부 테이블 이름이나 컬럼 이름을 넣지 않는다.
클라이언트에 내보내는 메시지에 내부 이름이나 컬럼 이름을 넣지 않는다.
참조 검사는 삭제 경로에서 돈다. 이 경로의 응답 시간을 늘리지 않는다.
오류 코드는 계약이 열거한다. 코드를 늘리면 계약과 반입한 두 저장소가 함께 움직인다.
## 선택지
**참조 검사를 종류별로 갈라 어느 것이 걸렸는지 돌려준다**
다섯 개의 존재 검사를 따로 돌리고 걸린 종류를 응답에 싣는다. 화면이 그 종류를 사용자가 고칠 수 있는 의 이름으로 옮긴다.
다섯 개의 존재 검사를 따로 돌리고 걸린 종류를 응답에 싣는다. 화면이 그 종류를 사용자가 고칠 수 있는 화면의 이름으로 옮긴다. 코드를 늘리지 않고 응답의 부가 필드로 실으면 계약 변경이 작다.
**오류 코드를 참조 종류만큼 나눈다**
문구가 코드마다 하나이므로 코드를 나누면 문구도 갈린다. 대신 계약의 열거형이 늘고 반입한 두 저장소가 함께 움직인다.
**막는 참조를 목록으로 돌려준다**
어느 기록이 걸었는지까지 보인다. 관계는 이름을 보일 수 있지만 프로젝트 링크와 주제 대표 기록은 다른 화면이라 이름만으로는 어디를 고칠지 알기 어렵다.
**문구만 고쳐 프로젝트 연결을 함께 언급한다**
가장 싸다. 다섯 중 어느 것인지는 여전히 말하지 못다.
가장 싸다. 다섯 중 어느 것인지는 여전히 말하지 못하고, 사용자가 확인할 화면이 셋으로 늘어난다.
## 다음 검증
1. 참조 검사를 종류별로 갈라 걸린 종류를 응답에 실어 보고, 삭제 경로의 응답 시간이 얼마나 달라지는지 잰다
2. 다섯 종류를 사용자가 고칠 수 있는 화면 이름으로 옮겨 적고 몇 가지로 줄어드는지 센다
3. 실제로 막힌 작업본 하나로 새 문구를 보여 주고 어디를 고쳐야 하는지 문구만으로 찾을 수 있는지 확인한다
3. 실제로 막힌 작업본 하나로 새 문구를 보여 주고, 어디를 고쳐야 하는지 문구만으로 찾을 수 있는지 확인한다
닫는 조건 : 삭제가 막혔을 때 어디를 고쳐야 하는지 문구만 보고 알 수 있으면 닫는다. 종류별로 가르는 비용이 응답 시간에 드러나면 문구만 고치는 쪽으로 정하고 Decision 으로 넘긴다
@@ -57,7 +57,7 @@ Dockerfile 이 그 경고를 문자 그대로 적어 두고 있는데도 빠뜨
## 빌드 인자는 이미지에 굳는다
이 프론트는 API 주소를 빌드 인자로 받는다. 런타임 환경 변수가 아니므로 배포한 뒤에는 바꿀 수 없고, 잘못 넣으면 다시 빌드해서 다시 올려야 한다.
이 프론트는 API 주소를 빌드 인자로 받는다. 번들러가 빌드할 때 그 값을 코드에 박아 넣으므로 런타임 환경 변수가 아니고, 배포한 뒤에는 바꿀 수 없다.
인자를 넘기지 않으면 기본값이 들어간다. 그 기본값은 존재하지 않는 주소다.
@@ -78,6 +78,18 @@ CI_RUNNER_IMAGE=node@sha256:… ← 반드시 @sha256 다이
SOURCE_DATE_EPOCH
```
## 인자마다 빠뜨렸을 때가 다르다
여섯 줄이 같은 무게는 아니다.
| 빠뜨리면 | 언제 드러나나 |
|---|---|
| API 주소 | 배포본이 첫 요청을 낼 때 |
| 러너 이미지 다이제스트 | 빌드가 재현되지 않는다 — 나중에 같은 커밋이 다른 결과를 낸다 |
| 빌드 식별자·커밋 SHA | 배포된 것이 어느 커밋인지 화면에서 못 읽는다 |
API 주소만 화면이 바로 깨지고 나머지는 조용하다. 그래서 이 목록에서 가장 눈에 띄는 것을 빠뜨렸다는 것이 오히려 이상한데, 인자가 여섯이고 그중 하나만 값의 모양이 URL 이라 붙여넣기에서 빠졌다.
## 되돌리고 다시 빌드했다
`kubectl rollout undo` 로 이전 리비전으로 되돌린 뒤 인자를 넣어 다시 빌드하고 다시 올렸다.
@@ -62,7 +62,7 @@ tech-log-frontend : 83409be
헬스 판정은 헬스 엔드포인트가 응답하는지를 본다. nginx 프로세스가 살아 있고 그 경로를 돌려주면 통과한다.
SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다. 그래서 파드는 healthy 로 올라오고 사이트만 안 된다.
SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다. 그래서 파드는 healthy 로 올라오고 사이트만 안 된다. 롤아웃도 정상으로 끝나므로 배포 도구는 아무 신호도 주지 않는다.
## 빌드가 쓴 권한
@@ -70,7 +70,7 @@ SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다.
증상이 404 가 아니라 403 이라는 것이 원인을 좁혔다. 404 면 파일이 없는 것이고 403 이면 파일은 있는데 읽지 못하는 것이므로, 이미지에 파일이 들어갔는지부터 확인할 필요가 없었다.
이미지가 권한을 정규화하도록 고쳤다.
이미지가 권한을 정규화하도록 고쳤다. 빌드 단계에서 쓰는 권한을 바꾸는 대신 이미지가 마지막에 정리하게 한 것은, 빌드 도구가 그 권한을 왜 그렇게 쓰는지가 이 저장소 밖의 사정이기 때문이다.
## 브라우저가 묻는 주소
@@ -83,7 +83,7 @@ SPA 가 부팅에 필요한 설정 파일은 그 판정에 들어 있지 않다.
| 설정 파일 | o | o | 권한이 0600 이라 읽지 못함 |
| favicon | o | o | 아무도 그 이름을 요청하지 않음 |
둘 다 「파일이 없다」가 아니었고, 그래서 이미지를 열어 파일을 찾는 방식으로는 둘 다 정상으로 보다.
둘 다 「파일이 없다」가 아니었다. 그래서 이미지를 열어 파일을 찾는 방식으로는 둘 다 정상으로 보인다 — 무엇이 실제로 요청되고 어떤 응답이 오는지를 봐야 갈린다.
## 확인하지 못한 것
@@ -66,6 +66,8 @@ DB : 실제 PostgreSQL (Testcontainers)
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
같은 어댑터의 다른 참조 검사들은 문서 식별자 컬럼을 가진 표를 조회하므로 그 이름이 맞는다. 한 함수 안에서 표마다 컬럼 이름이 다른데, 다섯 개가 맞으니 여섯 번째도 맞을 것으로 읽었다.
## 그 SQL 은 한 번도 실행되지 않았다
컬럼 이름보다 더 드러난 것은 검사 구조였다.
@@ -79,6 +81,22 @@ DB : 실제 PostgreSQL (Testcontainers)
표준 검사가 컨테이너를 띄우지 않으므로 어댑터의 SQL 은 한 줄도 실행되지 않은 채 빌드가 통과한다. 컬럼 이름이 맞는지 묻는 검사가 어디에도 없었다.
## 문자열로 조립한 SQL 은 컴파일러가 보지 않는다
이 어댑터는 SQL 을 문자열로 이어 붙여 만든다.
```java
"SELECT EXISTS ("
+ " SELECT 1 FROM document_relation WHERE target_document_id = :id"
+ " UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id"
+ ")"
```
컴파일러가 확인하는 것은 이 식이 문자열이라는 것까지다. 표 이름도 컬럼 이름도 실행해야 검증된다.
## 전용 태스크로 여덟 시나리오를 돌린다
삭제 경로 전용 통합 테스트 태스크를 만들고, 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
@@ -89,6 +107,6 @@ DB : 실제 PostgreSQL (Testcontainers)
이 태스크가 덮는 것은 삭제 경로다. 표준 검사는 여전히 컨테이너를 띄우지 않고, 새 어댑터 SQL 이 이 태스크에 등록되는지 보는 검사도 없다.
같은 참조 검사가 지금도 다섯 테이블을 하나로 묶어 확인하므로, 무엇이 막았는지를 응답이 말하지 못한다.
같은 참조 검사가 지금도 다섯 표를 하나로 묶어 확인하므로, 무엇이 막았는지를 응답이 말하지 못한다.
<!-- body:end -->
@@ -3029,5 +3029,5 @@
"unlisted": 0,
"candidates": 91
},
"ssotSha256": "6fbb5ffd943df5a6ce53624997f9ebc5b3c80de9c0adf17f62af951f8e55488d"
"ssotSha256": "75e2c6368a5f6866ad72956619d020c89d4442428a79093fec29b1dfc647490c"
}
@@ -64,6 +64,8 @@ tech-log-frontend : fd73bc8 · fe6b56a
> 화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위 두 결함과, 의도한 변경에 고정돼 있던 단언들이 **23건 빨간 채로 여러 커밋을 지나갔다.**
두 명령이 보는 디렉터리가 다르다. 단위 테스트 명령은 단위 테스트 디렉터리를, 화면 테스트 명령은 기능별 테스트 디렉터리를 돈다. 넷을 돌리고 「전부 통과」라고 읽으면 다섯 번째가 빠진다.
23건이 한 종류가 아니었다는 것이 문제를 키웠다. 일부는 실제 결함을 잡은 것이고 일부는 의도한 변경에 단언이 고정돼 있던 것이다. 둘을 구분하려면 그 명령을 돌려 하나씩 봐야 하는데, 돌리지 않으니 둘 다 그대로 남았다.
## 게이트 기준값을 빠뜨렸다
@@ -85,6 +87,12 @@ tech-log-frontend : fd73bc8 · fe6b56a
> **가드는 CI 에 묶여야 의미가 있습니다.** 사람이 기억해서 돌리는 가드는 절반만 존재합니다.
## 명령이 많으면 「다 돌렸다」가 흐려진다
이 저장소의 프론트엔드는 검사 명령이 수십 개다. 타입 검사만 여섯 개의 tsconfig 를 돌고, 테스트도 단위·컴포넌트·통합·기능별로 나뉜다.
명령을 나눈 이유는 각각 다른 대상을 보기 때문이고 그 자체는 맞다. 대신 「배포 전에 무엇을 돌리는가」를 목록으로 고정하지 않으면 그중 일부만 돌고도 전부 돌린 것으로 읽힌다.
## 확인하지 못한 것
다섯 명령을 CI 에 묶는 작업은 하지 않았다. 지금 남은 것은 메모리와 배포 전 검증 목록이고, 그 목록을 읽는 것도 사람이다.