본문 바로가기

카테고리 없음

Jackson 직렬화 시 getter에 부가적인 로직이 들어있다면 원래 필드값이 아닌 변경된 값이 직렬화될 수 있다.

문제 상황

Redis를 사용하며 특정 객체를 캐싱하고자 했다.
이 객체에는 민감한 데이터가 포함되어 있었고, 저장 시 해당 데이터를 암호화해 보안하고자 했다.

그래서 처음에는 다음과 같이 설계했다.

@NoArgsConstructor
@AllArgsConstructor
public class SecureData implements SecureInterface {

    private UUID id;
    private byte[] value;

    public static SecureData from(byte[] value) {
        return new SecureData(
            UUID.randomUUID(),
            EncryptionService.encrypt(value)
        );
    }

    @Override
    public UUID getId() {
        return this.id;
    }

    @Override
    public byte[] getValue() { 
        return EncryptionService.decrypt(value);
    }
}

 

 

이 클래스는 평범하게 직렬화 가능한 클래스처럼 보였다.

  • value 필드에는 민감한 데이터를 암호화한 값을 저장하고,
  • getValue()는 데이터를 복호화해 외부에서 접근할 수 있도록 제공하려는 의도였다.

하지만 실제로 Redis에 이 객체를 저장하고 다시 조회하려는 순간 다음과 같은 예외가 발생했다.

java.lang.IllegalStateException: Unable to invoke Cipher due to bad padding
...
Caused by: javax.crypto.BadPaddingException: Given final block not properly padded. Such issues can arise if a bad key is used during decryption.

 

decryption 과정 중 문제가 발생한 것으로 보였다.


 

문제를 추적하기 위해 from()과 getValue()에 다음과 같이 로그를 삽입했다.

@Slf4j
@NoArgsConstructor
@AllArgsConstructor
public class SecureData implements SecureInterface {

    private UUID id;
    private byte[] value;

    public static SecureData from(byte[] value) {
    	log.debug("value:{}", Arrays.toString(value));	// 로그 (1)
        log.debug("encryptedValue:{}", Arrays.toString(JwtKeyEncryptor.encrypt(value)));	// 로그 (2)
        return new SecureData(
            UUID.randomUUID(),
            EncryptionService.encrypt(value)
        );
    }

    @Override
    public UUID getId() {
        return this.id;
    }

    @Override
    public byte[] getValue() { 
    	log.debug("savedValue:{}", Arrays.toString(value));	// 로그 (3)
        log.debug("decryptedValue:{}", Arrays.toString(JwtKeyEncryptor.decrypt(value)));	// 로그 (4)
        return EncryptionService.decrypt(value));
    }
}

 

기대했던 흐름이라면, 객체를 Redis에 저장할 때 getValue()는 호출되지 않아야 했다.

그러나 실제 실행 결과에서는 로그(3), 로그(4)가 찍혔다.

 

 

왜 이런 문제가 발생할까?

 

 

Jackson 직렬화

Jackson의 ObjectMapper는 직렬화를 위해 getter 메서드를 기대하고, 이 getter가 JavaBeans 규칙을 준수하기를 기대한다.

(프로퍼티_property 를 기반으로 직렬화/역직렬화)

 

따라서 내가 단순히 '값을 암호화해 저장했으니, 외부에서 이 값에 접근하려 할 때는 복호화하도록 구성해야겠다'라고 생각하고 만든 getValue() 메서드가, Jackson 입장에서는 JSON으로 직렬화할 때 사용해야 할 메서드가 된다.

 

from() 메서드로 객체를 생성할 때 입력받은 value가 EncryptionService로 암호화되어 value 필드에 저장되는데,

직렬화 시 getValue()가 호출되어 value 필드를 다시 복호화한 뒤 직렬화하게 된다.

 

즉, 원본 값 value가 그대로 저장되었던 것이다.

 

실제로 로그를 확인해 보니 로그 (1)과 로그 (4)의 값이 일치함을 알 수 있었다.

 

직렬화와 역직렬화 시 예외가 발생하지는 않지만, 외부에서 값 획득을 위해 getValue() 메서드를 호출하면 결국 아까와 같은 BadPaddingException이 발생하게 된다.


