Dookmark · Engineering Portfolio

Dookmark 엔지니어링 포트폴리오
노기훈

익스텐션·웹·서버를 아우르는 크로스 플랫폼 북마크 서비스 Dookmark — 이 한 프로젝트에 집중한 엔지니어링 포트폴리오입니다. 프로젝트를 만들며 마주친 아키텍처·성능·테스트·보안·인프라 문제와 그 해결 과정을 실제 코드와 수치로 정리했습니다.

Dookmark · Architecture · 01

익스텐션·웹·서버가 하나의 타입 계약을 공유하는
단일 소스 모노레포

익스텐션·웹·서버가 같은 도메인 타입을 각자 재선언하면서 코드 중복과 타입 불일치 발생. pnpm 워크스페이스로 공유 패키지를 추출하고 TS 설정을 공통화하여, 플랫폼 간 타입 정합성을 컴파일 타임에 정적으로 보장하는 단일 소스 구조 구축.

pnpm workspace TS project references 4 shared packages NestJS · Next.js · WXT runtime: zero
01

Problem · 문제 원인동일 타입의 세 곳 수기 재선언

공유 계층 부재 시절, 플랫폼 경계를 넘는 값은 각 앱에 통째로 복제되어 존재. 한쪽 수정이 다른 쪽에 반영되지 않아 타입이 조용히 어긋나고, 서로 다른 파일이라 컴파일러도 경고 미발생. 기능이 커질수록 ‘어느 파일이 진짜인가’가 모호해지고, 사본 간 동기화 부담 가중.

북마크 네이티브 이벤트 액션  'create' | 'update' | 'delete' | 'move'
extension/domain/bookmarks/api.ts
action: 'create' | 'update' | …
extension/…/background/bookmark-sync.ts
type BookmarkEventAction = …
server/…/dto/bookmark-event.dto.ts
type BookmarkEventAction = …
server/…/handlers/bookmark-event.handler.ts
event: { action: 'create' | … }
DRIFT
동일 유니온이 4곳에 독립 선언. 여기에 DBBookmarkBookmark처럼 이름만 갈라진 타입, 서버 소켓 게이트웨이와 익스텐션 클라이언트가 수기로 맞추던 payload 형태까지 중첩.

Fig 1. 하나의 진실이 네 벌의 사본으로 흩어진 상태

더 큰 비용은 로직 중복. 401 → silent refresh(대기 큐·재시도) 인터셉터가 웹 약 83줄·익스텐션 약 157줄로 각각 복제되어, 인증 흐름 401을 갱신 대상에서 제외하는 버그 수정을 두 곳에 개별 반영 필요. 또한 Prisma의 BrowserType enum과 와이어 유니온이 별개로 존재하여, 한쪽에만 값 추가 시 컴파일러가 잡지 못한 채 런타임까지 불일치 잠복.

02

Solution · 해결 과정단일 소스 구조로 재편, 정합성은 빌드로 강제

해결 방향은 두 축. 첫째, 플랫폼이 공유해야 할 표면을 패키지로 격리해 사본 자체를 제거. 둘째, 사람이 지키던 동기화 규칙을 타입 시스템에 위임해 불일치를 빌드 단계에서 차단.

