dev.syw

대규모 모듈 아키텍처, 디자인 패턴, 번들러·트랜스파일, 배포·런타임 모니터링까지 실전 운영을 다룬다.

설계 패턴·빌드·운영 배포

모듈 문법을 익히는 것과 모듈을 활용해 대규모 애플리케이션을 설계하는 것은 전혀 다른 이야기입니다. 파일이 수백 개를 넘어서면 "어디에 무엇을 두는가", "경계를 어떻게 긋는가", "의존 관계를 어떻게 제어하는가"가 유지보수의 핵심 변수가 됩니다.

이 레슨은 입문편 8강(모듈 시스템)에서 배운 import/export 문법을 전제로, 그 위에 쌓이는 아키텍처 설계·번들·배포·운영 관점을 종합합니다. 코드가 팀 단위로 커지고, 프로덕션 환경에서 안정적으로 동작해야 할 때 반드시 마주치는 주제들을 다룹니다.

학습 목표

  • 옵저버·전략·어댑터·의존성 역전 패턴을 모듈 경계에 실전 적용할 수 있다.
  • 레이어드 아키텍처로 코드를 구조화하고 순환 의존성을 진단·제거할 수 있다.
  • esbuild/Rollup/Vite의 번들링 동작 원리와 트리 셰이킹 조건을 이해한다.
  • sideEffects·exports 등 패키지 필드를 올바르게 설계할 수 있다.
  • 캐시 버스팅·CDN·점진적 롤아웃·소스맵 기반 에러 추적 등 운영 전략을 적용할 수 있다.

실전 디자인 패턴: 모듈 경계에서의 적용

옵저버(Observer) — 이벤트 버스

옵저버 패턴은 발행자(publisher)가 구독자(subscriber)를 직접 알지 않아도 이벤트를 전달할 수 있게 합니다. 대규모 앱에서 모듈 간 직접 의존을 끊고 느슨하게 연결하는 핵심 도구입니다.

// event-bus.js  —  타입 안전한 이벤트 버스
const listeners = new Map();

export const EventBus = {
  on(event, handler) {
    if (!listeners.has(event)) listeners.set(event, new Set());
    listeners.get(event).add(handler);
    return () => listeners.get(event).delete(handler); // unsubscribe 반환
  },
  emit(event, payload) {
    listeners.get(event)?.forEach(fn => fn(payload));
  },
};

// order-module.js
import { EventBus } from './event-bus.js';

export function placeOrder(order) {
  // ... 주문 처리 로직
  EventBus.emit('order:placed', order); // ✅ 알림 모듈을 직접 import 하지 않음
}

// notification-module.js
import { EventBus } from './event-bus.js';

EventBus.on('order:placed', (order) => {
  console.log(`주문 확인 메일 발송: ${order.id}`);
});
JavaScript

💡 TIP on()이 반환하는 정리 함수를 반드시 호출해야 메모리 누수를 막을 수 있습니다. React라면 useEffect 정리 단계에, Node.js 서비스라면 종료 훅에 넣으세요.

전략(Strategy) — 런타임 알고리즘 교체

전략 패턴은 if/else나 switch로 분기하는 로직을 별도 객체로 분리해, 런타임에 알고리즘을 교체할 수 있게 합니다.

// pricing-strategies.js
export const standardPricing = {
  calculate(price) { return price; },
};

export const memberPricing = {
  calculate(price) { return price * 0.9; },
};

export const vipPricing = {
  calculate(price) { return price * 0.75; },
};

// checkout.js
export class Checkout {
  #strategy;

  constructor(strategy = standardPricing) {
    this.#strategy = strategy;
  }

  setStrategy(strategy) {        // 런타임 교체 가능
    this.#strategy = strategy;
  }

  getTotal(price) {
    return this.#strategy.calculate(price);
  }
}

// 사용
import { Checkout } from './checkout.js';
import { vipPricing } from './pricing-strategies.js';

const checkout = new Checkout();
checkout.setStrategy(vipPricing);
console.log(checkout.getTotal(10000)); // 7500
JavaScript

어댑터(Adapter) — 외부 의존성 격리

서드파티 라이브러리나 API를 직접 호출하면 라이브러리가 바뀔 때 사용 지점이 모두 바뀝니다. 어댑터로 감싸면 내부 코드는 안정적인 자체 인터페이스만 바라봅니다.

