fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 7월 4일

Datadog RUM에 사용자 세선 심기

서비스 레이어로 MFE에서 간편하게 사용하기

#UserTracking#React

개요

Datadog RUM(Real User Monitoring)은 실제 사용자의 세션을 기록하고 추적할 수 있는 모니터링 도구다.

우리 서비스에는 이미 Datadog RUM이 붙어 있었지만, 누가 이 세션의 주인인지는 알 수 없는 상태였다.

세션 리플레이를 열어봐도 "익명의 누군가가 이 화면에서 에러를 만났다" 정도만 알 수 있었지, 어떤 병원의 어떤 사용자인지 특정할 수 없었다.

CS 인입 시 "저 이거 안 돼요"라는 리포트를 받으면, 해당 사용자의 세션을 Datadog에서 찾아 재현하는 것이 이상적인 흐름인데, 지금은 타임스탬프와 증상을 조합해서 추측하는 수준이었다.

그래서 Datadog RUM의 setUser API를 활용해 세션에 사용자 정보를 심기로 했다.

단, 이메일 같은 개인정보를 평문으로 넣어선 안된다.

Datadog의 setUser, 뭘 넣어야 하나

Advanced Configuration

Advanced Configuration

Configure RUM Browser SDK to modify data collection, override view names, manage user sessions, and control sampling for your application

faviconhttps://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#access-user-session

Datadog RUM은 datadogRum.setUser()로 현재 세션에 사용자 정보를 바인딩할 수 있다.

공식 문서에서 제공하는 프로퍼티는 id, name, email 정도인데, 사실 커스텀 프로퍼티를 얼마든지 넣을 수 있다.

우리 서비스 특성상 필요한 정보는 이렇게 정리했다.

프로퍼티설명시점
id사용자 고유 식별자 (PaletteUserModel.id)로그인
email이메일 (SHA-256 해시)로그인
memberId병원 내 멤버 고유 아이디병원 선택
hospitalId현재 선택된 병원 아이디병원 선택

여기서 한 가지 고민이 있었다.

기존 모바일 쪽 Datadog 설정을 보니 id에 병원 아이디를 넣고 있었다. 하지만 id는 말 그대로 사용자를 고유하게 식별하는 값이어야 한다. 한 사용자가 여러 병원에 소속될 수 있는 우리 서비스 구조에서, 병원 아이디를 id로 쓰면 같은 사람이 병원을 바꿀 때마다 다른 사용자처럼 보인다.

그래서 id에는 PaletteUserModel의 고유 id를 넣고, 병원 아이디는 별도 프로퍼티로 분리했다.

이메일을 평문으로 보내면 안 되는 이유

Datadog은 SaaS다. 우리 사용자의 이메일이 외부 서비스의 데이터베이스에 평문으로 저장된다는 건, 개인정보 보호법 관점에서 꽤 민감한 문제다.

그렇다고 이메일을 아예 안 넣자니, CS 대응 시 이 세션이 누구 건지 특정하기 어렵다.

타협점은 SHA-256 해시였다.

  • Datadog에는 해시값만 저장된다. 해시만으로는 원본 이메일을 복원할 수 없다.
  • CS 인입 시 해당 사용자의 이메일을 같은 방식으로 해시해서 Datadog에서 검색하면 세션을 특정할 수 있다.

단방향이지만, 같은 입력은 항상 같은 출력을 내니까. 식별은 가능하되, 노출은 안 되는 구조다.

typescript
const _hashEmail = async (email: string): Promise<string> => {
  if (!email) return '';

  try {
    const encoder = new TextEncoder();
    const data = encoder.encode(email.trim().toLowerCase());

    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
  } catch (e) {
    console.error('datadogRumService: _hashEmail error', e);
    return _masking(email);
  }
};

trim().toLowerCase()를 먼저 하는 이유는, User@Example.com과 user@example.com이 같은 해시를 뱉어야 나중에 검색이 가능하기 때문이다. 정규화를 안 하면 같은 사용자인데 해시가 달라지는 불상사가 생긴다.

crypto.subtle이 없는 환경

crypto.subtle은 Secure Context(HTTPS 또는 localhost)에서만 사용할 수 있다.

로컬 개발 환경에서 http://192.168.x.x 같은 IP로 접근하거나, 특수한 네트워크 환경에서는 crypto.subtle 자체가 undefined다.

이때는 해시 대신 마스킹 처리로 폴백한다.

typescript
const _masking = (email: string): string => {
  if (!email) return '';

  const [local, domain] = email.split('@');
  if (!local || !domain) return '';

  if (local.length <= 2) {
    return `${'*'.repeat(local.length)}@${domain}`;
  }

  const first = local[0];
  const last = local[local.length - 1];
  const star = '*'.repeat(local.length - 2);

  return `${first}${star}${last}@${domain}`;
};

// hanul.lee@example.com → h******e@example.com

완벽한 해시는 아니지만, 최소한 평문 노출은 막는다. 프로덕션 환경은 항상 HTTPS이므로, 마스킹이 동작하는 건 사실상 로컬 개발 환경뿐이다.

서비스 레이어로 분리한 이유

처음에는 그냥 datadogRum.setUser()를 필요한 곳에서 직접 호출하려고 했다.

