leo.dev
backend

탐지 기준을 넓혀 다시 짠 가드레일

같은 레포에 정적 가드레일을 두 개 만들었다. 하나는 UUID 식별자 컬럼이 ascii에서 utf8mb4로 되돌아가는 걸 막고, 하나는 한 모듈이 다른 모듈의 엔티티를 직접 다루는 걸 막는다. 앞은 DB 스키마의 모양을, 뒤는 코드의 모듈 경계를 지키는 거라 지키는 대상도 탐지 방식도 다르다.

UUID 컬럼을 ascii로 통일하기

DB의 UUID 식별자 컬럼(PK·FK)이 뒤섞여 있었다. 어떤 건 varchar(255) utf8mb4, 어떤 건 varchar(36), 어떤 건 char(36) utf8mb4. 전부 char(36) CHARACTER SET ascii로 통일했다.

UUID는 16진수와 하이픈이라 100% ASCII다. 이걸 utf8mb4에 담는 건 유니코드 전체를 비교하는 엔진에 아스키만 넣는 낭비고, ascii면 글자당 1바이트에 단순 바이트 비교고 복합 인덱스 키 길이 한계 3072바이트에서도 여유가 늘어난다.

성능 때문에 한 작업은 아니다. 문자열 UUID를 유지하는 한 저장 공간이 실제로 주는 길은 BINARY(16)(36→16바이트)뿐인데 그건 컬럼 타입과 그 값을 다루는 코드를 같이 바꿔야 하는 별개 작업이다. 장애를 막으려고 한 게 아니라 기술부채를 치운 것이다.

미룰수록 하기 어려워지는 작업이라 지금 했다. 이 ALTER는 도는 동안 해당 테이블에 쓰기를 못 하는데, 행이 많을수록 그 시간이 길어지기 때문이다.

charset을 바꾸는 MODIFY온라인 DDL로 안 된다. 온라인 DDL은 데이터를 제자리에 둔 채 스키마만 바꿔서 그동안에도 테이블에 쓰기를 받는데, charset 변경은 문자셋마다 글자당 바이트 수가 달라 저장된 값을 전부 다시 인코딩해야 한다. 제자리에서는 못 하니 ALGORITHM=COPY, 테이블을 통째로 새로 만들어 전 행을 옮겨 담는 방식으로 도는데, 이 복사가 끝날 때까지 원본 테이블의 쓰기가 막힌다.

마이그레이션에서 지킨 불변식

FK 양쪽은 반드시 함께 바꾼다. FK는 자식 컬럼의 값이 부모 컬럼에 실제로 있는지 대조해 무결성을 지키는데, 문자열은 charset(바이트 인코딩)과 collation(비교 규칙)이 다르면 같은 값이라도 바이트 표현과 정렬이 어긋나 그 대조가 성립하지 않는다. 그래서 MySQL은 FK로 묶인 문자열 컬럼의 charset·collation이 같기를 요구하고, 부모만 ascii가 되고 자식이 utf8mb4로 남으면 FK를 못 건다.

컬럼을 하나씩 바꾸면 그렇게 어긋난 중간 상태를 반드시 지나므로, SET FOREIGN_KEY_CHECKS=0으로 세션의 FK 검사를 잠깐 꺼서 전 컬럼을 한 실행에 전환하고 끝나면 다시 켠다. 이 스위치는 FK 검사만 건너뛰지 데이터가 실제로 맞는지는 안 보므로, 데이터 검증은 끄기 전에 먼저 돌린다.

UnifyUuidColumnsToCharAscii.ts
const TARGET = "char(36) CHARACTER SET ascii COLLATE ascii_general_ci"
await this.assertSafe(qr, rows) // FK 체크 끄기 전에 데이터부터 검증
await qr.query("SET FOREIGN_KEY_CHECKS=0")
// ...컬럼별 MODIFY...
await qr.query("SET FOREIGN_KEY_CHECKS=1")

테이블 단위 CONVERT TO CHARACTER SET은 금지다. 한글 text 컬럼까지 ascii로 바꿔 데이터를 파괴한다. 반드시 컬럼별 MODIFY다.

후보는 이름이 아니라 “UUID 형태 + FK”로 잡는다. 컬럼명이 id·%_id인 것만 잡으면 created_by 같은 컬럼을 놓친다. users.id를 참조하는 FK인데 _id로 안 끝나서다.

