[업무일지] AOP와 어노테이션을 활용한 개인정보 마스킹 누락 최소화
관리자마다 개인정보 원문을 볼 수 있는 범위가 달라야 하는데, 마스킹을 조회 API마다 손으로 부르고 있었습니다. 그걸 응답 DTO 필드의 어노테이션으로 옮기고, 컨트롤러 반환값을 AOP 로 가로채 권한을 보고 가리게 했습니다. 선언은 한 곳으로 모였는데 그 선언을 읽는 코드는 두 벌이 됐습니다. 안 붙인 필드는 여전히 아무도 막아주지 않고요.
어드민을 새로 만드는 중입니다. 관리자가 셀러 상세를 열면 대표자명과 휴대폰번호, 정산계좌가 한 화면에 같이 나옵니다. 정보보호 관리체계 인증(ISMS) 심사를 준비하며 개인정보 접근 통제를 정리하는 중이고, 이 화면들이 첫 대상입니다. Java 17, Spring Boot 3.2.x, Spring AOP 와 Spring Security, JPA(Hibernate 6), MariaDB 를 쓰는 서버입니다. 코드는 최소 재현으로 다시 썼고 실제 클래스와는 구조만 같습니다.
마스킹을 조회 API마다 손으로 불렀다
원래는 응답을 만들 때 마스킹 유틸을 직접 불렀습니다.
1
2
3
4
// Java 17, Spring Boot 3.2.x
// 조회 서비스마다 반복되던 코드
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 는 안 됩니다. setAccessible(true) 가 성공해도 record 필드에 field.set 을 부르면 IllegalAccessException 이 납니다. 일반 클래스의 final 필드는 이 방법으로 바뀌는데, record 는 JDK 가 따로 막아 뒀거든요. 그래서 컴포넌트를 하나씩 읽어 마스킹한 값으로 배열을 채운 다음, 정식 생성자로 객체를 새로 만들었습니다.
어노테이션은 record 컴포넌트가 아니라 같은 이름의 필드에서 읽습니다. @Target 이 FIELD 라서 record 컴포넌트에 붙인 어노테이션은 백킹 필드로만 전파되고, RecordComponent.getAnnotation() 으로는 보이지 않거든요. 덕분에 일반 클래스 DTO 와 같은 방식으로 읽습니다.
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 쪽은 대가가 하나 더 붙습니다. 정식 생성자를 다시 부르니까 컴팩트 생성자에 써둔 검증이 한 번 더 돌고, 마스킹을 거친 값은 그 검증을 통과하지 못할 수 있습니다. 실제로 전화번호 형식을 검사하는 컴팩트 생성자를 만들어 돌려보니 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 가 반환값을 볼 때는 DTO 값이 이미 셀에 찍혀 있는 거죠. 화면에서는 가려진 계좌번호가 엑셀에서는 원문으로 나가고 있었습니다.
고친 방식은 같은 어노테이션을 엑셀 쪽에서 한 번 더 읽는 것이었습니다. 행을 만드는 코드가 필드에서 값을 꺼낼 때 어노테이션을 확인하고, 권한이 없으면 거기서 가립니다.
1
2
3
4
5
6
7
8
9
// accessCache 는 행을 돌기 전에 이 클래스가 쓰는 메뉴별 판정으로 미리 채워 둔다
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개월 뒤 다른 도메인 응답 일곱 곳이 빠져 있었다
3개월쯤 지나 다른 도메인 응답들을 훑었습니다. 계약, 자금, 결제, 정산, 환불 쪽 응답 DTO 일곱 개에 어노테이션이 빠져 있었고, 처음 작업 때 회원 관리 도메인만 봤으니 그 뒤에 생긴 응답들은 아무도 안 붙였던 거죠. 붙이면서 도메인과 어긋나 있던 메뉴 코드 하나도 바로잡았습니다.
이게 이 방식의 구조적인 구멍입니다. 어노테이션을 안 붙이면 아무 일도 안 일어납니다. 에러도 없고 경고도 없고 테스트도 안 깨집니다. 그냥 원문이 나갑니다. 손으로 마스킹을 부르던 시절과 빠뜨렸을 때의 결과가 똑같아요. 빠뜨리기가 덜 쉬워졌을 뿐입니다.
일단은 레포 문서에 규칙을 적었습니다. 응답 DTO 에 어떤 필드명이 보이면 어노테이션을 확인하라는 체크리스트까지 넣었고요. 그런데 문서는 게이트가 아닙니다. 누락을 테스트로 막아보자는 이야기가 있었는데 그건 아직 들어오지 못했고, 지금 믿고 있는 건 코드리뷰뿐입니다.
포기한 것도 적어둡니다. 마스킹은 보여주는 값만 가립니다. DB 에는 평문이 그대로 있고 검색과 정렬도 평문 기준으로 도는데, 저장된 값 자체를 가리는 건 컬럼 암호화 쪽 작업이라 그건 별개로 진행했습니다. 로그는 이 방식이 닿지 않는 곳이라 따로 봐야 할 자리입니다.