재편한 구조는 하나의 pnpm 워크스페이스 안에 세 개의 소비자 앱과 이들이 공유하는 계약(contract) 패키지 계층으로 구성. 익스텐션은 WXT, 웹은 Next.js, 서버는 NestJS로 각자의 빌드 도구·런타임을 그대로 유지하되, 플랫폼 경계를 넘나드는 타입·API 클라이언트·상수는 @dookmark/* 패키지에서만 정의하고 workspace:* 프로토콜로 참조. 앱은 계약을 소유하지 않고 소비만 하므로, 진실의 위치가 항상 패키지 한 곳으로 고정.

패키지는 도메인 계약(extension-bridge)·HTTP 클라이언트(api-client)·대시보드 계약(analytics)· 스타일 토큰(theme)으로 역할별 분리. 각 앱은 필요한 계약만 의존성으로 선언하므로, 서버는 UI 코드를, 익스텐션은 서버 내부 로직을 알 필요 없이 ‘공유해야 할 표면’만 공유하는 경계 확보.

Extension
브라우저 확장
WXT · React 19
Web
웹 앱 / 대시보드
Next.js 16
Server
API · 소켓 게이트웨이
NestJS · Prisma
import type { … } from '@dookmark/*'
단일 소스 · Shared Contracts
extension-bridge
북마크 모델 · 소켓 이벤트 · 메시징 · BrowserType
api-client
createApiClient 팩토리 · 세션 타입
analytics
대시보드 응답 계약 · 기간/활동 상수
theme
Tailwind v4 공유 토큰 CSS

Fig 2. 세 소비자 → 공유 계약 계층 (hub-and-spoke)

TS

공통 컴파일러 설정 상속

루트 tsconfig.base.jsonstrict·declaration·forceConsistentCasingInFileNames 등 컴파일 규칙을 단일 정의하고, 모든 패키지가 extends로 이를 상속 → 앱마다 컴파일러 설정이 갈라지는 것을 원천 차단. 루트 tsconfig.jsonreferences로 앱·패키지를 하나의 프로젝트 그래프로 결합하여, 계약 변경 시 의존 앱까지 타입 체크가 전파되도록 구성.

1

도메인 계약을 extension-bridge로 추출

북마크 모델·소켓 이벤트 payload·확장 메시징·BrowserTypenormalizeBookmarkNode 같은 순수 헬퍼를 도메인별(browser / messaging / bookmark)로 정리해 단일 출처로 승격.

2

인터셉터를 createApiClient 팩토리로 통합

복제됐던 refresh 로직을 단일 팩토리로 통합하고, 환경 차이는 TokenManager 주입으로 분기 — 익스텐션은 스토리지 토큰, 웹은 쿠키 기반 적용. 대기 큐·재시도 로직 일원화로 버그 수정이 한 곳에서 종결.

3

대시보드 계약을 analytics로 분리

서버 응답 형태와 DASHBOARD_PERIODS 같은 런타임 상수를 공유하여, 웹 차트 컴포넌트와 서버 집계 로직이 동일 타입을 참조하도록 정렬. 응답 필드 추가 시 차트 쪽 타입 에러로 즉시 감지.

common/browser-type-sync.ts — Prisma enum ↔ bridge union 컴파일 타임 parity 가드

// 두 집합의 차집합을 계산 — 어긋나면 아래 상수 할당에서 컴파일 에러가 난다.
type BrowserTypeMismatch =
  | Exclude<BridgeBrowserType, `${PrismaBrowserType}`>
  | Exclude<`${PrismaBrowserType}`, BridgeBrowserType>;

// 일치하면 true, 어긋나면 불일치 멤버 유니온이 되어 `= true` 할당이 실패
export const BROWSER_TYPE_IN_SYNC:
  BrowserTypeMismatch extends never ? true : BrowserTypeMismatch = true;

테스트가 아니라 빌드 시점에 강제되는 가드. Prisma enum과 wire 유니온 중 한쪽에만 브라우저를 추가/삭제하면 두 집합의 차집합이 발생하여 이 상수 할당이 즉시 컴파일 에러 유발 → 사람이 지키던 동기화 규칙을 타입 시스템에 위임.

패키지는 별도 컴파일 없이 원본 TypeScript를 main으로 노출. 따라서 각 앱이 이 소스를 자기 번들에 인라인하도록 소비 전략을 플랫폼별로 구성 — 런타임에 .ts를 로드하려다 크래시하는 문제를 빌드 단계에서 해소.

Web
transpilePackages
Next.js가 @dookmark/* 소스를 함께 트랜스파일.
Server
webpack allowlist
nest build --webpack + node-externals 예외로 @dookmark/*만 번들에 인라인, 나머지는 externalize.
Extension
WXT / Vite
번들러가 워크스페이스 소스를 그대로 흡수.
03

Result · 결과계약 한 번 수정으로 세 앱 동시 검증

공유 계약 파일 하나 수정 시 세 앱의 소비 지점이 컴파일 타임에 동시 검증. 불일치는 런타임 버그가 아니라 빌드 실패로 표면화되고, 리팩터링 시 수정 범위를 tsc가 안내하는 구조 확보. 단일 소스 계약(약 500줄)이 3개 앱 수십 개 파일의 진실로 기능하며, 계약 계층에는 런타임 의존성이 없어 앱 번들 부담 없이 타입 정합성만 정적으로 공유.

1
앱마다 흩어졌던 액션 유니온을 단일 정의로 통합
2 → 1
복제되던 refresh 인터셉터를 한 팩토리
~500
줄의 계약 소스가 3개 앱 수십 개 파일의 진실
0
공유 패키지의 런타임 의존성 — 순수 타입·소스
DBBookmark · Bookmark 혼재
Bookmark 단일 타입
도메인·UI 전반에서 이름이 갈라진 타입을 하나로 정리
enum ↔ union 수기 동기화
BROWSER_TYPE_IN_SYNC 가드
불일치가 빌드에서 실패 — 사람이 지키던 규칙을 컴파일러에 위임
소켓 payload 형태 수기 일치
게이트웨이·클라이언트 공유 타입
서버 송신과 익스텐션 수신이 같은 계약을 참조
Dookmark · Performance · DB · 재귀 CTE

폴더마다 반복되던 쿼리를 단일 재귀 CTE
접은 서브트리 조회

계층형 북마크 트리에서 폴더 하위 전체를 조회할 때 폴더마다 쿼리가 반복되는 N+1 문제를, 재귀 CTE(WITH RECURSIVE) 단일 쿼리로 전환하여 구조적으로 제거.

364 → 1쿼리 수 (364배)
111ms → 9.5ms평균 응답 약 11.7배
01

Problem · 문제 원인폴더 수만큼 쿼리가 곱해지는 N+1

서브트리를 재귀로 순회하면, 폴더를 만날 때마다 ‘그 폴더의 직계 자식’을 조회하는 쿼리가 반복. 즉 서브트리 안 폴더 수만큼 쿼리가 발생하는 전형적 N+1. 폴더가 수백 개면 쿼리도 수백 번, 운영(앱 EC2 ↔ 관리형 RDS)에서는 쿼리마다 네트워크 왕복 지연이 곱해져 불이익이 더 커짐.

N+1 · 재귀 순회
364
폴더 363개 → 폴더당 1회 + α
CTE · 단일 쿼리
1
규모와 무관하게 항상 1회로 고정

Fig 1. 쿼리 수가 서브트리 폴더 수에 비례(N+1) vs 규모와 무관하게 1회(CTE)

02

Solution · 해결 과정WITH RECURSIVE로 트리 순회를 DB에 위임

앵커(직계 자식)에서 시작해 재귀항(descendants의 노드를 부모로 갖는 행)UNION ALL로 반복 결합하는 재귀 CTE 한 번으로 서브트리 전체를 추출. 앱이 폴더마다 왕복하는 대신, DB 엔진이 트리 순회를 내부에서 한 번에 처리하므로 왕복이 1회뿐.

AS-IS앱 레벨 재귀 순회 (N+1)
async function collect(folderId) {
  // 폴더마다 직계 자식 1쿼리
  const children = await db.query(
    'SELECT * FROM Bookmark WHERE parentId = $1', folderId);
  for (const c of children)
    if (c.isFolder) await collect(c.id); // N+1
}
TO-BEbookmark-tree.util.ts · getAllChildren()
tx.$queryRaw`
  WITH RECURSIVE descendants AS (
    -- 앵커: folderId의 직계 자식
    SELECT * FROM "Bookmark"
    WHERE "parentId" = ${folderId} AND "isDeleted" = false
    UNION ALL
    -- 재귀: 자손의 자식을 반복 수집
    SELECT b.* FROM "Bookmark" b
    JOIN descendants d ON b."parentId" = d."browserBookmarkId"
  )
  SELECT * FROM descendants`;

구현: src/bookmark/utils/bookmark-tree.util.ts (getAllChildren) · 벤치: bench/bench-cte-vs-nplus1.mjs

03

Result · 결과쿼리 364회 → 1회, 응답 약 11.7배

폴더 363개(총 4,003노드) 서브트리에서 30회 반복 측정. 쿼리 수는 규모에 비례해 늘던 것이 항상 1회로 고정되고, 응답시간도 로컬 기준 약 11.7배 개선.

지표 N+1 CTE 개선
쿼리 수 364회 1회 364배
평균 응답 111.4ms 9.5ms 11.7배
p50 110.8ms 9.4ms 11.8배
p95 117.5ms 10.6ms 11.1배
구조적
개선 배수는 서브트리 폴더 수에 비례하며, ‘쿼리 N회 → 1회’라는 구조적 이득은 항상 유효. 측정은 앱·DB 동일 머신(로컬) 기준으로 N+1에 보수적 — 운영의 앱↔RDS 왕복 지연에서는 격차가 더 벌어짐.
Dookmark · Performance · 대시보드 렌더링

클라이언트 왕복을 서버로 걷어내고,
정적 셸을 먼저 흘려보내는 대시보드 렌더링

CSR 워터폴로 인한 느린 콘텐츠 표시와, 서버 데이터 렌더 대기로 인한 첫 바이트 지연을 Server Component 병렬 프리페치PPR 정적 셸 프리렌더 · Suspense 스트리밍으로 각각 해소.

−77%LCP 1,668ms → 384ms
−93%TTFB 20.9ms → 1.5ms
01

Topic 1 · LCP클라이언트 마운트 후 왕복하던 워터폴 제거

문제 원인

기존 대시보드는 클라이언트 컴포넌트로, 브라우저가 HTML을 받은 뒤 JS 번들 다운로드 → 하이드레이션 → 마운트 → useEffect에서 API 왕복을 순차로 거친 다음에야 콘텐츠 렌더. 실제 데이터가 오기 전까지는 스피너만 노출되어 LCP가 네트워크 왕복 뒤로 밀리는 워터폴 발생.

WATERFALL
렌더에 필요한 데이터 페치가 런타임 클라이언트에서야 시작 → HTML·JS·하이드레이션·네트워크가 직렬로 쌓여 최초 콘텐츠 표시 지연.
해결 과정

페이지를 async Server Component로 전환하여, 요청을 처리하는 서버에서 Promise.all로 두 API를 병렬 프리페치 후 완성된 마크업을 응답에 실어 전송. 클라이언트 JS·하이드레이션을 기다리지 않고 최초 응답에 LCP 요소가 포함되며, 두 요청을 병렬화해 요청 간 워터폴까지 제거. (인증 실패 시에만 기존 CSR 경로가 폴백으로 동작.)

AS-ISui/dashboard/dashboard-client-fallback.tsx
'use client';
export function Dashboard({ period, year }) {
  const [state, setState] = useState({ status: 'loading' });

  useEffect(() => {
    // 마운트 후에야 네트워크 시작
    // (JS 다운로드·하이드레이션 대기)
    Promise.all([
      fetchDashboardClient(period),
      fetchHeatmapClient(year),
    ]).then(([d, h]) => setState({ status: 'ready', ... }));
  }, [period, year]);

  if (state.status === 'loading')
    return <Spinner />;  // LCP까지 스피너
  return <DashboardView ... />;
}
TO-BEapp/dashboard/page.tsx
// Server Component — 요청 처리 중 서버에서 확보
export default async function DashboardPage({ searchParams }) {
  const period = normalizePeriod((await searchParams).period);
  const year = new Date().getFullYear();

  // 두 API를 서버에서 병렬 프리페치
  // (요청 간 워터폴 제거)
  const [dashboard, heatmap] = await Promise.all([
    getDashboardServer(period),
    getHeatmapServer(year),
  ]);

  // 완성된 마크업을 응답에 실어 전송
  return <DashboardView dashboard={dashboard} ... />;
}

병렬 프리페치 지점: app/dashboard/page.tsx L22 · 서버 페치 구현: domain/analytics/server.ts

결과
1,668 → 384 ms
LCP (Largest Contentful Paint)
−77%
직렬 → 병렬
두 API 왕복이 서버에서 동시 수행
Promise.all
런타임 → 응답
LCP 요소가 최초 HTML에 포함
no client round-trip
02

Topic 2 · TTFB서버 데이터 렌더를 기다리지 않는 첫 바이트

문제 원인

서버에서 데이터를 페치해 렌더하더라도, 페이지 트리 전체가 데이터 준비를 기다린 뒤 응답을 시작하면 첫 바이트가 상류 API 지연에 종속. 사용자는 그동안 빈 화면(흰 화면)에서 대기하며, TTFB가 데이터 렌더 시간만큼 지연 발생.

해결 과정
1

PPR 활성화 — 정적/동적 자동 분리

Next.js의 cacheComponents를 켜 한 라우트 안에서 정적으로 프리렌더 가능한 부분과 요청 시점에만 알 수 있는 동적 부분을 컴파일 단계에서 분리.

2

정적 셸을 layout으로 분리해 즉시 flush

헤더·배경·스켈레톤을 dashboard/layout.tsx에 두어 데이터와 무관하게 프리렌더 → 첫 바이트가 API 대기 없이 전송.

3

Suspense 경계로 동적 콘텐츠 스트리밍

데이터 의존 페이지를 <Suspense fallback={<DashboardSkeleton/>}>로 감싸, 프리페치가 끝나는 즉시 셸 위에 콘텐츠를 스트리밍 삽입.

AS-ISapp/dashboard/page.tsx (개념)
// 응답 전체가 데이터 준비까지 보류
export default async function Page() {
  // 이 await가 끝나야 HTML 전송 시작
  const data = await getDashboard();

  // 스트리밍 경계 없음 →
  // 첫 바이트가 API 지연에 종속
  return <FullPage data={data} />;
}
TO-BEnext.config.ts · app/dashboard/layout.tsx
// next.config.ts — PPR (정적/동적 자동 분리)
const nextConfig = { cacheComponents: true, ... };

// layout.tsx — 정적 셸 + 스트리밍 경계
export default function DashboardLayout({ children }) {
  return (
    <div>
      <DashboardHeader />        {/* 정적 셸: 즉시 flush */}
      <main>
        <Suspense fallback={<DashboardSkeleton />}>
          {children}             {/* 동적: 준비되면 스트리밍 */}
        </Suspense>
      </main>
    </div>
  );
}