부모만 ascii가 되고 자식이 utf8mb4로 남으면 FK charset이 어긋난다. 그래서 후보 집합을 세 갈래의 합집합으로 정의했다. char(36)인 것, 이름이 id·%_id인 것, KEY_COLUMN_USAGE로 찾은 FK 소스 컬럼.

down()은 char(36)을 유지한다. 되돌릴 때 varchar로 넓히고 싶은 유혹이 있다. 그런데 char(36) 갈래로만 잡히던 컬럼은 varchar가 되면 다음 up()이 후보로 못 잡아 영구 회귀한다. down()char(36) utf8mb4로 되돌려 char(36) 갈래가 다시 잡게 한다.

이름으로 거르면 빠지는 컬럼

마이그레이션은 그렇게 끝났다. 그런데 일회성으로 끝내면 다음에 만드는 테이블이 다시 utf8mb4로 돌아가니, schema.sql을 파싱해 char(36) 컬럼이 전부 ascii인지 강제하는 첫 번째 가드레일을 붙였다.

uuid-charset-guardrail.spec.ts
const isChar36 = declType === "char(36)"
const isNamedUuid = col === "id" || col.endsWith("_id")
if (!isChar36 && !isNamedUuid) continue // UUID 후보 아님 → 제외
if (!/CHARACTER SET ascii/.test(rest)) violations.push(key) // 후보는 ascii 강제

여기까지는 형태와 이름으로 잡는다. 그런데 sign_token처럼 이름이 비표준이고 FK도 없는 컬럼이 varchar로 회귀하면 이 검사가 놓친다. char(36)도 아니고 id·%_id로 끝나지도 않아 어느 갈래에도 안 걸리기 때문이다. FK조차 없어 통합 테스트의 FK charset 검증도 못 받는다.

형태와 이름으로 거르는 규칙은 컬럼의 클래스만 잡지 특정 컬럼을 지목하지 못한다. “char(36)이면 UUID다”, “_id로 끝나면 UUID다”는 클래스 정의고, 그 클래스에 안 들어가면서 지켜야 하는 개별 컬럼은 잡을 방법이 없다.

컬럼 집합을 스냅샷으로 고정하기

특정 컬럼을 지목하려면 지켜야 할 집합을 못 박아야 한다. 그래서 char(36) ascii UUID 컬럼 집합 전체를 스냅샷으로 고정했다. 고정한 시점에 307개였고, 컬럼이 늘어나면 스냅샷도 같이 갱신된다.

uuid-charset-guardrail.spec.ts
// char(36) ascii UUID 컬럼 전체를 스냅샷으로 고정. sign_token·created_by처럼
// 이름·FK 신호가 없는 컬럼도 집합에서 이탈하면 불일치로 탐지된다.
expect(asciiColumns).toMatchSnapshot()

어떤 컬럼이든 varchar로 회귀하면 이 집합에서 빠져 스냅샷이 어긋난다. 이름도 FK도 필요 없고 집합을 이탈했다는 사실만으로 잡힌다. 의도적으로 추가하거나 지울 때만 jest -u로 갱신하고, 그 갱신은 리뷰에 diff로 남는다.

모듈 경계 침범을 막는 쪽

두 번째 가드레일에서 같은 문제가 더 나쁜 형태로 나왔다.

모듈 A가 모듈 B의 엔티티를 직접 다루면 B의 서비스 게이트(권한·가시성 필터·기능 스위치)를 건너뛴다. 이 레포엔 실제 사고가 있었다. 전역 검색이 계약 엔티티를 직접 조회해서 계약 API에 걸어둔 기능 차단 스위치를 우회한 것이다. 그래서 현재의 침범을 allowlist로 동결하고 신규 유입을 막는 가드레일을 만들었다.

가드레일의 스캐너는 @InjectRepository(X), 즉 다른 모듈이 소유한 엔티티를 리포지토리로 주입하는 곳을 찾는다. 처음 돌렸을 때 47쌍이 나왔고 그중 크로스 도메인 쓰기는 1건이었다. 그 1건을 걷어내고 크로스 도메인 쓰기가 이제 0건이라고 적었다.

거짓이었다. 엔티티를 다루는 데 리포지토리 주입이 꼭 필요한 건 아니라는 걸 독립 리뷰가 짚었다.

em.getRepository(CompanyAdvisor).softDelete({ advisorUserId: userId }) // 회원탈퇴가 남의 배정을 해제
manager.save(TermAgreement, agreement) // auth가 term 엔티티를 생성
manager.update(CompanyInvite, invite.id, { status: "ACCEPTED" }) // auth가 초대 상태를 전이

