Gmail 링크 뒤 그 문자열, 알고 보니 인코딩된 thread ID였다
Gmail을 웹에서 쓰다 보면 메일 하나를 열었을 때 주소창이 이렇게 바뀐다.
https://mail.google.com/mail/u/0/#inbox/QgrcJHrhvXXVhMKdgvFSRvHcMBTKbNVfRtL
맨 뒤의 QgrcJHrhvXXVhMKdgvFSRvHcMBTKbNVfRtL. 대소문자가 뒤섞인 길쭉한 문자열이라 그냥 랜덤 세션 토큰 같은 거려니 하고 넘겼다. 그런데 이게 랜덤이 아니었다. 서버에 물어볼 것도 없이, 순수 계산만으로 Gmail API가 쓰는 thread ID를 여기서 복원할 수 있다. 그걸 알게 된 삽질 과정을 짧게 남긴다.
1. 발단: URL의 ID를 API에 그대로 넣었더니 터졌다
메일 링크를 프로그램으로 다루고 싶었다. 링크에서 ID를 떼어내 Gmail API로 스레드를 조회하면 되겠거니 했다. URL 뒤의 문자열을 그대로 threadId에 넣었다.
gmail.users().threads().get(userId="me",
id="QgrcJHrhvXXVhMKdgvFSRvHcMBTKbNVfRtL")
돌아온 건 데이터가 아니라 에러였다.
400: Invalid id value
여기서 잠깐 멈칫했다. 분명 이 메일의 링크에서 뽑은 ID인데 왜 "잘못된 id"라는 걸까. Gmail API가 기대하는 thread ID는 이렇게 생겼다.
120a32474e2064ce ← 16진수 문자열
즉 웹 URL에 박히는 ID와 API가 쓰는 thread ID는 서로 다른 표현이었다. 같은 스레드를 가리키지만 형식이 다르다. 여기서부터 "그럼 이 둘이 어떻게 대응되지?"가 궁금해졌다.
2. 발견: 랜덤이 아니라 "헷갈리는 글자를 뺀" 인코딩
찾아보니 이 토큰은 랜덤이 아니라 thread ID(정수)를 문자열로 인코딩한 것이었다. 핵심은 인코딩에 쓰인 문자셋이다. 토큰에 등장하는 글자를 모아보면 모음(A·E·I·O·U)과 숫자가 아예 없다. 실제로 쓰이는 문자셋은 이 40글자뿐이다.
BCDFGHJKLMNPQRSTVWXZ bcdfghjklmnpqrstvwxz (40자)
자음만 남기고, 사람이 보기에 헷갈리기 쉬운 글자(모음, 0/O, 1/l 같은 숫자·유사문자)를 빼버린 축약 문자셋(base-40)이다. 링크를 눈으로 읽거나 불러줄 때 오독을 줄이려는 의도로 보인다. 중요한 건, 이 축약 문자셋과 원래 thread ID가 1:1 무손실로 대응한다는 점이다. 즉 토큰만 있으면 원본 ID를 계산으로 정확히 되돌릴 수 있다.
3. 해결: 자릿수만 다시 세면 된다 (base 변환 → base64 디코드)
원리는 생각보다 단순하다. 진법 변환이다.
- 토큰을 40진수(reduced charset)로 읽어 하나의 큰 정수로 만든다.
- 그 정수를 64진수(base64 문자셋)로 다시 표기한다. 문자셋만 바꿔 자릿수를 다시 센 것이다.
- 이제 그건 평범한 base64 문자열이므로 base64 디코드한다.
- 디코드 결과에서 정규식으로 10진수만 뽑아 16진수로 바꾸면 끝.
3번에서 base64를 풀면 사람이 읽을 수 있는 문자열이 나온다.
thread-a:r-1299906724284622030
스레드 참조 문자열이 그대로 나온다. 여기서 뒤의 정수 1299906724284622030을 뽑아 16진수로 바꾸면 120a32474e2064ce — 아까 API가 원하던 바로 그 ID다. 코드로 옮기면 이 정도로 짧다.
import base64, re
# 축약 문자셋: 모음·숫자를 뺀 40자 (base-40)
REDUCED = "BCDFGHJKLMNPQRSTVWXZbcdfghjklmnpqrstvwxz"
# 표준 base64 문자셋 (base-64)
FULL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
def rebase(token, src, dst):
"""src 문자셋 기준 정수로 읽어 dst 문자셋으로 다시 표기 (진법 변환)"""
n = 0
for ch in token:
n = n * len(src) + src.index(ch)
out = []
while n:
n, r = divmod(n, len(dst))
out.append(dst[r])
return "".join(reversed(out)) or dst[0]
def url_token_to_thread_id(token):
b64 = rebase(token, REDUCED, FULL)
b64 += "=" * (-len(b64) % 4) # base64 패딩 보정
text = base64.b64decode(b64).decode() # "thread-a:r-1299906724284622030"
num = re.search(r"[fa]:r?-?(\d+)", text).group(1)
return format(int(num), "x") # 10진수 → 16진수
print(url_token_to_thread_id("QgrcJHrhvXXVhMKdgvFSRvHcMBTKbNVfRtL"))
# -> 120a32474e2064ce
실행하면 120a32474e2064ce가 찍힌다. 이제 이 값을 Gmail API의 threadId에 넣으면 정상 조회된다. 참고로 디코드된 문자열은 thread-a:(또는 thread-f:) 형태인데, 앞의 thread-가 인코딩 과정의 선행 0에 먹혀 잘려 나오기도 한다. 우리가 필요한 건 결국 뒤쪽 정수 하나뿐이라 정규식으로 그것만 집어내면 그만이다.
정리
| 단계 | 내용 |
|---|---|
| 삽질 | 웹 URL 뒤 문자열을 API threadId에 그대로 넣음 → 400 Invalid id value |
| 발견 | 그 문자열은 랜덤이 아니라, 모음·숫자를 뺀 40자 축약 문자셋으로 인코딩된 thread ID |
| 원리 | 40진수로 읽기 → 64진수(base64)로 다시 표기 → base64 디코드 → 정수 추출 → 16진수 |
| 결론 | 링크만 있으면 서버 왕복 없이 계산만으로 API용 thread ID 복원 가능 |
별것 아닌 문자열 하나였는데, 열어보니 "사람이 오독하지 않게 문자셋을 골라 정수를 인코딩한다"는 작은 설계가 들어 있었다. 랜덤처럼 보인다고 진짜 랜덤인 건 아니라는 것, 그리고 겉보기 불투명한 ID도 인코딩 규칙만 알면 서버 없이 되돌릴 수 있다는 걸 다시 확인한 삽질이었다.
이 디코딩 원리는 Arsenal Recon의 오픈소스 GmailURLDecoder에 정리돼 있다. 위 코드는 그 알고리즘을 진법 변환 관점으로 최소한만 다시 쓴 것이다.