PPR 설정: next.config.ts L4 · 스트리밍 경계: app/dashboard/layout.tsx L49

결과
20.9 → 1.5 ms
평균 TTFB (Time To First Byte)
−93%
셸 우선
첫 바이트가 데이터 렌더와 탈동조화
static shell first
흰 화면 제거
스켈레톤 → 스트리밍으로 매끄러운 전환
Suspense
03

Synthesis · 해결 설계두 최적화가 하나로 합쳐지는 렌더링 파이프라인

두 최적화는 하나의 응답 안에서 상호 보완. 헤더·배경·스켈레톤 같은 정적 셸은 PPR로 미리 렌더되어 데이터와 무관하게 즉시 flush(TTFB 결정)되고, 사용자·기간별 동적 데이터는 서버가 두 API (dashboard·heatmap)를 병렬 프리페치한 뒤 Suspense 경계로 스트리밍(LCP 결정). 첫 바이트와 콘텐츠 표시를 분리 최적화하는 단일 파이프라인.

요청 수신시간 →콘텐츠 완성
정적 · PPRprerendered
정적 셸 프리렌더 · 즉시 flush
TTFB · 1.5ms
이후 유휴
동적 · 스트림Suspense
셸 대기
서버 병렬 프리페치 · Promise.all
Suspense 해제 → 스트리밍
LCP · 384ms
정적 셸 (TTFB 결정) 서버 병렬 프리페치 (LCP 결정) 스트리밍 콘텐츠

