티스토리 뷰

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 디코드)

원리는 생각보다 단순하다. 진법 변환이다.

  1. 토큰을 40진수(reduced charset)로 읽어 하나의 큰 정수로 만든다.
  2. 그 정수를 64진수(base64 문자셋)로 다시 표기한다. 문자셋만 바꿔 자릿수를 다시 센 것이다.
  3. 이제 그건 평범한 base64 문자열이므로 base64 디코드한다.
  4. 디코드 결과에서 정규식으로 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에 정리돼 있다. 위 코드는 그 알고리즘을 진법 변환 관점으로 최소한만 다시 쓴 것이다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
페이지
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함