// logger-adapter.js  —  내부 인터페이스 정의
export const logger = {
  info: (msg, meta) => void 0,
  error: (msg, meta) => void 0,
};

// logger-winston.adapter.js  —  실제 구현
import winston from 'winston';

const winstonLogger = winston.createLogger({ /* ... */ });

export const logger = {
  info(msg, meta)  { winstonLogger.info(msg, meta); },
  error(msg, meta) { winstonLogger.error(msg, meta); },
};

// 앱 코드는 logger-adapter 인터페이스만 사용
// Winston → Pino 로 교체해도 앱 코드는 변경 없음 ✅
JavaScript

의존성 역전(DIP) — 고수준 모듈이 저수준을 몰라야 한다

고수준 비즈니스 로직이 저수준 구현 세부사항(파일 I/O, DB, HTTP)에 직접 의존하면 테스트와 교체가 어렵습니다.

// ❌ 나쁜 예: 비즈니스 로직이 구현 상세에 의존
import { saveToMySQL } from './mysql-adapter.js';

export async function createUser(user) {
  await saveToMySQL(user); // MySQL이 바뀌면 여기도 바뀜
}

// ✅ 좋은 예: 추상화(인터페이스)에 의존
export async function createUser(user, userRepository) {
  await userRepository.save(user); // 어떤 저장소든 상관없음
}

// 합성 루트(Composition Root)에서 주입
import { createUser } from './user-service.js';
import { mysqlUserRepository } from './mysql-user-repository.js';

createUser(newUser, mysqlUserRepository);
JavaScript

⚠️ 주의 JavaScript에는 인터페이스 타입이 없으므로 약속된 메서드 계약을 문서나 JSDoc으로 명확히 명시해야 합니다. TypeScript를 쓴다면 interface UserRepository를 선언하세요.

대규모 코드 구조화

레이어드 아키텍처

계층형 아키텍처는 의존 방향을 단방향으로 고정하는 구조입니다. 전형적인 4계층은 다음과 같습니다.

계층역할예시 파일
PresentationUI·API 엔드포인트routes/, controllers/
Application유스케이스 오케스트레이션services/, use-cases/
Domain비즈니스 규칙·엔티티domain/, entities/
InfrastructureDB·HTTP·파일 I/Orepositories/, adapters/
src/
├── presentation/
│   └── api/routes/user.route.js
├── application/
│   └── use-cases/create-user.usecase.js
├── domain/
│   ├── entities/user.entity.js
│   └── repositories/user.repository.js  ← 인터페이스만
└── infrastructure/
    └── persistence/mysql-user.repository.js  ← 구현체

규칙은 단순합니다. 위 계층은 아래 계층을 참조할 수 있지만, 아래 계층은 위 계층을 절대 참조하지 않습니다. Domain은 Infrastructure를 모릅니다.

순환 의존성 진단과 제거

순환 의존성(A → B → A)은 모듈 초기화 순서가 보장되지 않아 런타임에 undefined 참조 오류를 일으킬 수 있습니다.

# madge 로 순환 의존성 시각화
npx madge --circular src/

# 예시 출력
# Circular dependency found!
# src/user-service.js > src/order-service.js > src/user-service.js

제거 전략은 두 가지입니다.

1. 공통 의존성 추출: 두 모듈이 공통으로 필요한 타입·상수를 별도 파일로 분리합니다.

// ❌ 순환
// user-service.js  → order-service.js → user-service.js

// ✅ 공통 타입을 shared/types.js 로 분리
// user-service.js → shared/types.js
// order-service.js → shared/types.js
JavaScript

2. 이벤트 버스 도입: 앞서 본 옵저버 패턴으로 직접 참조를 이벤트 참조로 대체합니다.

번들러 동작 원리와 트리 셰이킹

esbuild / Rollup / Vite 비교

도구핵심 특징주 사용처
esbuildGo 기반, 극단적 빌드 속도라이브러리·서버 번들, Vite 내부
RollupESM 기반, 탁월한 트리 셰이킹npm 라이브러리 패키징
Viteesbuild(개발) + Rollup(프로덕션) 조합웹 앱 개발/배포

