Jackson과 커스텀 어노테이션을 활용한 API 응답 데이터 마스킹 구현

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;
    }
}

태그: Jackson SpringBoot DataMasking JavaAnnotation JsonSerialize

6월 18일 17:55에 게시됨