아... 마스킹 함수를 변수에 일일이 씌우기 귀찮은데? 에서 출발한 글
처음엔 인터셉터로 응답을 가로채서 적용시키면 되지 않을까? 싶었는데, 모든 응답에 마스킹을 적용할 것이 아니라서 기각하고 다시 생각해보았다.
어쨌든 다음과 같은 개인적인 희망사항이 있었다.
1) 마스킹 처리를 위한 로직을 비지니스 로직에 추가하고 싶지 않음
1-1) 생성자에 메서드를 씌워서 반영하는 노가다 또한 하고 싶지 않음
2) 요청에 존재하는 마스킹 해제 여부가 "Y" 면 마스킹 하지않고,
"N" 이거나 빈 값이면 무조건 마스킹이 적용됨
문득 내가 로깅을 구현할 때 AOP를 썼던게 생각이 났다.
이거면 서비스 내부에 별도 로직을 추가할 필요 없이 어노테이션을 떡칠하는 선에서 정리가 가능해보였다.ㅋㅋ
https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/
https://backtony.github.io/spring/2021-12-29-spring-aop-2/
역시 이론은 이미 다른사람들이 아주 잘 설명해뒀다. 😀
다음 API 예제들에 마스킹을 적용해보자.
implementation 'org.projectlombok:lombok' implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.aspectj:aspectjweaver' testImplementation 'org.springframework.boot:spring-boot-starter-test' annotationProcessor 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok'
일단 프로젝트 경로 내부에 model 폴더를 만든다. 요청과 응답 객체들이 있을 곳이다.
public interface MaskingDto {
String getDisableMaskingYn();
}
희망사항 2) 을 위해 만든 인터페이스다. 요청 DTO들에 implements 해서 사용한다.
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoRequestDto implements MaskingDto{
private String id;
private String disableMaskingYn;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserListRequestDto implements MaskingDto{
private String disableMaskingYn;
}
import com.example.masking.masking.Mask;
import com.example.masking.masking.MaskingType;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoResponseDto {
private String userId;
@Mask(type = MaskingType.NAME)
private String userName;
@Mask(type = MaskingType.PHONE_NUMBER)
private String phoneNumber;
@Mask(type = MaskingType.EMAIL)
private String email;
}
마스킹 관련 클래스들을 모아둘 폴더이다.
public enum MaskingType {
NAME,
PHONE_NUMBER,
EMAIL
}
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Mask {
MaskingType type(); // 클래스 멤버변수 필드에 사용할 마스킹 어노테이션
}
응답 DTO 필드 위에 붙어있던 어노테이션. type 에 MaskingType 이라는 Enum 값을 세팅하여, 원하는 마스킹 메서드를 적용시켜줄 것이다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApplyMasking {
// 마스킹을 적용시키고 싶은 메서드에 사용할 마스킹 어노테이션
Class<?> typeValue();
Class<?> genericTypeValue() default Void.class; // <Generic> 사용시
}
@Mask는 클래스 멤버변수 필드에 어떤 마스킹을 적용할 지 결정하는 어노테이션이고, @ApplyMasking은 해당 응답에 마스킹을 적용할 것임을 알려주는 어노테이션이다. 처음엔 typeValue만 썼는데, List<DTO
> 같이 제네릭 타입이 있는 경우는 마스킹 적용이 안되는 이슈가 있어 genericTypeValue 필드를 추가해주었다.
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MaskingUtil {
public static String MaskingOf(MaskingType maskType, String value){
return switch(maskType){
case NAME -> nameMaskOf(value);
case PHONE_NUMBER -> phoneNumberMaskOf(value);
case EMAIL -> emailMaskOf(value);
};
}
private static String nameMaskOf(String value){
// 홍*동 마스킹
String regex = "(?<=.{1})(.*)(?=.$)";
String maskedValue = value.replaceFirst(regex, "*".repeat(value.length() - 2));
return maskedValue;
}
private static String phoneNumberMaskOf(String value){
// 010-****-1234
String regex = "(\\d{2,3})-?(\\d{3,4})-?(\\d{4})$";
Matcher matcher = Pattern.compile(regex).matcher(value);
if(matcher.find()) {
String maskedValue = matcher.group(0).replaceAll(matcher.group(2),"****");
return maskedValue;
}
return value;
}
private static String emailMaskOf(String value){
// abc****@gmail.com
return value.replaceAll("(?<=.{3}).(?=[^@]*?@)", "*");
}
}
이름, 전화번호, 이메일에 대한 마스킹 함수들을 구현한다.
정규식은 그냥 구글링해서 나오는거 ctrl c + v 함 ㅋㅋ
import com.example.masking.model.MaskingDto;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Aspect
@EnableAspectJAutoProxy
@Component
public class MaskingAspect {
@Around("@annotation(applyMasking)")
public Object applyMaskingAspect(ProceedingJoinPoint joinPoint, ApplyMasking applyMasking) throws Throwable {
Object []args = joinPoint.getArgs();
Object response = joinPoint.proceed();
MaskingDto maskingOn = (MaskingDto) args[0];
if(maskingOn.getDisableMaskingYn() == null && response != null) {
// 필드 누락 시
return applyMaskingUtil(applyMasking.typeValue(), applyMasking.genericTypeValue(),response);
}
if(maskingOn.getDisableMaskingYn().equals("Y")){
// 원본 데이터
return response;
}
else{
// 마스킹 적용
if(response!=null){
return applyMaskingUtil(applyMasking.typeValue(), applyMasking.genericTypeValue(),response);
}
}
return response;
}
private static <T> T applyMaskingUtil(Class<?> clazz, Class<?> klass, Object response)
throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
if(response instanceof List){
return applyMaskingUtilForList(klass,response); // 마스킹 적용할 데이터가 List<?> 형태인 경우
}
else {
return applyMaskingUtilForDto(clazz,response);
}
}
private static <T> T applyMaskingUtilForDto(Class<?> clazz, Object response)
throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Field[] fields = clazz.getDeclaredFields();
Object responseDto = clazz.getDeclaredConstructor().newInstance();
Arrays.stream(fields).forEach(
field -> {
field.setAccessible(true);
try{
Object fieldValue = field.get(response);
if(fieldValue instanceof String && field.isAnnotationPresent(Mask.class)){
Mask mask = field.getAnnotation(Mask.class); // Mask 어노테이션을 가져옴
MaskingType maskingType = mask.type(); // 해당 어노테이션이 보유한 Enum 타입을 가져옴
String maskedValue = MaskingUtil.MaskingOf(maskingType, (String) fieldValue); // 마스킹 적용
field.set(responseDto,maskedValue);
}
else {
field.set(responseDto,fieldValue);
}
}catch (Exception e){}
}
);
return (T) responseDto;
}
private static <T> T applyMaskingUtilForList(Class<?> klass, Object response)
throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
List<Object> responseDtoList = new ArrayList<>();
List<?> responseList = (List<?>) response;
for(Object responseDto : responseList){
if(responseDto != null && responseDto.getClass().equals(klass)){
Object maskedResponseDto = applyMaskingUtilForDto(klass,responseDto);
responseDtoList.add(maskedResponseDto);
}
else {
responseDtoList.add(responseDto);
}
}
return (T) responseDtoList;
}
}
ProceedingJoinPoint 가 현재 실행중인 메서드를 가져오고 Args를 통해 인자를 잡아낸다. disalbeMaskingYn 이라는 필드 자체가 아예 없거나, 해당 필드가 "Y"가 아니면 무조건 마스킹을 적용하도록 했다.
어떤 DTO 형태에도 유연하게 적용할 수 있도록 reflection을 사용했다.
클래스 필드에 직접 접근하면서 @Mask가 붙어있는지 검사하고,
있으면 마스킹을 적용시킨다.
응답이 List<DTO
> 형태라면 객체가 담긴 리스트를 순회하면서 마스킹을 적용시킨다.
예제 서비스 파일을 추가한다.
import com.example.masking.model.UserInfoRequestDto;
import com.example.masking.model.UserInfoResponseDto;
import com.example.masking.masking.ApplyMasking;
import com.example.masking.model.UserListRequestDto;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@Service
public class ExampleService {
private HashMap<String, UserInfoResponseDto> users;
@PostConstruct
public void init(){
users = new HashMap<>();
users.put("1",new UserInfoResponseDto("1","홍길동","010-1234-5678","honggildong@email.com"));
users.put("2",new UserInfoResponseDto("2","이영희","010-9999-8765","yhlee@email.com"));
users.put("3",new UserInfoResponseDto("3","김철수","010-5678-4321","ironwater@email.com"));
}
@ApplyMasking(typeValue = UserInfoResponseDto.class) // UserInfoResponseDto 에 마스킹 적용
public UserInfoResponseDto getUserInfo(UserInfoRequestDto request){
UserInfoResponseDto userInfo = users.get(request.getId());
return userInfo;
}
@ApplyMasking(typeValue = List.class,genericTypeValue = UserInfoResponseDto.class)
public List<UserInfoResponseDto> getUserInfoList(UserListRequestDto request){
return new ArrayList<>(users.values());
}
}
예제 컨트롤러 파일을 추가한다.
import com.example.masking.model.UserInfoRequestDto;
import com.example.masking.model.UserListRequestDto;
import com.example.masking.service.ExampleService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class ExampleController {
private final ExampleService service;
@GetMapping("/userinfo")
public ResponseEntity<Object> userInfo(UserInfoRequestDto request){
return ResponseEntity.ok(service.getUserInfo(request));
}
@GetMapping("/userlist")
public ResponseEntity<Object> userList(UserListRequestDto request){
return ResponseEntity.ok(service.getUserInfoList(request));
}
}
Postman 으로 요청을 날려서 마스킹이 잘 적용되는지 확인해보았다.
😎 잘 된다.