셋 다 크로스 도메인 쓰기인데 스캐너에는 하나도 안 잡혔다. “유일한 크로스 도메인 쓰기를 걷어냈다”는 커밋 메시지도 따라서 거짓이 됐다. 걷어낸 뒤에도 3건이 남아 있었다.

흔한 형태만 보던 탐지 기준

@InjectRepository는 이 레포에서 엔티티를 다루는 가장 흔한 방법이지 유일한 방법이 아니다. 흔한 형태를 탐지 기준으로 삼고 그게 전부라고 가정한 게 원인이다.

탐지 방식도 코드 포맷에 약했다. @InjectRepository\((\w+)\) 정규식은 줄바꿈만 들어가도 안 잡혀서, 포매터가 한 번 훑으면 뚫린다.

정적 가드레일의 탐지 기준이 실제 위험 경로의 부분집합이면, 그 가드레일의 “위반 없음”은 “안전하다”가 아니라 “안 보인다”는 뜻이다. 그리고 나는 그 보고를 보고 여기는 됐다고 넘어갔다. 가드레일이 없었으면 안 넘어갔을 것이다.

탐지 기준을 import로 바꾸기

엔티티를 런타임에 다루려면 어떤 문법을 쓰든 그 클래스를 값으로 import해야 한다. import는 @InjectRepository·manager.save·getRepository 등 모든 경로의 상위집합이고 포맷에도 안 흔들린다.

module-boundary-guardrail.spec.ts
// 남의 모듈 엔티티를 "값으로" import하는가?
// import type은 제외 — 타입으로만 쓰면 런타임에 엔티티를 못 다룬다(= 게이트 우회 불가)
for (const stmt of src.matchAll(/import\s+(type\s+)?\{([^}]*)\}\s+from\s+'[^']+'/g)) { ... }

기준을 바꾸자 침범이 47쌍에서 51쌍으로, 크로스 도메인 쓰기가 0건에서 3건으로 정정됐다. 주입하는 서비스가 없는 죽은 forFeature 등록 3건도 덤으로 드러났다.

다음 가드레일에서 스스로 묻는 것

UUID 가드레일은 클래스 정의가 개별 컬럼을 못 잡았고, 모듈 경계 가드레일은 흔한 문법이 다른 문법을 못 잡았다. 둘 다 내가 정한 탐지 기준이 실제 위험 경로보다 좁았다.

그래서 새 가드레일을 만들 때 스스로 묻는 것을 적어뒀다.

탐지 기준이 위험 경로의 상위집합인가, 부분집합인가. 가장 흔한 형태를 기준으로 잡았다면 부분집합일 확률이 높다. 같은 일을 하는 다른 문법을 먼저 나열해본다.

스캐너가 고장나면 통과로 보이는가. 대부분 그렇다. 0건은 곧 초록불이니 스캔 결과 개수를 allowlist 개수와 정확히 일치시킨다. size > 30 같은 임의 임계값은 스캐너가 절반만 찾아도 통과시킨다.

expect(violations.size).toBe(Object.keys(ALLOWLIST).length) // 절반만 찾아도 실패

위반을 실제로 심어봤는가. 탐지 기준이 못 볼 만한 형태로 위반을 주입해도 테스트가 초록이면 그 형태를 놓치고 있다. v1 스캐너는 @InjectRepository 없이 manager로만 쓰는 주입을 통과시켰다.

두 가드레일의 탐지 범위

UUID 가드레일은 스키마의 컬럼 모양을 지킨다. 형태·이름으로 클래스를 거르고, 그것만으로 못 잡는 개별 컬럼은 전체 집합 스냅샷으로 잡는다.

모듈 경계 가드레일은 코드의 모듈 경계를 지킨다. 값 import를 스캔하고, 결과 개수를 allowlist와 정확히 대조하고, 스캐너가 못 볼 만한 형태를 심어보는 테스트로 받친다.

“0건”이라는 보고가 다시 나오면 그건 탐지 기준의 상위집합성과 주입 테스트가 함께 보증하는 0건이다.

모듈 경계 쪽은 탐지를 고친 뒤가 더 문제였다. 51쌍을 등급별로 세어보니 대부분이 버그가 아니라 플랫폼 관리자의 정당한 전 회사 접근이거나 우회할 게이트가 애초에 없는 단순 조회여서, 걷어내는 대신 목록으로 동결하고 신규 유입만 막았다.

↑↓ 이동 열기esc 닫기