마스킹 확장 개발기 (1)
- 개발
- 2026. 2. 5. 00:00
어떻게 하면 마스킹 부분을 확장하여 개발할 수 있을까?
이전에 API 요청/응답 로깅 과정에서 민감 정보를 마스킹하는 로직을 적용한 경험이 있습니다.String maskedRequestBody = SensitiveDataMasker.maskSensitiveData( new String(requestBodyBytes, StandardCharsets.UTF_8));내부 API
b-programmer.tistory.com
이번글은 하드코딩된 마스킹을 어떻게 하면 확장성 있게 가져면서 리펙토링 한 기록입니다.
(자세한 배경은 위 포스트를 참고해 주세요.)
기존 코드
String maskedRequestBody = SensitiveDataMasker.maskSensitiveData(
new String(requestBodyBytes, StandardCharsets.UTF_8));
문제점
private static final Set<String> SENSITIVE_FIELDS = Set.of(
"password", "email", "cvc", "cardNumber"
);
다음과 같은 json이 있다고 가정해 봅시다.
{
"email" : "*******",
"password" : "*******"
}
현재는 필드이 email과 password와 일치하기 때문에 괜찮지만 지속적으로 필드들이 추가가 될 예정입니다.
만약에 다음과 같이 추가가 되었다고 가정해 봅시다.
private static final Set<String> SENSITIVE_FIELDS = Set.of(
// 인증 / 계정
"password", "passwd", "pwd", "pass", "secret", (등등100개)
// 개인정보
"email", "phone", "phoneNumber", "mobile",(등등 200개)
etc...
);
칼럼이 콤팩트하게 정해져 있으면 값을 수정하는 건 딱 하나의 클래스만 변경하면 되기 때문에 괜찮다고 생각할 수 있습니다.
하지만 모든 외부 api가 통신할 때 개발자가 어떤 값인지 체킹을 하고 일일이 확인을 해가면서 값을 추가하는 건 굉장히 쉽지 않은 작업일 겁니다. 또한 무엇 보다도 길어지는 SENSITIVE_FIELDS는 코드를 더럽히길 충분하죠.
그러면 어떻게 해야 할까요?
제가 선택한 방법은 플러그인 아키텍처입니다. 쉽게 말하면 전략패턴의 확장 버전이라 생각하시면 될 거 같습니다.
인터페이스 추상화
전략 패턴에서 나온 아키텍처이기 때문에 대표 플러그인부터 만들어봅시다.
public interface MaskingPlugin {
String apply(String body);
}
json형태의 body를 받아서 변환된 값을 리턴을 시켜주면 되는 간단한 코드입니다.
그러면 이걸 이용해서 하위 플러그인들을 만들어봅시다.
하위 플러그인 생성
@Component
@RequiredArgsConstructor
public class UserEmailMaskingPlugin implements MaskingPlugin {
private final ObjectMapper objectMapper;
@Override
public String apply(String body) {
try {
JsonNode root = objectMapper.readTree(body);
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
return body;
}
}
}
대략적으로 이렇게 만들었습니다. 하지만 이렇게 해버리면 body값을 body로 그대로 리턴하는 꼴일 겁니다.
그렇다면!
마스킹을 시켜주는 부분이 있어야 한다고 생각합니다.
저는 재귀를 활용해서 진행을 시켜줬습니다. 그 이유는 email이라는 값이 무조건 최상위에 있다고 보장할 수 없다고 생각하였습니다.
private static void maskRecursive(JsonNode node) {
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
for (Map.Entry<String, JsonNode> entry : node.properties()) {
String fieldName = entry.getKey();
JsonNode value = entry.getValue();
if ("email".equalsIgnoreCase(fieldName) && value.isTextual()) {
obj.put(fieldName, "*******");
} else {
maskRecursive(value);
}
}
} else if (node.isArray()) {
for (JsonNode child : node) {
maskRecursive(child);
}
}
}
이를 해석해 보면 filedName이 email인 경우에는 *******으로 변경하라는 메시지를 담고 있습니다.
아마 다른 플러그인들도 비슷한 액션이 될 거라 예상이 됩니다.
public interface MaskingPlugin {
String apply(String body);
default void maskRecursive(JsonNode node, String name, String masking) {
...
}
그래서 인터페이스에 이관을 시키도록 하였습니다.
플러그인 사용
저는 모든 플러그인이 모두 사용이 되기를 바랐습니다. 그래야 새로운 플러그인을 추가하는 것만으로 확장이 된다고 생각하였습니다.
@Component
@RequiredArgsConstructor
public class MaskingPluginRegistry {
private final List<MaskingPlugin> plugins;
public String applyAll(String body) {
if (body == null || body.isBlank()) return body;
return plugins.stream()
.reduce(body,
(acc, plugin) -> plugin.apply(acc),
(a, b) -> b
);
}
}
모든 플러그인들을 전부 실행을 시켜주는 코드로 변경하였습니다.
@Component
@RequiredArgsConstructor
public class MaskingFacade {
private final MaskingPluginRegistry registry;
public String mask(String rawBody) {
return registry.applyAll(rawBody);
}
}
이것을 퍼사드를 이용하여 레파지토리를 실행을 시키도록 만들었습니다.
그림을 그려보면 대략적으로 요런 그림입니다.

이제 플러그인을 추가하는 것만으로 마스킹 적용할 수 있게 되었습니다.
확인을 해보면
{
"email" : "******",
"password" : "password123!"
}
정상적으로 마스킹이 되었다는 것을 알 수 있습니다. 플러그인만 추가하면 되니 확장성 측면에서는 나쁘지 않다고 생각할 수 있습니다.
하지만, 현재 버전에는 몇 가지 한계점이 존재합니다.
1. 마스킹할 필드를 알아야 한다.
2. api마다 플러그인 조합을 사용할 수 없다.
3. 여전히 하드코딩이 된 값들이 존재한다.
마스킹할 필드를 알아야 한다.
일단 이 말이 어떤말인지 이해를 해야 한다고 생각합니다. 다시 마스킹하는 코드를 살펴보겠습니다.
maskRecursive(root,"email","******");
현재 email이라는 값을 "******"으로 마스킹을 시키고 있습니다.
그렇다는 이야기는 email이라는 값이 정확하게 들어와야 ****** 마스킹이 되어진다는 사실을 알 수 있죠.
만약에, "google_email"이라고 들어오는 경우라면? 마스킹이 되지 않는 상황이 발생합니다.
그렇다면, 어떻게 수정할 수 있을까요?
여기에는 몇가지 방법이 있습니다.
1. 전용 SENSITIVE_FIELDS를 만든다.
필드를 1000-2000개를 분산해서 저장을 시키기 때문에 안전성 측면에서 생각해보면 나쁘지 않는 전략일 수 있다고 생각합니다.
하지만 이 방법은 입구를 여러개를 만드는 꼴이니 확장성을 생각하면 그리 좋은 방법이 아닐겁니다.
그렇다면 방법이 없는걸까요?
2. 필드가 아닌 값으로 조회를 한다.
모든 이메일의 형식은 xxx@abc.zz 형식입니다. 이 값이 들어온다면, 마스킹을 처리하게 만드는 방법도 있습니다.
// 값 내용으로 민감 정보 판단
if (pattern.matcher(text).matches()) {
obj.put(entry.getKey(), masking);
}
이렇게 해버리면, 필드내용과는 상관없이 마스킹이 되어진다는 사실을 알 수 가 있습니다.

하지만 이 방법도 만능은 아닙니다.
- 모든 값을 검사해야 하기 때문에 CPU비용이 높습니다.
- 또한 일반 텍스트도 이메일 형식이 가능하기 때문에 오탐률이 높은 편입니다.
구조는 확장되었지만, 정책은 여전히 고정되어 있는 상태입니다. email로 예시로 들어봅시다.
email이 항상 민감한 정보일까요? 어째서 민감한 정보라고 생각해야 하는 걸까요?
api마다 플러그인 조합을 사용할 수 없다.
마스킹 방법을 두 가지나 적용했지만, 여전히 마스킹 정책 자체는 고정된 상태입니다.
현재 구조에서는 모든 플러그인이 항상 실행됩니다. 새로운 플러그인을 추가할 수는 있지만,
어떤 API에서 어떤 마스킹을 적용할지 선택할 수는 없습니다.
이렇게 되면 결국 모든 조합을
무조건 실행하게 되고,
복잡도만 증가하며,
중앙에서 SENSITIVE_FIELDS를 관리하던 방식과 본질적으로 크게 다르지 않게 됩니다.
email이라는 필드에 다시 주목해봅시다
이 문제를 더 명확히 보기 위해, 지금은 email 필드 하나에만 집중해보겠습니다.
email을 기준으로 다음과 같은 질문들을 던질 수 있습니다.
- email 필드를 사용하는 API는 어떤 성격의 API인가?
- 이 API는 누가 호출했는가?
- 해당 요청은 내부 호출인가, 외부 호출인가?
- 이 요청은 신원이 확인된 상태로 들어온 요청인가?
이 질문들에 대한 답이 달라지면, 같은 email 필드라도 마스킹 여부는 달라질 수 있습니다.
필드 이름만으로는 판단할 수 없는 이유
지금까지의 마스킹 방식은 "email이라는 필드면 무조건 가린다"는 전제를 가지고 있습니다.
하지만 위 질문들을 보면 알 수 있듯이, email이 민감한 정보인지 아닌지는 필드 이름만으로는 판단할 수 없습니다.
외부 공개 API에서는 반드시 가려야 할 정보일 수 있고 내부 시스템 간 통신에서는 식별자 역할을 하며
디버깅을 위해 오히려 노출되어야 할 수도 있습니다.
그래서 필요한 것이 Context입니다
필드 이름만으로는 이 값이 지금 이 순간에도 마스킹 대상인지 판단할 수 없습니다.
이를 해결하기 위해 마스킹 로직은 요청이 어떤 맥락(Context) 에서 처리되고 있는지에 대한 정보를 함께 알아야 합니다.
여기서 말하는 Context란, 요청이 발생한 배경 정보를 의미합니다.
- 내부 호출인지, 외부 호출인지
- 누가 호출했는지
- 인증된 요청인지
이러한 정보들을 바탕으로
마스킹 로직은 다음 질문에 답하게 됩니다.
"지금 이 email을 가려야 하는가?"
저는 이 Context 정보를 다음과 같이 record로 정의했습니다.
public record MaskingContext(
RequestOrigin origin,
String path
) {
public enum RequestOrigin {CLIENT, SYSTEM}
}
Context 는 마스킹 정책을 결정하지 않습니다. 단지 판단에 필요한 최소한의 정보만 전달합니다.
실제 마스킹 여부와 강도는 이 Context를 해석하는 플러그인의 책임입니다.
이를 통해 플러그인을 언제 사용할지에 대한 판단 책임을 중앙 로직이 아니라 각 플러그인에 분산시킬 수 있습니다.
예를 들어, 이메일은 요청의 출처와 무관하게 항상 마스킹하기로 정책을 정했다면
해당 플러그인은 모든 Context를 지원하도록 구현할 수 있습니다.
Client: 외부
System: 내부
테스트 삼아 Client로 진행하겠습니다.
ctx.origin() == RequestOrigin.CLIENT
마스킹이 되지 않는것을 확인 할 수 있습니다.
{
"email" : "test@example.com",
"password" : "password123!"
}
이제 마지막입니다.
여전히 하드코딩이 된 값들이 존재한다.
현재까지 정리된 마스킹 로직을 살펴보면, 다음과 같은 정보들이 코드 안에 직접 정의되어 있습니다.
email 패턴: "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
masking 방법: ***MASKED***
칼럼 명: "email"
구조적으로는 충분히 유연해졌고, Context를 통해 정책 분기도 가능해졌습니다.
하지만 새로운 민감 정보가 추가되는 상황이라면 결국 해당 플러그인을 찾아가 코드를 직접 수정해야 합니다.
물론 이것이 잘못된 방식은 아닙니다. 다만, 확장성과 별개로 사용성과 편의성은 여전히 아쉬운 지점입니다.
이러한 설정들을 한 곳에서 관리할 수 있다면 어떨까요?
여러 가지 선택지가 있겠지만, 저는 가장 간편한 YAML 기반 설정 방식을 선택했습니다.
민감 정보 규칙을 DB로 관리하는 방법도 고려해볼 수 있습니다.
다만 마스킹 로직은 로깅과 직결되는 영역이고, 외부 의존성이 증가할수록 장애 상황에서 함께 영향을 받을 가능성이 커집니다.
특히 외부 서비스나 데이터베이스에 문제가 발생했을 때 마스킹 자체가 동작하지 않는 상황은
보안 측면에서 가장 피해야 할 시나리오라고 판단했습니다.
반면 YAML 설정은 애플리케이션과 함께 배포되며, 외부 의존성 없이 항상 동일한 기준으로 동작할 수 있습니다.
이번 구조에서는 복잡한 동적 관리보다는, 안정성과 예측 가능성을 우선하여 YML 기반 설정을 선택했습니다.
YAML로 관리해보자.
masking:
rules:
email:
match:
type: REGEX
pattern: "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
mask:
type: FIXED
value: "***MASKED***"
요런식으로 관리를 하였습니다.
이제 이것을 사용하는 properties를 생성해봅시다.
@ConfigurationProperties(prefix = "masking")
public class MaskingProperties {
private final Map<String, MaskingRule> rules = new HashMap<>();
public record MaskingRule(Match match, Mask mask) {}
...
}
이제 이것을 사용해봅시다.
public String apply(String body) {
MaskingRule rule = properties.getRules().get("email");
try {
JsonNode root = objectMapper.readTree(body);
maskNode(root, Pattern.compile(rule.match().pattern()), rule.mask().value());
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
return body;
}
}

다행히 마스킹이 된것을 확인 할 수 있었습니다.
결론
플러그인을 하나 추가하는 것만으로도 자연스럽게 확장될 수 있게 되었습니다. 더 이상 중앙에서 모든 마스킹 정책을 관리하지 않고, 필요한 마스킹 규칙을 개별 플러그인 단위로 커스터마이징할 수 있는 구조로 변경된 셈입니다. 다만, 이 글에서 다룬 마스킹 확장은 여기서 끝이 아닙니다. 현재 구조에서 가장 큰 한계는 다음 코드에 있습니다. 사실 마스킹 확장은 이 글이 마지막이 아닙니다. 현재 가장 큰 문제가
MaskingRule rule = properties.getRules().get("email");
여전히 "email"이라는 값이 코드에 하드코딩되어 있으며, 이로 인해 YAML 기반 설정의 장점이 충분히 살아나지 못하고 있습니다. 극단적으로 말하면, YAML을 도입한 의미의 약 20% 정도만 활용하고 있는 상태입니다. 또한 현재 구현은 email 마스킹만을 기준으로 동작합니다. 다른 민감 정보 규칙을 추가했을 때 어떤 문제가 발생하는지, 그리고 이 구조가 어디까지 버틸 수 있는지는 직접 테스트해봐야 판단할 수 있을 것 같습니다. 이 부분은 실제로 규칙을 몇 가지 더 추가해 보며 검증한 뒤, 다음 글에서 이어서 정리해보겠습니다.
'개발' 카테고리의 다른 글
| slow 쿼리는 어떻게 탐지할 수 있을까? (0) | 2026.02.08 |
|---|---|
| 마스킹 확장 개발기 (2) (0) | 2026.02.06 |
| API 트래픽이 늘어나면 우리는 어떤 선택들을 하게 될까? (0) | 2026.02.03 |
| 어떻게 하면 마스킹 부분을 확장하여 개발할 수 있을까? (2) | 2026.02.02 |
| 리트라이 전략과 서킷 브레이커와의 관계 (0) | 2026.01.31 |