이 이슈는 2023년 12월에 시작되었으며...[더 보기]

jjwt 이슈를 읽고 새로운 PR을 내는 과정

이승재 · NHN AD
플랫폼서비스랩 · 2026년 8월 · 약 12분

들어가며

안녕하세요, 오늘은 2023년 12월에 시작된 issue #877 (Option to enable strict duplicate detection)를 하나 가져왔습니다. 이번 글은 jjwt와 Jackson의 동작 과정과 PR을 생성하기까지의 기록입니다.

jjwt와 jackson

아마 백엔드 개발을 하시는 분들에게는 jjwt와 Jackson 모두 익숙하실 것 같습니다. Spring Boot가 JSON 변환 라이브러리로 Jackson을 기본으로 쓰기 때문에 더욱 그럴 것이며, jjwt는 이름 그대로 java-jwt라 이 라이브러리를 몰라도 JWT와 관련된 것임을 알 수 있습니다.

그래도 이 글의 이야기가 둘의 경계에서 벌어지니, 각자 무슨 일을 하는지부터 짧게 짚고 가겠습니다.

먼저 JWT입니다. 로그인한 사용자가 누구인지를 서버가 매번 세션에서 찾지 않고, 토큰 하나로 확인할 수 있게 해주는 규격입니다. 생김새는 점 두 개로 나뉜 세 조각이고 각각 헤더, 페이로드, 시그니처입니다. 헤더와 페이로드는 그냥 JSON을 Base64로 인코딩한 것이고, 시그니처는 그 헤더와 페이로드를 더해 해시 알고리즘으로 잠근 값입니다. 그래서 내용을 몰래 바꾸면 검증에서 걸립니다. 반대로 말하면 내용 자체는 누구나 열어볼 수 있는 평범한 JSON입니다.

eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhbGljZSJ9 . dBjftJeZ4CVP 헤더 {"alg":"HS256"} 서명 알고리즘 Base64로 인코딩된 JSON 페이로드 {"sub":"alice"} 클레임 — 누구인지, 언제 만료인지 Base64로 인코딩된 JSON 시그니처 헤더+페이로드를 해시로 잠근 값 내용을 바꾸면 검증에 걸린다 앞의 두 조각은 그냥 JSON이다 — 그래서 JSON 파서가 필요하다
서명은 내용의 위조를 막아줄 뿐, 내용을 어떻게 읽을지는 정해주지 않는다.

jwtk/jjwt는 그 JWT를 자바에서 만들고 검증하는 라이브러리입니다. 별이 1만 개가 넘는, 자바 진영에서 사실상 표준처럼 쓰이는 오픈소스입니다. 토큰을 만들고, 서명을 확인하고, 만료됐는지 보고, 클레임을 꺼내주는 일을 합니다.

Jackson은 자바에서 JSON을 객체로 바꾸고 객체를 JSON으로 바꾸는 라이브러리입니다. Spring Boot를 쓰신다면 이미 매일 쓰고 계십니다. 컨트롤러에 @RequestBody를 붙였을 때 요청 본문을 DTO로 만들어주는 게 Jackson입니다.

이 둘이 같이 나오는 이유는 jjwt가 JSON을 직접 읽지 않기 때문입니다. 토큰의 페이로드를 파싱하는 일은 외부 라이브러리에 맡기고 자기는 JWT 규격에 집중하겠다는 것이죠. 그 자리에 가장 흔히 꽂히는 것이 Jackson이고요.

이제 이슈로 다시 돌아가서 요청을 요약하면 다음과 같습니다. 토큰 페이로드에 같은 이름이 두 번 들어 있으면 그걸 거부할 수 있게 해달라.

이슈...있슈?

2023년 12월 4일에 laurids라는 사용자가 연 이슈이고, 제목은 Option to enable strict duplicate detection입니다. 우리말로 옮기면 중복 검출을 엄격하게 켤 수 있게 해달라는 요청입니다.

Jackson은 기본적으로 중복된 이름에 대해서 후에 입력된 값을 덮어 씁니다.

{
  "k": "A",
  "k": "B"
}

→  {k=B}

