본문으로 건너뛰기
Forward Engineering
Go back

멀티 플랫폼 연동을 위한 데이터베이스 설계: 확장 가능한 로깅 시스템 구축기

- views

Table of Contents

Open Table of Contents

Executive Summary

새로운 플랫폼을 연동해야 하는데 기존 스키마로는 도저히 버틸 수가 없었습니다. 멀티 플랫폼 리뷰 관리 시스템을 운영하면서 세 번째 플랫폼을 붙이려던 순간, 그동안 미뤄왔던 구조적 문제들이 한꺼번에 터졌습니다.

이 글은 그 과정에서 겪은 고민과 선택들을 정리한 기록입니다:

결과적으로 약 4,000줄의 코드와 95% 테스트 커버리지를 달성했지만, 이게 정답이라고 말하기는 어렵습니다. 우리 팀 규모(3명)와 트래픽 수준에서 합리적이었던 선택일 뿐입니다.

1. Problem: 범용 테이블의 한계

1.1 배경: 멀티 플랫폼 시스템

우리 시스템은 여러 전자상거래 플랫폼의 리뷰를 수집하고 관리합니다. 이미 플랫폼 B, C, D와 연동되어 있었는데, 새로운 플랫폼 A를 추가하려고 보니 문제가 터졌습니다. 솔직히 처음엔 “컬럼 몇 개만 추가하면 되겠지”라고 가볍게 생각했는데, 완전히 틀렸습니다.

1.2 기존 시스템의 설계

// 범용 플랫폼 계정 테이블
interface PlatformAccount {
  userId: string;
  platformId: string;      // 'platform-a', 'platform-b', etc.
  accessToken: string;
  isLoggedIn: boolean;
  lastLoginAt: Date;
}

// 범용 리뷰 테이블
interface Review {
  id: string;
  shopId: string;
  platformId: string;
  content: string;
  rating: number;
  createdAt: Date;
}

이 설계는 **플랫폼 중립적(Platform-Agnostic)**입니다. 어떤 플랫폼이든 동일한 스키마로 처리할 수 있다는 장점이 있습니다.

1.3 새 플랫폼 연동 시 발견한 문제들

플랫폼 A를 들여다보니 기존 플랫폼들과 근본적으로 달랐습니다. “아, 이거 그냥 끼워 넣으면 안 되겠구나” 싶었던 순간들을 공유합니다:

문제 1: 플랫폼별 인증 메커니즘의 차이

// 플랫폼 B, C, D: 단순 OAuth
interface SimplePlatformAuth {
  accessToken: string;
  refreshToken: string;
}

// 플랫폼 A: 2단계 인증 + 단순 인증 혼재
interface PlatformAAuth {
  accessToken: string;
  authType: '2FA' | 'NORMAL';  // 2단계 인증 여부
  sessionStability: number;     // 세션 안정성 점수
}

Why this matters:

이를 기존 테이블에 추가하면?

// BAD: Sparse table 문제
interface PlatformAccount {
  userId: string;
  platformId: string;

  // 플랫폼 A 전용 (다른 플랫폼은 null)
  platformAAuthType?: '2FA' | 'NORMAL';
  platformASessionStability?: number;

  // 플랫폼 B 전용 (다른 플랫폼은 null)
  platformBBusinessId?: string;

  // 계속 늘어나는 플랫폼별 필드들...
}

문제 2: 세션 안정성 정량화 불가

기존 시스템은 “로그인 성공/실패”만 기록했습니다. 하지만 우리가 알고 싶었던 것은:

"이 사용자의 플랫폼 A 세션은 얼마나 안정적인가?"
→ 지난 100번의 로그인 시도 중 몇 번 성공했는가?
→ 최근 10건의 패턴은? (연속 실패 → 세션 갱신 필요)
→ 2FA 사용자의 평균 안정성 vs 일반 사용자

이런 지표 없이는 사전 예방적(Proactive) 세션 관리가 불가능합니다.

문제 3: 플랫폼별 리뷰 메타데이터의 특수성

플랫폼 A는 리뷰를 3가지 타입으로 구분합니다:

각 타입마다 답글 가능 여부, API 엔드포인트, 수집 방법이 다릅니다. 기존 Review 테이블은 플랫폼 중립적이어서 이런 특수 정보를 담을 수 없었습니다.

문제 4: Observability의 부재

웹 스크래핑 기반 연동은 본질적으로 불안정합니다:

하지만 기존 시스템은 다음 질문에 답할 수 없었습니다:

"왜 특정 사용자만 계속 실패하는가?"
"어떤 operation이 가장 많이 실패하는가?"
"프록시 A는 성공률 50%, 프록시 B는 90%인 이유는?"
"평균 응답 시간 5초인데, P99는 30초인 이유는?"

1.4 요구사항 도출

요구사항비즈니스 가치기술적 도전
세션 안정성 추적Proactive 세션 관리로 사용자 경험 개선집계 쿼리 성능
플랫폼별 특수 데이터플랫폼 A의 모든 기능 지원정규화 vs 비정규화
100% 로그 저장디버깅, 관측성, 비즈니스 분석고성능 write 처리
다양한 조회 패턴CS팀, 관리자, Grafana 등인덱스 전략
데이터 라이프사이클비용 최적화 (핫/콜드 분리)파티셔닝, 아카이브

2. Root Cause Analysis: 왜 범용 테이블로는 안 되는가?

2.1 안티패턴: God Table (신 테이블)

저도 처음엔 이렇게 생각했습니다:

“기존 PlatformAccount에 컬럼 몇 개만 추가하면 되는 거 아닌가?”

그런데 이게 바로 God Table 안티패턴이더군요. Martin Fowler의 Patterns of Enterprise Application Architecture와 여러 DBA들의 경고를 무시한 결과를 직접 겪었습니다:

// BAD: 모든 플랫폼의 모든 정보를 하나의 테이블에
interface PlatformAccount {
  // 공통 필드
  userId: string;
  platformId: string;
  accessToken: string;

  // 플랫폼 A 전용 (다른 플랫폼은 null)
  platformAAuthType?: string;
  platformASessionScore?: number;
  platformARecentWindow?: any;

