문제 상황
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