Vite의 빌드 흐름을 이해하면 세 도구를 동시에 이해할 수 있습니다.

개발 서버        프로덕션 빌드
┌──────────┐    ┌──────────────────┐
│ esbuild  │    │ Rollup           │
│ deps 사전│    │ · 번들+청크 분할 │
│ 번들링   │    │ · 트리 셰이킹    │
│ HMR 지원 │    │ · 소스맵 생성    │
└──────────┘    └──────────────────┘

트리 셰이킹 조건

번들러가 dead code를 제거하려면 반드시 ESM 정적 구문(import/export)을 사용해야 합니다. CommonJS의 동적 require()는 런타임까지 무엇을 가져오는지 알 수 없어 트리 셰이킹이 불가능합니다.

// ✅ ESM — 트리 셰이킹 가능
export function used()   { /* ... */ }
export function unused() { /* ... */ }  // 사용 안 하면 번들에서 제외

// ❌ CJS — 트리 셰이킹 불가
module.exports = {
  used()   { /* ... */ },
  unused() { /* ... */ },  // 항상 포함됨
};
JavaScript

사이드이펙트(전역 변수 수정, CSS 주입 등)가 있는 파일은 번들러가 임포트 여부와 무관하게 포함시킵니다. package.jsonsideEffects 필드로 이를 명시하면 불필요한 포함을 막을 수 있습니다.

모든 파일에 사이드이펙트가 없다면 false로 선언합니다.

{
  "name": "my-library",
  "sideEffects": false
}

CSS 주입이나 폴리필처럼 사이드이펙트가 있는 파일만 예외로 두려면 배열로 명시합니다(이 파일들은 임포트하지 않아도 번들에 유지됩니다).

{
  "name": "my-library",
  "sideEffects": ["./src/polyfills.js", "*.css"]
}

exports 맵으로 패키지 진입점 설계

exports 필드를 사용하면 번들러·Node.js 조건(ESM/CJS/개발/운영)에 따라 다른 진입점을 제공할 수 있습니다.

{
  "name": "my-library",
  "exports": {
    ".": {
      "import": "./dist/index.esm.js",   // ESM 환경
      "require": "./dist/index.cjs.js",  // CJS 환경
      "types": "./dist/index.d.ts"       // TypeScript
    },
    "./utils": {
      "import": "./dist/utils.esm.js",
      "require": "./dist/utils.cjs.js"
    }
  }
}

⚠️ 주의 exports 필드가 선언되면 해당 경로 외의 내부 경로 직접 접근이 차단됩니다(import 'my-library/src/internal.js' 불가). 이는 의도된 캡슐화지만, 내부 모듈에 의존하던 기존 코드가 깨질 수 있으니 major 버전에 도입하세요.

트랜스파일·폴리필·타깃 설정

// vite.config.js — 빌드 타깃 설정
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: ['es2020', 'chrome90', 'firefox88', 'safari14'],
    // 구형 브라우저 지원이 필요하면 @vitejs/plugin-legacy 사용
  },
});
JavaScript
# .browserslistrc — Babel/PostCSS 공통 타깃 설정
last 2 Chrome versions
last 2 Firefox versions
> 0.5%
not dead

트랜스파일(문법 변환)과 폴리필(런타임 기능 주입)은 다릅니다. ?. 옵셔널 체이닝을 구형 문법으로 바꾸는 것은 트랜스파일이고, Array.prototype.at()을 없는 환경에 추가하는 것은 폴리필입니다. 번들 크기를 줄이려면 폴리필을 필요한 환경에만 조건부로 주입하는 core-js + useBuiltIns: 'usage' 전략을 씁니다.

배포 전략

캐시 버스팅과 해시 파일명

브라우저 캐시를 최대한 활용하면서 새 배포를 즉시 반영하려면 콘텐츠 기반 해시 파일명이 필수입니다.

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 진입점: 항상 새로운 이름으로 → HTML이 참조, 캐시 무효화
        entryFileNames: 'assets/[name]-[hash].js',
        // 청크: 내용이 변하지 않으면 같은 이름 유지 → 최대 캐시 활용
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
      },
    },
  },
});
JavaScript

이 설정이 만들어내는 배포 패턴을 정리하면 다음과 같습니다.