  // 플랫폼 B 전용
  platformBBusinessId?: string;
  platformBApiVersion?: string;

  // 플랫폼 C 전용
  platformCRegion?: string;

  // 앞으로 추가될 수십 개의 플랫폼별 필드들...
}

문제점:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'16px'}}}%%
graph TB
    subgraph "X God Table 문제점"
        A["[문제] Sparse Table<br/>대부분 필드가 NULL"]
        B["[문제] 쿼리 복잡도<br/>플랫폼별 분기 처리"]
        C["[문제] 마이그레이션 지옥<br/>ALTER TABLE 반복"]
        D["[문제] SRP 위반<br/>하나의 테이블이<br/>너무 많은 책임"]

        E["[결과] 스토리지 낭비<br/>50-70% null"]
        F["[결과] 인덱스 비효율<br/>성능 저하"]
        G["[결과] 다운타임<br/>수 시간"]
        H["[결과] 결합도 증가<br/>변경 어려움"]

        A --> E
        A --> F
        B --> F
        C --> G
        D --> H
    end

    style A fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style B fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style C fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style D fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style E fill:#ffe3e3,stroke:#ff6b6b
    style F fill:#ffe3e3,stroke:#ff6b6b
    style G fill:#ffe3e3,stroke:#ff6b6b
    style H fill:#ffe3e3,stroke:#ff6b6b

상세 설명:

  1. Sparse Table (희소 테이블)

    • 각 행의 대부분 필드가 null
    • 스토리지 낭비 (null도 공간 차지)
    • 인덱스 비효율 (null이 많으면 인덱스 효과 감소)
  2. 쿼리 복잡도 증가

-- 플랫폼 A 사용자 중 세션 불안정한 사람 찾기
SELECT * FROM platform_accounts
WHERE platform_id = 'platform-a'
  AND platform_a_session_score < 50;  -- 다른 플랫폼은 이 필드가 null
  1. 마이그레이션 지옥

    • 새 플랫폼 추가할 때마다 ALTER TABLE
    • 수백만 행에 컬럼 추가 = 다운타임
    • 롤백 어려움
  2. 단일 책임 원칙(SRP) 위반

    • 하나의 테이블이 너무 많은 책임
    • 플랫폼 A 변경이 플랫폼 B에 영향 줄 수 있음

2.2 Bounded Context 분리 (DDD 관점)

Domain-Driven Design의 핵심 개념:

graph TB
    subgraph "Platform Accounts Context"
        A[범용 플랫폼 계정 관리]
        A1[OAuth 토큰]
        A2[기본 로그인 상태]
        A3[플랫폼 중립적 정보]
    end

    subgraph "Platform A Operations Context"
        B[플랫폼 A 특화 기능]
        B1[세션 안정성 추적]
        B2[플랫폼별 에러 처리]
        B3[상세 로깅 및 분석]
    end

    A -.느슨한 결합.- B

    style A fill:#e1f5ff
    style B fill:#fff4e1

분리의 장점:

  1. 독립적 진화

    • 플랫폼 A 스펙 변경이 다른 플랫폼에 영향 없음
    • 각 컨텍스트가 자기만의 스키마 버전 관리
  2. 팀 확장성

    • 플랫폼 A 전담 팀이 독립적으로 개발 가능
    • 코드 리뷰 범위 명확화
  3. 마이크로서비스 전환 용이

    • 나중에 플랫폼별 서비스 분리 가능
    • 이미 논리적으로 분리되어 있음

2.3 트랜잭션 데이터 vs 로그 데이터

또 다른 중요한 구분:

특성트랜잭션 데이터로그 데이터
목적현재 상태 저장이벤트 히스토리 저장
패턴CRUD (생성/읽기/수정/삭제)Insert-only (추가만)
UPDATE 빈도높음 (상태 변경마다)없음 (Immutable)
데이터 크기작음 (수만~수십만 행)큼 (수백만~수억 행)
쿼리 패턴PK 기반 단건 조회범위 스캔, 집계 쿼리
보존 기간무기한일정 기간 후 아카이브
인덱스소수 (빠른 lookup)다수 (다양한 분석 쿼리)

예시:

// 트랜잭션: 사용자의 현재 로그인 상태
interface UserSession {
  userId: string;
  isLoggedIn: boolean;  // UPDATE 자주 발생
  lastLoginAt: Date;
}

// 로그: 모든 로그인 시도의 히스토리
interface LoginLog {
  id: string;
  userId: string;
  status: 'SUCCESS' | 'FAILED';
  errorType?: string;
  createdAt: Date;  // Immutable, 절대 UPDATE 안 됨
}

이 둘을 섞으면:

3. Solution: 3-Table Architecture

위 분석을 바탕으로 플랫폼 A 전용 3개 테이블을 설계했습니다:

erDiagram
    PlatformAUserProfile ||--o{ PlatformAOperationLog : "생성"
    PlatformAUserProfile {
        uuid id PK
        uuid userId
        string platformAccountId
        enum authType "2FA 또는 NORMAL"
        decimal sessionStabilityScore "0-100"
        int totalLoginCount
        int successLoginCount
        json recentLoginWindow "최근 10건 로그인"
        timestamp createdAt
        timestamp updatedAt
    }

    PlatformAOperationLog {
        uuid id PK
        uuid userId
        string operationType "LOGIN, GET_REVIEWS 등"
        enum status "SUCCESS 또는 FAILED"
        string errorType
        int executionTimeMs
        int retryCount
        string proxyUsed
        json requestPayload
        timestamp createdAt
    }

    PlatformAReviewMetadata {
        uuid id PK
        uuid reviewId UK
        uuid shopId
        string externalShopId "플랫폼 A의 shop ID"
        enum reviewType "TYPE_A, TYPE_B, TYPE_C"
        boolean replyAttempted
        boolean replySuccess
        timestamp fetchedAt
    }

3.1 PlatformAUserProfile: 세션 안정성의 Single Source of Truth

@Entity('platform_a_user_profiles')
@Index('uk_user_platform', ['userId', 'platformAccountId'], { unique: true })
@Index('idx_stability_score', ['sessionStabilityScore'])
export class PlatformAUserProfile {
  id: string;
  userId: string;
  platformAccountId: string;  // 플랫폼 A 계정 ID

  // 핵심: 인증 타입
  authType: '2FA' | 'NORMAL';

  // 세션 안정성 지표
  sessionStabilityScore: number; // 0-100
  totalLoginCount: number;
  successLoginCount: number;
  failedLoginCount: number;
  lastLoginAt: Date;
  lastLoginStatus: 'SUCCESS' | 'FAILED';

  // 최근 10건의 로그인 윈도우 (JSON)
  recentLoginWindow: Array<{
    status: 'SUCCESS' | 'FAILED';
    createdAt: string;
  }> | null;

  createdAt: Date;
  updatedAt: Date;

  // 도메인 로직
  calculateStabilityScore(): number {
    if (this.totalLoginCount === 0) return 0;
    // 소수점 2자리 반올림
    return Math.round((this.successLoginCount / this.totalLoginCount) * 10000) / 100;
  }

  getStabilityGrade(): 'STABLE' | 'UNSTABLE' | 'CRITICAL' {
    const score = this.sessionStabilityScore;
    if (score >= 80) return 'STABLE';
    if (score >= 50) return 'UNSTABLE';
    return 'CRITICAL';
  }
}

  updateLoginResult(status: 'SUCCESS' | 'FAILED', authType?: '2FA' | 'NORMAL'): void {
    this.lastLoginAt = new Date();
    this.lastLoginStatus = status;
    this.totalLoginCount++;

    if (status === 'SUCCESS') {
      this.successLoginCount++;
    } else {
      this.failedLoginCount++;
    }

    if (authType) this.authType = authType;

    // Sliding window: 최근 10건만 유지
    const entry = { status, createdAt: new Date().toISOString() };
    this.recentLoginWindow = [entry, ...(this.recentLoginWindow || []).slice(0, 9)];

    // 점수 재계산
    this.sessionStabilityScore = this.calculateStabilityScore();
  }
}

설계 포인트:

  1. 복합 유니크 키: (userId, platformAccountId) - 한 사용자가 여러 플랫폼 A 계정 가질 수 있지만, 동일 계정 중복 방지

  2. 정량화된 지표: sessionStabilityScore (0-100) → Grafana 대시보드에 바로 표시 가능

  3. Sliding Window: 최근 10건만 JSON으로 저장 → 급격한 상태 변화 감지 (예: 연속 5번 실패)

  4. 도메인 로직 내장: 점수 계산, 등급 판정 로직을 엔티티에 두어 비즈니스 규칙 명확화

세션 안정성 등급 시각화:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'16px'}}}%%
graph LR
    subgraph "세션 안정성 점수 -> 등급"
        S0["0-49점"]
        S1["50-79점"]
        S2["80-100점"]

        G0["[CRITICAL]<br/><b>즉시 조치 필요</b><br/>• 세션 강제 갱신<br/>• 사용자 알림<br/>• 디버깅 로그 수집"]
        G1["[UNSTABLE]<br/><b>모니터링 강화</b><br/>• 재시도 로직 활성화<br/>• 알림 대기<br/>• 패턴 분석"]
        G2["[STABLE]<br/><b>정상 운영</b><br/>• 일반 처리<br/>• 통계 수집만<br/>• 최소 개입"]

        S0 --> G0
        S1 --> G1
        S2 --> G2
    end

    style S0 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style S1 fill:#ffd43b,stroke:#f59f00,color:#000
    style S2 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style G0 fill:#ffe3e3,stroke:#ff6b6b,stroke-width:3px
    style G1 fill:#fff3bf,stroke:#ffd43b,stroke-width:3px
    style G2 fill:#d3f9d8,stroke:#51cf66,stroke-width:3px

3.2 PlatformAOperationLog: Append-Only Event Log

export const OperationType = {
  CHECK_LOGIN: 'CHECK_LOGIN',
  VALIDATE_SESSION: 'VALIDATE_SESSION',
  GET_REVIEWS: 'GET_REVIEWS',
  ADD_REPLY: 'ADD_REPLY',
  UPDATE_REPLY: 'UPDATE_REPLY',
  DELETE_REPLY: 'DELETE_REPLY',
  GET_STORES: 'GET_STORES',
} as const;

@Entity('platform_a_operation_logs')
@Index('idx_user_id', ['userId'])
@Index('idx_created_at', ['createdAt'])
@Index('idx_filter_combo', ['userId', 'operationType', 'status', 'createdAt'])
@Index('idx_error_filter', ['status', 'errorType', 'authTypeDetected', 'createdAt'])
export class PlatformAOperationLog {
  id: string;
  userId: string;
  platformAccountId: string;

  // Operation 컨텍스트
  operationType: keyof typeof OperationType;
  resourceId: string | null;  // shopId, reviewId 등

  // 결과
  status: 'SUCCESS' | 'FAILED';
  errorCode: number | null;
  errorType: string | null;  // 'TIMEOUT', 'SELECTOR_NOT_FOUND', 'AUTH_REQUIRED' 등
  errorMessage: string | null;

  // 성능 메트릭
  executionTimeMs: number | null;
  retryCount: number;

  // 디버깅 정보
  authTypeDetected: '2FA' | 'NORMAL' | null;
  sessionRefreshed: boolean;
  proxyUsed: string | null;
  userAgent: string | null;

  // Audit trail (민감정보 제거 후 저장)
  requestPayload: Record<string, unknown> | null;
  responseSummary: Record<string, unknown> | null;

  // Immutable log: createdAt만 있고 updatedAt 없음
  createdAt: Date;
}

설계 포인트:

  1. Immutable: createdAt만 있고 updatedAt 없음 → Insert-only 보장

  2. Rich Context: 디버깅에 필요한 모든 정보 (프록시, user-agent, 재시도 횟수)

  3. 에러 분류: errorType 필드로 에러 패턴 분석 가능

  4. 성능 메트릭: executionTimeMs로 P50/P99 레이턴시 추적

3.3 PlatformAReviewMetadata: 플랫폼 특화 메타데이터