정적 셸(PPR)과 동적 스트림(RSC 병렬 프리페치)의 분리 — 개념도(실척 아님)

Dookmark · Testing · 대시보드 E2E

스트리밍 레이스와 콜드스타트 플레이크를
스코프와 커버리지 정리로 걷어낸 E2E

RSC 스트리밍의 중복 렌더와 콜드스타트 병렬 부하로 인한 간헐적 실패(flaky)를, <main> 스코프 기반 로케이터WebKit 테스트 제거로 해소. 제품 버그가 아닌 인프라 타이밍 조건임을 분리 진단해 근본 원인만 제거.

7 → 0하드 실패 · flaky
~31s → ~15s실행 시간 (−52%)
46/46콜드 2회 연속 통과 (retries:0)
01

Problem · 문제 원인스트리밍이 남긴 ‘숨은 복사본’과 콜드 지연

pnpm run test:e2e 실행 시 대시보드·반응형 테스트가 간헐적으로 실패(flaky). 초기 관측 7건이 두 종류로 분화 — (A) strict mode 위반(같은 요소 2개, 주로 chromium), (B) 단순 타임아웃(5s 내 미발견, 주로 webkit). 결정적 단서는 같은 페이지에서 getByRole('heading')은 통과하는데 바로 아래 getByText('전체 북마크')만 실패하는 비대칭.

두 증상 모두 제품 버그가 아니라 특정 실행 구조에서만 재현. Web 단독 Playwright E2E는 매 실행마다 새로 빌드된 콜드 서버를 대상으로 하며, globalSetup이 Mock API(:3101)를 띄우고, webServerpnpm build && pnpm start로 갓 뜬 Next 서버(:3100)를 기동한 뒤 fullyParallel 워커들이 동시에 /dashboard(RSC + proxy + mock fetch)를 최초 요청. 이 ‘콜드 서버 × 병렬 첫 요청’ 조건이 스트리밍 조정 창(window)을 늘려 레이스를 표면화.

webServer · 콜드 빌드
pnpm build && pnpm start · :3100
매 실행 새 빌드 → 초기 응답 지연
globalSetup · Mock API
:3101 스텁 — RSC 서버 fetch + 클라 axios 응답
▼  fullyParallel — 워커들이 동시에 첫 요청
W1
W2
W3
W4
W5
▼  각 워커가 /dashboard 진입
/dashboard · RSC 스트리밍
<main> 안 async 서버 컴포넌트를 <Suspense>로 스트리밍 — 조정(reconcile) 창 존재

Fig 1. 콜드 서버 + 병렬 워커 + RSC 스트리밍 — 세 조건이 겹칠 때만 재현

검증
웜(warm) 서버에서는 8/8 통과로 재현 불가 → 코드 회귀가 아니라 콜드 × 병렬 인프라 조건에 종속된 타이밍 플레이크로 확정.
메커니즘 ① — RSC 스트리밍의 숨은 복사본

App Router는 <Suspense> 경계를 스트리밍 SSR로 처리. 콘텐츠가 준비되면 ① 실제 콘텐츠를 <body> 최상위 <div hidden>에 먼저 흘리고, ② 인라인 스크립트 $RC(...)가 이를 <main>의 fallback 자리로 이동시키며 hidden 컨테이너를 비움. $RC 실행 직전의 찰나에는 동일 콘텐츠가 두 곳에 공존 → 콜드에서 이 창이 길어지면 단언이 그 순간을 포착해 2개를 매칭.

스트리밍 순간의 DOM — $RC 실행 직전
<body>
  <div hidden> ... 전체 북마크 ... </div>   ← getByText가 함께 잡음 (숨김 복사본)
  <div><main> ... 전체 북마크 ... </main></div>  ← 사용자가 보는 표시 복사본
  <script>$RC("main-slot", "hidden")</script>  ← 실행되면 숨김본 제거
</body>
비대칭의 정체 — 로케이터 가시성 시맨틱
로케이터 숨김 요소 매칭 결과
getByRole('heading', …) 접근성 트리 기반 → 숨김 제외 표시본 1개 → 통과
getByText('전체 북마크') 문서 전역 → 숨김 포함 표시 + 숨김 = 2개 → strict 위반

런타임 DOM 프로브로 조정 완료 후 리프가 1개뿐이고 body 첫 자식이 비워진 div[hidden]임을 확인 → 지속 버그가 아니라 스트리밍 타이밍 레이스로 확정.

메커니즘 ② — 콜드 서버 지연

콜드 서버에 5개 워커가 동시에 첫 요청을 보내면 초기 응답·스트리밍이 느려져 기본 5s 한도 초과. 특히 WebKit이 가장 느려 heading·canvas가 5s 안에 <main>으로 스왑되지 못하고 hidden 상태로 관측. 두 증상 모두 제품 버그가 아닌 인프라 타이밍에 종속.

02

Solution · 해결 과정정확성 픽스는 남기고, 비용만 내는 커버리지는 제거

1

<main> 스코프로 correctness 픽스

콘텐츠 단언을 사용자가 실제로 보는 <main> 영역으로 스코프. 숨김 복사본은 main 밖 (body-level div[hidden])에 있어 원천 제외 → 증상 A를 결정적으로 제거하며, ‘보이는 것’을 검증하는 더 정확한 테스트로 승격.

2

WebKit · mobile-webkit 프로젝트 제거

관측된 webkit 실패는 전부 콜드스타트 타이밍이며 실제 WebKit 렌더링 회귀를 잡은 사례 0건. flaky 비용·실행 시간만 늘리던 커버리지를 제거하고, 임시로 넣었던 expect.timeout·로컬 retries 밴드에이드는 원복.

