pyjwt-rs: JWT 처리를 Rust로 옮기면 정말 빨라지는가

(수정: )

Python 웹 서비스에서 인증 경로는 요청마다 반복된다. 그 안에는 JWT 문자열 처리, JSON 파싱, key parse, signature 검증이 겹겹이 쌓여 있다. 라이브러리를 새로 만들고 싶어서 시작한 일이 아니었다. 이 반복 구조를 볼 때마다 “여기 어딘가는 Rust로 옮기면 이득이 있을 것 같다”는 생각이 계속 들었고, 그 감을 실제로 확인해보기로 했다.

그렇게 나온 게 pyjwt-rs다. PyJWT와 호환되는 API를 갖춘, Rust로 구현한 Python JWT 라이브러리다. 사용법은 import jwt_rs as jwt로 기존 PyJWT를 쓰던 자리에 그대로 넣을 수 있게 맞췄다.

이 글이 확인하려는 두 가지

이 프로젝트를 시작할 때 확인하고 싶었던 건 두 가지였다.

첫째, Python의 JWT 처리에서 Rust가 성능상 유리하게 들어갈 지점이 실제로 있는가. JWT는 요청마다 반복되고, 그 안에 key parse, JSON, signature 검증이 섞여 있어서 Rust가 유리한 지점과 FFI 때문에 손해 볼 지점이 함께 있을 후보라고 봤다.

둘째, vibe coding으로 빠르게 만든 구현 후보를 반복 확인만으로 실제 성능 개선까지 끌고 갈 수 있는가. Rust 코드를 Python 모듈로 노출해 주는 PyO3 바인딩, 알고리즘 레지스트리, 테스트, benchmark 스크립트를 AI-assisted coding으로 빠르게 작성했는데, 이렇게 나온 코드를 얼마나 믿고 써도 되는지가 궁금했다.

두 가설 모두 “Rust로 옮기면 빠르다”, “AI로 짜면 빠르게 완성된다” 같은 넓은 선언으로 답할 수 있는 문제가 아니었다. 그래서 이 글은 benchmark 수치와 그 수치가 만든 결정을 순서대로 따라간다.

JWT 처리에서 반복되는 것

JWT는 대략 header.payload.signature 세 부분으로 구성된다. encode는 header와 payload를 만들고 서명해서 하나의 문자열로 붙인다. decode는 그 문자열을 세 부분으로 나누고, 서명을 검증하고, expiat 같은 claim을 확인한다.

이 처리 비용은 알고리즘마다 다르다. HMAC 계열(HS256)은 대칭키 연산이라 가볍다. 반면 RSA(RS256), ECDSA(ES256), EdDSA는 공개키 연산과 key 파싱 비용이 붙어서 상대적으로 무겁다. 그래서 “JWT가 빨라졌다”는 말은 의미가 없다. 어떤 알고리즘의 어떤 경로가 빨라졌는지를 따로 봐야 했고, 그보다 먼저 Rust로 옮기는 선택 자체가 항상 이득인지도 확인해야 했다.

FFI를 먼저 의심한 이유

Rust로 옮기는 게 항상 이득은 아니다. Python에서 Rust 함수를 호출할 때는 FFI(Foreign Function Interface) 경계를 오가야 하고, 이 경계를 건널 때마다 인자 변환, 타입 체크, GIL 처리 같은 비용이 붙는다. 작은 함수를 자주 건너뛰는 방식으로 Rust를 쓰면 이 왕복 비용이 native code로 얻는 이득을 다 깎아먹을 수 있다.

그래서 Rust가 유리한 후보는 “작은 함수를 자주 건너뛰는 곳”이 아니라 “큰 처리 묶음을 Rust 안에서 한 번에 끝낼 수 있는 곳”이라고 판단했다. JWT encode/decode처럼 key parse부터 signature 검증까지 한 흐름으로 묶을 수 있는 작업은 이 조건에 맞아 보였는데, 이 판단을 실제로 재보려면 먼저 그 측정에 쓸 코드부터 믿을 수 있어야 했다.

vibe coding을 확인 절차로 다루기

pyjwt-rs의 PyO3 바인딩, 알고리즘 레지스트리, 초기 테스트 코드 상당 부분은 AI-assisted coding, 이른바 vibe coding으로 빠르게 작성했다. 이 자체는 문제가 아니다. 문제는 빠르게 만든 코드를 그대로 믿을 근거가 없다는 점이다.

