[업무일지] AOP와 어노테이션을 활용한 개인정보 마스킹 누락 최소화
관리자마다 개인정보 원문을 볼 수 있는 범위가 달라야 하는데, 마스킹을 조회 API마다 손으로 부르고 있었습니다. 그걸 응답 DTO 필드의 어노테이션으로 옮기고, 컨트롤러 반환값을 AOP 로 가로채 권한을 보고 가리게 했습니다. 선언은 한 곳으로 모였는데 그 선언을 읽는 코드는 두 벌이 됐습니다. 안 붙인 필드는 여전히 아무도 막아주지 않고요.
어드민을 새로 만드는 중입니다. 관리자가 셀러 상세를 열면 대표자명과 휴대폰번호, 정산계좌가 한 화면에 같이 나옵니다. 정보보호 관리체계 인증(ISMS) 심사를 준비하며 개인정보 접근 통제를 정리하는 중이고, 이 화면들이 첫 대상입니다. Java 21, Spring Boot 3.3.5, Spring AOP 와 Spring Security, JPA(Hibernate 6), MariaDB 를 쓰는 서버입니다. 코드는 최소 재현으로 다시 썼고 실제 클래스와는 구조만 같습니다.
마스킹을 조회 API마다 손으로 불렀다
원래는 응답을 만들 때 마스킹 유틸을 직접 불렀습니다.
1
2
3
4
// Java 21, Spring Boot 3.3.5
// 조회 서비스마다 반복되던 코드
response.setUserName(MaskingUtil.maskGeneral(user.getUserName()));
response.setPhone(MaskingUtil.maskPhone(user.getPhone()));
문제는 두 가지입니다. 하나는 빠뜨리기 쉽다는 것이고, 다른 하나는 가릴지 말지를 정하는 기준이 코드 어디에도 없다는 것입니다. 위 코드는 누가 부르든 무조건 가리는데, 정작 필요한 판단은 무조건이 아니라 “이 관리자가 이 값을 볼 수 있는가”였거든요.
조회 API 는 계속 늘어납니다. 늘어날 때마다 두 줄을 기억해서 넣어야 한다면, 그건 규칙이 아니라 요행입니다.
메뉴에 들어가는 것과 원문을 보는 것을 나눴다
관리자마다 볼 수 있는 메뉴가 다릅니다. 여기까지는 흔한 메뉴 권한이고요. 여기에 축을 하나 더 붙였습니다.
메뉴에 들어갈 수 있다는 것과 그 메뉴에서 개인정보 원문을 볼 수 있다는 건 다른 이야기입니다. CS 담당자는 회원 관리 메뉴에 들어가야 일을 할 수 있지만 계좌번호 전체가 필요하진 않고, 뒷자리만 보여도 문의 온 셀러가 맞는지 확인은 되죠. 반대로 정산을 처리하는 사람은 원문이 있어야 합니다.
그래서 (관리자, 메뉴) 쌍마다 불리언을 두 개 뒀습니다. 메뉴 접근 여부와 개인정보 열람 여부. 메뉴 권한만 있으면 화면은 열리고 값은 가려집니다.
이걸 한 축으로 묶지 않은 게 이 설계에서 제일 중요한 결정이었습니다. 하나로 묶으면 “계좌 원문을 봐야 하니까 메뉴 권한을 주자”가 되거든요. 원문 하나 보여주려고 그 메뉴에서 할 수 있는 일이 전부 같이 열립니다.
필드에 어노테이션을 붙이고 응답을 가로챘다
가리는 기준을 필드 선언 옆에 뒀습니다.
1
2
3
4
5
6
7
// 어떤 메뉴의 열람 권한을 볼지, 어떤 모양으로 가릴지
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PersonalInfo {
Menu menu();
MaskingType type() default MaskingType.GENERAL;
}
응답 DTO 는 이렇게 됩니다.
1
2
3
4
5
6
public record UserResponse(
Long userId,
@PersonalInfo(menu = Menu.USERS, type = MaskingType.GENERAL) String userName,
@PersonalInfo(menu = Menu.USERS, type = MaskingType.PHONE) String phone,
@PersonalInfo(menu = Menu.USERS, type = MaskingType.ACCOUNT_NUMBER) String accountNumber
) {}
그리고 컨트롤러 반환값을 통째로 가로챕니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Aspect
@Component
@RequiredArgsConstructor
public class PersonalInfoMaskingAspect {
private final PersonalInfoAccessService accessService;
@Around("execution(* com.example..api.*Controller.*(..))")
public Object mask(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result == null) {
return null;
}
// 요청 하나 안에서 같은 메뉴를 여러 번 묻지 않게 캐시를 들고 내려간다
Map<Menu, Boolean> accessCache = new EnumMap<>(Menu.class);
return maskObject(result, accessCache);
}
}
포인트컷을 컨트롤러 전체로 잡은 건 의도한 겁니다. 특정 API 에만 걸면 새로 만든 API 가 또 그물 밖에 생기니까요. 그물은 전부 덮어두고, 무엇을 가릴지는 어노테이션이 정하게 했습니다.
record 는 제자리에서 못 고쳐서 다시 만들었다
여기서 예상 못 한 갈래가 생겼습니다. 응답 DTO 가 두 종류거든요.
일반 클래스는 리플렉션으로 필드를 그 자리에서 바꿀 수 있습니다.
1
2
field.setAccessible(true);
field.set(obj, MaskingUtil.mask(value, annotation.type()));
record 는 안 됩니다. 필드가 final 이라 setAccessible(true) 를 불러도 IllegalAccessException 이 납니다. 그래서 컴포넌트를 하나씩 읽어 마스킹한 값으로 배열을 채운 다음, 정식 생성자로 객체를 새로 만들었습니다.
여기서 한 번 더 걸렸습니다. 어노테이션을 record 컴포넌트에 붙여놨는데 RecordComponent.getAnnotation() 으로는 null 이 나오거든요. @Target 이 FIELD 라서 백킹 필드에만 달라붙고 컴포넌트 쪽에서는 안 보입니다. @Target 에 RECORD_COMPONENT 를 같이 넣으면 양쪽 다 읽히는데, 이 어노테이션은 일반 클래스 DTO 에도 쓰니까 FIELD 하나로 두고 읽는 쪽을 맞췄습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
RecordComponent[] components = clazz.getRecordComponents();
Object[] values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
RecordComponent rc = components[i];
Object value = rc.getAccessor().invoke(recordObj);
// 값은 접근자로 꺼내지만, 어노테이션은 같은 이름의 필드에서 찾는다
PersonalInfo anno = clazz.getDeclaredField(rc.getName()).getAnnotation(PersonalInfo.class);
values[i] = resolve(anno, value, accessCache); // 중첩 객체면 재귀로 내려간다
}
Class<?>[] paramTypes = Arrays.stream(components)
.map(RecordComponent::getType)
.toArray(Class<?>[]::new);
return clazz.getDeclaredConstructor(paramTypes).newInstance(values);
같은 어노테이션인데 처리하는 코드가 둘로 갈린 겁니다. 그리고 record 쪽은 대가가 하나 더 붙습니다. 정식 생성자를 다시 부르니까 컴팩트 생성자에 써둔 검증이 한 번 더 도는데, 마스킹을 거친 값이 그 검증을 통과하지 못하면 응답을 만들다가 그 자리에서 터집니다. 실제로 그런 컴팩트 생성자를 만들어 Java 21 에서 돌려보니 010-****-5678 에서 그대로 걸립니다.
지금은 검증을 넣은 응답 record 가 없어서 안 터졌습니다. 운이 좋았던 거고, 구조적으로 해결한 건 아닙니다.
권한 조회가 행 수만큼 나갔다
붙이고 나니 목록 조회가 느려졌습니다.
권한 확인이 DB 조회거든요. (관리자, 메뉴) 로 권한 행을 찾아 열람 여부를 봅니다. 그런데 마스킹은 필드마다 일어나니까, 목록 200행에 개인정보 필드가 다섯 개면 전부 같은 답을 받으려고 조회가 천 번 나갑니다.
요청 하나 안에서 메뉴별 판정은 바뀌지 않습니다. 그래서 응답 하나를 훑는 동안만 쓸 EnumMap 을 만들어 재귀에 같이 넘겼습니다.
1
2
3
private boolean hasAccess(Menu menu, Map<Menu, Boolean> accessCache) {
return accessCache.computeIfAbsent(menu, accessService::hasPersonalInfoAccess);
}
캐시를 빈의 필드로 두지 않고 인자로 넘긴 이유는 수명 때문입니다. 빈에 들고 있으면 요청 사이에 판정이 남아서, 권한을 방금 회수당한 관리자가 캐시가 만료될 때까지 원문을 계속 보게 됩니다. 권한 회수는 사고가 의심될 때 제일 먼저 하는 조치인데, 그 상황에서 캐시 만료를 기다리고 있을 수는 없고요. 응답 하나짜리 캐시는 그 위험이 없습니다.
엑셀 다운로드는 이 그물 밖이었다
어드민에는 목록을 엑셀로 내려받는 기능이 있습니다. 여기서 마스킹이 안 걸렸습니다.
원인은 단순합니다. AOP 는 컨트롤러 반환값을 가로채는데 엑셀 경로가 반환하는 건 DTO 가 아니라 파일 바이트라, 어노테이션이 붙은 객체는 AOP 가 손댈 수 있는 지점에 오기 전에 이미 셀에 다 찍히고 난 뒤거든요. 화면에서는 가려진 계좌번호가 엑셀에서는 원문으로 나가고 있었습니다.
고친 방식은 같은 어노테이션을 엑셀 쪽에서 한 번 더 읽는 것이었습니다. 행을 만드는 코드가 필드에서 값을 꺼낼 때 어노테이션을 확인하고, 권한이 없으면 거기서 가립니다.
1
2
3
4
5
6
7
8
Object value = field.get(data);
if (value instanceof String strValue) {
PersonalInfo personalInfo = field.getAnnotation(PersonalInfo.class);
if (personalInfo != null && !accessCache.getOrDefault(personalInfo.menu(), false)) {
value = MaskingUtil.mask(strValue, personalInfo.type());
}
}
선언은 한 곳인데 그 선언을 읽는 코드는 두 벌이 됐습니다. AOP 쪽과 엑셀 rowMapper 쪽. 지금은 둘이 같은 결과를 내지만 한쪽만 고치면 그날부터 갈립니다.
이게 어노테이션 방식의 값이자 대가라고 봅니다. 선언을 한곳에 모은 건 확실히 나아진 부분이에요. 그런데 그 선언을 해석하는 책임은 모이지 않아서, 응답이 나가는 경로가 하나 더 생기면 그 경로도 어노테이션을 읽어야 한다는 걸 누군가 기억하고 있어야 합니다.
사업자번호는 도로 벗겼다
붙이고 2주쯤 뒤에 하나를 되돌렸습니다.
동일인 검사 화면이 있습니다. 같은 사람이 계정을 여러 개 만들었는지 보는 화면인데 판별의 핵심 키가 사업자번호고, 마스킹 타입이 앞 세 자리만 남기고 다 가리다 보니 123-**-***** 만 남아서 화면이 열려도 할 수 있는 일이 없어졌습니다. 응답 다섯 곳에서 사업자번호 어노테이션을 뺐습니다.
기준을 다시 잡아야 했습니다. 처음엔 “개인정보처럼 보이면 붙인다”로 훑었거든요. ISMS 심사를 앞두고 빠진 게 없나부터 봤으니 판단이 한쪽으로 쏠린 겁니다. 사업자번호를 가려야 하는 화면도 분명히 있습니다. 그런데 동일인 검사는 그 값 위에 서 있는 화면이라, 여기서 가리면 보호되는 것 없이 기능만 죽습니다. 가리는 기준은 그 값이 민감한가가 아니라, 그 화면에서 하는 일이 그 값을 필요로 하는가입니다.
그래서 남은 어노테이션들도 화면 단위로 다시 봤습니다. 관리자 상세에서 대표자명을 가리는 건 맞지만 회원을 찾아 들어오는 검색 결과에서 이름을 다 가리면 검색이 의미를 잃으니까, 같은 필드라도 화면이 다르면 판단이 달라집니다. 열람 권한을 메뉴별로 쪼갠 게 여기서 값을 했습니다.
맺음말
3개월쯤 지나 다른 도메인 응답들을 훑었습니다. 계약, 자금, 결제, 정산, 환불 쪽 응답 DTO 여덟 개에 어노테이션이 빠져 있었고, 처음 작업 때 회원 관리 도메인만 봤으니 그 뒤에 생긴 응답들은 아무도 안 붙였던 거죠. 붙이면서 메뉴 코드도 도메인에 맞게 다시 맞췄습니다.
이게 이 방식의 구조적인 구멍입니다. 어노테이션을 안 붙이면 아무 일도 안 일어납니다. 에러도 없고 경고도 없고 테스트도 안 깨집니다. 그냥 원문이 나갑니다. 손으로 마스킹을 부르던 시절과 빠뜨렸을 때의 결과가 똑같아요. 빠뜨리기가 덜 쉬워졌을 뿐입니다.
일단은 레포 문서에 규칙을 적었습니다. 응답 DTO 에 어떤 필드명이 보이면 어노테이션을 확인하라는 체크리스트까지 넣었고요. 그런데 문서는 게이트가 아닙니다. 누락을 테스트로 막아보자는 이야기가 있었는데 그건 아직 들어오지 못했고, 지금 믿고 있는 건 코드리뷰뿐입니다.
포기한 것도 적어둡니다. 마스킹은 보여주는 값만 가립니다. DB 에는 평문이 그대로 있고 검색과 정렬도 평문 기준으로 도는데, 저장된 값 자체를 가리는 건 컬럼 암호화 쪽 작업이라 그건 별개로 진행했습니다. 로그도 이 그물 밖입니다. 응답에서 가려진 값이 그 위 레이어의 디버그 로그에는 원문으로 찍힐 수 있고, 거기까지는 아직 손대지 않았습니다.