마스킹 확장 개발기 (2)

반응형

1편을 작성한 뒤 곰곰이 생각해보았습니다. 확장성 측면에서 보면, 이전에 사용하던 SENSITIVE_FIELDS 방식보다는 분명 개선이 있었습니다. 이제는 더 이상 하나의 Set을 수정하는 구조가 아니라, 규칙을 YAML에 추가하는 것만으로 마스킹 대상이 확장됩니다. 또한 설정이 YAML로 분리되면서, 전체 마스킹 정책을 한눈에 파악하기도 훨씬 쉬워졌습니다. 하지만 여전히 아쉬운 점도 남아 있습니다. password, pass, secret, code 처럼 의미는 같지만 형태가 다른 필드들에 대해서는 아직 충분히 우아한 처리가 되지 않고 있다는 점입니다. 결국 문제는 단순히 "확장 가능하게 만들었는가"가 아니라, 비슷한 의미를 가진 필드들을 어떻게 관리할 것인가에 있었습니다. 이 고민을 바탕으로,
다음 글에서는 이 문제를 조금 더 깊이 파고들어 보려 합니다.

2편, 시작해봅시다.

 

어떻게 하면 마스킹 부분을 확장하여 개발할 수 있을까?

이전에 API 요청/응답 로깅 과정에서 민감 정보를 마스킹하는 로직을 적용한 경험이 있습니다.String maskedRequestBody = SensitiveDataMasker.maskSensitiveData( new String(requestBodyBytes, StandardCharsets.UTF_8));내부 API

b-programmer.tistory.com

 

 

마스킹 확장 개발기 (1)

어떻게 하면 마스킹 부분을 확장하여 개발할 수 있을까?이전에 API 요청/응답 로깅 과정에서 민감 정보를 마스킹하는 로직을 적용한 경험이 있습니다.String maskedRequestBody = SensitiveDataMasker.maskSensiti

b-programmer.tistory.com

어떻게 관리를 할지 고민을 하다 기존에 만들었던 YMAL를 활용하기로 했습니다.

의미그룹 관리

password는 credential 그룹(신원과 권한을 증명하는 수단)에 속해 있습니다.
이 그룹에 속한 값들은 email과 달리 value가 아닌 key를 기준으로 판단해야 합니다.

그 이유는 credential 계열의 값들은 이미 암호화되거나 난수화된 상태로 전달되기 때문입니다.
즉, 결과 값의 형태가 제각각이며, value만으로는 해당 값이 민감 정보인지 판단할 수 없습니다.
따라서 credential 계열은 필드의 역할과 의미를 기준으로 구분하는 것이 더 적절합니다.

이를 반영하여 YAML에 다음과 같은 의미 그룹을 추가했습니다.

credential:
   match:
     type: FIELD_REGEX
     pattern: "(?i).*(password|pass|pwd|secret|token|code|key|pin).*"
   mask:
      type: FIXED
    value: "**********"

그렇다면 기존의 SENSITIVE_FIELDS 방식과 무엇이 다를까요?

차이점은 단순히 관리 위치가 YAML로 이동한 것이 아닙니다.
기존 SENSITIVE_FIELDS는 단순히 필드 이름을 나열한 목록에 가까웠다면,
의미 그룹을 도입한 이후에는 해당 필드들이 왜 민감한지에 대한 기준이 명확해졌다는 점이 가장 큰 차이입니다.

새로운 필드명이 추가되더라도,
동일한 보안 정책과 마스킹 강도를 가진다면 기존 의미 그룹에 포함시키는 것이 맞다고 판단했습니다.
의미 그룹을 분리하는 기준은 단어의 증가가 아니라, 마스킹 정책이나 보안 요구사항이 달라지는 시점이라 생각합니다.

이제 실행해봅시다.

이제 실제로 마스킹 로직을 실행해보겠습니다.
기존 방식에서는 이메일 전용 플러그인을 별도로 만들어 마스킹을 처리했습니다.
하지만 YAML 기반으로 여러 마스킹 룰을 정의한 이후, 새로운 고민이 생겼습니다.