혹시 정렬 같은 다른 규칙이 있나 싶어 값을 오름차순으로도, 내림차순으로도 넣어봤습니다. 결과는 정렬과 아무 상관이 없었고 그냥 원문에 쓰인 순서로 결정되더군요.

오름차순으로 넣은 경우
{
  "k": "aaa",
  "k": "zzz"
}
→  {k=zzz}

내림차순으로 넣은 경우
{
  "k": "zzz",
  "k": "aaa"
}
→  {k=aaa}

그러면 이게 어떤 문제를 발생시킬 수 있을까요? k 자리에 실제 클레임을 넣어보겠습니다.

{
  "sub":  "alice",
  "sub":  "attacker",
  "role": "user",
  "role": "admin"
}

→  sub=attacker   role=admin

주체가 alice에서 attacker로, 권한이 user에서 admin으로 바뀌고 Jackson은 이 동작을 허용합니다. 따라서 중복을 검출할 수 있도록 만들어달라는 요청입니다.

그런데 Jackson은 이미 JsonParser.Feature.STRICT_DUPLICATE_DETECTION 옵션으로 중복을 막는 기능을 갖고 있습니다. 켜두면 덮어쓰는 대신 예외를 던집니다.

JsonParseException: Duplicate field 'sub'

이슈를 연 laurids도 이 옵션에 대해 언급합니다.

This can be done by enabling JsonParser.Feature.STRICT_DUPLICATE_DETECTION on the underlying ObjectMapper. The problem is that there is no way to access the objectMapper in JacksonDeserializer.

밑에 깔린 ObjectMapper에 그 기능을 켜면 되는데, JacksonDeserializer 안의 objectMapper에 접근할 방법이 없다는 이야기입니다. 이미 있는 스위치에 손이 닿게 해달라는 요청인 셈입니다.

여기에 의견이 둘 달립니다. 먼저 bdemers가 답합니다. 그러면 ObjectMapper를 직접 만들어 넣으면 되지 않느냐는 겁니다.

You can create a new instance of JacksonDeserializer, and customize the ObjectMapper

그럴 수 있는 생성자는 이미 있습니다. 그가 코멘트에 링크로 짚은 세 줄을 당시 릴리스인 0.12.3에서 그대로 열어보면 이렇습니다.

// extensions/jackson/…/io/JacksonDeserializer.java · 0.12.3 · L91-93
public JacksonDeserializer(ObjectMapper objectMapper) {
    this(objectMapper, (Class<T>) Object.class);
}

설정을 마친 ObjectMapper를 만들어 여기 넘기면 중복 검출도 같이 켜진다는 답입니다. 세 시간 뒤에는 메인테이너 lhazlewood가 훨씬 무거운 반문을 답니다.

It is unclear to me if we should change the default behavior of implicitly-created ObjectMapper instances since the RFCs explicitly allow this behavior. Assuming signature verification or AEAD decryption are successful before accessing the payload, what vulnerabilities are you referring to?

암묵적으로 만들어지는 ObjectMapper의 기본 동작을 바꿔야 하는지 모르겠다, RFC가 이 동작을 명시적으로 허용하기 때문이다. 그리고 페이로드를 읽기 전에 서명 검증이 먼저 성공한다고 치면 대체 어떤 취약점을 말하는 거냐는 겁니다.

짧은 대화인데 확인할 게 여럿 남았습니다. RFC는 정확히 뭐라고 적었길래 "명시적으로 허용"이라는 말이 나오는지, ObjectMapper를 직접 만들어 넣으라는 답으로는 왜 부족했는지, 그리고 서명 검증이 먼저인데도 정말 위험한지. 하나씩 들어가 보겠습니다.

RFC 톺아보기

JSON 표준 자체는 이름이 유일해야 한다고 권고만 하지만, JWT 쪽 RFC는 그 수위를 올렸습니다. RFC 7515 §4RFC 7519 §4가 같은 문장을 반복합니다.

The Header Parameter names within the JOSE Header1 MUST be unique; JWS2 parsers MUST either reject JWSs with duplicate Header Parameter names or use a JSON parser that returns only the lexically last duplicate member name.