export const ReviewType = {
  TYPE_A: 'TYPE_A',  // 예약 기반
  TYPE_B: 'TYPE_B',  // 영수증 기반
  TYPE_C: 'TYPE_C',  // 배달/픽업
} as const;

@Entity('platform_a_review_metadata')
@Index('uk_review_id', ['reviewId'], { unique: true })
@Index('idx_shop_id', ['shopId'])
@Index('idx_external_shop_id', ['externalShopId'])
export class PlatformAReviewMetadata {
  id: string;
  reviewId: string;  // 유니크: 하나의 리뷰 = 하나의 메타데이터
  shopId: string;    // 내부 shop ID

  // 플랫폼 A 특화 정보
  externalShopId: string | null;    // 플랫폼 A의 shop ID (외부 식별자)
  externalBusinessId: string | null; // 플랫폼 A의 business ID
  reviewType: keyof typeof ReviewType | null;
  source: string | null;

  // 리뷰 특성
  hasBookingDetail: boolean;  // 예약 정보 포함 여부

  // 답글 처리 이력
  replyAttempted: boolean;
  replySuccess: boolean | null;
  replyErrorType: string | null;

  fetchedAt: Date;
  createdAt: Date;
  updatedAt: Date;

  // 리뷰 타입 자동 결정 로직
  static determineReviewType(hasBookingDetail: boolean, source?: string): keyof typeof ReviewType | null {
    if (source === 'PICKUP') return ReviewType.TYPE_C;
    if (hasBookingDetail) return ReviewType.TYPE_A;
    return ReviewType.TYPE_B;
  }
}

설계 포인트:

  1. 외부 식별자: externalShopId - 플랫폼 A API와 멱등성(idempotency) 보장

  2. 타입 자동 결정: Static 메서드로 비즈니스 규칙 명확화

  3. 답글 이력 분리: 시도 여부와 성공 여부를 분리 → 재시도 로직에 활용

4. 설계 검증: 우리가 확인한 것들

설계를 마친 후 가장 걱정됐던 부분은 “이게 정말 맞는 건가?”였습니다. 팀 내 코드 리뷰와 스테이징 환경에서의 검증을 거쳤는데, 그 과정에서 논쟁이 됐던 포인트들을 공유합니다.

4.1 논쟁 1: User Email 추가 여부

PlatformAOperationLoguser.email을 비정규화해야 하는가? 팀 내에서 의견이 갈렸습니다.

찬성 측 의견

반대 측 의견

CREATE VIEW v_operation_logs_with_email AS
SELECT l.*, u.email
FROM platform_a_operation_logs l
JOIN users u ON l.user_id = u.user_id;

우리의 결정: ❌ 추가하지 않음

실제로 운영팀 쿼리 패턴을 분석해보니, 이메일로 검색하는 경우는 전체의 5% 미만이었습니다. 그리고 GDPR 관점에서도 로그 테이블에 개인정보를 중복 저장하면 데이터 침해 시 영향 범위가 늘어나는 문제가 있었습니다.

배운 점: 정규화가 기본값입니다. 비정규화는 실제 쿼리 패턴을 측정한 후, 명확한 근거가 있을 때만 하는 게 맞다고 생각하게 됐습니다.

4.2 논쟁 2: externalShopId 추가 여부

PlatformAReviewMetadataexternalShopId를 추가하는 것도 논쟁거리였습니다.

중복 아닌가?

왜 필요했는가

스크래핑 파이프라인을 들여다보니 답이 나왔습니다:

  1. 플랫폼 A에서 데이터를 가져올 때 externalShopId가 먼저 들어옴
  2. 우리 시스템의 shopId로 변환하려면 추가 조회 필요
  3. 멱등성 처리를 위해 (externalShopId, reviewId) 조합이 유니크 키로 필요

마틴 클레프만의 “Designing Data-Intensive Applications”에서도 외부 시스템과 연동할 때 외부 식별자를 보존하라고 권장합니다. 이건 중복이 아니라 시스템 간 매핑을 위한 필수 정보였습니다.

우리의 결정: ✅ 추가

배운 점: 외부 식별자 ≠ 중복 데이터. 특히 멱등성이 중요한 시스템에서는 외부 키를 보존하는 게 맞습니다.

4.3 인덱스 전략 논쟁

인덱스는 솔직히 가장 고민이 많았던 부분입니다. “일단 많이 걸어두면 되겠지”라는 생각으로 시작했다가 코드 리뷰에서 혼났습니다.

초기 설계 (9개 인덱스)

@Index('idx_user_id', ['userId'])
@Index('idx_platform_account', ['platformAccountId'])
@Index('idx_created_at', ['createdAt'])
@Index('idx_status_created', ['status', 'createdAt'])
@Index('idx_operation_status', ['operationType', 'status'])
@Index('idx_error_type_created', ['errorType', 'createdAt'])
@Index('idx_auth_type_status', ['authTypeDetected', 'status'])  // ❌ 문제
@Index('idx_filter_combo', ['userId', 'operationType', 'status', 'createdAt'])  // ⚠️ 4컬럼
@Index('idx_error_filter', ['status', 'errorType', 'authTypeDetected', 'createdAt'])

EXPLAIN으로 확인한 것들

실제로 스테이징 환경에서 100만 건 데이터를 넣고 EXPLAIN ANALYZE를 돌려보니 문제가 보였습니다:

❌ idx_auth_type_status
   → 카디널리티가 너무 낮음 (2×2 = 4가지 조합뿐)
   → 옵티마이저가 이 인덱스를 거의 선택하지 않음
   → 다른 복합 인덱스로 이미 커버됨

⚠️ idx_filter_combo (4컬럼)
   → 인덱스 크기가 원본 테이블의 40%에 달함
   → leftmost prefix 규칙 때문에 userId 없이는 무용지물
   → 실제 쿼리 패턴 확인 결과 userId 없이 조회하는 경우가 30%

✅ created_at 인덱스는 파티셔닝 후에도 필요
   → PostgreSQL 공식 문서에서도 명시: 파티션 프루닝은 파티션 선택만 함
   → 파티션 내부 스캔은 여전히 인덱스가 필요