파일 유형캐시 정책이유
index.htmlno-cache항상 최신 진입점 참조
app-[hash].jsimmutable, 1년해시가 달라지면 새 URL
vendor-[hash].jsimmutable, 1년의존성 변경 없으면 동일 해시
*.svg, *.pngimmutable, 1년동일 정책

CDN과 점진적 롤아웃

# 정적 파일을 S3에 업로드하고 CloudFront 무효화
aws s3 sync ./dist s3://my-app-bucket --cache-control "max-age=31536000,immutable" \
  --exclude "index.html"
aws s3 cp ./dist/index.html s3://my-app-bucket/index.html \
  --cache-control "no-cache, no-store"
aws cloudfront create-invalidation \
  --distribution-id ABCDE12345 \
  --paths "/index.html"

점진적 롤아웃(Progressive Rollout)은 전체 사용자에게 한 번에 배포하는 대신 일부 트래픽에만 새 버전을 노출하는 전략입니다.

// feature-flag.js — 간단한 퍼센트 기반 플래그
export function isFeatureEnabled(featureName, userId) {
  const flags = {
    'new-checkout': { enabled: true, rolloutPercent: 20 },
    'ai-search':    { enabled: false, rolloutPercent: 0 },
  };

  const flag = flags[featureName];
  if (!flag?.enabled) return false;

  // 사용자 ID를 해시해 결정론적으로 버킷 배정
  const hash = [...userId].reduce((acc, c) => acc + c.charCodeAt(0), 0);
  return (hash % 100) < flag.rolloutPercent;
}

// 사용
if (isFeatureEnabled('new-checkout', currentUser.id)) {
  renderNewCheckout();
} else {
  renderLegacyCheckout();
}
JavaScript

실무에서는 LaunchDarkly, GrowthBook, Flagsmith 같은 전문 기능 플래그 서비스를 사용하면 배포 없이 즉시 플래그를 토글할 수 있습니다.

운영 모니터링

소스맵 기반 에러 추적

프로덕션 빌드는 난독화·압축되어 있어 스택 트레이스만으로는 디버깅이 불가능합니다. 소스맵을 사용하면 압축된 번들의 위치를 원본 소스 위치로 역추적할 수 있습니다.

// vite.config.js — 소스맵 설정
export default defineConfig({
  build: {
    sourcemap: true,          // ✅ 번들과 함께 .map 파일 생성
    // sourcemap: 'hidden',   // 번들에 참조 없이 .map 만 생성 (Sentry 업로드용)
  },
});
JavaScript

⚠️ 주의 sourcemap: true를 쓰면 .map 파일에 원본 소스가 노출됩니다. 외부에서 접근 가능한 CDN에 업로드하지 마세요. Sentry 같은 에러 추적 서비스에만 업로드하고 공개 서버에서는 숨기는 hidden 모드가 권장됩니다.

Sentry 통합

// main.js — Sentry 초기화 (앱 진입점에서 최우선 실행)
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.MODE,   // 'production' | 'staging'
  release: import.meta.env.VITE_APP_VERSION, // 예: git SHA
  integrations: [
    Sentry.browserTracingIntegration(),
    Sentry.replayIntegration({ maskAllText: true }), // Session Replay
  ],
  tracesSampleRate: 0.1,   // 프로덕션에서 10% 트레이스
  replaysOnErrorSampleRate: 1.0, // 에러 발생 시 100% 리플레이
});

// 커스텀 컨텍스트 추가
Sentry.setUser({ id: currentUser.id, email: currentUser.email });

// 경계를 넘기는 에러 수동 캡처
try {
  await processPayment(order);
} catch (err) {
  Sentry.captureException(err, {
    tags: { area: 'payment' },
    extra: { orderId: order.id },
  });
  throw err; // 상위로 재전파
}
JavaScript

배포 시 소스맵을 Sentry에 업로드하면 원본 파일명·줄 번호로 에러를 확인할 수 있습니다.

# sentry-cli로 소스맵 업로드 (CI/CD 파이프라인)
npx @sentry/cli releases new "$VERSION"
npx @sentry/cli releases files "$VERSION" upload-sourcemaps ./dist \
  --url-prefix '~/assets'
npx @sentry/cli releases finalize "$VERSION"

