Primary Key (PK) ID 노출 암호화/복호화
문제
프론트와 데이터를 주고받을 때, 엔티티를 식별하기 위한 리소스가 필요하다. 하지만 엔티티의 PK를 직접 노출하는 것은 보안 문제가 발생할 수 있다.
DELETE /comment/1 와 같이 리소스가 노출된다면 댓글의 주인이 아닌 다른 사람이 해당 요청을 서버에 보낼 수도 있고, 해당 댓글에 대한 id값이 1이라는 것이 외부에 노출된다.
해결
해당 문제에 대한 해결 방법으로 두가지를 생각해 봤다.
UUID 대체키
해당 엔티티의 테이블에 code와 같은 추가 속성을 추가하여 대체키로 사용하는 것이다. UUID를 대체키로 지정하여 외부에 노출시켜 엔티티 식별을 위한 리소스로 쓴다면 문제 해결은 가능할 것이다. 하지만 이렇게 추가한 UUID는 여러가지 단점이 존재한다.
1. 엔티티를 식별할 수 있는 고유 식별자라는 PK (id)의 역할과 중복된다.
2. 테이블에 추가 속성이 필요하다. 그리고 그 크기가 크다
- DB CHARSET이 utf8mb4 기준1문자당 4바이트, 32개의 16진수로 구성이 되며 5개의 그룹을 하이픈(-)으로 구분하기 때문에 36자 필요. 36 * 4 = 144 바이트
3. Index 성능이 좋지 않다
- MySQL의 인덱스는 B- 트리 구조의 클러스터드 인덱스로 되어 있어 항상 정렬된 상태를 유지한다. 따라서 무작위 값인 UUID는 데이터를 INSERT 할 때 마다 구조를 재배치 해야하기 때문에 성능에 부정적인 영향을 끼친다. 추가적으로, 값 크기 자체가 크기 때문에 인덱스 페이지의 크기가 커져 인덱스 조회 성능에도 부정적인 영향을 끼친다.
PK 암호화
UUID 대체키의 단점이 상당히 크기 때문에, PK를 암호화하여 외부에 노출시키는 방식으로 결정했다. 이로서 고유 식별자 및 Sequence의 혜택을 모두 볼 수 있게 됐고, 해당 방식의 단점으로는
1. 암호화를 위한 secret key의 관리 추가
2. 매 요청마다 암호화/복호화 단계가 추가
정도가 있는데, Spring Boot로 키 관리는 어렵지 않고, 매 요청마다 암호화/복호화 하는 단계의 시간을 최소화 하기 위해 AES 알고리즘을 이용했다.