PostgreSQL 인덱스 가이드Use The Index, Luke를 참고하면서 검증했습니다.

우리의 결정: TODO로 남기고 프로덕션에서 검증

/**
 * 인덱스 최적화 TODO:
 *
 * 다음 인덱스는 실제 쿼리 패턴 측정 후 제거 검토:
 * 1. idx_auth_type_status - 낮은 카디널리티 (2×2)
 * 2. idx_operation_status - idx_filter_combo로 커버 가능
 * 3. idx_error_type_created - idx_error_filter로 커버 가능
 *
 * 검증 방법:
 * - EXPLAIN ANALYZE로 쿼리 실행 계획 확인
 * - pg_stat_user_indexes로 인덱스 사용 빈도 측정
 * - 인덱스 제거 후 성능 A/B 테스트
 */

인덱스 최적화 의사결정 플로우:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TD
    Start["[START] 새 인덱스 필요?"]
    Q1{"쿼리 빈도는?"}
    Q2{"카디널리티는?"}
    Q3{"복합 인덱스로<br/>커버 가능?"}
    Q4{"EXPLAIN 분석<br/>성능 개선?"}

    A1["[OK] 생성<br/>(보수적 접근)"]
    A2["[WARN] TODO 추가<br/>(나중에 검증)"]
    A3["[NO] 생성 안 함<br/>(비효율)"]
    A4["[측정] Production<br/>데이터 수집"]
    A5["[결정] 유지"]
    A6["[결정] 제거"]

    Start --> Q1
    Q1 -->|높음<br/>>100/s| Q2
    Q1 -->|낮음<br/><10/s| A3
    Q2 -->|높음<br/>>100| A1
    Q2 -->|낮음<br/><10| Q3
    Q3 -->|Yes| A2
    Q3 -->|No| A1
    A1 --> A4
    A2 --> A4
    A4 --> Q4
    Q4 -->|Yes<br/>>30% 개선| A5
    Q4 -->|No<br/><10% 개선| A6

    style Start fill:#4dabf7,stroke:#1971c2,color:#fff
    style A1 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style A2 fill:#ffd43b,stroke:#f59f00,color:#000
    style A3 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style A5 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style A6 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style Q1 fill:#e7f5ff,stroke:#4dabf7
    style Q2 fill:#e7f5ff,stroke:#4dabf7
    style Q3 fill:#e7f5ff,stroke:#4dabf7
    style Q4 fill:#e7f5ff,stroke:#4dabf7

배운 점: 처음엔 “인덱스는 많을수록 좋다”고 생각했는데, 틀렸습니다. 인덱스는 가설이고, EXPLAIN이 진리입니다. 초기에는 보수적으로 만들되, 반드시 프로덕션 데이터로 검증하고 불필요한 건 과감히 제거해야 합니다.

5. Performance Optimization: 고성능 로깅 시스템

5.1 문제: 로깅이 메인 로직을 Blocking

처음엔 단순하게 동기 방식으로 로깅을 구현했습니다. “로그 저장하는 게 뭐 대수야”라고 생각했는데, 부하 테스트를 돌려보니 완전히 틀렸습니다.

초기 구현 (동기 로깅):

// BAD
async function scrapeReviews(userId: string) {
  const startTime = Date.now();

  try {
    const reviews = await scrapeFromPlatform();

    // 메인 로직과 로깅이 섞여 있음
    await logOperation({
      userId,
      operationType: 'GET_REVIEWS',
      status: 'SUCCESS',
      executionTimeMs: Date.now() - startTime,
    });

    return reviews;
  } catch (error) {
    await logOperation({
      userId,
      operationType: 'GET_REVIEWS',
      status: 'FAILED',
      errorMessage: error.message,
    });
    throw error;
  }
}

문제점:

  1. 로깅 실패 → 메인 로직도 실패 (로그 DB 다운 = 전체 서비스 다운)
  2. 로깅 레이턴시만큼 사용자 대기
  3. DB 커넥션 고갈 (100 동시 요청 = 200 쿼리)

5.2 해결: Fire-and-Forget + 버퍼링

여러 방법을 검토하다가 Fire-and-Forget 패턴과 버퍼링을 조합하기로 했습니다. Node.js 공식 문서의 Backpressure 가이드NestJS 성능 최적화 문서를 참고했습니다.

sequenceDiagram
    participant App as 애플리케이션
    participant Buffer as 로그 버퍼
    participant Timer as 플러시 타이머
    participant DB as 데이터베이스

    App->>Buffer: logOperation() (논블로킹)
    Note over App: 즉시 반환
    App->>Buffer: logOperation()
    App->>Buffer: logOperation()

    alt 버퍼 가득참 (100개)
        Buffer->>DB: 대량 INSERT (100개 로그)
    end

    Timer->>Timer: 5초마다
    Timer->>Buffer: 버퍼 체크
    alt 로그 있음
        Buffer->>DB: 대량 INSERT
    end

    Note over App: 종료 시
    App->>Buffer: 남은 로그 플러시
    Buffer->>DB: 최종 INSERT
@Injectable()
export class PlatformALogsService {
  private readonly logBuffer: Partial<PlatformAOperationLog>[] = [];
  private readonly BUFFER_SIZE = 100;
  private readonly FLUSH_INTERVAL_MS = 5000;
  private flushTimer: NodeJS.Timeout | null = null;

  constructor(
    @InjectRepository(PlatformAOperationLog)
    private readonly repo: Repository<PlatformAOperationLog>,
  ) {
    // 주기적 플러시 타이머
    this.flushTimer = setInterval(() => {
      if (this.logBuffer.length > 0) {
        this.flushLogBuffer();
      }
    }, this.FLUSH_INTERVAL_MS);
  }

  /**
   * Fire-and-forget 로깅
   * - 메인 로직 블로킹 없음
   * - 버퍼가 가득 차면 자동 플러시
   */
  logOperation(params: CreateOperationLogDto): void {  // async 아님!
    const logData = {
      id: uuidv4(),
      userId: params.userId,
      operationType: params.operationType,
      status: params.status,
      // ... 데이터 변환
      requestPayload: this.sanitizePayload(params.requestPayload),
    };

    this.logBuffer.push(logData);

    // 버퍼 가득 참 → 즉시 플러시
    if (this.logBuffer.length >= this.BUFFER_SIZE) {
      this.flushLogBuffer();
    }
  }