이걸 어떻게 해결할 수 있을까?

내가 원하는 건 Redis에 암호화된 value가 저장되고, getValue()는 객체 사용 시에만 복호화를 위해 호출되도록 하는 것이다.

이를 위해선 직렬화 대상에서 getValue()를 제외해야 하며, 대신 암호화된 value 필드 자체는 직렬화되도록 허용해야 한다.

@NoArgsConstructor
@AllArgsConstructor
public class SecureData implements SecureInterface {

    private UUID id;

    @JsonProperty	// value 필드를 직렬화/역직렬화 대상으로 설정
    private byte[] value;

    public static SecureData from(byte[] value) {
        return new SecureData(
            UUID.randomUUID(),
            EncryptionService.encrypt(value)
        );
    }

    @Override
    public UUID getId() {
        return this.id;
    }

    @Override
    @JsonIgnore	// getValue()를 직렬화/역직렬화 대상에서 제외
    public byte[] getValue() { 
        return EncryptionService.decrypt(value));
    }
}
 

 

@JsonProperty

: JSON 필드 이름을 명시하거나, 필드/메서드를 직렬화 및 역직렬화 대상으로 포함시키는 어노테이션

- getter/setter/field/생성자 파라미터 등에 붙일 수 있다.

- 본래 Jackson에서 private 필드는 직렬화 대상이 아니지만, @JsonProperty를 붙이면 포함된다.

 

@JsonIgnore

: 해당 필드/메서드/생성자 파라미터를 직렬화 및 역직렬화 대상에서 제외하는 어노테이션

- 한쪽(getter나 setter)에만 붙여도 전체 property가 제외된다.

- 읽기 전용, 쓰기 전용 속성처럼 분리하려면 @JsonProperty와 함께 조합해 써야 한다. (위 코드에서 value는 역직렬화만 가능한  쓰기 전용 속성이 된다.)

 

이제 Jackson은 getValue() 메서드를 무시하고, 필드 value만 직렬화하게 된다.


Redis 저장 대상 객체에 한 번에 적용하기

 

앞서 겪은 문제 상황은 다른 객체를 Redis에 저장할 때도 겪을 수 있는 문제다.

getter에 별도 로직이 들어가는 경우 다른 값으로 직렬화될 가능성이 있다.

 

하지만 이런 문제를 방지하려고 매번 별도의 캐싱용 클래스를 만들고 직렬화/역직렬화를 고려한 어노테이션을 설정하기는 매우 번거로운 일이다. 그래서 개발자가 임의로 정의할 수 있는 생성자, getter, setter 등을 제외하고, 데이터 고유의 값인 field의 값만을 가지고 직렬화할 수 있도록 ObjectMapper의 Visibility 설정을 변경하기로 했다.

new ObjectMapper
    .setVisibility(PropertyAccessor.ALL, Visibility.NONE)
    .setVisibility(PropertyAccessor.FIELD, Visibility.ANY);

 

PropertyAccessor로 무엇을 대상으로 할지를 설정한다.

- ALL : 모든 요소(필드, getter, setter, 생성자 등)

- FIELD

- GETTER

- SETTER

- IS_GETTER : isXxx() 메서드 (boolean gettter)

- CREATOR

- NONE

 

Visibility로 어떤 접근 수준일 때 허용할지를 설정한다.

- ANY : 접근 제한자 무시하고 전부 포함

- NONE_PRIVATE

- PROTECTED_AND_PUBLIC

- PUBLIC_ONLY

- NONE

- DEFAULT : Jackson 내부 기본값(주로 PUBLIC_ONLY)

 

즉 어떤 요소(PropertyAccessor)가, 어떤 접근 수준(Visibility)일 때 직렬화/역직렬화 대상이 될지를 설정하는 것.

따라서 위 설정은 모든 요소를 NONE으로 사용하지 않음 설정한 뒤에, FIELD면 전부 역직렬화/직렬화에 사용하겠다고 설정하는 것이다.

 

 

