TT Lab
블로그 시작하기
배우기 러닝패스 코스

필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다

옛 판과 새 판이 함께 돌 때

TT Lab 에서 이어서 보기

한 줄 요약

옛 판과 새 판이 함께 도는 동안 생기는 호환성 사고는 오류를 내지 않는다. 그래서 진단은 로그가 아니라 같은 바이트를 두 스키마로 읽어 맞대는 것에서 시작하고, 어긋난 이름 → 그 이름의 번호 → 바이트의 그 레코드 → 두 .proto 의 그 번호 순으로 내려간다. 고침은 언제나 새 판 쪽이다.

왜 이게 필요했나

앞 모듈들에서 규칙을 배웠다 — 번호를 바꾸지 말고, 지운 번호는 reserved 로 봉인하고, 타입은 와이어에서 호환되는 것끼리만. 그런데 현장에서 사람을 찾아오는 것은 규칙 위반이 아니라 증상이다. "정산 숫자가 어제부터 안 맞는다, 배치는 성공으로 끝났다." 예외도 경고도 없으니 로그를 아무리 뒤져도 나오지 않는다. 모범 사례 문서가 말하듯 클라이언트와 서버는 같은 순간에 배포되지 않고, 몇 달 전 빌드한 배치가 오늘도 돈다. 그 배치는 빌드할 때의 .proto 를 품고 있고, 서버가 무엇으로 바뀌었는지 모른다.

어떻게 동작하나

두 프로세스, 하나의 바이트. 새 판 서버는 데이터베이스의 값을 필드 이름으로 찾아 자기 .proto 가 정한 번호·타입으로 싣는다. 옛 판 클라이언트는 그 바이트를 번호로 읽어 자기 .proto 의 이름을 붙인다. 이름은 양쪽 .proto 에만 있고 와이어에는 없다. 그래서 같은 응답을 두 클라이언트로 읽어 이름별로 맞대면, 어긋난 이름이 곧 사고 자리다. 이 실습의 서버는 gRPC 의 메시지 틀 — 압축 표시 1바이트, 빅엔디언 길이 4바이트, 메시지 — 을 TCP 위에 바로 싣는다. 진짜 gRPC 는 이 틀을 HTTP/2 DATA 프레임에 싣는다(gRPC over HTTP/2). TCP 는 메시지 경계를 모르므로 길이만큼 다 올 때까지 읽는 것이 클라이언트의 몫이다.

오류 없이 틀리는 다섯 가지. 실습의 데모에서 넷(field-renumbered 는 앞 모듈에서 봤다)을, 사건에서 다섯 모두를 만난다.

코드 v2 에서 한 일 옛 판이 보는 것
enum-renumbered enum 의 1 에 새 값을 끼우고 뒤를 밀었다 이름이 한 칸씩 밀린다(PAID → SHIPPED)
field-renumbered 같은 타입 두 필드의 번호를 맞바꿨다 두 값이 뒤바뀐다
number-reused 문자열 필드를 지우고 그 번호를 새 문자열 필드에 줬다 지운 칸에 남의 값이 보인다
sint-changed int32 를 sint32 로 같은 VARINT 라 ZigZag 된 숫자 — 150 이 300, -40 이 79
wire-type-changed int32 를 fixed32 로 와이어 타입이 달라 모르는 필드로 넘기고 0

넷째와 다섯째의 차이가 흥미롭다. proto3 언어 안내는 sint32 가 sint64 와만 호환되고 다른 정수 타입과는 아니라고 적는다. 와이어 타입이 같으니 파서는 아무 의심 없이 값을 바꿔 읽는다. fixed32 는 와이어 타입 자체가 I32 라 이 실습의 디코더는 그 레코드를 모르는 필드로 넘긴다 — 구현에 따라서는 파싱 오류가 되기도 하는데, 오류가 나는 쪽이 오히려 나은 경우다.

거짓 경보 둘. 두 판의 차이가 전부 사고는 아니다. 새 판이 지운 필드는 옛 판에게 기본값으로 보이는 것이 맞고, enum 끝에 더한 새 값은 옛 판에게 모르는 숫자로 보이는 것이 맞다. proto3 의 enum 은 열려 있어(open) 모르는 번호도 값으로 담는다 — 언어마다 보이는 모양은 다르다. 시험이 이 둘을 사고로 잡으면 아무도 그 시험을 믿지 않게 된다.

바이트에서 원인으로. 어긋난 이름을 찾았으면 옛 판의 .proto 에서 그 이름의 번호를 본다. 스키마 없이 바이트를 레코드로 편 덤프에서 그 번호를 찾으면, 와이어 타입과 원시값이 원인을 가리킨다. 그다음 그 번호에서 두 .proto 의 선언을 맞대면 다섯 중 하나로 좁혀진다. 새 판에는 안전한 변경도 섞여 있으므로 차이를 다 되돌리는 것이 아니라 그 하나를 고친다.

현장에서 만나는 모습

가장 흔한 그림은 "새 판끼리는 정상" 이다. 새 판 클라이언트와 새 판 서버는 같은 .proto 를 쓰니 어떤 변경이든 서로는 맞게 읽는다. 그래서 새 판 팀의 통합 시험은 전부 초록이고, 문제는 다른 팀의 옛 배치에서만 보인다. 옛 판을 고쳐 맞추고 싶어지지만 대개 불가능하다 — 다른 팀의 바이너리이고, 모바일 앱이라면 사용자 기기에 깔려 있다. 그래서 고침은 새 판의 스키마를 옛 번호와 호환되게 되돌리는 것이고, 다시는 같은 일이 없게 두 판을 함께 띄워 같은 바이트를 맞대는 시험을 배포 앞에 둔다. 정적인 계약 검사기가 놓치는 것(enum 값 번호처럼 도구가 비교하지 않는 자리)도 이 시험은 잡는다.

또 하나 — 고친 .proto 를 저장만 하고 서버를 다시 띄우지 않아 "고쳤는데 그대로" 인 경우가 잦다. 서버는 스키마를 시작할 때 컴파일해 들고 있다. 진짜 gRPC 서버는 생성된 코드로 컴파일되어 있으니 더 그렇다.

다음 실습에서 할 것

gRPC 메시지 틀을 소켓으로 직접 주고받는 작은 도구로 시작한다. 그다음 해로운 변경 넷(위 표에서 field-renumbered 를 뺀 것)이 섞인 데모 v2 서버의 같은 응답을 옛 판·새 판 두 클라이언트로 읽어 어긋난 이름을 짚고, 덤프에서 번호와 와이어 타입을 찾아 원인을 붙이고, 새 기능은 살린 채 호환되게 고친다. 그 절차를 시험 스크립트로 굳힌 뒤, 세 서비스가 같은 증상을 서로 다른 원인으로 내는 사건을 진단하고 고친다.