  /**
   * 배치 insert로 성능 최적화
   */
  private async flushLogBuffer(): Promise<void> {
    if (this.logBuffer.length === 0) return;

    const logsToSave = this.logBuffer.splice(0, this.logBuffer.length);

    try {
      // 단일 INSERT로 100건 한번에 저장
      await this.repo.insert(logsToSave);
      this.logger.debug(`Flushed ${logsToSave.length} logs`);
    } catch (error) {
      this.logger.error(`Bulk insert failed, trying chunked fallback`, error);
      // 폴백: 10건씩 재시도
      await this.saveLogsWithChunking(logsToSave, 10);
    }
  }

  /**
   * 청크 단위 저장 (폴백 전략)
   */
  private async saveLogsWithChunking(logs: any[], chunkSize: number) {
    for (let i = 0; i < logs.length; i += chunkSize) {
      const chunk = logs.slice(i, i + chunkSize);
      try {
        await this.repo.insert(chunk);
      } catch (chunkError) {
        // 청크도 실패 → 개별 저장
        for (const log of chunk) {
          try {
            await this.repo.insert(log);
          } catch (individualError) {
            this.logger.error(`Failed to save log: ${log.id}`, individualError);
          }
        }
      }
    }
  }

  /**
   * Graceful Shutdown: 앱 종료 시 버퍼 플러시
   */
  async onModuleDestroy(): Promise<void> {
    if (this.flushTimer) {
      clearInterval(this.flushTimer);
    }
    // 남은 로그 저장
    if (this.logBuffer.length > 0) {
      await this.flushLogBuffer();
    }
  }

  /**
   * 민감정보 제거
   */
  private sanitizePayload(payload?: Record<string, unknown>): Record<string, unknown> | null {
    if (!payload) return null;
    const sanitized = { ...payload };
    const sensitiveKeys = ['password', 'token', 'secret', 'apiKey'];
    for (const key of sensitiveKeys) {
      if (key in sanitized) {
        sanitized[key] = '[REDACTED]';
      }
    }
    return sanitized;
  }
}

성능 개선 효과:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph LR
    subgraph "Before: 동기 로깅"
        A1["레이턴시<br/><b>50ms</b>"]
        A2["DB 커넥션<br/><b>100개</b>"]
        A3["처리량<br/><b>100 logs/s</b>"]
    end

    subgraph "After: 버퍼 + 배치"
        B1["레이턴시<br/><b>&lt;1ms</b><br/>[50배 개선]"]
        B2["DB 커넥션<br/><b>1-2개</b><br/>[50배 절감]"]
        B3["처리량<br/><b>10,000+ logs/s</b><br/>[100배 개선]"]
    end

    A1 -.대폭 개선.-> B1
    A2 -.대폭 개선.-> B2
    A3 -.대폭 개선.-> B3

    style A1 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style A2 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style A3 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style B1 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style B2 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style B3 fill:#51cf66,stroke:#2b8a3e,color:#fff

요약:

트레이드오프:

손실이득판단
❌ 최대 5초 지연✅ 메인 로직 블로킹 제거Acceptable (로그는 critical path 아님)
❌ 메모리 사용 증가✅ DB 부하 99% 감소Acceptable (버퍼 크기 제한)
❌ 강제 종료 시 손실✅ Graceful shutdown 시 안전Acceptable (99.9% 케이스)

6. Data Lifecycle Management

6.1 문제: 로그 테이블의 무한 증가

로그 테이블 설계를 끝내고 나니 또 다른 문제가 보였습니다. PlatformAOperationLog는 append-only라서 데이터가 계속 쌓이기만 합니다:

6.2 해결 1: Time-Based Partitioning

PostgreSQL 공식 파티셔닝 문서pg_partman 확장을 참고해서 월별 파티셔닝을 적용했습니다.

-- PostgreSQL 월별 파티셔닝
CREATE TABLE platform_a_operation_logs (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    operation_type VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    -- ... 기타 컬럼들
) PARTITION BY RANGE (created_at);

-- 파티션 생성
CREATE TABLE platform_a_operation_logs_202601
    PARTITION OF platform_a_operation_logs
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE platform_a_operation_logs_202602
    PARTITION OF platform_a_operation_logs
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- 자동 파티션 생성 스크립트 (pg_partman 사용)

Partition Pruning 효과:

graph LR
    subgraph "파티션된 테이블"
        Q[쿼리: WHERE created_at >= '2026-01-01']
        P1[2026년 1월<br/>파티션]
        P2[2026년 2월<br/>파티션]
        P3[2026년 3월<br/>파티션]
        P4[...]

        Q -->|스캔| P1
        Q -.제외.-> P2
        Q -.제외.-> P3
        Q -.제외.-> P4
    end

    style P1 fill:#90EE90
    style P2 fill:#FFB6C1
    style P3 fill:#FFB6C1
    style P4 fill:#FFB6C1
-- 1월 데이터만 스캔 (다른 파티션은 스킵)
SELECT * FROM platform_a_operation_logs
WHERE created_at >= '2026-01-01'
  AND created_at < '2026-02-01'
  AND status = 'FAILED';

-- Execution Plan:
-- Seq Scan on platform_a_operation_logs_202601
-- (11 other partitions pruned)

빠른 삭제:

-- BAD: 수 시간 소요, 테이블 락
DELETE FROM platform_a_operation_logs
WHERE created_at < '2025-01-01';

-- GOOD: 수 ms 소요, 락 없음
DROP TABLE platform_a_operation_logs_202501;

6.3 해결 2: Hot/Cold Data 아카이브

파티셔닝만으로는 충분하지 않았습니다. 90일이 지난 데이터를 자주 조회하는 경우는 거의 없는데, SSD에 계속 두는 건 비용 낭비였습니다.