이 설정은 Redis 캐싱에만 적용하고 싶었기 때문에, RedisTemplate 빈을 구성할 때 별도로 ObjectMapper를 설정했다. 정의한 ObjectMapper를 GenericJackson2JsonRedisSerializer에 넘긴다. (범용적 타입 캐싱)

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);

    ObjectMapper mapper = new ObjectMapper();
    mapper.setVisibility(PropertyAccessor.ALL, Visibility.NONE)
        .setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
    GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer =
        new GenericJackson2JsonRedisSerializer(mapper);

    template.setKeySerializer(new StringRedisSerializer());
    template.setHashKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(genericJackson2JsonRedisSerializer);
    template.setHashValueSerializer(genericJackson2JsonRedisSerializer);

    template.setEnableTransactionSupport(true);
    return template;
}

 

하지만 이렇게만 설정하면 아까 작성한 SecureData를 조회할 때 ClassCastException이 발생할 수 있다.

 

redisTemplate 빈을 구성할 때 범용적으로 사용 가능하도록 역직렬화할 클래스 타입 대신 Object 타입으로 넘기는데, 캐싱할 때 사용한 정확한 타입으로 캐스팅하려고 할 때 해당 타입에 대한 정보가 없어 문제가 발생하게 된다.

 

ObjectMapper에 DefaultTyping 옵션을 설정해주면 해당 문제가 해결된다.

activateDefaultTypingAsProperty 메서드로 DefaultTyping을 활성화해주었다.

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);

    ObjectMapper mapper = new ObjectMapper();
    mapper.activateDefaultTyping(
        LaissezFaireSubTypeValidator.instance,
        ObjectMapper.DefaultTyping.NON_FINAL,
        JsonTypeInfo.As.PROPERTY
    );
    mapper.setVisibility(PropertyAccessor.ALL, Visibility.NONE)
        .setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
    GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer =
        new GenericJackson2JsonRedisSerializer(mapper);

    template.setKeySerializer(new StringRedisSerializer());
    template.setHashKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(genericJackson2JsonRedisSerializer);
    template.setHashValueSerializer(genericJackson2JsonRedisSerializer);

    template.setEnableTransactionSupport(true);
    return template;
}

- LaissezFaireSubTypeValidator : (LaissezFaire : 자유방임주의) 모든 서브타입 허용 (보안상 유의)

- NON_FINAL : String, Integer 같은 final 타입 제외

- As.PROPERTY : JSON에 @class 필드로 타입 정보 포함

 

이 옵션이 활성화되면 GenericJackson2JsonRedisSerializer는 객체를 직렬화할 때 타입 정보를 함께 JSON에 포함하는데,

예를 들어 다음과 같이 User 객체를 직렬화해 저장한다면 JSON에 @class 필드가 포함되어 저장된다.

User user = new User("sharpie", 26);
redisTemplate.opsForValue().set("user:1", user);

{
  "@class": "com.example.User",
  "name": "sharpie",
  "age": 26
}

 

이 @class 정보가 역직렬화 시 사용된다.


 

복호화 문제가 터지지 않았다면 영영 몰랐을 문제였다. 잘 직렬화/역직렬화 되는 줄 알고 넘어갈 뻔 했다. Jackson이 getter 메서드(프로퍼티)를 우선시해 직렬화한다는 점을 잘 기억해두자!


참고

https://blogs.oracle.com/javamagazine/post/java-json-serialization-jackson

 

Efficient JSON serialization with Jackson and Java

In modern Java applications, serialization is usually performed using an external library, and JavaScript Object Notation (JSON) is a popular choice.

blogs.oracle.com

https://github.com/FasterXML/jackson-annotations/wiki/Jackson-Annotations

 

Jackson Annotations

Core annotations (annotations that only depend on jackson-core) for Jackson data processor - FasterXML/jackson-annotations

github.com

https://hyperconnect.github.io/2019/10/28/jackson-serialize-for-global-caching.html

 

Jackson 직렬화 옵션의 적절한 활용과 Jackson에 기여하기까지 (feat. 글로벌 캐싱)

글로벌캐싱을 하기 위해 자바의 오픈소스 JSON 직렬화 라이브러리인 Jackson의 직렬화 옵션을 활용한 경험과 Jackson에 기여한 경험을 이야기합니다.

hyperconnect.github.io