public String apply(String body) {
    try {
      JsonNode root = objectMapper.readTree(body);
      for (Entry<String, MaskingRule> entry : properties.rules().entrySet()) {
        MaskingRule rule = entry.getValue();
        maskNode(
            root,
            Pattern.compile(rule.match().pattern()),
            rule.mask().value()
        );
      }
      return objectMapper.writeValueAsString(root);

    } catch (JsonProcessingException e) {
      return body;
    }
}

위 코드처럼 모든 마스킹 룰을 순회하며 한 번에 적용하는 방식도 충분히 고려해볼 수 있습니다.
JSON을 한 번만 파싱하고, 여러 룰을 연속으로 적용할 수 있기 때문에 구현 자체는 단순하고 효율적입니다.

하지만 이 구조를 적용하면서 한 가지 의문이 들었습니다.
모든 민감 정보 규칙이 YAML에 정의되어 있고, 하나의 플러그인이 이를 순회하며 처리한다면
플러그인을 추가하는 구조가 과연 의미가 있을까?라는 점입니다.

즉, 이 방식은 플러그인을 확장 포인트로 두기보다는 마스킹을 하나의 룰 엔진처럼 사용하는 구조에 가깝습니다.
이 경우 플러그인은 단순히 "룰을 실행하는 역할"에 머물게 되고, 상황이나 맥락에 따라 동작을 분기하기에는 한계가 있습니다.

이 지점에서 저는 선택을 해야 했습니다. 마스킹을 단순히 규칙 기반 처리로 가져갈 것인지,
아니면 요청의 맥락에 따라 다르게 해석되는 정책으로 볼 것인지에 대한 고민이었습니다.

현재 방식은 플러그인에서 바로 실행을 시키는 구조이기 때문에 사실상 하나의 플러그인만 존재하면 되었습니다. 

엔진 추가

그래서 저는 플러그인 뒤에 마스킹 엔진을 두는 구조로 설계를 변경했습니다.
플러그인은 더 이상 마스킹 규칙을 직접 실행하지 않고, 마스킹 엔진을 언제 실행할지 결정하는 역할만 맡도록 책임을 분리했습니다.
이에 따라 코드 구조도 자연스럽게 변경되었습니다.
기존 apply 메서드는 이름과 달리 사실상 마스킹 규칙 전체를 실행하는 엔진 역할을 하고 있었습니다.

이 코드는 플러그인이라는 이름을 가지고 있었지만, 실제로는 룰 순회, 적용, 변환까지 모두 담당하는 중앙 처리 로직이었습니다.

  public String mask(String body) {
    if (body == null || body.isBlank()) {
      return body;
    }

    try {
      JsonNode root = objectMapper.readTree(body);

      for (MaskingRule rule : properties.rules().values()) {
        applyRule(root, rule);
      }

      return objectMapper.writeValueAsString(root);
    } catch (JsonProcessingException e) {
      return body;
    }
  }

이 로직을 MaskingEngine으로 이관하면서 역할이 명확해졌습니다.

  • MaskingEngine
    • YAML에 정의된 모든 마스킹 룰 적용
    • "무엇을 가릴지"에만 집중
  • Plugin
    • 현재 요청/로그의 맥락(Context)을 보고
    • "지금 이 시점에 마스킹 엔진을 실행할지"만 판단

즉, 플러그인이 강해진 것이 아니라 플러그인의 책임이 명확해지고, 단순해졌다고 보는 것이 정확합니다.
변경 전

@Component
@RequiredArgsConstructor
public class SensitiveMaskingPlugin implements MaskingPlugin {
  private final ObjectMapper objectMapper;
  private final MaskingProperties properties;

  @Override
  public boolean supports(MaskingContext ctx) {
    return true;
  }

  @Override
  public String apply(String body) {
    try {
      JsonNode root = objectMapper.readTree(body);
      for (Entry<String, MaskingRule> entry : properties.rules().entrySet()) {
        MaskingRule rule = entry.getValue();
        maskNode(
            root,
            Pattern.compile(rule.match().pattern()),
            rule.mask().value()
        );
      }

      return objectMapper.writeValueAsString(root);

    } catch (JsonProcessingException e) {
      return body;
    }
  }
}