그래서 vibe coding으로 만든 코드가 실제로 쓸 만한지 가르는 장치로 세 가지를 뒀다.

  • 같은 API benchmark: PyJWT와 완전히 동일한 함수 시그니처로 알고리즘별 encode/decode 속도를 재는 benchmark_same_api.py.
  • component benchmark: decode 내부를 load, verify_only, decode 단계로 쪼개 어디서 비용이 발생하는지 보는 benchmark_decode_components.py.
  • compatibility checklist + test suite: PyJWT 2.12.1을 기준으로 동작을 맞추고, 어긋나는 부분을 남겨두는 COMPATIBILITY_CHECKLIST.md와 테스트.

이 세 장치가 있어야 vibe coding으로 만든 코드를 “일단 짠 것”에서 “측정으로 확인한 것”으로 바꿀 수 있다고 봤다.

첫 benchmark: 좋은 값과 나쁜 값이 같이 나왔다

OpenSSL backend로 전환하기 전, 커밋 1347c1b 시점의 README에 남은 같은 API benchmark 결과는 이랬다.

알고리즘encodedecodedecode_complete
HS2560.32x0.29x0.28x
RS25656.60x0.88x0.90x
ES2561.49x1.15x1.11x
EdDSA1.21x0.89x0.88x

RS256 encode는 이미 PyJWT보다 56.60배 빨랐다. 하지만 HS256은 encode, decode 모두 PyJWT보다 느렸고, RS256과 EdDSA의 decode 경로도 1.0x를 밑돌았다. 앞서 “여기 어딘가는 Rust로 옮기면 이득이 있을 것 같다”고 봤던 처지에서는 반가운 결과보다 당혹스러운 결과가 먼저 눈에 들어왔다. “Rust로 옮기면 빠르다”는 감이 반은 맞고 반은 틀린 상태로 나온 셈이다.

이 표를 보고 알게 된 건, RustCrypto 계열 crate(jsonwebtoken 등 순수 Rust crypto 구현)를 그대로 쓰는 조합이 항상 PyJWT + cryptography(내부적으로 OpenSSL을 쓴다)보다 빠르지는 않다는 사실이었다. 특히 HMAC처럼 가벼운 연산에서는 FFI 경계를 오가는 비용이 RustCrypto 구현이 아낀 비용보다 커 보였다.

RustCrypto 대신 Rust 안에서 OpenSSL을 쓰기로 한 이유

이 표를 보고 언어를 바꾸는 것 자체가 답이 아니라는 걸 확인했다. 그래서 Rust 코드 안에서 순수 RustCrypto crate 대신 OpenSSL backend를 호출하는 방향으로 방향을 바꿨다. Rust는 파싱과 흐름 제어, FFI 경계 관리를 맡고, 실제 서명/검증 연산은 검증된 OpenSSL 구현에 맡기는 구조다.

이 결정은 “Rust가 빠르다”가 아니라 “어떤 backend를 어디서 호출하느냐가 빠르다”로 판단 기준을 옮긴 것이다. 그렇다면 backend를 바꾼 뒤 decode 경로는 실제로 얼마나 가벼워졌을까.

decode를 component로 쪼개 본 이유

backend를 바꾼 뒤에도 decode 경로가 왜 여전히 무거운지는 알고리즘별 encode/decode 숫자만으로는 보이지 않았다. 그래서 decode 내부를 load(key 준비), verify_only(서명 검증), decode(전체 decode) 세 단계로 쪼개 각각 시간을 쟀다.

당시 DEVLOG에 남긴 component-level 측정은 이랬다. RS256은 load 약 10µs, verify_only 약 22µs, decode 약 60µs였고, HS256은 load 약 7µs, verify_only 약 19µs, decode 약 50µs였다.

RS256은 verify_only(22µs)가 load(10µs)보다 크긴 하지만, 전체 decode(60µs)는 그 둘을 합친 것보다 훨씬 크다. 즉 비용의 상당 부분이 서명 검증 자체가 아니라 segment 분리, payload decode, claim 검증 같은 decode 흐름 전체에 퍼져 있다는 뜻이었다. HS256도 마찬가지로 verify_only가 가벼운데도 decode가 무거운 걸 보면, HMAC은 연산 자체보다 Python/Rust 경계를 오가는 호출 구조의 비용이 크게 보였다.

이 분해 결과가 다음에 손댈 곳의 순서를 정했다. 서명 검증 알고리즘이 아니라, decode 흐름 전체를 얼마나 적은 FFI 왕복으로 묶어낼 수 있는지가 v1.1.0에서 실제로 손댈 지점이었다.

v1.1.0에서 바꾼 것들