As-Is / To-Be ① — 로케이터 스코프
AS-IStest/e2e/tests/dashboard.spec.ts
// 문서 전역 단언 → 숨김 복사본까지 포착
await expect(
  page.getByText('전체 북마크')
).toBeVisible();   // strict 위반 (2개)
TO-BEtest/e2e/tests/dashboard.spec.ts
// 보이는 main 영역으로 스코프
const main = page.getByRole('main');
await expect(
  main.getByText('전체 북마크')
).toBeVisible();   // 표시본 1개만

적용: dashboard.spec.ts · responsive.spec.ts — 숨김 복사본을 매칭 범위에서 원천 배제

As-Is / To-Be ② — 브라우저 프로젝트
AS-ISplaywright.config.ts
projects: [
  { name: 'chromium', ... },
  { name: 'firefox', ... },
  { name: 'webkit', ... },
  { name: 'mobile-webkit', ... },
  { name: 'mobile-chromium', ... },
]
// + expect.timeout 상향, 로컬 retries (밴드에이드)
TO-BEplaywright.config.ts
projects: [
  { name: 'chromium', ... },
  { name: 'firefox', ... },
  { name: 'mobile-chromium',
    grep: /@responsive/ },
]
// 밴드에이드 원복 · retries: CI만 2
03

Result · 결과flaky 0건, 실행 시간 절반

항목 이전 지금
하드 실패 7건 0건
flaky 최대 1건 (재시도 의존) 0건
실행 시간 ~31s ~15s
첫 시도 통과 (retries:0) ✓ · 콜드 2회 연속 46/46
TRADE-OFF
Safari/WebKit 회귀를 E2E로 못 잡음. 근거 있는 수용 — 이번 실패는 전부 인프라 타이밍이었고 실제 webkit 버그는 없었으며, 웹은 Chromium + Firefox 두 엔진으로 커버, 핵심 제품(익스텐션)은 Chrome/FF/Edge 타깃. 되살릴 땐 메인 스위트가 아니라 야간 non-blocking 잡(웜 서버·단독 워커)으로 분리해 병렬 콜드 부하 회피.
핵심
‘브라우저를 더 돌린다’가 항상 이득은 아니라는 비용-효익 판단. 실제로 잡는 버그 없이 flaky 비용만 내는 커버리지는 제거하고, 사용자가 보는 것을 검증하는 스코프 픽스만 남겨 안정성과 속도를 동시 확보.
Dookmark · Sync · 실시간 동기화

자기 자신이 일으킨 삭제가 되돌아오는
에코 루프를 끊어낸 동기화

북마크를 삭제하면 잠깐 휴지통에 들어갔다 사라지는 문제 — 확장이 프로그램적으로 일으킨 네이티브 삭제가 다시 ‘사용자 삭제’로 서버에 보고되어 하드 삭제되는 에코 루프를, 실행 컨텍스트 간 공유 채널 기반 에코 억제 가드로 차단.

🗑 휴지통 유지자기 삭제 에코 → 하드 삭제 차단
사용자 삭제 보존직접 삭제는 종전대로 동작
01

Problem · 문제 원인자기 자신이 일으킨 삭제의 에코 루프

삭제 자체는 서버에서 정상적으로 소프트 삭제(isDeleted=true)되어 휴지통에 진입. 문제는 그 직후 서버가 삭제를 일으킨 원본 브라우저에도 native_action('delete')을 보내고, 확장이 이를 받아 네이티브 북마크를 지우면 그 제거 이벤트가 다시 ‘사용자 삭제’로 서버에 보고되어 물리(하드) 삭제—휴지통 레코드까지 제거—로 이어지는 것.

1
[사이드패널] 삭제 클릭
2
DELETE /bookmarks/:id → 서버 소프트 삭제 · 휴지통 진입 ✓
3
서버 → native_action('delete')원본 브라우저에도 전송
4
[사이드패널] browser.bookmarks.removeTree(id) — 실제 네이티브 제거
5
[background] onRemoved 발화
6
POST /bookmarks/event { action:'delete' }
7
서버 하드 삭제 — 휴지통 레코드까지 제거 ✕
3–7단계는 사용자가 아니라 우리 코드가 일으킨 ‘에코’

Fig 1. 소프트 삭제 직후, 자기 삭제가 사용자 삭제로 되돌아와 하드 삭제를 유발

CONTEXT
사이드패널(소켓 핸들러)과 background는 서로 다른 실행 컨텍스트. 단순 메모리 플래그로는 서로의 상태를 알 수 없어, ‘내가 방금 지운 것’인지 background가 판별할 방법이 없음.
02

Solution · 해결 과정공유 채널 기반 에코 억제 가드

서버의 소프트/하드 삭제 정책은 그대로 두고, 확장이 직접 일으킨 네이티브 삭제만 background가 서버로 재전송하지 않도록 억제. 두 컨텍스트가 storage.local공유 채널로 사용해, 삭제 직전에 대상 id를 등록하고 onRemoved에서 등록된 id면 소비 후 무시. 지연·실패로 남는 항목의 영구 억제를 막기 위해 TTL 적용.

사이드패널 · native_action
mark markProgrammaticDeletion(id)
→ browser.bookmarks.removeTree(id)
storage
.local
공유 채널
background · onRemoved
consume 등록됐으면 true
→ return (서버 재전송 안 함)

Fig 2. 삭제 직전 등록 → 에코 이벤트에서 소비, 컨텍스트 경계는 storage.local로 연결

AS-ISbackground/bookmark-sync.ts
onRemoved.addListener(async (id) => {
  // 우리가 일으킨 삭제인지 구분 없이
  // 무조건 서버로 보고 → 에코
  dispatchBookmarkEvent('delete', { id });
});
TO-BEnative-bookmark-writer.ts · background/bookmark-sync.ts
// 삭제 직전: 프로그램적 삭제 id 등록
await markProgrammaticDeletion(id);
await browser.bookmarks.removeTree(id);

// onRemoved: 등록된 id면 에코 차단
onRemoved.addListener(async (id) => {
  if (await consumeProgrammaticDeletion(id)) return;
  dispatchBookmarkEvent('delete', { id });
});

가드: domain/bookmarks/sync/native-sync-guard.ts · 등록: native-bookmark-writer.ts · 소비: background/bookmark-sync.ts

삭제 vs 생성 — 억제 전략 분기

삭제는 id를 미리 알 수 있어 id별 억제가 가능하지만, 생성(복원·크로스 브라우저 이동)은 새 네이티브 id를 생성 후에야 알 수 있어 id 매칭이 불가능. 그래서 생성은 시간 윈도우onCreated를 일괄 억제.