RUM(Real User Monitoring)과 Web Vitals

// web-vitals.js — Core Web Vitals 수집
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics({ name, value, rating, id }) {
  // rating: 'good' | 'needs-improvement' | 'poor'
  fetch('/api/metrics', {
    method: 'POST',
    body: JSON.stringify({ name, value, rating, pageUrl: location.href }),
    keepalive: true,  // 페이지 언로드 시에도 전송 보장
  });
}

onCLS(sendToAnalytics);   // Cumulative Layout Shift
onINP(sendToAnalytics);   // Interaction to Next Paint  (2024~ FID 대체)
onLCP(sendToAnalytics);   // Largest Contentful Paint
onFCP(sendToAnalytics);   // First Contentful Paint
onTTFB(sendToAnalytics);  // Time to First Byte
JavaScript
지표좋음개선 필요나쁨
LCP≤ 2.5s2.5–4s> 4s
INP≤ 200ms200–500ms> 500ms
CLS≤ 0.10.1–0.25> 0.25

💡 TIP keepalive: true 옵션은 fetch가 페이지 언로드 이후에도 요청을 완료할 수 있게 합니다. navigator.sendBeacon()의 현대적 대안입니다.

요약

  • 디자인 패턴은 모듈 경계에서 진가를 발휘합니다. 옵저버로 직접 의존을 끊고, 어댑터로 외부 라이브러리를 격리하며, DIP로 비즈니스 로직을 구현 세부사항에서 독립시키세요.
  • 레이어드 아키텍처는 의존 방향을 단방향으로 고정해 변경의 영향 범위를 예측 가능하게 만듭니다. madge 같은 도구로 순환 의존성을 주기적으로 점검하세요.
  • 트리 셰이킹은 ESM 정적 구문이 전제입니다. sideEffects: falseexports 맵을 올바르게 설정해야 번들 크기를 최소화할 수 있습니다.
  • 해시 파일명 + CDN immutable 캐시는 성능과 즉각적 배포를 동시에 달성하는 황금률입니다. index.htmlno-cache로 유지하세요.
  • 점진적 롤아웃과 기능 플래그는 대규모 서비스에서 위험을 분산하는 필수 배포 전략입니다.
  • 소스맵 + Sentry + Web Vitals를 조합하면 프로덕션 에러를 원본 코드 수준에서 추적하고 사용자 경험 저하를 수치로 감지할 수 있습니다.

연습문제

  1. 아래 두 모듈은 순환 의존성을 가집니다. 어떤 방법으로 해소할 수 있는지 코드로 보여주세요.

    // cart-service.js
    import { getUserDiscount } from './user-service.js';
    export function getCartTotal(cart, userId) { /* ... */ }
    
    // user-service.js
    import { getCartTotal } from './cart-service.js';
    export function getUserDiscount(userId) { /* ... */ }
    
    JavaScript

    힌트 두 모듈이 공통으로 필요한 인터페이스를 별도 파일로 추출하거나, 이벤트 버스로 직접 참조를 제거해 보세요.

  2. 다음 라이브러리의 package.json에 ESM/CJS 이중 진입점을 가지는 exports 맵을 작성하세요. 진입점은 dist/index.esm.js(ESM)와 dist/index.cjs.js(CJS)입니다. sideEffects 필드도 함께 설정하세요.

    힌트 exports["."] 아래에 "import""require" 조건을 중첩하세요.

  3. 아래 전략 패턴 코드에서 결제 수단(card, virtualAccount, transfer)을 전략으로 구현하되, 각 전략이 { pay(amount): Promise<{ success, txId }> } 인터페이스를 따르도록 작성하세요.

    힌트 각 전략 객체를 paymentStrategies 맵에 등록해 PaymentService가 키로 전략을 선택하게 하면 분기 없이 확장할 수 있습니다.

  4. Vite 프로젝트에서 sourcemap: 'hidden' 모드로 빌드 후 Sentry CLI로 소스맵을 업로드하는 CI 스크립트(package.json scripts 항목)를 작성하세요. SENTRY_AUTH_TOKEN은 환경 변수에서 읽도록 합니다.

    힌트 vite build 이후 sentry-cli releases 명령 3개를 순서대로 실행합니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

JavaScript 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.