CHANGELOG v1.1.0에 따르면 이번 성능 개선 작업은 네 가지로 이뤄졌다.

  • direct HMAC backend: HS256처럼 가벼운 연산에서 불필요한 중간 계층을 걷어내고 OpenSSL HMAC을 직접 호출하도록 바꿨다. component benchmark에서 HS256의 decodeverify_only보다 훨씬 무거웠던 문제에 대응한다.
  • raw-bytes sign/verify 경로: 서명 데이터를 Python 객체로 몇 번씩 왕복시키지 않고 raw bytes로 바로 주고받게 했다. FFI 경계를 오갈 때마다 붙는 변환 비용을 줄이는 방향이다.
  • one-shot FFI encode/decode: encode/decode 전체를 Rust 쪽에서 한 번의 호출로 끝내도록 묶었다. component benchmark가 보여준 “decode 전체 비용이 verify_only보다 훨씬 크다”는 문제, 즉 흐름 전체에 퍼진 비용을 줄이기 위한 변경이다.
  • release profile 튜닝: 릴리스 빌드 설정을 조정해 최적화 수준을 끌어올렸다.

이 네 가지는 각각 다른 문제에 대응한다. direct HMAC backend와 raw-bytes 경로는 HS256처럼 가벼운 알고리즘에서 커 보였던 FFI 왕복 비용을, one-shot FFI 경로는 RS256 decode에서 나타난 흐름 전체 비용을 겨냥했다.

전후 수치와 현재 수치

uv run python scripts/benchmark_same_api.py --iterations 80 --warmup 10을 2026-07-03에 로컬 재검증한 결과는 이렇다.

알고리즘·경로PyJWT 평균jwt_rs 평균배수
HS256 encode19µs13µs1.45x
HS256 decode15µs8µs1.77x
RS256 encode31.17ms0.52ms59.43x
RS256 decode70µs27µs2.58x
ES256 encode94µs33µs2.83x
ES256 decode110µs59µs1.86x
EdDSA encode79µs51µs1.56x
EdDSA decode112µs100µs1.12x

첫 benchmark에서 PyJWT보다 느렸던 HS256 encode(0.32x)와 decode(0.29x)는 이번 재검증에서 각각 1.45x, 1.77x로 뒤집혔다. RS256 decode도 0.88x에서 2.58x로 방향이 바뀌었다. RS256 encode는 처음부터 강했던 경로답게 이번에도 59.43x로 여전히 가장 큰 배수를 유지했다. 다만 EdDSA decode는 1.12x로, 다른 경로에 비해 개선 폭이 작다.

2026-06-19 로컬 검증에서는 같은 벤치마크가 RS256 decode 2.15x를 기록했는데, 2026-07-03 재검증에서는 2.58x가 나왔다. 이 차이는 회귀가 아니라 같은 머신에서 반복 측정할 때 생기는 실행 편차로 본다.

이 표에서 확인한 건 “Rust로 옮겨서 빨라졌다”가 아니라 “병목을 재고 backend와 경로를 다시 고른 뒤 빨라졌다”는 쪽이다. 첫 benchmark 단계에서 backend를 바꾸지 않고 언어만 Rust로 유지했다면 HS256과 decode 경로의 역전은 일어나지 않았을 것이다.

어디까지 말할 수 있나

pyjwt-rs는 PyPI 기준 아직 Development Status :: 4 - Beta다. 테스트는 로컬에서 324개가 통과했고, README에 기록된 값과도 같다. 하지만 compatibility checklist에는 key 길이 강제, PyJWK native key 노출, PyJWKSet의 사용 불가 key 처리, 네트워크 에러 표면 쪽에 아직 부분적으로 남은 gap이 있다.

그래서 이 프로젝트는 아직 완성된 PyJWT 대체재가 아니라 측정 중인 실험으로 둔다. 59.43x는 RS256 encode 경로에 한정된 수치이고, 다른 경로까지 같은 배수로 읽을 수는 없다. EdDSA decode처럼 아직 1.12x에 머무는 경로도 있다.

두 가설에 대한 답은 이렇다. 첫째, Rust가 유리한 경계는 실제로 있었지만 그 경계는 언어 자체가 아니라 backend 선택과 FFI 호출 묶음 단위에서 갈렸다. 둘째, vibe coding으로 시작한 구현도 같은 API benchmark, component benchmark, compatibility checklist라는 반복 확인을 통과시키면 실제 성능 개선으로 이어질 수 있었다. 다만 그 확인 절차가 없었다면 첫 benchmark의 실패한 절반을 놓쳤을 것이다.

여기서 재사용할 수 있는 규칙 하나는, 성능이나 구현 방식에 대한 판단은 그 자체로 넓게 선언하지 않고 항상 측정 단위와 검증 장치 안에서만 말해야 한다는 것이다. “Rust라서 빠르다”가 아니라 “이 backend와 이 호출 경로에서 빠르다”고 말해야 다음 번 병목도 같은 방식으로 찾을 수 있다.