경로 억제 방식 API TTL
삭제 (delete) 대상 id 등록 → 소비 mark / consumeProgrammaticDeletion 10s
생성 (create·restore) 시간 윈도우 일괄 억제 begin / isCreationSuppressed 5s
03

Result · 결과휴지통 유지, 사용자 삭제는 그대로

UI 삭제 → 소프트 삭제 → 휴지통 표시까지는 동일하되, 이어지는 자기 네이티브 삭제 이벤트는 억제되어 서버로 재전송되지 않음 → 휴지통에 그대로 유지. 반면 사용자가 브라우저에서 직접 삭제하면 억제 등록이 없어 종전대로 정상 동작. 동일 에코 구조를 가진 복원·크로스 브라우저 생성 경로까지 같은 가드로 확장.

차단
‘자기가 일으킨 이벤트’와 ‘사용자가 일으킨 이벤트’를 실행 컨텍스트 경계를 넘어 구분 — 에코는 억제하고 실제 사용자 행위는 보존해 실시간 동기화의 무결성 확보.
Dookmark · Security · 리프레시 토큰

탈취된 리프레시 토큰을 재사용 감지
스스로 무력화하는 세션 구조

고정 리프레시 토큰의 무제한 재발급 위험을, 토큰 로테이션이전 토큰 재사용 감지 기반 세션 폐기로 해결하여 탈취를 자동 탐지·차단.

🔐 무제한 재발급 차단refresh 재사용 감지 → 세션 폐기
해시 저장bcrypt — 평문 미보관
01

Problem · 문제 원인고정 토큰은 탈취돼도 구분·탐지 불가

리프레시 토큰이 고정(장수명)이면, 한 번 탈취당한 토큰으로 만료 전까지 액세스 토큰을 무제한 재발급 가능. 게다가 정상 사용자와 공격자가 같은 토큰을 제시하므로 서버가 둘을 구분할 단서가 없어 탈취 자체를 탐지 불가. 로그아웃·비밀번호 변경 전까지 침해가 조용히 지속.

RISK
토큰 유출 = 사실상 무기한 세션 장악. 서버는 정상 갱신과 탈취 재사용을 동일하게 처리하므로 대응 시점 자체가 없음.
02

Solution · 해결 과정로테이션으로 옛 토큰을 ‘탈취 신호’로 전환

로테이션 + 재사용 감지를 도입. 세션은 리프레시 토큰의 bcrypt 해시만 저장(평문 미보관)하고, 갱신마다 새 리프레시 토큰을 발급하며 저장 해시를 교체(rotation). 제시된 토큰이 저장 해시와 불일치하면 — 이미 로테이션되어 폐기됐어야 할 옛/탈취 토큰 — 즉시 세션을 폐기하고 재로그인을 강제.

정상
refresh 요청
저장 해시와 일치
새 토큰 발급 + 해시 교체
탈취
옛 토큰 재사용
해시 불일치 감지
세션 즉시 폐기 · 재로그인 강제

Fig 1. 로테이션으로 옛 토큰이 곧 ‘탈취 신호’가 되는 구조

AS-IS고정 refresh 토큰
// 로테이션·재사용 감지 없음
const payload = jwt.verify(refreshToken);
// 탈취돼도 만료 전까지 무제한 재발급
// 정상/공격자 구분 불가 → 탐지 불가
return issueAccessToken(payload.sub);
TO-BEauth.service.ts · refreshTokens()
const matches = await compare(
  refreshToken, session.hashedRefreshToken);

if (!matches) {
  // 옛/탈취 토큰 → 세션 즉시 폐기
  await sessionService.revokeSession(sessionId);
  throw new UnauthorizedException('탈취 감지로 세션 만료');
}
// 일치 → 새 토큰 발급 + 저장 해시 교체(로테이션)
return generateTokensAndSave(..., sessionId);

재사용 감지·폐기: auth.service.ts L358–373 · 해시 교체: generateTokensAndSave() · 스키마: Session.hashedRefreshToken

03

Result · 결과탈취 자동 탐지·차단

탈취된 옛 토큰의 재사용이 자동 탐지 → 세션 폐기로 이어져 무제한 재발급을 원천 차단. 침해가 발생해도 다음 갱신 시점에 서버가 스스로 탈취를 인지하고 세션을 무효화하는 대응 지점을 확보.

자동 폐기
재사용 감지 시 세션 즉시 revoke
해시 저장
bcrypt — 평문 토큰 미보관
15분
액세스 토큰 수명 — 노출 창 최소화
14일
리프레시 + 매 갱신 로테이션
Dookmark · Security · WebSocket 스케일아웃

인스턴스 경계를 넘어 이벤트를 브로드캐스트하는
실시간 동기화 계층

서버 다중화 시 WebSocket이 특정 인스턴스에 고정되어 다른 인스턴스의 클라이언트가 이벤트를 누락하는 문제를, Socket.IO Redis pub/sub 어댑터로 인스턴스 간 이벤트를 브로드캐스트하도록 해결하여 스케일아웃 환경에서도 실시간 동기화 일관성 보장.

🔁 스케일아웃 일관성인스턴스 간 이벤트 브로드캐스트
sticky 세션 불필요수평 확장 자유
01

Problem · 문제 원인다중화 시 타 인스턴스 클라이언트가 이벤트 누락

서버를 다중화하면 WebSocket 연결은 특정 인스턴스에 고정. 인스턴스 A가 발생시킨 emit은 A에 붙은 소켓에만 도달하고, 인스턴스 B에 연결된 클라이언트는 이벤트를 누락. 크로스 브라우저 북마크 동기화처럼 실시간 방송이 핵심인 기능이 스케일아웃에서 깨짐.

RISK
기본 인메모리 어댑터는 단일 프로세스 메모리에만 소켓 상태를 두므로, 인스턴스 수를 늘릴수록 이벤트 도달률이 오히려 하락.
02

Solution · 해결 과정Redis pub/sub 어댑터로 이벤트 중계

NestJS IoAdapter를 확장한 RedisIoAdapter로 pub/sub 클라이언트 한 쌍을 구성해 createAdapter(pubClient, subClient)를 소켓 서버에 주입. 어느 인스턴스에서 emit하든 Redis 채널로 발행되어 구독 중인 전 인스턴스가 각자의 소켓에 재방송.