1JOSE — Javascript Object Signing and Encryption. JWS·JWE·JWK·JWT 규격을 한 세트로 만든 IETF 작업반 이름입니다. JOSE 헤더는 그 세트가 공통으로 쓰는 헤더, 그러니까 앞에서 본 토큰의 첫 번째 조각입니다.

2JWS — JSON Web Signature. 서명된 토큰의 형식 자체를 가리킵니다. 헤더·페이로드·시그니처 세 조각 구조가 바로 JWS이고, 암호화된 형식은 JWE로 따로 있습니다. 우리가 JWT라고 부르는 것은 대개 그 페이로드에 클레임을 담은 JWS입니다.

한 문장 안에 규칙이 둘입니다. 만드는 쪽은 이름이 유일해야 하고, 읽는 쪽은 거부하거나 마지막 값을 쓰거나 둘 중 하나를 반드시 해야 합니다. 그러니까 중복이 든 토큰은 그 자체로 이미 규격 위반이지만, 그걸 읽는 파서에게는 두 선택지가 모두 적법합니다.

뒤에 쓴 값이 이기는 Jackson의 동작이 RFC가 말하는 lexically last입니다. 사전순이 아니라 원문에 쓰인 순서상 마지막이라는 뜻이고, RFC가 굳이 ECMAScript 5.1의 절을 인용해 붙여둔 것도 이 모호함을 없애기 위해서입니다.

그리고 선택지가 둘이라는 사실 자체가 위험이라는 걸 RFC 7515 §10.12가 직접 적어뒀습니다.

Ambiguous and potentially exploitable situations could arise if the JSON parser used does not enforce the uniqueness of member names or returns an unpredictable value for duplicate member names.

만드는 쪽 발급자 · 인가 서버 이름은 유일해야 한다 · MUST 갈래가 하나뿐이다 읽는 쪽 파서 · 라이브러리 거부한다 마지막 값을 쓴다 둘 다 적법하다 RFC 7515 §10.12 — 모호하고 악용될 수 있는 상황 같은 토큰을 어떤 파서로 읽느냐에 따라 결과가 갈릴 수 있다는 뜻
규격 위반은 발급자 쪽에서 이미 일어나 있고, 그 뒤를 어떻게 처리할지는 파서마다 다를 수 있다.

모호함에서 오는 취약점

추가적으로 메인테이너 lhazlewood는 이렇게 말합니다. DefaultJwtParser에서 서명 검증이 클레임 파싱보다 먼저 일어나므로, 중복 키가 파서까지 왔다면 그건 이미 신뢰하는 키로 서명된 토큰이고 공격자가 임의로 만들 수 없다는 뜻입니다.

그런데 다음 날 laurids가 위협을 다른 방향으로 전개합니다.

In general, I would think duplicate properties in a JWT is a sign of a buggy or compromised authorization server. … If multiple parsers are used in a stack including JJWT, and some parsers are wrongly implemented to use the first value instead of the last, that could lead to vulnerabilities. However, if JJWT rejects it, this type of vulnerability is not possible.

정리하면 위협은 토큰을 위조하는 게 아니라 같은 토큰을 계층마다 다르게 읽는 것입니다. 토큰이 정상인 것과 발급자가 정상인 것은 다르니까요. 또한 근거로 Bishop Fox의 JSON 상호운용성 취약점 분석Black Hat US-23의 JWT 공격 발표를 붙였습니다.

서명이 유효한 토큰 하나 {"sub":"alice","sub":"attacker"} 서명 검증은 여기서 한 번만 일어난다 게이트웨이 · 로깅 파서 A — 앞의 값을 읽는다 sub = alice 내부 서비스 · 인가 파서 B — 뒤의 값을 읽는다 sub = attacker 같은 토큰 다른 판정 두 컴포넌트 모두 자기 기준으로는 정상 동작한다 감사 로그에는 alice가 남고, 실제로 일한 건 attacker다
위협은 위조가 아니라 해석의 불일치다. 서명 검증은 이 문제를 막아주지 않는다.

