1. 데이터 마스킹 유형 및 커스텀 어노테이션 정의
API 응답에서 민감한 정보를 자동으로 숨기기 위해 Jackson의 직렬화 프로세스에 개입할 수 있는 커스텀 어노테이션을 생성합니다. @JacksonAnnotationsInside를 사용하여 메타 어노테이션으로 구성하며, 마스킹 규칙을 세부적으로 설정할 수 있도록 속성을 추가합니다.
public enum MaskingType {
CUSTOM,
NAME,
IDENTIFICATION,
PHONE_NUMBER,
EMAIL_ADDRESS
}
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskingSerializer.class)
public @interface DataMasking {
MaskingType type() default MaskingType.CUSTOM;
int prefixLength() default 1;
int suffixLength() default 1;
String maskCharacter() default "*";
}
2. Jackson 직렬화(Serialization) 로직 구현
어노테이션에 명시된 규칙을 바탕으로 실제 문자열을 변환하는 직렬화 클래스를 구현합니다. ContextualSerializer를 구현하여 필드에 적용된 어노테이션의 속성 값을 동적으로 읽어올 수 있도록 합니다.
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;
import java.util.Objects;
public class MaskingSerializer extends JsonSerializer<String> implements ContextualSerializer {
private MaskingType type;
private int prefixLength;
private int suffixLength;
private String maskCharacter;
public MaskingSerializer() {}
public MaskingSerializer(MaskingType type, int prefixLength, int suffixLength, String maskCharacter) {
this.type = type;
this.prefixLength = prefixLength;
this.suffixLength = suffixLength;
this.maskCharacter = maskCharacter;
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null || type == null) {
gen.writeNull();
return;
}
String maskedValue;
switch (type) {
case NAME:
maskedValue = MaskingUtils.maskName(value);
break;
case IDENTIFICATION:
maskedValue = MaskingUtils.maskIdCard(value);
break;
case PHONE_NUMBER:
maskedValue = MaskingUtils.maskPhone(value);
break;
case EMAIL_ADDRESS:
maskedValue = MaskingUtils.maskEmail(value);
break;
case CUSTOM:
maskedValue = MaskingUtils.maskText(value, prefixLength, suffixLength, maskCharacter);
break;
default:
maskedValue = value;
}
gen.writeString(maskedValue);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
if (property != null && Objects.equals(property.getType().getRawClass(), String.class)) {
DataMasking annotation = property.getAnnotation(DataMasking.class);
if (annotation == null) {
annotation = property.getContextAnnotation(DataMasking.class);
}
if (annotation != null) {
return new MaskingSerializer(
annotation.type(),
annotation.prefixLength(),
annotation.suffixLength(),
annotation.maskCharacter()
);
}
}
return prov.findValueSerializer(property.getType(), property);
}
}
3. 데이터 마스킹 유틸리티 클래스
다양한 데이터 유형(이름, 연락처, 이메일 등)에 맞는 마스킹 포맷을 처리하는 유틸리티 클래스를 작성합니다. 정규식 대신 문자열 인덱스 연산을 활용하여 가독성과 성능을 개선합니다.
public class MaskingUtils {
public static String maskText(String origin, int prefix, int suffix, String maskChar) {
if (origin == null || origin.length() <= prefix + suffix) {
return origin;
}
int maskLen = origin.length() - prefix - suffix;
StringBuilder sb = new StringBuilder();
sb.append(origin, 0, prefix);
for (int i = 0; i < maskLen; i++) {
sb.append(maskChar);
}
sb.append(origin.substring(origin.length() - suffix));
return sb.toString();
}
public static String maskName(String name) {
if (name == null || name.length() < 2) return name;
return maskText(name, 1, 0, "*");
}
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return phone;
return maskText(phone, 3, 4, "*");
}
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
int atIndex = email.indexOf("@");
int prefixLen = atIndex > 1 ? 1 : 0;
return maskText(email.substring(0, atIndex), prefixLen, 0, "*") + email.substring(atIndex);
}
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 8) return idCard;
return maskText(idCard, 4, 4, "*");
}
}
4. DTO 및 컨트롤러 적용 예시
생성한 어노테이션을 실제 DTO 클래스의 필드에 적용하고, Spring Boot 컨트롤러를 통해 직렬화 결과를 확인합니다.
public class UserProfile {
private Long userId;
@DataMasking(type = MaskingType.NAME)
private String fullName;
@DataMasking(type = MaskingType.PHONE_NUMBER)
private String contactNumber;
@DataMasking(type = MaskingType.EMAIL_ADDRESS)
private String emailAddress;
@DataMasking(type = MaskingType.CUSTOM, prefixLength = 2, suffixLength = 2, maskCharacter = "#")
private String customCode;
private String status;
// Getters and Setters 생략
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserProfileController {
@GetMapping("/profile")
public UserProfile getUserProfile() {
UserProfile profile = new UserProfile();
profile.setUserId(1001L);
profile.setFullName("홍길동");
profile.setContactNumber("01012345678");
profile.setEmailAddress("test.user@example.com");
profile.setCustomCode("ABCDE12345");
profile.setStatus("ACTIVE");
return profile;
}
}