PK 암호화 구현
AESUtil
@Component
public class AESUtil {
private static final String ALGORITHM = "AES";
private static String secretKey; // 반드시 16, 24, 또는 32바이트여야 합니다.
public static String encrypt(String input) {
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), ALGORITHM);
Cipher cipher = null;
try {
cipher = Cipher.getInstance(ALGORITHM);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new RuntimeException(e);
}
try {
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
} catch (InvalidKeyException e) {
throw new RuntimeException(e);
}
byte[] encryptedBytes = null;
try {
encryptedBytes = cipher.doFinal(input.getBytes());
} catch (IllegalBlockSizeException | BadPaddingException e) {
throw new RuntimeException(e);
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
}
public static String decrypt(String encryptedInput) {
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), ALGORITHM);
Cipher cipher = null;
try {
cipher = Cipher.getInstance(ALGORITHM);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new RuntimeException(e);
}
try {
cipher.init(Cipher.DECRYPT_MODE, keySpec);
} catch (InvalidKeyException e) {
throw new RuntimeException(e);
}
byte[] encryptedBytes = Base64.getUrlDecoder().decode(encryptedInput);
byte[] decryptedBytes = null;
try {
decryptedBytes = cipher.doFinal(encryptedBytes);
} catch (IllegalBlockSizeException | BadPaddingException e) {
throw new RuntimeException(e);
}
return new String(decryptedBytes);
}
@Value("${aes.secret-key}")
public void setSecretKey(String secretKey) {
AESUtil.secretKey = secretKey;
}
여기서 핵심 부분은
public static String encrypt(String input) {
...
return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
}
public static String decrypt(String encryptedInput) {
...
byte[] encryptedBytes = Base64.getUrlDecoder().decode(encryptedInput);
...
}
이 두 부분으로, 암호화된 결과에 "/, \"등의 특수문자가 포함되지 않도록 해야 URL로 리소스를 전달받을 때 정상적인 결과가 나온다.
암호화/복호화
초기 방식
처음에는 DTO에서 암호화/복호화를 진행했다.
@Getter
@AllArgsConstructor
public class PostEditRequest {
private String idToken;
public Long getPostId() {
return Long.parseLong(AESUtil.decrypt(idToken));
}
}
@Getter
@AllArgsConstructor
@Builder
public class PostResponse {
private String idToken;
public static PostResponse from(Post post) {
return PostResponse.builder()
.idToken(AESUtil.encrypt(post.getId().toString()))
.build();
}
}
DTO에 암호화된 PK값이 필요하면 idToken으로 암호화, 암호화된 idToken을 받아오면 DTO에서 get할때 복호하 하는 방식으로 진행했다.
문제
1. DTO에 암호화/복호화 책임이 추가됐다.
- DTO를 만들때 마다 추가적인 암호화/복호화 코드가 필요하게 됐다.
2. 단위테스트 진행 시 DTO 내부에 AESUtil 코드가 포함되기 때문에 서비스 단위테스트임에도 불구하고 AESUtil을 빈으로 등록해야 했고, 메소드가 위 예시같은 PostResponse를 반환한다면, secret key 할당까지 해줘야 했다.
3. 변수명을 idToken으로 했기에 entity.getIdToken()과 같이 어색한 getter가 생겼다.
리팩토링
복호화
커스텀 어노테이션과 ConditionalGenericConverter를 상속한 클래스를 이용해서 요청의 역직렬화 시점에 자동으로 복호화 되도록 설계했다.
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptId {
}
@Slf4j
public class DecryptIdConverter implements ConditionalGenericConverter {
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
return targetType.hasAnnotation(DecryptId.class);
}
@Override
public Set<ConvertiblePair> getConvertibleTypes() {
return Set.of(new ConvertiblePair(String.class, Long.class));
}
@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
return Long.parseLong(AESUtil.decrypt((String) source));
}
}
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
...
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new DecryptIdConverter());
}
}
WebMvcConfigurer에 Converter를 등록하여, 요청 역직렬화 시 @DecryptId가 붙은 필드에 대해서 해당 컨버터를 이용한다.
사용 예시:
@DeleteMapping("/{commentId}")
public ResponseEntity<Void> delete(
@PathVariable @DecryptId Long commentId) {
commentService.delete(commentId);
return ResponseEntity.ok().build();
}
@Setter
@Getter
@AllArgsConstructor
public class CommentCursorRequestDto {
private LocalDateTime createdAt;
@DecryptId
private Long id;
}
암호화
커스텀 어노테이션과 BeanSerializerModifier를 상속받는 클래스를 이용해 응답 직렬화 시점에 자동으로 암호화 되도록 설계했다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptId {
}
public class EncryptIdSerializer extends JsonSerializer<Object> {
@Override
public void serialize(Object id, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
try {
if (id != null) {
String strValue = id.toString();
String encrypted = AESUtil.encrypt(strValue);
gen.writeString(encrypted);
}
} catch (Exception e) {
throw new RuntimeException("EncryptIdSerializer - Encryption Failed", e);
}
}
}
public class EncryptIdBeanSerializerModifier extends BeanSerializerModifier {
@Override
public List<BeanPropertyWriter> changeProperties(SerializationConfig config,
BeanDescription beanDesc,
List<BeanPropertyWriter> beanProperties) {
for (BeanPropertyWriter writer : beanProperties) {
Field field = ReflectionUtils.findField(beanDesc.getBeanClass(), writer.getName());
if (field != null && field.isAnnotationPresent(EncryptId.class)) {
writer.assignSerializer(new EncryptIdSerializer());
}
}
return beanProperties;
}
}
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer addCustomEncryption() {
return builder -> {
SimpleModule module = new SimpleModule();
module.setSerializerModifier(new EncryptIdBeanSerializerModifier());
builder.modules(module);
builder.modules(module, new JavaTimeModule());
};
}
}
JacksonConfig에 구현한 커스텀 Serializer를 등록하여, 응답 직렬화 시 @EncryptId가 붙은 필드에 대해서 EncryptIdSerializer를 이용해 직렬화 한다.
추가적으로, objectMapper를 이용해서 직렬화를 수행할 경우 Java 8에 추가된 날짜/시간 타입인 LocalDate, LocalTime, LocalDateTime이 기본적으로 Jackson 라이브러리에 의해 지원되지 않기 때문에 JavaTimeModule()를 추가하지 않는다면 해당 에러가 발생하게 된다.
@danger
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain:
builder.modules(module, new JavaTimeModule());로 JavaTimeModule()를 추가
사용 예시:
@Setter
@Getter
@AllArgsConstructor
public class CommentCursorResponseDto {
@EncryptId
private Long id;
public static CommentCursorResponseDto from(Comment comment) {
return new CommentCursorResponseDto(comment.getId());
}
}
Reference