그리고 같은 코멘트에서 자기가 원한 게 무엇인지도 다시 짚습니다.

Yes, I did notice the constructor taking an ObjectMapper. However, I wanted to use the MappedTypeDeserializer as well, since it was conveniently implemented there:) Maybe I was not clear enough about that part. So my request was for an option to configure the ObjectMapper, specifically when using the JacksonDeserializer(Map<String, Class<?>> claimTypeMap) constructor.

ObjectMapper를 받는 생성자는 자기도 봤지만 MappedTypeDeserializer도 같이 쓰고 싶었다는 겁니다. 정리하자면 JacksonDeserializer(Map<String, Class<?>> claimTypeMap) 생성자를 쓸 때 ObjectMapper를 설정할 수 있게 해달라는 것이었습니다.

사라진 모호함

이틀 뒤 lhazlewood의 입장이 바뀝니다.

A more targeted solution … would be to just default to enabling STRICT_DUPLICATE_DETECTION. I agree this would likely be a better default, and, since the RFC allows it, application developers can override/unset that feature if they desire.

수정은 2024년 1월 17일 커밋 86e0655, 릴리스로는 0.12.4에 실렸습니다. 요청받은 API를 만들어주는 대신 기본 ObjectMapper에 중복 검출을 켰습니다.

static ObjectMapper newObjectMapper() {
    return new ObjectMapper()
            .registerModule(MODULE)
            .configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true) // issues/877
            ...
}

요청받은 생성자도 같은 커밋에 함께 들어가긴 했습니다. 다만 잠긴 채였습니다.

//TODO: Make this public on a minor release
private JacksonDeserializer(ObjectMapper objectMapper, Map<String, Class<?>> claimTypeMap) {

semver 때문입니다. 0.12.x는 패치 릴리스라 public API를 늘리면 규약 위반이 되니, 요청받은 API를 당장 열어주는 대신 기본값에 중복 검출을 켜는 쪽을 택한 것입니다.

2023-12-04 #877 제기 12-04 · 12-06 반대 → 전환 근거를 받고 이틀 만에 2024-01-17 86e0655 · 0.12.4 기본값 변경 + 생성자 봉인 2024-01-29 #914 개설 semver 대기 2025-08 0.13.0 575일 뒤 공개 잠긴 생성자가 열리기까지 575일 CHANGELOG는 모듈 이름 없이 정책으로 적혔다 그런데 실제로 적용된 것은 세 확장 중 Jackson 하나뿐이었다
기본값을 고치는 데는 4주가 걸렸고, public API 하나를 여는 데는 575일이 걸렸다.

jjwt 톺아보기

모호함에서 오는 취약점은 사라졌지만 아직 의문이 남습니다. laurids가 원한 claimTypeMap은 어떤 역할을 하는 걸까요? 답을 보려면 jjwt가 JSON을 어떻게 읽는지부터 봐야 합니다.

jjwt는 토큰이 어떤 클레임을 담고 올지 미리 알 수 없습니다. 그건 발급자가 런타임에 정하니까요. 그래서 Object.class로 읽습니다.

{
  "sub": "alice",
  "user": {
    "first": "Jill",
    "last":  "Coder"
  }
}

readValue(json, Object.class)
→ {sub=alice, user={first=Jill, last=Coder}}      ← user 도 그냥 Map

문제는 이게 불편하다는 겁니다. userUser 객체로 쓰려면 맵에서 하나씩 꺼내 손으로 조립해야 하고, 선언 타입이 Object라 층마다 캐스팅이 붙습니다.

Map claims = readValue(json, Object.class);
Map um = (Map) claims.get("user");
User u = new User();
u.first = (String) um.get("first");
u.last  = (String) um.get("last");

claimTypeMap은 그 불편함을 해결해줍니다. ["user": User.class]처럼 이름과 클래스를 짝지어 넘기면, jjwt가 JSON을 읽어 내려가다가 지금 읽는 이름이 그 명부에 있는지 확인하고, 있으면 그 자리의 값만 따로 객체로 만들어 줍니다.

// jjwt · JacksonDeserializer.MappedTypeDeserializer
public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
    String name = parser.currentName();                        // ← 지금 클레임의 이름
    if (claimTypeMap != null && name != null && claimTypeMap.containsKey(name)) {
        Class<?> type = claimTypeMap.get(name);                // ← 이름으로 클래스를 찾는다
        return parser.readValueAsTree().traverse(parser.getCodec()).readValueAs(type);
    }
    return super.deserialize(parser, context);                 // 아니면 평소대로 Map
}