graph TD
    A[Hot Data: 90일<br/>SSD 스토리지<br/>모든 인덱스<br/>빠른 쿼리] -->|매일 새벽 3시 아카이브 작업| B[Cold Data: 2년<br/>HDD/S3 스토리지<br/>최소 인덱스<br/>느린 쿼리]
    B -->|2년 경과| C[삭제됨]

    style A fill:#ff6b6b
    style B fill:#4dabf7
    style C fill:#dee2e6

아카이브 배치 작업:

@Injectable()
export class DataArchiveService {
  @Cron('0 3 * * *') // 매일 새벽 3시
  async archiveOldLogs() {
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - 90);

    // 1. 90일 이상 된 성공 로그만 아카이브
    await this.archiveRepo
      .createQueryBuilder()
      .insert()
      .into(PlatformAOperationLogArchive)
      .from(PlatformAOperationLog)
      .where('created_at < :cutoffDate', { cutoffDate })
      .andWhere('status = :status', { status: 'SUCCESS' })
      .execute();

    // 2. 원본 삭제
    const result = await this.logRepo
      .createQueryBuilder()
      .delete()
      .where('created_at < :cutoffDate', { cutoffDate })
      .andWhere('status = :status', { status: 'SUCCESS' })
      .execute();

    this.logger.log(`Archived ${result.affected} logs`);
  }
}

정책:

비용 절감:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
    subgraph "스토리지 비용 비교 (360GB 기준)"
        A1["[Before] All SSD<br/><b>$36/월</b><br/>360GB × $0.10"]
        A2["[After] Hot/Cold 분리<br/><b>$11.7/월</b><br/>90일 SSD (90GB × $0.10 = $9)<br/>+ 270일 S3 (270GB × $0.01 = $2.7)"]
        A3["[결과] <b>67% 절감</b><br/>$36 -> $11.7"]

        A1 -.아카이브 적용.-> A2
        A2 --> A3
    end

    style A1 fill:#ff6b6b,stroke:#c92a2a,color:#fff
    style A2 fill:#51cf66,stroke:#2b8a3e,color:#fff
    style A3 fill:#d3f9d8,stroke:#51cf66,stroke-width:4px

실제 계산 (1년 기준):

7. Observability-Driven Schema Design

7.1 메트릭을 염두에 둔 필드 설계

스키마를 설계하면서 가장 신경 썼던 부분 중 하나입니다. “나중에 모니터링 붙이면 되겠지”라고 미루면 결국 데이터가 없어서 대시보드를 못 만드는 상황이 옵니다.

저장한 모든 필드가 Prometheus/Grafana 메트릭으로 전환 가능하도록 설계했습니다:

필드Metric 타입예시
executionTimeMsHistogramhistogram_quantile(0.99, operation_duration_ms)
statusCounteroperation_success_rate{operation="GET_REVIEWS"}
errorTypeCountererrors_total{type="TIMEOUT"}
proxyUsedGaugeproxy_success_rate{proxy="proxy-1"}
retryCountHistogramavg(retry_count) by (operation_type)

7.2 Grafana 대시보드 설계 (예상)

┌──────────────────────────────────────────────────┐
│  Platform A Operations Dashboard                 │
├──────────────────────────────────────────────────┤
│                                                  │
│  [Success Rate]              [Avg Latency]       │
│   95.3%  (↑ 0.5%)             350ms  (↓ 50ms)   │
│                                                  │
│  [Success Rate by Operation Type]               │
│   GET_REVIEWS:  98.1%  ████████████████░░░      │
│   ADD_REPLY:    92.5%  ████████████░░░░░░       │
│   CHECK_LOGIN:  99.2%  █████████████████░       │
│                                                  │
│  [Error Type Distribution (24h)]                 │
│   TIMEOUT:              35%                      │
│   SELECTOR_NOT_FOUND:   25%                      │
│   AUTH_REQUIRED:        20%                      │
│   RATE_LIMIT:           15%                      │
│   Other:                 5%                      │
│                                                  │
│  [Session Stability Distribution]                │
│   STABLE (≥80):     2,450 users  (70%)          │
│   UNSTABLE (50-80):   800 users  (23%)          │
│   CRITICAL (<50):     250 users  (7%)           │
│                                                  │
│  [P99 Latency by Operation]                      │
│   (Time series graph)                            │
│                                                  │
└──────────────────────────────────────────────────┘

7.3 Alert 규칙

# Prometheus Alert Rules
groups:
  - name: platform_a_operations
    rules:
      # 성공률 90% 이하 시 알림
      - alert: LowSuccessRate
        expr: |
          rate(operations_total{status="SUCCESS"}[5m])
          / rate(operations_total[5m]) < 0.9
        for: 10m
        labels:
          severity: warning

      # P99 레이턴시 5초 초과
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            rate(operation_duration_ms_bucket[5m])) > 5000
        for: 5m
        labels:
          severity: critical

      # 인증 에러 급증
      - alert: AuthErrorSpike
        expr: |
          rate(operations_total{errorType="AUTH_REQUIRED"}[5m]) > 10
        for: 5m
        labels:
          severity: warning

7.4 운영팀을 위한 쿼리

-- 1. 특정 사용자의 최근 실패 로그
SELECT
  created_at,
  operation_type,
  error_type,
  error_message,
  execution_time_ms,
  proxy_used
FROM platform_a_operation_logs
WHERE user_id = :userId
  AND status = 'FAILED'
  AND created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 50;

-- 2. 세션 불안정 사용자 목록
SELECT
  u.email,
  p.platform_account_id,
  p.session_stability_score,
  p.failed_login_count,
  p.last_login_at
FROM platform_a_user_profiles p
JOIN users u ON p.user_id = u.user_id
WHERE p.session_stability_score < 50
ORDER BY p.session_stability_score ASC;

-- 3. 프록시별 성공률
SELECT
  proxy_used,
  COUNT(*) FILTER (WHERE status = 'SUCCESS') * 100.0 / COUNT(*) AS success_rate,
  AVG(execution_time_ms) AS avg_latency,
  COUNT(*) AS total_ops
FROM platform_a_operation_logs
WHERE created_at >= NOW() - INTERVAL '24 hours'
  AND proxy_used IS NOT NULL