그런데 생각해보니, 이메일 해시 로직이 호출하는 쪽마다 중복될 수밖에 없다. setUser에서도 해시해야 하고, setUserProperty로 이메일만 업데이트할 때도 해시해야 한다.

그래서 datadogRumService라는 서비스 객체로 감쌌다.

typescript
export const datadogRumService = {
  getUser: () => datadogRum.getUser(),

  setUserSession: async (args: DatadogRumSetUserArgs) => {
    const hashedEmail = await _hashEmail(args.email);
    datadogRum.setUser({ ...args, email: hashedEmail });
  },

  updateSession: async (args: DatadogRumUpdateSessionArgs = {}) => {
    for (const key of Object.keys(args) as (keyof DatadogRumUpdateSessionArgs)[]) {
      const value = args[key];
      if (!value) continue;

      if (key === 'email') {
        datadogRum.setUserProperty(key, await _hashEmail(value));
      } else {
        datadogRum.setUserProperty(key, value);
      }
    }
  },

  clearUser: () => datadogRum.clearUser(),
};

이메일이 들어오면 무조건 해시를 거치게 된다. 호출하는 쪽에서는 해시를 신경 쓸 필요가 없다.

이 서비스는 palette-datadog 라이브러리에 추가해서, 나중에 다른 모듈에서도 동일한 방식으로 사용할 수 있게 했다.

언제 setUser하고, 언제 clearUser하나

사용자 세션의 라이프사이클은 우리 서비스의 인증 흐름을 그대로 따라간다.

plain text
로그인 성공
  → setUserSession(id, email)        // 사용자 식별 시작

    병원 선택
      → updateSession(memberId, hospitalId)  // 컨텍스트 보강

        로그아웃 or 토큰 만료
          → clearUser()                        // 세션 초기화

1) 로그인 성공 시 — authentication-layer.tsx

GetActiveMemberShipInfo 쿼리가 성공하면 사용자 정보를 세팅한다.

typescript
useEffect(() => {
  if (!loading && data) {
    activeMemberShipOnSuccess(data);

    if (data.auth.me.account.user) {
      datadogRumService.setUserSession({
        id: data.auth.me.account.user.id,
        email: data.auth.me.account.email,
      });
    }
  }
}, [data, loading]);

이 시점에서는 아직 어떤 병원을 선택했는지 모른다. id와 email만 먼저 세팅한다.

기존 쿼리에 email과 user { id, name } 필드가 없어서 GraphQL 쿼리도 함께 수정했다.

2) 병원 선택 시 — filtered-route.tsx

병원을 선택하고 라우트가 결정되는 시점에서 memberId와 hospitalId를 추가한다.

typescript
datadogRumService.updateSession({
  memberId: data.auth.me.member.id,
  hospitalId: hospitalId,
});

setUser가 아니라 updateSession(setUserProperty)을 쓴 이유는, 기존에 세팅된 id와 email을 덮어쓰지 않기 위해서다.

3) 로그아웃/토큰 만료 시 — auth.ts

typescript
await logout();
datadogRumService.clearUser();

refreshToken 실패 시, 토큰 만료 시 모두 clearUser()를 호출한다. 로그아웃 이후의 세션이 이전 사용자의 정보를 물고 있으면 안 되니까.

끝맺음

정리하면 이번 작업은 크게 세 가지다.

  1. 이메일 SHA-256 해시 — 개인정보를 지키면서도 세션 식별이 가능한 구조
  2. 서비스 레이어 분리 — 해시 로직을 한 곳에서 관리하고, 호출부는 신경 쓰지 않게
  3. 인증 라이프사이클에 맞춘 세션 관리 — 로그인, 병원 선택, 로그아웃 각 시점에서 적절한 API 호출

기술적으로 어려운 작업은 아니었다. 하지만 "모니터링에 사용자 정보를 넣자"라는 단순한 요구에서 시작해, 개인정보 보호와 실용성 사이의 균형점을 찾는 과정이 의미 있었다.

crypto.subtle이 Secure Context에서만 동작한다는 사실도 이번에 처음 알았다. 당연히 어디서든 쓸 수 있을 줄 알았는데, MDN 문서를 보니 명확하게 제한이 걸려 있었다. 폴백을 안 넣었으면 로컬에서 에러를 마주하고 당황했을 것이다.

이 작업의 진짜 가치는 다음 CS 인입 때 드러난다.

"에러 확인 부탁드립니다" 라는 연락이 왔을 때, 해당 사용자의 이메일을 해시해서 Datadog에 검색하면 세션 리플레이를 바로 찾을 수 있다. 추측이 아니라 재현이 가능해진다.

모니터링은 결국 문제가 터졌을 때 얼마나 빠르게 원인을 찾느냐의 싸움이고, 그 첫걸음은 이 세션이 누구 건지 아는 것이 아닐까.

  • 개요
  • Datadog의 setUser, 뭘 넣어야 하나
  • 이메일을 평문으로 보내면 안 되는 이유
  • crypto.subtle이 없는 환경
  • 서비스 레이어로 분리한 이유
  • 언제 setUser하고, 언제 clearUser하나
  • 1) 로그인 성공 시 — authentication-layer.tsx
  • 2) 병원 선택 시 — filtered-route.tsx
  • 3) 로그아웃/토큰 만료 시 — auth.ts
  • 끝맺음