가운데 한 줄에서 실제로 무슨 일이 일어나는지가 이 기능의 성격을 보여줍니다. 이름이 걸리면 그 값의 서브트리를 메모리에 통째로 담고, 그 트리 위에 새 파서를 얹어 다시 읽어 객체로 만듭니다. 읽는 도중에야 타입이 정해지므로 스트림을 되돌릴 수 없어서, 그 조각만 한 번 더 읽는 겁니다.

JSON을 읽어 내려가다 이름을 만난다 parser.currentName() = "user" claimTypeMap.containsKey(name) 없다 있다 평소대로 {first=Jill, last=Coder} readValueAsTree() 서브트리를 메모리에 담고 traverse() 그 위에 새 파서를 얹어 readValueAs(User.class) Map 그대로 판정은 클레임 이름 하나로, 값을 읽기 전에 끝난다
이름이 명부에 걸린 조각만 한 번 더 읽어 객체가 된다. 나머지는 그대로 맵이다.

판정 기준이 안쪽 필드 구성이 아니라 클레임 이름 하나라는 점도 여기서 나옵니다. 안쪽 필드가 똑같아도 이름이 명부에 없으면 그냥 Map입니다.

laurids가 놓지 못한 게 이 편의였습니다. 맵에서 값을 하나씩 꺼내 조립하던 코드를 지운 채로 중복 키까지 막고 싶었던 거죠.

런타임과 컴파일타임

그런데 여기서 의문이 하나 생기지 않나요? 같은 Jackson을 사용하는 SpringBoot에서는 @RequestBody만 지정하면 readValue(json, Dto.class)처럼 한 번에 읽어줍니다.

jjwt는 왜 Object.class로 고정해두고, 처음부터 사용자가 만든 타입으로 읽게 하지 않는 걸까요?

차이는 라이브러리가 아니라 타입을 언제 아느냐에 있습니다.