변경 후

@Component
@RequiredArgsConstructor
public class DefaultMaskingPlugin implements MaskingPlugin {
  private final MaskingEngine engine;

  @Override
  public boolean supports(MaskingContext ctx) {
    return true;
  }

  @Override
  public String apply(String body) {
    return engine.mask(body);
  }
}

현재는 support는 context기반으로 적용되어지고 있습니다. context에 대한 내용은 이전 장에 작성이 되어있습니다.
간단히 설명하면, 요청과 응답의 문맥을 받아서 사용한다고 생각하시면 될거 같습니다. 

다만, 현재 구조에서는 개별 rule을 선택하여 특정 Context에서만 적용하는 방식은 지원하지 않습니다.
즉, 어떤 요청에서는 이메일 룰만, 어떤 요청에서는 패스워드 룰만 실행하는 식의 세밀한 제어는 할 수 없습니다.

현재로서는 Context를 기준으로 이 시점에 마스킹 엔진을 실행할 것인가, 말 것인가만을 판단할 수 있습니다.
마스킹이 실행되기로 결정되면, 엔진은 YAML에 정의된 모든 rule을 일관되게 적용합니다.

이는 기능 부족이라기보다는, 플러그인이 마스킹 정책(rule)을 알지 않도록 하기 위한 의도적인 설계 선택입니다.
룰 선택 로직을 플러그인으로 끌어오지 않음으로써, 마스킹 규칙은 오직 설정(YAML)과 엔진에만 집중되도록 했습니다.

사용 방법

1. Rule 생성

   - logging.yml에서 rules하위에 작성합니다. 

  rules:
    {customeName}:
      match:
        type: VALUE_REGEX (VALUE_REGEX:키값 정규화, FIELD_REGEX: 필드값 정규화)
        pattern: {pattern}
      mask:
        type: FIXED (FIXED: 고정 식, PARTIAL: 부분, HASH: 해쉬값): 현재는 FIXED만 지원합니다.
        value: {마스킹이 되어지는 방법}

2. 플러그인 적용

  - context여부를 이용하여 마스킹을 시킬지 안 할지 결정합니다. 

public record MaskingContext(
    RequestOrigin origin,
    String path
) {
  public enum RequestOrigin {CLIENT, SYSTEM}
}

Client: 외부 API 사용시
System: 내부 API 사용시

마무리

이제 YAML에 rule을 추가하는 것만으로도 새로운 마스킹 대상과 마스킹 방식을 확장할 수 있는 구조가 되었습니다. 현재 버전에서는 마스킹 방식이 FIXED 하나만 구현되어 있으며, PARTIAL, HASH 등의 방식은 이후 확장 대상으로 남겨두었습니다. 이번 리팩터링을 통해 가장 크게 느낀 점은, 확장성을 고려한 설계는 단순히 코드 몇 줄을 고치는 문제가 아니라는 것이었습니다. 기존에는 하나의 클래스에서 해결되던 로직이, 역할을 분리하면서 엔진, 플러그인, 설정 클래스 등 총 8개의 클래스와 추가적인 YAML 설정으로 나뉘게 되었습니다. 클래스의 개수만 보면 오히려 복잡해진 것처럼 느껴질 수도 있습니다. 하지만 그만큼 각 구성 요소의 책임이 명확해졌고, 새로운 요구사항이 추가되었을 때 어디를 확장해야 할지 예측 가능한 구조를 갖게 되었습니다. 이번 작업을 통해 "확장성 있게 개발한다"는 말이 생각보다 쉽지 않고, 많은 고민과 트레이드오프 위에 성립한다는 것을 체감할 수 있었습니다. 다음 글에서는 이 구조의 한계와, rule 단위 제어가 필요해지는 순간 어떤 선택을 할 수 있을지 고민해보려 합니다.

반응형

댓글

Designed by JB FACTORY