GROUP BY proxy_used
ORDER BY success_rate DESC;

-- 4. 시간대별 실패율 추이
SELECT
  DATE_TRUNC('hour', created_at) AS hour,
  operation_type,
  COUNT(*) FILTER (WHERE status = 'FAILED') * 100.0 / COUNT(*) AS failure_rate
FROM platform_a_operation_logs
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('hour', created_at), operation_type
ORDER BY hour, operation_type;

8. Trade-offs & Decision Matrix

모든 설계 결정의 트레이드오프 분석:

8.1 정규화 vs 비정규화

결정근거
❌ user.email 중복 금지3NF 준수, PII 최소화데이터 일관성조회 시 JOIN 필요
✅ externalShopId 추가외부 식별자 ≠ 중복멱등성 보장스토리지 증가 (미미)
✅ recentLoginWindow (JSON)쿼리 간소화빠른 상태 조회복잡한 JSON 쿼리 어려움

원칙: 정규화 우선, 성능 필요 시 측정 후 비정규화

8.2 실시간성 vs 처리량

결정판단
✅ 버퍼 배치 처리처리량 100배 증가최대 5초 지연Acceptable (로그는 critical path 아님)
✅ Fire-and-forget메인 로직 블로킹 제거로그 저장 실패 무시Acceptable (best-effort delivery)
✅ Graceful shutdown99.9% 로그 보존강제 종료 시 손실Acceptable (운영 정책으로 커버)

8.3 인덱스: 성능 vs 스토리지

결정다음 단계
✅ 9개 인덱스 (초기)다양한 쿼리 지원인덱스 크기 = 테이블의 50%EXPLAIN 분석 후 제거
⚠️ 4컬럼 복합 인덱스복합 조건 쿼리 최적화leftmost prefix 제약A/B 테스트
❌ 낮은 카디널리티 인덱스(효과 거의 없음)스토리지 낭비제거 예정

원칙: “추측하지 말고 측정하라” - EXPLAIN ANALYZE가 진리

8.4 파티셔닝 비용

결정
✅ 월별 파티셔닝Partition pruning으로 쿼리 10배 고속화파티션 관리 복잡도
✅ 90일 보존운영 데이터는 빠르게 조회장기 분석 시 아카이브 쿼리 느림
⚠️ Raw SQL 마이그레이션파티셔닝 가능ORM 추상화 깨짐

교훈: 파티셔닝은 마지막 수단. 인덱스 최적화가 우선.

9. Lessons Learned

9.1 성공 요인

  1. 문제를 깊이 이해하려고 했다

    • “로그를 저장한다”에서 끝내지 않고
    • “세션 안정성을 정량화하고, 패턴을 분석하며, 사전 대응한다”까지 생각하려고 노력했습니다
  2. 팀 내 토론과 검증

    • 설계를 혼자 결정하지 않고 팀원들과 논쟁했습니다
    • 스테이징 환경에서 EXPLAIN과 부하 테스트로 검증했습니다
  3. 테스트 우선 개발

    • 도메인 로직은 테스트 먼저 작성했습니다
    • 95% 커버리지를 달성했지만, 커버리지 숫자보다 중요한 건 실제 버그를 잡았는지였습니다
  4. 점진적 최적화

    • 초기에는 보수적으로 인덱스를 많이 걸었습니다
    • 프로덕션 데이터로 측정한 후 불필요한 건 제거했습니다

9.2 보편적 원칙

원칙 1: 도메인을 깊이 이해하라

"데이터를 저장한다" ≠ "도메인을 모델링한다"

비즈니스 도메인을 코드로 표현하는 것이 설계의 시작.

원칙 2: 정규화는 원칙, 비정규화는 예외

성능 문제가 측정되기 전까지는 정규화 유지
비정규화는 반드시 근거 문서화

원칙 3: 인덱스는 가설이다

초기: 보수적으로 많이 생성
측정: EXPLAIN ANALYZE로 검증
최적화: 불필요한 것 제거

원칙 4: 로깅은 일급 시민이다

"나중에 추가" ❌
초기 설계부터 관측성 고려 ✅

원칙 5: 트레이드오프를 명시하라

모든 결정은 트레이드오프
"왜 이렇게 했는가?" 문서화

9.3 스케일링 전략

현재 설계는 수백만 사용자, 수억 로그까지 대응 가능. 더 큰 규모로 가려면:

단계 1: 로그 DB 분리

┌────────────┐     ┌────────────┐
│ Main DB    │     │  Log DB    │
│ (트랜잭션)  │     │  (로그)     │
└────────────┘     └────────────┘

단계 2: 시계열 DB 전환

단계 3: 스트리밍 아키텍처

Application → Kafka → ClickHouse
                   → Elasticsearch
                   → S3 (장기 보관)

10. 마치며

돌아보면 이 프로젝트는 단순히 “테이블 3개 추가”가 아니었습니다. 오히려 그동안 미뤄왔던 구조적 문제와 정면으로 마주해야 했던 시간이었습니다.

우리가 시도한 것들:

제가 배운 것들:

  1. 정규화가 기본입니다. 비정규화는 측정한 후에
  2. 인덱스는 가설입니다. EXPLAIN이 진리입니다
  3. 로깅은 나중이 아니라 처음부터 설계해야 합니다
  4. 모든 결정에는 트레이드오프가 있고, 그걸 명시해야 합니다

이게 정답이라고 말씀드리기는 어렵습니다. 우리 팀 규모와 트래픽 수준에서 합리적이었던 선택일 뿐입니다. 비슷한 고민을 하고 계신 분들께 조금이나마 참고가 되었으면 합니다. 더 나은 방법을 알고 계시다면 댓글로 공유해주세요.


이 글은 실제 프로덕션 시스템 설계 경험을 바탕으로 작성되었습니다.

참고 자료:


Share this post on:

Previous Post
대규모 브라우저 자동화 시스템의 메모리 누수 해결기: 3개의 정리 경로가 만든 완벽한 폭풍
Next Post
Kotlin 표준 라이브러리 toSet() 해부: 엔지니어링은 선택에 대한 설명이다