@PostMapping("/api/orders")
public OrderResponse create(@RequestBody OrderRequest request) {
    // ↑ 타입이 여기 박혀 있다

컨트롤러는 받을 타입을 메서드 시그니처에 적어둡니다. 계약이 컴파일 시점에 고정돼 있으니 Jackson은 스트림을 읽으면서 곧바로 DTO를 채웁니다. 반면 jjwt는 토큰이 어떤 클레임을 담고 올지 미리 알 수 없습니다. 그건 발급자가 런타임에 정하니까요. 그래서 Object.class로 읽고, 결과는 POJO가 아니라 중첩된 Map이 됩니다.

Spring도 타입 명시가 되어있지 않다면 똑같습니다.

@RequestBody OrderRequest request       →   OrderRequest
@RequestBody Map<String, Object> body   →   LinkedHashMap

같은 매퍼에 같은 바이트를 넣었는데 결과가 갈립니다. 둘을 가르는 건 라이브러리가 아니라 타입을 알려줬는가입니다.

Spring · @RequestBody jjwt · 토큰 파싱 타입을 내가 정한다 create(@RequestBody OrderRequest r) 타입을 발급자가 정한다 {"iss":…,"exp":…,"tenant":{…}} readValue(in, OrderRequest.class) readValue(in, Object.class) OrderRequest — 캐스팅 없이 쓴다 Map — 꺼내 쓴다 가르는 것은 라이브러리가 아니라 타입을 알려줬는가다
같은 Jackson인데 결과가 갈린다. Spring은 계약이 컴파일 시점에 고정돼 있고, jjwt는 그럴 수 없다.

jjwt도 발급자에 맞추어 타입을 지정해 읽게 하는 옵션을 주면 되지 않나 싶은데, 그럴 수 없는 이유가 있습니다. 이런 토큰 둘을 생각해 보겠습니다.

A · 발급자가 만료를 넣었다
{
  "sub": "alice",
  "exp": 1786000000
}

B · 발급자가 안 넣었다
{
  "sub": "alice"
}

exp를 선언하지 않은 DTO로 이 둘을 받으면 결과가 똑같아집니다. 내용이 다른 두 토큰이 같아지는 겁니다. Map으로 받으면 containsKey("exp")가 각각 참과 거짓이라 구별이 됩니다.

Map에서 exp가 없다는 건 발급자가 안 보냈다는 뜻 하나뿐이지만, DTO에서 exp가 없다는 건 발급자가 안 보냈거나 소비자가 필드를 안 적었거나 둘 중 무엇인지 알 수 없습니다.

A · 발급자가 만료를 넣었다 {"sub":"alice","exp":1786000000} B · 발급자가 안 넣었다 {"sub":"alice"} DTO로 받으면 — exp를 선언하지 않은 클래스 MyClaims(sub=alice) MyClaims(sub=alice) = 다른 토큰이 같아졌다 Map으로 받으면 containsKey(exp) = true containsKey(exp) = false "없다"의 원인이 하나뿐이라 구별된다
무타입으로 읽는 것은 게으름이 아니라 계약이다. 토큰에 무엇이 들어 있었는지를 잃지 않기 위한 선택이다.

통일되지 않은 동작

지금까지의 이야기는 전부 Jackson 위에서 벌어졌습니다. 그런데 jjwt가 쓰는 JSON 라이브러리는 Jackson만 있는 게 아닙니다. jjwt-jackson·jjwt-gson·jjwt-orgjson 셋 중 하나를 사용자가 골라 넣게 돼 있고, 그 선택이 JSON 처리 동작을 결정합니다.

jjwt-api + jjwt-impl JSON 파서를 갖고 있지 않다 ServiceLoader — 셋 중 하나를 골라 의존성에 넣는다 jjwt-jackson JacksonDeserializer jjwt-gson GsonDeserializer jjwt-orgjson OrgJsonDeserializer Jackson ObjectMapper Gson org.json JSONObject 어느 확장을 골랐느냐가 JSON 처리 동작을 결정한다
jjwt는 어댑터만 만들고 실제 파싱은 외부 라이브러리가 한다. 그 선택이 사용자에게 맡겨져 있다.

그래서 셋을 같은 페이로드로 직접 돌려봤습니다. 앞에서 본 그 중복 클레임입니다.

{
  "sub":  "alice",
  "sub":  "attacker",
  "role": "user",
  "role": "admin"
}

Jackson은 앞서 본 그 이슈로 막혔습니다. 0.12.4부터 jjwt가 기본 ObjectMapperSTRICT_DUPLICATE_DETECTION을 켜두니까요.

org.json은 jjwt가 손댄 적이 없는데도 막힙니다. 라이브러리 자체가 중복 키를 만나면 예외를 던집니다.

org.json 20250517
JSONException: Duplicate key "sub" at 21 [character 22 line 1]

그런데 Gson은 그냥 통과시킵니다.

gson 2.13.2 · jjwt 기본 설정(LONG_OR_DOUBLE · disableHtmlEscaping)
{sub=attacker, role=admin}
jjwt-jackson 거부 jjwt가 2024년에 명시적으로 켜뒀다 jjwt-gson 통과 방어가 아예 없다 Gson 기본 동작 그대로 jjwt-orgjson 거부 라이브러리가 우연히 막는다 같은 라이브러리, 같은 버전, 같은 토큰인데 판정이 정반대다 게이트웨이는 jjwt-jackson, 내부 서비스는 jjwt-gson을 쓰는 구성이면 한쪽만 뚫린다 확장끼리는 서로를 모르기 때문에 한쪽에 건 방어가 다른 쪽으로 퍼지지 않는다
2024년에 정책이 선언됐지만 적용은 한 모듈에만 됐다. 나머지 둘은 각각 다른 이유로 위태롭다.

그래서 이 부분을 기여해보기로 했습니다. 새로 설득할 것이 없는, 이미 선언된 기준이 아직 닿지 않은 자리니까요.

gson 톺아보기

처음 계획은 Gson의 Object 담당 어댑터를 제 것으로 갈아끼우는 것이었습니다. 그런데 문제가 있었습니다. Object 담당 어댑터를 바꾸지 못하도록 강제한다는 것입니다.

① 보통 타입(Person) 가로채기    → 된다
② Map 타입 가로채기             → 된다
③ Object 타입 가로채기          → IllegalArgumentException
④ 팩토리로 우회                 → 예외는 없는데 호출조차 안 된다

이유는 Object가 Gson이 타입을 모를 때 마지막으로 찾는 담당자이기 때문입니다. Object.class로 읽을 때만 쓰이는 게 아니라, 맵이나 배열 안에 들어 있어 타입을 알 수 없는 값도 전부 이 담당자에게 갑니다. 다시 말해 변경의 영향 범위가 너무 넓다는 겁니다.

private static boolean hasNonOverridableAdapter(Type type) {
    return type == Object.class;
}

더 깊숙이 들어가기로 했고, 최종적으로는 JsonReader를 변경하는 것을 선택했습니다. Jackson 또한 STRICT_DUPLICATE_DETECTION 기능은 JsonParser의 기능이고 ObjectMapper는 진입점을 노출할 뿐이라는 것에서 착안했습니다.

Jackson Gson ObjectMapper 스위치를 노출할 뿐이다 Gson 기본 인스턴스에 설정을 박는다 JsonDeserializer 타입별 변환 — jjwt가 여기를 쓴다 TypeAdapter Object 자리만 잠겨 있다 — 막힌 길 JsonParser + DupDetector 깊이마다 DupDetector 하나 이름을 읽는 순간 검사 JsonReader 상속 깊이마다 Set 하나 이름을 읽는 순간 검사 차이는 Jackson엔 스위치가 있었고 Gson엔 없어 직접 만들었다는 것뿐이다
중복 검사는 값을 만드는 층이 아니라 토큰을 읽는 층의 일이다. 두 라이브러리 모두 그렇다.
private static final class DuplicateNameRejectingJsonReader extends JsonReader {
    private final Deque<Set<String>> names = new ArrayDeque<>();

    @Override public void beginObject() throws IOException { super.beginObject(); names.push(new HashSet<String>()); }
    @Override public void endObject()   throws IOException { super.endObject();   names.pop(); }

    @Override public String nextName() throws IOException {
        String name = super.nextName();
        Set<String> seen = this.names.peek();
        if (seen != null && !seen.add(name)) {
            throw new JsonParseException("Duplicate JSON member name '" + name + "' at " + getPath());
        }
        return name;
    }
}

깊이마다 집합이 따로라는 점이 형제 객체의 같은 이름은 통과시키고 중첩 객체나 배열 원소 안의 중복은 거부하게 만듭니다.

읽는 순서 호출되는 메서드 깊이1 기록장 깊이2 기록장 { "x" : { "k": 1 }, "y" : { "k": 2 } } beginObject() nextName() beginObject() nextName() endObject() nextName() beginObject() nextName() endObject() endObject() [ ] 생성 [ x ] [ x, y ] 버림 [ ] 생성 [ k ] 버림 [ ] 새로 생성 [ k ] 버림 기록장은 beginObject 에서 만들고 endObject 에서 버린다. 검사는 nextName 에서만.

꺼진 불도 다시 보자

기존에 있던 테스트는 전부 통과했습니다. 다만 통과했다는 게 안전하다는 뜻은 아닙니다. 테스트는 누군가 미리 적어둔 경우만 확인해주니까요.

그래서 여러 입력을 직접 만들어 변경 전 코드와 변경 후 코드에 나란히 넣어봤습니다. 중복이 든 입력만 결과가 갈리고 나머지는 전부 같아야 정상인데, 하나가 더 걸렸습니다.

{
  "sub": "alice"
}trailing

기존 거부     변경 후 통과

원인을 보려면 ReaderJsonReader가 다른 층이라는 것부터 짚어야 합니다.

jjwt가 확장 모듈에 넘겨주는 건 java.io.Reader입니다. 문자를 순서대로 흘려주는 JDK 표준 스트림이고, JSON이 뭔지는 모르는 물건입니다. jjwt 입장에서는 Jackson·Gson·org.json 중 무엇이 꽂힐지 모르니 특정 라이브러리의 타입을 계약에 쓸 수가 없거든요.

// jjwt-api
public interface Deserializer<T> {
    T deserialize(Reader reader);        // java.io.Reader
}

Gson은 그 Reader를 받아 안에서 자기 JsonReader를 만들어 씁니다.

그런데 중복 검사를 넣으려면 nextName()에 끼어들어야 하고, 그러려면 그 JsonReader를 제가 만들어 넘겨야 합니다. 그 순간 호출되는 메서드가 바뀝니다.

// 변경 전 — 인자가 Reader
gson.fromJson(reader, returnType);
  → fromJson(Reader, Class)      Gson 이 리더를 만들고, 다 읽은 뒤 남은 게 있는지 확인한다

// 변경 후 — 인자가 JsonReader
gson.fromJson(new DuplicateNameRejectingJsonReader(reader), returnType);
  → fromJson(JsonReader, Type)   남이 준 리더라 그 확인을 하지 않는다

뒤엣것이 확인을 안 하는 데는 이유가 있습니다. 한 스트림에서 문서를 여러 개 연달아 읽는 경우가 있어서, 문서 하나를 읽었다고 거기가 끝이라고 단정할 수 없기 때문입니다. 바이트코드로 확인해 보니 그대로였습니다.

fromJson(Reader, ...)      assertFullConsumption 호출 1회
fromJson(JsonReader, ...)  assertFullConsumption 호출 0회

중복을 막자고 넣은 변경이 다른 쪽 입력 검증을 푸는 꼴이었습니다. 이런 입력을 확인하는 테스트가 원래 없었으니 통과 여부로는 알 길이 없었고요. 파싱 후에 같은 검사를 직접 수행하도록 고치고 테스트 둘을 더 붙였습니다.

이런 작업이 가장 중요한 것 같습니다. 새로운 기능을 넣을 때는 기존 기능과 완벽히 호환되는지, 동작의 정합이 바뀌지 않는지가 중요한 것 같습니다.

성능상 오버헤드가 없는지도 살펴봤습니다. 객체 하나당 HashSet 하나가 늘어나는 구조라 그 값을 실제로 재봐야 했습니다. JMH로 측정했고 코드와 원본 결과 파일은 별도 저장소에 남겼습니다.

PR을 적용한 jjwt를 직접 빌드해 변경 전후를 나란히 돌렸더니, 서명이 붙은 토큰 하나를 파싱하는 데 136ns가 늘었습니다. 서명 검증과 Base64 디코딩이 종단 시간의 대부분을 가져가는 덕에 전체의 1.8%였고 메모리 할당은 2.3% 늘었는데, 전체에서 차지하는 몫이 작아 우려할 수준은 아닌 것 같습니다.

종단 parseSignedClaims — 서명 검증과 Base64 디코딩 포함 시간 7,562.7 ns +135.9 ns · 1.8% 메모리 할당 48,736 B +1,136 B · 2.3% 토큰 하나를 파싱하는 데 시간 1.8%, 메모리 할당 2.3%
JMH · 워밍업 8회 × 측정 8회 × fork 6회 × JDK 2종. 신뢰구간이 겹치지 않는다.

마치며

PR #1073 (Align the Gson extension with Jackson's duplicate member name rejection)은 제출하였으며 글을 쓰는 시점에는 아직 리뷰를 기다리는 중입니다.

돌아보면 재밌는 건 제가 실제로 쓴 코드가 30줄 남짓이라는 점입니다. 시간의 대부분은 이슈 하나를 처음부터 거슬러 읽고, RFC 문장을 뜯어보고, 이미 내려진 결정이 어디까지 적용됐는지 세는 데 들어갔습니다.

코드는 싸고 검증은 비싼 시대가 되었음을 다시 한번 느낍니다.

읽어주셔서 감사합니다.

참조