Instance A
🔌 Client 1 연결 · emit 발생
Instance B
🔌 Client 2 연결 · 이벤트 수신 ✓
▲  Redis pub/sub 어댑터가 인스턴스 간 이벤트 중계
Redis pub/sub · createAdapter(pub, sub) → 모든 인스턴스로 브로드캐스트

Fig 1. 공유 Redis 채널이 인스턴스 경계를 넘어 emit을 전파

AS-IS기본 인메모리 어댑터
// 단일 프로세스 메모리에만 존재
@WebSocketGateway()
// emit → 같은 인스턴스 소켓에만 도달
// 다중화 시 타 인스턴스 클라이언트 누락
TO-BEmain.ts · redis-io.adapter.ts
// main.ts — 부팅 시 Redis 어댑터 주입
const adapter = new RedisIoAdapter(app);
await adapter.connectToRedis();
app.useWebSocketAdapter(adapter);

// redis-io.adapter.ts
const pub = new Redis(opts), sub = new Redis(opts);
server.adapter(createAdapter(pub, sub));

어댑터 주입: main.ts L17–19 · pub/sub 구성: common/adapters/redis-io.adapter.ts

03

Result · 결과스케일아웃에서도 동기화 일관성 보장

클라이언트가 어느 인스턴스에 붙어도 동일 이벤트를 수신 → 스케일아웃 환경에서도 실시간 동기화 일관성 보장. 로드밸런서의 sticky 세션 의존을 제거해 인스턴스 수평 확장이 자유로워지고, 인스턴스 추가가 곧 실시간 처리량 증가로 직결.

일관성
단일 프로세스 한계를 넘어, 이벤트 도달을 인스턴스 수와 무관하게 보장. 크로스 브라우저 북마크 동기화가 다중 인스턴스에서도 일관 동작.
Dookmark · Security · SMTP 이메일 인증

인증 코드 남용을 다중 방어
봉쇄한 SMTP 인증 시스템

SMTP 이메일 인증 코드의 brute-force·재전송 남용·타이밍 공격 위험을, 코드 만료 · 시도 횟수 제한 · 재전송 쿨다운 · 상수시간 비교의 다중 방어로 해결하여 안전한 SMTP 이메일 인증 시스템 구축.

🛡 4중 방어공격 표면별 계층 분담
+ IP 레이트리밋분산 대입 상한
01

Problem · 문제 원인단순 비교·무제한 시도는 세 위험에 노출

이메일 인증 코드는 세 갈래로 노출. brute-force(짧은 코드를 반복 대입), 재전송 남용(메일 폭탄·발송 비용), 타이밍 공격(문자열 비교의 조기 반환 시간차로 코드 추론). 단순 === 비교와 무제한 시도는 세 위험에 모두 취약.

RISK
한 겹의 방어(예: 만료만)로는 부족. 공격 표면이 서로 달라 계층별로 각각 막지 않으면 어느 한 경로가 뚫림.
02

Solution · 해결 과정공격 표면별로 분담하는 계층 방어

단일 방어가 아니라 계층별 다중 방어(defense-in-depth)를 적용. 각 계층이 서로 다른 공격 표면을 담당하도록 배치.

재전송 쿨다운 · 1분
재전송 남용 — 마지막 발송 후 60초 내 재요청 거부
코드 만료 · 5분
유효창 확대 — 만료 코드는 검증 시 즉시 삭제·거부
시도 횟수 제한 · 5회
brute-force — 초과 시 코드 무효화 + 경고 로깅
상수시간 비교
타이밍 공격 — 길이 가드 + timingSafeEqual로 시간차 제거
IP 레이트리밋 · 10/분
분산 대입 — 민감 엔드포인트 Throttler로 요청 상한

Fig 1. 공격 표면별로 분담하는 5계층 방어

AS-IS단순 비교 · 무제한 시도
// 타이밍 누출 + 무제한 대입
if (record.code !== code) {
  throw new BadRequestException('불일치');
}
TO-BEverification-code.service.ts
// 시도 횟수 초과 → 코드 무효화
if (record.attempts >= MAX_ATTEMPTS - 1) {
  await deleteCode(record); throw ...('시도 초과 무효화');
}
// 타이밍 공격 방지: 길이 가드 + 상수시간 비교
const ok = record.code.length === code.length &&
  crypto.timingSafeEqual(
    Buffer.from(record.code), Buffer.from(code));
if (!ok) { await increment(attempts); throw ...; }

구현: verification-code.service.ts (verifyCode · checkCooldown) · 상수: auth/constants.ts

03

Result · 결과안전한 SMTP 인증 시스템 구축

세 공격 경로가 각자 다른 계층에서 차단되어, 한 겹이 뚫려도 다음 계층이 방어하는 구조 확보. brute-force는 시도 제한, 남용은 쿨다운, 추론은 상수시간 비교, 분산 대입은 IP 레이트리밋이 담당.

5분
코드 만료 — 유효창 최소화
5회
시도 초과 시 코드 무효화
1분
재전송 쿨다운 — 남용 차단
O(1)ᵗ
상수시간 비교 — 타이밍 누출 제거
Dookmark · Infra · Terraform IaC

클릭으로 쌓인 인프라를 코드로 고정
재현 가능·드리프트 없는 시스템

AWS 인프라의 수동 구성으로 인한 재현 불가·드리프트 문제를, 모듈형 Terraform 코드화버전 고정, S3 원격 state 및 잠금으로 해결하여 재현 가능하고 드리프트 없는 인프라 시스템 구축.

📦 7 모듈network·data·secrets·ecr·compute·monitoring·cicd
🔒 원격 state + 잠금S3 · use_lockfile
01

Problem · 문제 원인콘솔 수동 구성은 재현 불가·드리프트

인프라를 콘솔에서 손으로 구성하면, 무엇을 어떤 순서로 만들었는지 기록이 남지 않아 재현 불가. 누군가 콘솔에서 값을 바꾸면 실제 상태와 의도가 어긋나는 드리프트가 조용히 누적되고, 재해 복구·이관 시 되살릴 근거가 없음.

RISK
‘동작하는 인프라’가 한 사람의 머릿속·콘솔 상태에만 존재 → 검토·롤백·복제가 모두 불가능한 단일 실패점.
02

Solution · 해결 과정모듈화 · 버전 고정 · 원격 state 잠금

1

역할별 모듈로 코드화

