Base64 디코더 URL-safe 양쪽 허용으로 버그 수정
목차
zlgoon에서 Base64 디코딩 버그를 잡았다. 증상이 "특정 상황에서 가끔 이상한 값이 나온다"는 식이었는데, 이런 버그가 제일 성가시다. 재현 조건이 까다롭고, 운이 좋으면 수정 없이 한동안 넘어갈 수도 있어서 오래 방치되는 경우가 많다. 원인을 파고들어 보니 URL-safe Base64와 표준 Base64를 구분하지 않고 한 쪽만 처리하던 내부 클래스가 문제였다. 변경 파일은 하나였지만, 원인을 제대로 이해하지 않으면 같은 버그를 다른 형태로 다시 만나게 된다.
Base64가 두 가지인 이유
Base64는 바이너리 데이터를 ASCII 텍스트로 변환하는 인코딩인데, RFC 4648 기준으로 보면 두 가지 변형이 공존한다.
표준 Base64는 A-Z, a-z, 0-9, +, /를 문자셋으로 쓰고 패딩에 =를 붙인다. URL-safe 변형은 +를 -로, /를 _로 바꾼다. 패딩 =는 URL 쿼리 파라미터 구분자(&, =)와 혼동될 수 있어서 생략하는 경우도 많다.
왜 두 가지냐면, 표준 Base64의 +와 /가 URL 컨텍스트에서 다른 의미를 갖기 때문이다. +는 URL 인코딩에서 공백 문자로, /는 경로 구분자로 해석될 수 있다. 그래서 Base64 문자열을 URL 파라미터나 쿠키, HTTP 헤더에 실어 보낼 때 표준 형태를 그대로 쓰면 전송 과정에서 값이 뒤틀릴 수 있다.
JWT가 대표적인 예다. header.payload.signature 구조에서 각 파트는 URL-safe Base64로 인코딩된다. OAuth 토큰, PKCE code challenge 값, 웹 API에서 주고받는 바이너리 ID들도 대부분 URL-safe 변형을 쓴다. 문제는 외부 시스템이나 라이브러리가 어느 쪽 변형을 내보내는지 명세에 명시적으로 적혀 있지 않은 경우가 있다는 점이다.
zlgoon 내부에서도 직접 생성한 값과 외부에서 넘어오는 값이 섞였는데, 디코더가 표준 Base64만 처리하도록 되어 있었다. URL-safe 형태가 들어오면 두 가지 중 하나가 일어났다. 디코딩 예외를 내거나, 더 나쁜 케이스로 조용히 잘못된 바이트 배열을 반환했다. 후자가 훨씬 찾기 어렵다. 오류가 나면 스택 트레이스라도 있는데, 잘못된 값을 반환하면 한참 뒤 하류 로직에서 이상 증상으로 나타나고 거기서 디버깅을 시작하게 된다.
수정 방향과 케이스별 처리
해법은 디코딩 전에 입력을 정규화하는 것이다. URL-safe 문자를 표준 문자로 치환하고, 빠진 패딩을 보정한 다음 디코딩한다.
import base64
def decode_base64_any(value: str) -> bytes:
# URL-safe 변형(-_)을 표준 Base64(+/)로 치환
normalized = value.replace("-", "+").replace("_", "/")
# 패딩 보정: 길이가 4의 배수가 되도록 = 추가
padding_needed = (4 - len(normalized) % 4) % 4
normalized += "=" * padding_needed
return base64.b64decode(normalized)
이 방식의 장점은 입력 변형에 무감각해진다는 것이다. 표준 Base64가 들어오면 -나 _가 없어서 치환이 무해하게 통과하고, 패딩이 이미 있으면 padding_needed가 0이 되어 =가 추가되지 않는다. "표준이면 A 처리, URL-safe면 B 처리" 식의 분기를 둘 필요가 없다.
케이스별로 정리하면 이렇다.
| 입력 형태 | 처리 결과 | 비고 |
|---|---|---|
| 표준 Base64 (패딩 있음) | 정상 처리 | 치환 무해 |
| URL-safe Base64 (패딩 있음) | 정상 처리 | 치환 후 디코딩 |
| URL-safe Base64 (패딩 없음) | 정상 처리 | 패딩 자동 보정 |
| 표준 Base64 (패딩 없음) | 정상 처리 | 패딩 자동 보정 |
| 잘못된 문자열 | 예외 발생 | 상위에서 핸들링 필요 |
한 가지 트레이드오프를 짚자면, "어느 변형이든 받는다"는 관대한 파서는 잘못된 입력을 조용히 수용하는 부작용도 있다. -나 _를 포함해선 안 되는 특수한 맥락이라면 치환이 오히려 문제를 숨길 수 있다. 이 프로젝트에서는 그런 제약이 없어서 선택한 방향이지만, 컨텍스트에 따라 "어느 변형인지 명확히 확인하고 다르면 오류를 내는" 방식이 더 맞는 경우도 있다. 관대한 파서가 항상 옳은 건 아니다.
수정 범위는 내부 클래스 하나였지만, 작업 전에 같은 패턴의 디코딩 로직이 코드베이스 어디에 더 있는지 훑었다. 분산되어 있으면 한 곳만 고쳐도 의미가 없어진다. 다행히 이 클래스가 단일 진입점 역할을 하고 있어서 이 파일 하나로 범위가 한정됐다. 이 확인을 건너뛰면 같은 버그를 다른 경로에서 다시 만나게 된다.
버그 수정할 때 챙기는 것들
증상을 가리는 게 아니라 원인을 고친다는 원칙을 지키려 한다. 이번에도 단순히 예외를 try-except로 잡고 기본값을 반환하는 식으로 처리할 수도 있었다. 그렇게 하면 오류는 사라지지만, 잘못된 값을 받은 하류 로직이 언젠가 다시 문제를 일으킨다. 그 시점에서는 원인이 훨씬 멀리 있어서 추적이 더 어려워진다.
버그를 하나 잡을 때마다 체크하는 항목들이다.
- 같은 로직이 다른 경로에도 있는지. 중복 코드가 있으면 하나만 고쳐서는 의미 없다.
- 수정 전에 증상과 재현 조건을 메모해 둔다. 수정 후 같은 조건으로 다시 확인하기 위해.
- 기존 정상 케이스가 깨지지 않는지. 회귀는 버그 픽스에서 자주 빠지는 항목이다.
- 관련된 화면이나 API 응답이 있으면 값을 다른 지점과 cross-check한다.
사내 서비스를 계속 만들다 보면 기능 하나가 화면 버튼 하나 추가로 끝나지 않는다는 걸 반복해서 체감한다. 인코딩 레이어, 비즈니스 로직, 렌더링, 권한 체크가 모두 엮여 있어서 어느 하나만 빠뜨려도 특정 사용자에게만 이상한 화면이 나오거나 숫자가 틀어진다. 엣지 케이스를 꼼꼼히 따지는 게 귀찮아 보여도 나중에 같은 버그로 다시 오는 시간이 훨씬 크다. 특히 데이터 정합성이 중요한 도메인에서는 "대충 맞는 것 같다"로 넘어가면 반드시 돌아온다.
커밋 메시지도 "무엇을"보다 "왜"를 담으려 한다. 몇 달 뒤에 히스토리를 뒤졌을 때 변경 이유를 코드만 봐서는 모를 때가 생각보다 많다. fix: Base64 URL-safe 허용보다 fix: 외부 토큰 입력이 URL-safe 변형일 때 디코딩 실패하는 문제 수정처럼. 논리적으로 독립된 단위로 커밋을 쪼개는 습관도 계속 유지 중이다. 문제가 생겼을 때 git bisect로 어느 변경에서 깨졌는지 찾을 수 있으면 디버깅 시간이 크게 줄어든다. 작은 습관인데 쌓이면 차이가 꽤 난다.
댓글 0
첫 댓글 달아줘.