docs(TechLog): 주제 4·5 를 스킬대로 다시 쓴다

what-the-compiler-lets-through  중앙값 2,096 → 2,590 자
  seams-no-test-crosses                    → 3,091 자

bivariance 가 왜 느슨한 판정을 받는지, never 캐스트가 왜 아무것도 요구하지 않는지처럼
「이름을 댔으면 왜 있는지도 댄다」를 채웠다. 네 가지가 각각 무엇을 통과시키고 어디서
드러났는지를 표로 갈랐다. 검사 넷 중 무엇이 SQL 을 실제로 돌리는지도 표로 세웠다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 18:57:59 +09:00
co-authored by Claude Opus 5
parent a8ce0dda07
commit 53537e37e8
8 changed files with 140 additions and 51 deletions
@@ -62,11 +62,13 @@ tech-log-frontend : 03986da · 7600711
## 세 결함이 서로를 가렸다
공개 소스가 정적 픽스처에서 HTTP 어댑터로 바뀐 뒤 로그아웃 상태 방문자가 요청을 하나도 내보내지 못했다. 이 경로는 그 주까지 브라우저에서 한 번도 돌지 않았다.
첫 번째만 고쳤을 때 요청이 나가기 시작했고, 그러자 두 번째가 드러났다. 두 번째를 고치니 세 번째가 나왔다.
`attachCredentials` 가 Studio 헬퍼에 먼저 묻는데, 그 헬퍼는 자기 것이 아닌 프로파일에 `null` 을 돌려준다. 그 아래 폴백이 세션을 읽고 인증되지 않은 것을 거절한다. 공개 읽기는 ANONYMOUS 프로파일을 선언하므로 그 폴백에 떨어졌다.
`attachCredentials` 가 Studio 헬퍼에 먼저 묻는데, 그 헬퍼는 자기 것이 아닌 프로파일에 `null` 을 돌려준다. 그 아래 폴백이 세션을 읽고 인증되지 않은 것을 거절한다. 공개 읽기는 익명 프로파일을 선언하므로 그 폴백에 떨어졌다 — 「이 프로파일은 내 것이 아니다」와 「이 요청은 인증되지 않았다」가 같은 `null` 로 표현되고 있었다.
요청이 흐르자 두 번째가 나왔다. `envelopeError()``ApiError.code` 를 Studio enum 에 고정해 세 표면이 공유했다. 공개와 관리는 각자 자기 계약에 enum 을 선언하므로 그들이 돌려준 모든 오류가 검증에 실패해 `CONTRACT_VIOLATION` 으로 도착했다.
요청이 흐르자 두 번째가 나왔다. `envelopeError()``ApiError.code` 를 Studio enum 에 고정해 세 표면이 공유했다. 공개와 관리는 각자 자기 계약에 enum 을 선언하므로 그들이 돌려준 모든 오류가 검증에 실패해 계약 위반으로 도착했다.
세 번째는 not-found 경로가 봉투에 없는 `status` 를 읽고 있던 것이다.
@@ -74,13 +76,25 @@ tech-log-frontend : 03986da · 7600711
> 엄격한 enum 을 잘못된 표면의 계약에 대고 검사해도 여전히 엄격해 보인다.
검증은 돌고 있었고 통과하고 있었다.
검증은 돌고 있었고 통과하고 있었다. 대상이 틀렸을 뿐이다. 검증이 「값이 이 enum 에 있는가」를 묻는데 그 enum 이 다른 표면의 것이면, 물음은 성립하고 답만 늘 거짓이 된다.
## 스텁이 이음매를 덮지 않는다
> 이 결함은 공개 소스가 HTTP 가 된 뒤에야 나타날 수 있었다. 이번 주까지 그 경로는 브라우저에서 한 번도 돌지 않았다. **스위트가 잡지 못한 이유는 게이트웨이와 화면을 검사할 뿐 합성 루트의 credential 결정은 검사하지 않기 때문이다 — 그 이음매에는 테스트가 없고, 이것이 그 대가다.**
같은 모양이 HTTP 매퍼에서도 났다. 게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠는데, 화면 테스트가 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않았다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣는 테스트를 넣으니 되돌려 보면 운영에서 난 것과 같은 오류로 실패한다.
게이트웨이 테스트는 실행기를 스텁으로 바꾸고 화면 테스트는 게이트웨이를 스텁으로 바꾼다. 둘 다 자기 층은 검사하지만 그 사이에서 credential 을 정하는 코드는 어느 쪽에도 들어가지 않는다.
## 같은 모양이 HTTP 매퍼에서도 났다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다.
> 이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기 때문이다. **화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않는다.**
계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는 테스트를 넣었다. 되돌려 보면 운영에서 난 것과 같은 오류로 실패한다.
## 회귀 테스트가 무엇을 조립하나
이 이음매의 회귀 테스트는 실제 런타임 어댑터를 배포된 백엔드의 실제 404 본문에 대고 조립한다. 스텁을 하나도 쓰지 않으므로 credential 결정과 봉투 해석이 둘 다 실제로 돈다.
## 확인하지 못한 것
@@ -66,26 +66,39 @@ tech-log-backend : 365560e
## 계약 결함처럼 보이지 않았다
서가 거절한 스키마 15개는 전부 `type: object` 를 명시하고 있었다. 메시지는 「is not of type `object`」였다.
생 스펙을 파서에 넣으면 스키마 15개를 「is not of type `object`」로 거절했다. 그 스키마들은 전부 `type: object` 를 명시하고 있어서, 메시지가 가리키는 곳을 열어 봐도 고칠 것이 없었다.
원인은 그 스키마가 아니라 파생 스펙의 표현이었다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고, snakeyaml 은 같은 인스턴스가 두 번 나오면 두 번째를 alias 로 덤프한다. 그 지점이 34곳이었다.
원인은 그 스키마가 아니라 파생 스펙의 표현이었다. 변환들이 같은 `Map` 인스턴스를 여러 property 에 재사용했고, snakeyaml 은 같은 인스턴스가 두 번 나오면 두 번째를 anchor 와 alias 로 덤프한다. 파서가 alias 를 만나면 그 노드의 타입을 판정할 수 없으므로 거절한다.
파생 스펙에 alias 가 34곳 있었다.
## 검증을 끄면 생성이 성공한다
`validateSpec` 을 끄고 생성하면 모델이 만들어진다. 다만 거절되던 스키마의 일부 필드가 빠진 채로 만들어진다.
빠진 것은 넷이었다 — `LatestEntry.publishedAt`, `ProjectListItem.updatedAt`, `SearchResultItem.matchedFields`, `ReleaseListItem.changeTypes`.
| 빠진 필드 | 어디 쓰는 칸인가 |
|---|---|
| `LatestEntry.publishedAt` | 홈 최근 기록의 게시일 |
| `ProjectListItem.updatedAt` | 프로젝트 목록의 갱신일 |
| `SearchResultItem.matchedFields` | 검색 결과에서 찾은 말 표시 |
| `ReleaseListItem.changeTypes` | 릴리스 목록의 변경 종류 |
컴파일은 통과한다. 아직 아무도 그 필드를 안 쓰기 때문이다.
컴파일은 통과한다. 아직 그 필드를 쓰는 코드가 없기 때문이다 — 넷 다 화면이 그리기로 되어 있지만 아직 안 그리고 있던 칸이라, 모델에 없어도 아무도 부르지 않는다.
## 두 가지로 막았다
덤프 직전에 deep copy 로 노드 identity 를 끊었다. 같은 인스턴스가 두 번 나오지 않으면 alias 가 생기지 않는다. 그래도 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀고, `validateSpec` 은 다시 켰다.
덤프 직전에 deep copy 로 노드 identity 를 끊었다. 같은 인스턴스가 두 번 나오지 않으면 snakeyaml 이 alias 를 만들 이유가 없다.
생성 모델 대조도 바꿨다. schema 이름만 세던 것을 property 단위로 강화했다. 이번 누락을 이름 대조가 통과시켰기 때문이다. 지금은 schema 62개와 property 250개를 센다.
그래도 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀고, `validateSpec` 은 다시 켰다. 원인을 끊은 것과 남은 것을 잡는 것을 둘 다 둔 이유는, deep copy 를 지나지 않는 새 변환이 생기면 alias 가 다시 나올 수 있기 때문이다.
## 이름 대조에서 property 대조로
생성 모델 대조도 바꿨다. schema 이름만 세던 것을 property 단위로 강화했다.
이름 대조는 이번 누락을 통과시켰다. 스키마 자체는 만들어졌고 그 안의 필드만 빠졌기 때문이다. 지금은 schema 62개와 property 250개를 센다.
## 확인하지 못한 것
지금 세는 수는 사람이 갱신한다. 계약이 줄어드는 방향의 누락은 이 게이트가 잡지 않는다.
지금 세는 수는 사람이 갱신한다. 계약이 줄어드는 방향의 누락은 이 게이트가 잡지 않는다 — 수를 함께 줄이면 통과한다.
<!-- body:end -->
@@ -60,26 +60,35 @@ DB : 실제 PostgreSQL (Testcontainers)
## 없는 컬럼을 조회했다
`public_resource_projection` 은 한 테이블이 case·question·project·release 를 모두 담는다. 그래서 기록을 가리킬 때 종류와 식별자 컬럼을 함께 쓴다. `document_id` 라는 컬럼 없다.
작업본을 지우려 하면 500 이 났다. 참조 검사가 공개 투영에서 문서 식별자 컬럼을 조회하는데 그런 컬럼 없다.
참조 검사가 그 이름을 쓰고 있었고, 실행하면 500 이 났다.
## 다섯은 대조했고 하나는 가정했다
이 테이블은 하나로 case·question·project·release 를 모두 담는다. 그래서 기록을 가리킬 때 종류와 식별자 두 컬럼을 함께 쓰고, 종류별 식별자 컬럼은 두지 않는다.
> 그 쿼리의 여섯 컬럼 중 다섯은 마이그레이션과 대조했다. 이 하나만 가정했고, 그것이 틀렸다.
## 그 SQL 은 한 번도 실행되지 않았다
표준 `check` 는 Testcontainers 를 띄우지 않으므로 persistence SQL 이 한 줄도 실행되지 않은 채 빌드가 통과한다.
컬럼 이름보다 더 드러난 것은 검사 구조였다.
컴파일은 SQL 문자열 안을 보지 않는다. 단위 테스트는 어댑터를 스텁으로 바꾼다. 컬럼 이름이 맞는지 묻는 검사가 어디에도 없었다.
| 무엇이 도는가 | SQL 을 실행하는가 |
|---|---|
| 컴파일 | x — 문자열 안을 보지 않는다 |
| 단위 테스트 | x — 어댑터를 스텁으로 바꾼다 |
| 표준 check | x — Testcontainers 를 띄우지 않는다 |
| 삭제 경로 통합 테스트 | o — 실제 PostgreSQL |
표준 검사가 컨테이너를 띄우지 않으므로 어댑터의 SQL 은 한 줄도 실행되지 않은 채 빌드가 통과한다. 컬럼 이름이 맞는지 묻는 검사가 어디에도 없었다.
## 전용 태스크로 여덟 시나리오를 돌린다
삭제 경로 전용 통합 테스트 태스크를 만들었다. 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
삭제 경로 전용 통합 테스트 태스크를 만들고, 실패했던 그 쿼리를 포함해 여덟 시나리오를 실제 PostgreSQL 에서 돌린다.
전용 태스크로 뺀 이유는 컨테이너를 띄우는 데 시간이 들어 표준 검사에 넣으면 모든 빌드가 느려지기 때문이다. 대신 그 태스크를 돌리는 것은 사람이 기억해야 한다.
## 확인하지 못한 것
이 태스크가 덮는 것은 삭제 경로다. 표준 `check` 는 여전히 Testcontainers 를 띄우지 않고, 새 어댑터 SQL 이 이 태스크에 등록되는지 보는 검사도 없다.
이 태스크가 덮는 것은 삭제 경로다. 표준 검사는 여전히 컨테이너를 띄우지 않고, 새 어댑터 SQL 이 이 태스크에 등록되는지 보는 검사도 없다.
같은 참조 검사가 지금도 다섯 테이블을 하나로 묶어 확인하므로, 무엇이 막았는지를 응답이 말하지 못한다.
<!-- body:end -->
@@ -65,13 +65,17 @@ Jackson : 이 빌드는 Jackson 3, 클래스패스에 Jackson 2 타입이 전이
새 활동 어댑터가 생성자를 둘 갖고 있었다. 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것이다. 둘 중 어느 것에도 `@Autowired` 가 없어 컴포넌트 스캔이 고르지 못했다.
스캔은 생성자가 하나면 그것을 쓰고, 여럿이면 `@Autowired` 가 붙은 것을 쓴다. 둘 다 아니면 어느 것을 쓸지 정할 수 없으므로 컨텍스트가 refresh 에 실패한다.
> 컴파일도, 단위 테스트도, **실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부 통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다.** 운영에서 파드가 CrashLoopBackOff 로 들어갔고, 그때서야 드러났다.
통합 테스트가 실제 데이터베이스를 쓴다는 것과 애플리케이션 컨텍스트를 띄운다는 것은 다르다. 어댑터를 직접 만들어 SQL 을 돌리는 테스트는 컨테이너가 필요하지만 스프링 컨텍스트는 필요하지 않다.
## 클래스패스에 남은 옛 타입
두 번째 건은 import 였다. `JdbcProjectRepositoryAdapter``com.fasterxml.jackson.databind.ObjectMapper` 를 요구했다. 이 빌드는 `tools.jackson.databind` 를 쓰므로 그런 빈이 없고, 컨텍스트가 refresh 에 실패한다.
두 번째 건은 import 였다. 어댑터가 Jackson 2 의 `ObjectMapper` 를 요구했는데 이 빌드는 Jackson 3 을 쓴다. 그런 빈이 없으므로 컨텍스트가 refresh 에 실패한다.
컴파일이 잡지 못한 이유는 어떤 전이 의존성이 Jackson 2 타입을 클래스패스에 올려 두어 import 가 정상적으로 해석되기 때문이다. 빈이 없다는 것은 컨텍스트를 띄워야 알 수 있다.
컴파일이 잡지 못한 이유는 어떤 전이 의존성이 Jackson 2 타입을 클래스패스에 올려 두어 import 가 정상적으로 해석되기 때문이다. 컴파일러가 보는 것은 그 타입이 클래스패스에 있는지까지이고, 그 타입의 빈이 컨텍스트에 있는지는 컨텍스트를 띄워야 알 수 있다.
| 원인 | 왜 컴파일·테스트가 못 잡았나 | 커밋 |
|---|---|---|
@@ -80,10 +84,14 @@ Jackson : 이 빌드는 Jackson 3, 클래스패스에 Jackson 2 타입이 전이
## D20 규칙으로 막은 것
D20 규칙을 세웠다. 스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 `@Autowired` 가 붙어야 한다. 결함을 되돌려 규칙이 실제로 멈추는 것을 확인한 뒤 커밋했다.
스캔되는 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 `@Autowired` 가 붙어야 한다는 아키텍처 규칙을 세웠다.
이 규칙은 컨텍스트를 띄우지 않고도 돈다. 바이트코드를 훑어 스캔 대상 애노테이션이 붙은 클래스의 생성자를 세면 되므로 빌드 시간이 늘지 않는다. 결함을 되돌려 규칙이 실제로 멈추는 것을 확인한 뒤 커밋했다.
## D20 이 보지 않는 것
D20 은 생성자 다. 클래스패스에 남은 옛 라이브러리 타입을 import 하는 것은 이 규칙이 잡지 않는다. 그 경로를 막는 검사는 아직 없고, 컨테이너가 뜰 때 알게 된다.
이 규칙은 생성자 개수다. 클래스패스에 남은 옛 라이브러리 타입을 import 하는 것은 대상이 아니다.
그 경로를 막으려면 컨텍스트를 실제로 띄우는 검사가 필요하고, 그것은 아직 없다. 지금은 컨테이너가 뜰 때 알게 된다.
<!-- body:end -->
@@ -29,38 +29,47 @@ source:
## 목적
층을 스텁으로 나눠 시험하는 구조에서 그 나눈 자국이 검사되지 않고 남는 것을 막는다. 이 부류는 모든 검사가 초록불인 상태로 배포된다.
층을 스텁으로 나눠 시험하는 구조에서 그 나눈 자국이 검사되지 않고 남는 것을 막는다.
이 부류는 모든 검사가 초록불인 상태로 배포된다. 각 층의 검사는 자기 층을 정확히 보고 통과하며, 통과하지 않은 것은 층과 층 사이다.
## 규칙
**스프링 컨텍스트를 띄우는 검사를 하나 둔다**
컴파일도 단위 테스트도 실제 DB 위의 통합 테스트도 컨텍스트를 띄우지 않을 수 있다. 스캔되는 컴포넌트의 생성자 규칙은 아키텍처 검사로 대신할 수 있다.
컴파일도 단위 테스트도, 실제 데이터베이스를 쓰는 통합 테스트도 컨텍스트를 띄우지 않을 수 있다. 컨테이너가 필요해서 통합 테스트인 것과 컨텍스트를 띄우는 것은 다르다. 스캔되는 컴포넌트의 생성자 규칙처럼 정적으로 셀 수 있는 것은 아키텍처 검사로 대신할 수 있고, 그 편이 빌드 시간을 늘리지 않는다.
**persistence SQL 을 실제 DB 에서 돌리는 태스크를 둔다**
표준 `check` 가 컨테이너를 띄우지 않으면 어댑터의 SQL 은 한 줄도 실행되지 않는다. 컬럼 이름은 실행해야만 검증된다.
**persistence SQL 을 실제 데이터베이스에서 돌리는 태스크를 둔다**
표준 검사가 컨테이너를 띄우지 않으면 어댑터의 SQL 은 한 줄도 실행되지 않는다. 컴파일은 문자열 안을 보지 않고 단위 테스트는 어댑터를 스텁으로 바꾸므로, 컬럼 이름은 실행해야만 검증된다.
**계약 모양 그대로의 응답을 진짜 게이트웨이에 넣는 검사를 둔다**
화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않는다.
화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지 않는다. 픽스처를 게이트웨이가 읽는 모양으로 만들면 그 테스트는 늘 통과한다.
**실제 런타임 어댑터를 실제 응답 본문에 대고 조립하는 검사를 둔다**
게이트웨이 테스트는 실행기를 스텁으로 바꾸고 화면 테스트는 게이트웨이를 스텁으로 바꾼다. 합성 루트 credential 결정은 둘 다 덮지 않는다.
게이트웨이 테스트는 실행기를 스텁으로 바꾸고 화면 테스트는 게이트웨이를 스텁으로 바꾼다. 합성 루트에서 credential 을 정하는 코드는 둘 다 덮지 않는다.
**생성기는 모델이 만들어졌는지가 아니라 property 가 계약과 같은지로 본다**
모델은 필드가 빠져도 만들어진다. 아직 그 필드를 쓰는 코드가 없으면 컴파일도 통과한다.
**전용 태스크로 뺐으면 그것을 돌리는 것이 사람 몫이라는 것도 적는다**
컨테이너를 띄우는 검사를 표준 검사에 넣으면 모든 빌드가 느려진다. 빼는 것은 되지만, 뺀 뒤에 그 태스크가 돌지 않으면 검사가 없는 것과 같다.
## 적용 조건
스텁으로 층을 나눠 시험하는 구조. 계약이 다른 저장소에 있고 생성기를 지나 들어오거나, 컨테이너가 필요한 검사를 별도 태스크로 뺀 저장소에서 걸린다.
「모든 검사가 통과했는데 운영에서 깨졌다」가 나오면 무엇이 깨졌는지보다 어느 이음매를 아무 검사도 지나지 않았는지를 먼저 센다.
## 예외
그 층을 실제로 지나는 검사가 이미 있으면 더 두지 않는다.
스텁을 쓰는 테스트를 늘리는 것은 이 문제를 덮지 않는다.
스텁을 쓰는 테스트를 늘리는 것은 이 문제를 덮지 않는다. 스텁의 개수가 아니라 어느 층을 대신했는지가 기준이다.
정적으로 셀 수 있는 규칙은 컨텍스트를 띄우지 않고도 걸린다. 그때는 무거운 검사를 새로 두지 않는다.
## 예시
컨텍스트를 띄우지 않아 파드가 CrashLoopBackOff 로 들어간 것을 아키텍처 규칙으로 막았다.
컨텍스트를 띄우지 않아 파드가 두 번 CrashLoopBackOff 로 들어갔다. 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 통과한 상태였다.
삭제 경로의 SQL 이 한 번도 실행된 적이 없어서 전용 통합 테스트 태스크를 만들었다.
@@ -68,24 +68,30 @@ deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string):
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string) { }
```
메서드 매개변수가 bivariant 므로 이 둘은 호환된다고 판정된다. 구현 안의 삼항 사슬은 CONCEPT 을 마지막 `else` 로 떨어뜨린다.
메서드 문법으로 쓴 매개변수를 TypeScript 가 bivariant 로 다루므로 이 둘은 호환된다고 판정된다. 매개변수를 엄격하게 따지면 콜백을 넘기는 흔한 패턴이 거절되기 때문에 언어가 이 판정을 느슨하게 둔다.
구현 안의 삼항 사슬은 CONCEPT 을 마지막 `else` 로 떨어뜨린다. 실행하면 개념 삭제가 질문 삭제 경로로 나간다.
## 왜 반영된 줄 알았나
앞선 커밋에서 게이트웨이를 고쳤다고 판단한 근거가 타입 검사 통과였다. 좁게 적힌 구현을 그 커밋이 건드리지 않았고, 검사는 그것을 묻지 않았다.
배포된 번들에서 서버 로그를 보고서야 알았다. 삭제 요청이 질문 경로로 나가고 있었다.
배포된 번들에서 서버 로그를 보고서야 알았다. 삭제 요청이 `DELETE /api/v1/studio/questions/{id}` 로 나가고 404 를 받고 있었다.
## 포트와 어댑터가 필터 타입을 따로 들고 있었다
증상이 오류였다는 것이 그나마 다행이었다. 개념 조회 쪽은 같은 원인으로 질문 조회를 불렀고 그쪽도 404 였는데, 만약 그 종류에 실제 기록이 있었다면 다른 기록을 지웠을 것이다.
포트에 축 필터를 더해도 어댑터의 타입은 그대로였고, `satisfies` 도 같은 이유로 통과했다.
## 같은 병이 필터에서도 났다
포트와 정적 어댑터가 필터 타입을 따로 들고 있었다. 포트에 축 필터를 더해도 어댑터의 타입은 그대로였고, `satisfies` 도 같은 이유로 통과했다 — 어댑터가 자기 타입을 만족하는지만 물었기 때문이다.
타입을 하나로 합쳐서 고쳤다. 포트가 아는 필터와 어댑터가 아는 필터가 같은 타입이면 한쪽만 늘어날 수 없다.
## 확인한 것과 확인하지 못한 것
CONCEPT 을 `deleteQuestion`로 되돌려 가드가 깨지는 것을 확인했다.
CONCEPT 을 질문 삭제로 되돌려 가드가 깨지는 것을 확인한 뒤 커밋했다.
배포된 번들에 옛 삼항이 남아 있던 것은 서버 로그의 404 로 확인했다. 그 로그 원문은 이 저장소에 없다.
같은 모양으로 포트와 구현이 어긋난 곳을 전수로 세지 않았다. 고친 것은 삭제 경로와 필터 타입 둘이다.
<!-- body:end -->
@@ -62,20 +62,33 @@ tsconfig : 루트가 project references 만 나열
## 검사할 파일이 없는 tsconfig
루트 tsconfig 는 이 저장소에서 컴파일 대상을 갖지 않는다. `"files": []` 에 project references 만 나열한다. 각 프로젝트는 자기 tsconfig 를 갖고 있고, 루트는 그들을 가리키기만 한다.
이 저장소의 루트 tsconfig 는 컴파일 대상을 갖지 않는다. `"files": []` 에 project references 만 나열하고, 각 프로젝트는 자기 tsconfig 를 갖다.
`npx tsc --noEmit` 은 references 를 따라가지 않는다. 루트가 지목한 파일 집합만 보고, 그 집합이 비어 있으므로 아무것도 검사하지 않은 채 0 으로 끝난다.
`npx tsc --noEmit` 은 references 를 따라가지 않는다. 루트가 지목한 파일 집합만 보고, 그 집합이 비어 있으므로 아무것도 검사하지 않은 채 0 으로 끝난다. references 를 따라가려면 그 모드로 부르는 별도 명령이 필요하다.
## 통과가 무엇을 뜻했나
이 명령은 루트 tsconfig 가 유효한지까지만 확인하고, 코드가 컴파일되는지는 묻지 않다.
이 명령이 성공했을 때 확인된 것루트 tsconfig 가 유효하다」까지다. 코드가 컴파일되는지는 묻지 않다.
운영에서 릴리즈 목록 화면이 비었고 콘솔에 `ReferenceError` 가 났다. 링크 컴포넌트 import 가 빠졌고, 이동 함수는 정의된 적이 없었다. 둘 다 컴파일이 잡는 종류의 오류인데 컴파일이 돌지 않았다.
## 올바른 명령으로 돌렸을 때
`npm run check:types`여섯 프로젝트를 각각 돌린다. 그 명령으로 돌리자 릴리즈 목록의 두 오류에 더해 네 가지가 나왔다 — export 되지 않은 타입, `unknown` 인 라우트 파라미터, 파라미터를 받도록 등록되지 않은 메시지 키, 목록 항목에 없는 요약.
여섯 프로젝트를 각각 돌리는 명령으로 바꾸자 릴리즈 목록의 두 오류에 더해 네 가지가 함께 나왔다.
| 무엇이 나왔나 | 어떤 종류인가 |
|---|---|
| `CatalogEntry` 가 export 되지 않음 | 다른 모듈에서 그 타입을 못 쓴다 |
| 라우트 파라미터가 `unknown` | 쓰는 쪽에서 캐스트해야 한다 |
| 메시지 키가 파라미터를 받도록 등록되지 않음 | 그 문구에 값이 안 들어간다 |
| `ReleaseIndexItem``summary` 가 없음 | 목록이 요약을 그릴 수 없다 |
넷 다 그 명령이 안 도는 동안 쌓인 것이다. 검사가 통과하고 있었으므로 언제 들어왔는지는 알 수 없다.
## 남은 것
루트 tsconfig 는 그대로 뒀다. 누군가 다시 `npx tsc --noEmit` 을 쓰는 것을 막는 검사는 없고, 배포 전에 돌릴 다섯 명령의 목록에 적어 뒀다. 이 건은 메모리에도 남겨 뒀다 — `tech-log-frontend-typecheck-command.md`.
루트 tsconfig 는 그대로 뒀다. project references 구성 자체는 프로젝트를 나눠 빌드하기 위한 것이고 그 목적에는 맞는다.
누군가 다시 그 짧은 명령을 쓰는 것을 막는 검사는 없다. 배포 전에 돌릴 다섯 명령의 목록에 적어 두고 메모리에 남긴 것이 지금의 대책이다.
<!-- body:end -->
@@ -31,17 +31,25 @@ source:
<!-- body:start -->
## 검사가 무엇을 묻는가
「타입 검사가 통과했다」가 뜻하는 것은 컴파일러가 물은 질문에 코드가 답했다는 것이다. 이 저장소에서 네 번, 컴파일러가 물었어야 할 질문을 묻지 않았다.
넷은 서로 다른 방식으로 질문을 바꾼다. bivariance 는 「이 구현이 그 포트와 맞는가」를 「두 시그니처가 호환되는가」로, `as` 는 「이 이름이 그 타입에 있는가」를 작성자의 선언으로, `never` 캐스트는 「이 인자를 다 적었는가」를 검사 없음으로, 검사 대상이 없는 tsconfig 는 「이 코드가 컴파일되는가」를 대상 없음으로 바꾼다.
## 메서드 매개변수는 bivariant 다
포트가 네 값을 받는다고 선언하고 구현이 세 값만 적어도 두 시그니처는 호환된다고 판정된다. TypeScript 는 메서드 문법으로 쓴 매개변수를 bivariant 로 다룬다.
포트가 네 값을 받는다고 선언하고 구현이 세 값만 적어도 두 시그니처는 호환된다고 판정된다.
```ts
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string): Promise<void>;
```
구현이 이 시그니처를 만족한다고 통과해도, 실행할 때 네 번째 값이 어디로 가는지는 구현 안의 분기가 정한다.
메서드 문법으로 쓴 매개변수를 TypeScript 가 bivariant 로 다루기 때문이다. 매개변수를 엄격하게 반변으로 따지면 콜백을 넘기는 흔한 패턴이 대부분 거절되므로, 언어가 이 판정을 느슨하게 둔다.
같은 이유로 포트와 어댑터가 타입을 따로 들고 있으면 `satisfies` 도 한쪽이 좁아진 것을 잡지 못한다.
느슨한 판정 아래에서는 구현이 좁게 적혀 있어도 넓은 포트를 만족한 것이 된다. 네 번째 값이 어디로 가는지는 구현 안의 분기가 정하고, 그 분기에 그 값이 없으면 마지막 `else` 로 간다.
같은 이유로 포트와 어댑터가 타입을 따로 들고 있으면 `satisfies` 도 한쪽이 좁아진 것을 잡지 못한다. 타입을 하나로 합치면 한쪽만 늘어날 수 없다.
## as 단언은 이름을 묻지 않는다
@@ -49,22 +57,31 @@ deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION" | "CONCEPT", id: string):
const summary = body.purposeSummary as string; // 계약에 그런 칸이 없다
```
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이라, 그 이름이 응답 타입에 있는지 묻지 않는다. 실행하면 `undefined` 가 나온다.
`as` 는 「이 값을 이 타입으로 다루겠다」는 선언이다. 그 이름이 응답 타입에 있는지를 컴파일러가 묻지 않고, 실행하면 `undefined` 가 나온다.
객체를 배열로 읽고 `.filter` 를 부르면 매핑이 통째로 터지는데, `as` 캐스트가 그 어긋남을 타입 검사에서 가다.
모양이 다른 경우에는 더 나빠진다. 객체를 배열로 읽고 `.filter` 를 부르면 매핑이 통째로 터지는데, 캐스트가 그 어긋남을 타입 검사에서 가리므로 배포된 뒤에 알게 된다.
## never 로 받으면 아무것도 요구하지 않는다
요청을 만드는 조립기가 입력을 `(input: never)` 로 받아 캐스팅하면, 계약에 인자를 더해도 컴파일러가 아무 말도 하지 않는다. 조립기가 손으로 나열한 질의 인자에 그 값이 없으면 요청에서 조용히 빠진다.
요청을 만드는 조립기가 입력을 `(input: never)` 로 받아 캐스팅하면, 계약에 인자를 더해도 컴파일러가 아무 말도 하지 않는다. `never` 는 어떤 값도 속하지 않는 타입이라 무엇을 넘겨도 검사할 것이 없다.
목록의 페이지 번호를 눌러도 쪽이 넘어가지 않던 것이 이 모양이었다. `page` 가 조립기의 목록에 없었다.
조립기가 손으로 나열한 질의 인자에 그 값이 없으면 요청에서 조용히 빠진다. 목록의 페이지 번호를 눌러도 쪽이 넘어가지 않던 것이 이 모양이었다 `page` 가 조립기의 목록에 없었다.
## 검사할 파일이 없는 tsconfig
루트 tsconfig 가 `"files": []` 에 project references 만 나열하면 `npx tsc --noEmit` 은 한 파일도 검사하지 않고 성공한다. references 를 따라가는 것은 별도 명령이다.
루트 tsconfig 가 `"files": []` 에 project references 만 나열하면, 그 파일을 직접 부르는 명령은 한 파일도 검사하지 않고 성공한다. references 를 따라가려면 그 모드로 부르는 별도 명령이 필요하다.
## 네 가지가 공통으로 하는 일
이 상태에서 통과가 확인해 준 것은 「루트 tsconfig 가 유효하다」까지다.
넷 다 「이 코드가 그 타입과 맞는가」라는 질문을 다른 질문으로 바꾼다. bivariance 는 시그니처 호환으로, `as` 는 작성자의 선언으로, `never` 는 검사 없음으로, 빈 tsconfig 는 대상 없음으로 바꾼다.
## 네 가지가 통과시킨 것
| 무엇이 통과했나 | 무엇이 확인되지 않았나 | 어디서 드러났나 |
|---|---|---|
| bivariance | 구현이 네 번째 값을 다루는가 | 배포본의 서버 로그 404 |
| `as` 단언 | 그 이름이 응답 타입에 있는가 | 공개 화면이 비어 있음 |
| `never` 캐스트 | 새 질의 인자를 조립기가 적었는가 | 페이지 번호가 안 넘어감 |
| 검사 대상 없는 tsconfig | 이 코드가 컴파일되는가 | 운영의 ReferenceError |
넷 다 실행하거나 배포해야 드러났다. 그래서 반영의 증거는 타입 검사가 아니라 그 값의 여정 끝이다.
<!-- body:end -->