VPC·서브넷(network), RDS·Redis(data), SSM(secrets), 레지스트리(ecr), EC2·IAM(compute), 알람(monitoring), 배포(cicd)를 독립 모듈로 분리하고 envs/prod에서 조립. 재사용·검토 단위 확보.

2

Terraform·프로바이더 버전 고정

required_version >= 1.11, aws ~> 6.0를 명시하고 .terraform.lock.hcl로 프로바이더 해시 고정 → 누가·언제 apply해도 동일 버전으로 동일 결과.

3

S3 원격 state + 잠금

state를 암호화된 S3 버킷에 원격 저장하고 use_lockfile로 동시 apply 충돌을 차단 → 팀 공유 단일 진실, 병렬 변경으로 인한 state 파손 방지.

envs/prod — 모듈 조립 + 변수 주입
▲  source = "../../modules/*"
network
VPC · 2AZ 서브넷 · SG
data
RDS · ElastiCache
secrets
SSM Parameter Store
ecr
컨테이너 레지스트리
compute
EC2 · IAM · user-data
monitoring
CloudWatch · SNS · Lambda
cicd
배포 파이프라인
재사용 가능 단위
▼  terraform apply
state → S3 (encrypt=true) · use_lockfile 동시성 잠금 · 프로바이더 lock.hcl 고정

Fig 1. envs/prod가 역할별 모듈을 조립하고, state는 잠금된 원격 S3에 단일 관리

envs/prod/versions.tf — 버전 고정 + S3 원격 state 백엔드

terraform {
  required_version = ">= 1.11.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
  # 원격 state 백엔드 (S3) + 동시성 잠금
  backend "s3" {
    bucket       = "dookmark-tfstate-…"
    key          = "prod/terraform.tfstate"
    encrypt      = true
    use_lockfile = true
  }
}

구성: infra/terraform/envs/prod/versions.tf · 모듈: infra/terraform/modules/*

03

Result · 결과재현 가능·드리프트 없는 인프라

인프라 전체가 코드로 버전 관리되어, 동일 코드로 언제든 동일 환경을 재현 가능. 변경은 plan으로 사전 검토 후 apply되고, 원격 state가 실제 상태를 추적해 드리프트를 감지·수렴. 모든 리소스에 default_tags(Project·Env·ManagedBy)가 자동 부착되어 비용 추적·정리도 일관.

재현성
‘동작하는 인프라’가 사람의 머릿속이 아니라 검토·롤백·복제 가능한 코드로 이동 — 단일 실패점을 제거하고 이관·복구를 코드 실행으로 환원.
Dookmark · Infra · 모니터링 · 알림

이상 징후를 실시간으로 감지·통지하는
운영 알림 파이프라인

AWS 인프라에 CloudWatch 경보 → SNS → Lambda → Discord 웹훅 알림 파이프라인을 구축하여 ALB/EC2/RDS 이상 징후를 실시간 감지. 보안 비밀은 SSM SecureString으로 관리하며 운영 알림을 자동화.

🚨 5종 경보ALB · EC2 · RDS 커버
🔑 SSM SecureString웹훅 URL 암호화 관리
01

Problem · 문제 원인이상을 인지할 실시간 채널이 없음

지표를 사람이 콘솔에서 수동으로 들여다봐야만 이상을 알 수 있으면, 5xx 급증·타깃 다운·CPU 과부하·디스크 소진 같은 징후를 발생 시점에 놓치기 쉬움. 장애 인지가 늦어 대응이 사후로 밀리고, 알림 채널에 웹훅 URL 같은 비밀을 평문으로 두면 그 자체가 위험.

RISK
모니터링이 수동 관찰에 의존하면 장애를 사용자가 먼저 발견. 실시간 통지 채널과 비밀의 안전한 관리가 함께 필요.
02

Solution · 해결 과정경보 → SNS → Lambda → Discord 파이프라인

CloudWatch 경보가 임계값 위반 시 SNS 토픽에 발행하고, 이를 구독한 Lambda가 메시지를 Discord 임베드로 변환해 웹훅으로 중계. 상태 전이(ALARM/OK)를 색상으로 구분해 통지하며, 웹훅 URL은 SSM SecureString에 저장하고 Lambda가 WithDecryption으로 런타임에만 복호화.

CloudWatch
5종 경보 임계값 평가
SNS
alarms 토픽 발행
Lambda
임베드 변환 · SSM 복호화
Discord
웹훅 실시간 통지
ALB 5xx 급증
HTTPCode_ELB_5XX · 5분 합계 임계값
정상 타깃 없음
UnHealthyHostCount ≥ 1 (서비스 다운)
EC2 CPU 과부하
CPUUtilization 10분 초과
RDS CPU 과부하
CPUUtilization 10분 초과
RDS 여유 스토리지 부족
FreeStorageSpace 임계값 미만 (디스크 소진 위험)

Fig 1. 경보에서 통지까지 서버리스 파이프라인 (모든 경보의 ALARM·OK가 SNS로 수렴)

lambda/index.mjs — SSM SecureString 복호화 후 Discord 중계

// 웹훅 URL은 평문이 아니라 SSM SecureString에서만
const res = await ssm.send(new GetParameterCommand({
  Name: process.env.WEBHOOK_SSM_NAME,
  WithDecryption: true,   // KMS 복호화
}));

// ALARM/OK 상태를 색상으로 구분해 임베드 통지
const color = state === 'ALARM' ? 0xe01e5a
            : state === 'OK' ? 0x2eb67d : 0xecb22e;
await postToDiscord(webhook, { embeds: [embed] });

파이프라인: infra/terraform/modules/monitoring/main.tf · 중계: modules/monitoring/lambda/index.mjs · IAM: ssm:GetParameter + kms:Decrypt

03

Result · 결과실시간 감지 · 운영 알림 자동화

ALB·EC2·RDS의 이상 징후가 발생 즉시 Discord로 통지되어, 콘솔을 지켜보지 않아도 장애를 조기 인지. 상태 복구 시 OK 전이도 함께 통지되어 해소 여부까지 자동 추적. 웹훅 비밀은 SecureString + KMS로 보호되어 코드·환경변수에 평문 노출 없음. 파이프라인 전체가 Terraform 모듈로 코드화되어 재현 가능.

자동화
모니터링이 수동 관찰 → 이벤트 기반 통지로 전환. 사람이 발견하기 전에 시스템이 먼저 알리고, 비밀은 암호화 저장·런타임 복호화로 노출 표면 최소화.
Dookmark · Engineering Portfolio problem → solution → result