익스텐션·웹·서버를 아우르는 크로스 플랫폼 북마크 서비스 Dookmark — 이 한 프로젝트에 집중한 엔지니어링 포트폴리오입니다. 프로젝트를 만들며 마주친 아키텍처·성능·테스트·보안·인프라 문제와 그 해결 과정을 실제 코드와 수치로 정리했습니다.
익스텐션·웹·서버가 같은 도메인 타입을 각자 재선언하면서 코드 중복과 타입 불일치 발생. pnpm 워크스페이스로 공유 패키지를 추출하고 TS 설정을 공통화하여, 플랫폼 간 타입 정합성을 컴파일 타임에 정적으로 보장하는 단일 소스 구조 구축.
공유 계층 부재 시절, 플랫폼 경계를 넘는 값은 각 앱에 통째로 복제되어 존재. 한쪽 수정이 다른 쪽에 반영되지 않아 타입이 조용히 어긋나고, 서로 다른 파일이라 컴파일러도 경고 미발생. 기능이 커질수록 ‘어느 파일이 진짜인가’가 모호해지고, 사본 간 동기화 부담 가중.
Fig 1. 하나의 진실이 네 벌의 사본으로 흩어진 상태
더 큰 비용은 로직 중복. 401 → silent refresh(대기 큐·재시도) 인터셉터가 웹 약 83줄·익스텐션 약 157줄로 각각 복제되어, 인증 흐름 401을 갱신 대상에서 제외하는 버그 수정을 두 곳에 개별 반영 필요. 또한 Prisma의 BrowserType enum과 와이어 유니온이 별개로 존재하여, 한쪽에만 값 추가 시 컴파일러가 잡지 못한 채 런타임까지 불일치 잠복.
해결 방향은 두 축. 첫째, 플랫폼이 공유해야 할 표면을 패키지로 격리해 사본 자체를 제거. 둘째, 사람이 지키던 동기화 규칙을 타입 시스템에 위임해 불일치를 빌드 단계에서 차단.
재편한 구조는 하나의 pnpm 워크스페이스 안에 세 개의 소비자 앱과 이들이 공유하는 계약(contract) 패키지 계층으로 구성. 익스텐션은 WXT, 웹은 Next.js, 서버는 NestJS로 각자의 빌드 도구·런타임을 그대로 유지하되, 플랫폼 경계를 넘나드는 타입·API 클라이언트·상수는 @dookmark/* 패키지에서만 정의하고 workspace:* 프로토콜로 참조. 앱은 계약을 소유하지 않고 소비만 하므로, 진실의 위치가 항상 패키지 한 곳으로 고정.
패키지는 도메인 계약(extension-bridge)·HTTP 클라이언트(api-client)·대시보드 계약(analytics)· 스타일 토큰(theme)으로 역할별 분리. 각 앱은 필요한 계약만 의존성으로 선언하므로, 서버는 UI 코드를, 익스텐션은 서버 내부 로직을 알 필요 없이 ‘공유해야 할 표면’만 공유하는 경계 확보.
Fig 2. 세 소비자 → 공유 계약 계층 (hub-and-spoke)
루트 tsconfig.base.json이
strict·declaration·forceConsistentCasingInFileNames
등
컴파일 규칙을 단일 정의하고, 모든 패키지가 extends로 이를 상속 → 앱마다 컴파일러 설정이 갈라지는 것을 원천 차단.
루트 tsconfig.json은 references로 앱·패키지를 하나의 프로젝트 그래프로 결합하여,
계약 변경 시 의존 앱까지 타입 체크가 전파되도록 구성.
extension-bridge로 추출북마크 모델·소켓 이벤트 payload·확장 메시징·BrowserType과 normalizeBookmarkNode
같은 순수 헬퍼를
도메인별(browser / messaging / bookmark)로 정리해 단일 출처로 승격.
createApiClient 팩토리로 통합복제됐던 refresh 로직을 단일 팩토리로 통합하고, 환경 차이는 TokenManager 주입으로 분기 —
익스텐션은 스토리지 토큰, 웹은 쿠키 기반 적용. 대기 큐·재시도 로직 일원화로 버그 수정이 한 곳에서 종결.
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를 로드하려다 크래시하는 문제를 빌드 단계에서 해소.
@dookmark/* 소스를 함께 트랜스파일.nest build --webpack + node-externals 예외로 @dookmark/*만
번들에 인라인, 나머지는 externalize.공유 계약 파일 하나 수정 시 세 앱의 소비 지점이 컴파일 타임에 동시 검증.
불일치는 런타임 버그가 아니라 빌드 실패로 표면화되고, 리팩터링 시 수정 범위를
tsc가 안내하는 구조 확보.
단일 소스 계약(약 500줄)이 3개 앱 수십 개 파일의 진실로 기능하며, 계약 계층에는 런타임 의존성이 없어
앱 번들 부담 없이 타입 정합성만 정적으로 공유.
계층형 북마크 트리에서 폴더 하위 전체를 조회할 때 폴더마다 쿼리가 반복되는 N+1 문제를,
재귀 CTE(WITH RECURSIVE) 단일 쿼리로 전환하여 구조적으로 제거.
서브트리를 재귀로 순회하면, 폴더를 만날 때마다 ‘그 폴더의 직계 자식’을 조회하는 쿼리가 반복. 즉 서브트리 안 폴더 수만큼 쿼리가 발생하는 전형적 N+1. 폴더가 수백 개면 쿼리도 수백 번, 운영(앱 EC2 ↔ 관리형 RDS)에서는 쿼리마다 네트워크 왕복 지연이 곱해져 불이익이 더 커짐.
Fig 1. 쿼리 수가 서브트리 폴더 수에 비례(N+1) vs 규모와 무관하게 1회(CTE)
앵커(직계 자식)에서 시작해 재귀항(descendants의 노드를 부모로 갖는 행)을
UNION ALL로 반복 결합하는 재귀 CTE 한 번으로 서브트리 전체를 추출. 앱이 폴더마다 왕복하는 대신,
DB 엔진이 트리 순회를 내부에서 한 번에 처리하므로 왕복이 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 }
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
폴더 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배 |
CSR 워터폴로 인한 느린 콘텐츠 표시와, 서버 데이터 렌더 대기로 인한 첫 바이트 지연을 Server Component 병렬 프리페치와 PPR 정적 셸 프리렌더 · Suspense 스트리밍으로 각각 해소.
기존 대시보드는 클라이언트 컴포넌트로, 브라우저가 HTML을 받은 뒤 JS 번들 다운로드 → 하이드레이션 →
마운트 → useEffect에서 API 왕복을 순차로 거친 다음에야 콘텐츠 렌더. 실제 데이터가 오기 전까지는
스피너만 노출되어 LCP가 네트워크 왕복 뒤로 밀리는 워터폴 발생.
페이지를 async Server Component로 전환하여, 요청을 처리하는 서버에서 Promise.all로 두 API를 병렬 프리페치 후 완성된 마크업을 응답에 실어 전송. 클라이언트 JS·하이드레이션을 기다리지 않고 최초 응답에 LCP 요소가 포함되며, 두 요청을 병렬화해 요청 간 워터폴까지 제거. (인증 실패 시에만 기존 CSR 경로가 폴백으로 동작.)
'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 ... />; }
// 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
서버에서 데이터를 페치해 렌더하더라도, 페이지 트리 전체가 데이터 준비를 기다린 뒤 응답을 시작하면 첫 바이트가 상류 API 지연에 종속. 사용자는 그동안 빈 화면(흰 화면)에서 대기하며, TTFB가 데이터 렌더 시간만큼 지연 발생.
Next.js의 cacheComponents를 켜 한 라우트 안에서 정적으로 프리렌더 가능한 부분과
요청 시점에만 알 수 있는 동적 부분을 컴파일 단계에서 분리.
layout으로 분리해 즉시 flush헤더·배경·스켈레톤을 dashboard/layout.tsx에 두어 데이터와 무관하게 프리렌더 →
첫 바이트가 API 대기 없이 전송.
Suspense 경계로 동적 콘텐츠 스트리밍데이터 의존 페이지를 <Suspense fallback={<DashboardSkeleton/>}>로 감싸,
프리페치가 끝나는 즉시 셸 위에 콘텐츠를 스트리밍 삽입.
// 응답 전체가 데이터 준비까지 보류 export default async function Page() { // 이 await가 끝나야 HTML 전송 시작 const data = await getDashboard(); // 스트리밍 경계 없음 → // 첫 바이트가 API 지연에 종속 return <FullPage data={data} />; }
// 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
두 최적화는 하나의 응답 안에서 상호 보완. 헤더·배경·스켈레톤 같은 정적 셸은 PPR로 미리 렌더되어 데이터와 무관하게 즉시 flush(TTFB 결정)되고, 사용자·기간별 동적 데이터는 서버가 두 API (dashboard·heatmap)를 병렬 프리페치한 뒤 Suspense 경계로 스트리밍(LCP 결정). 첫 바이트와 콘텐츠 표시를 분리 최적화하는 단일 파이프라인.
정적 셸(PPR)과 동적 스트림(RSC 병렬 프리페치)의 분리 — 개념도(실척 아님)
RSC 스트리밍의 중복 렌더와 콜드스타트 병렬 부하로 인한 간헐적 실패(flaky)를,
<main> 스코프 기반 로케이터와 WebKit 테스트 제거로 해소.
제품 버그가 아닌 인프라 타이밍 조건임을 분리 진단해 근본 원인만 제거.
pnpm run test:e2e 실행 시 대시보드·반응형 테스트가 간헐적으로 실패(flaky). 초기 관측 7건이 두 종류로
분화 —
(A) strict mode 위반(같은 요소 2개, 주로 chromium), (B) 단순 타임아웃(5s 내 미발견,
주로 webkit).
결정적 단서는 같은 페이지에서 getByRole('heading')은 통과하는데 바로 아래
getByText('전체 북마크')만 실패하는 비대칭.
두 증상 모두 제품 버그가 아니라 특정 실행 구조에서만 재현. Web 단독 Playwright E2E는 매 실행마다 새로 빌드된 콜드
서버를 대상으로 하며,
globalSetup이 Mock API(:3101)를 띄우고, webServer가
pnpm build && pnpm start로
갓 뜬 Next 서버(:3100)를 기동한 뒤 fullyParallel 워커들이 동시에
/dashboard(RSC + proxy + mock fetch)를 최초 요청.
이 ‘콜드 서버 × 병렬 첫 요청’ 조건이 스트리밍 조정 창(window)을 늘려 레이스를 표면화.
Fig 1. 콜드 서버 + 병렬 워커 + RSC 스트리밍 — 세 조건이 겹칠 때만 재현
App Router는 <Suspense> 경계를 스트리밍 SSR로 처리. 콘텐츠가 준비되면 ① 실제 콘텐츠를
<body> 최상위 <div hidden>에 먼저 흘리고, ② 인라인 스크립트 $RC(...)가 이를
<main>의 fallback 자리로 이동시키며 hidden 컨테이너를 비움. $RC 실행 직전의
찰나에는
동일 콘텐츠가 두 곳에 공존 → 콜드에서 이 창이 길어지면 단언이 그 순간을 포착해 2개를 매칭.
<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
상태로 관측.
두 증상 모두 제품 버그가 아닌 인프라 타이밍에 종속.
<main> 스코프로 correctness 픽스콘텐츠 단언을 사용자가 실제로 보는 <main> 영역으로 스코프. 숨김 복사본은
main 밖
(body-level div[hidden])에 있어 원천 제외 → 증상 A를 결정적으로 제거하며, ‘보이는 것’을 검증하는 더 정확한
테스트로 승격.
관측된 webkit 실패는 전부 콜드스타트 타이밍이며 실제 WebKit 렌더링 회귀를 잡은 사례 0건.
flaky 비용·실행 시간만 늘리던 커버리지를 제거하고, 임시로 넣었던 expect.timeout·로컬
retries 밴드에이드는 원복.
// 문서 전역 단언 → 숨김 복사본까지 포착 await expect( page.getByText('전체 북마크') ).toBeVisible(); // strict 위반 (2개)
// 보이는 main 영역으로 스코프 const main = page.getByRole('main'); await expect( main.getByText('전체 북마크') ).toBeVisible(); // 표시본 1개만
적용: dashboard.spec.ts ·
responsive.spec.ts — 숨김 복사본을 매칭 범위에서 원천 배제
projects: [
{ name: 'chromium', ... },
{ name: 'firefox', ... },
{ name: 'webkit', ... },
{ name: 'mobile-webkit', ... },
{ name: 'mobile-chromium', ... },
]
// + expect.timeout 상향, 로컬 retries (밴드에이드)
projects: [
{ name: 'chromium', ... },
{ name: 'firefox', ... },
{ name: 'mobile-chromium',
grep: /@responsive/ },
]
// 밴드에이드 원복 · retries: CI만 2
| 항목 | 이전 | 지금 |
|---|---|---|
| 하드 실패 | 7건 | 0건 |
| flaky | 최대 1건 (재시도 의존) | 0건 |
| 실행 시간 | ~31s | ~15s |
| 첫 시도 통과 (retries:0) | ✗ | ✓ · 콜드 2회 연속 46/46 |
북마크를 삭제하면 잠깐 휴지통에 들어갔다 사라지는 문제 — 확장이 프로그램적으로 일으킨 네이티브 삭제가 다시 ‘사용자 삭제’로 서버에 보고되어 하드 삭제되는 에코 루프를, 실행 컨텍스트 간 공유 채널 기반 에코 억제 가드로 차단.
삭제 자체는 서버에서 정상적으로 소프트 삭제(isDeleted=true)되어 휴지통에 진입.
문제는 그 직후 서버가 삭제를 일으킨 원본 브라우저에도 native_action('delete')을 보내고,
확장이 이를 받아 네이티브 북마크를 지우면 그 제거 이벤트가 다시 ‘사용자 삭제’로 서버에 보고되어
물리(하드) 삭제—휴지통 레코드까지 제거—로 이어지는 것.
DELETE /bookmarks/:id → 서버 소프트 삭제 · 휴지통 진입 ✓native_action('delete') 를 원본 브라우저에도 전송browser.bookmarks.removeTree(id) — 실제 네이티브 제거onRemoved 발화POST /bookmarks/event { action:'delete' }Fig 1. 소프트 삭제 직후, 자기 삭제가 사용자 삭제로 되돌아와 하드 삭제를 유발
서버의 소프트/하드 삭제 정책은 그대로 두고, 확장이 직접 일으킨 네이티브 삭제만 background가 서버로 재전송하지 않도록 억제.
두 컨텍스트가 storage.local을 공유 채널로 사용해, 삭제 직전에 대상 id를
등록하고
onRemoved에서 등록된 id면 소비 후 무시. 지연·실패로 남는 항목의 영구 억제를 막기 위해
TTL 적용.
Fig 2. 삭제 직전 등록 → 에코 이벤트에서 소비, 컨텍스트 경계는 storage.local로 연결
onRemoved.addListener(async (id) => { // 우리가 일으킨 삭제인지 구분 없이 // 무조건 서버로 보고 → 에코 dispatchBookmarkEvent('delete', { id }); });
// 삭제 직전: 프로그램적 삭제 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
삭제는 id를 미리 알 수 있어 id별 억제가 가능하지만, 생성(복원·크로스 브라우저 이동)은
새 네이티브 id를 생성 후에야 알 수 있어 id 매칭이 불가능. 그래서 생성은 시간 윈도우로
onCreated를 일괄 억제.
| 경로 | 억제 방식 | API | TTL |
|---|---|---|---|
| 삭제 (delete) | 대상 id 등록 → 소비 | mark / consumeProgrammaticDeletion | 10s |
| 생성 (create·restore) | 시간 윈도우 일괄 억제 | begin / isCreationSuppressed | 5s |
UI 삭제 → 소프트 삭제 → 휴지통 표시까지는 동일하되, 이어지는 자기 네이티브 삭제 이벤트는 억제되어 서버로 재전송되지 않음 → 휴지통에 그대로 유지. 반면 사용자가 브라우저에서 직접 삭제하면 억제 등록이 없어 종전대로 정상 동작. 동일 에코 구조를 가진 복원·크로스 브라우저 생성 경로까지 같은 가드로 확장.
고정 리프레시 토큰의 무제한 재발급 위험을, 토큰 로테이션과 이전 토큰 재사용 감지 기반 세션 폐기로 해결하여 탈취를 자동 탐지·차단.
리프레시 토큰이 고정(장수명)이면, 한 번 탈취당한 토큰으로 만료 전까지 액세스 토큰을 무제한 재발급 가능. 게다가 정상 사용자와 공격자가 같은 토큰을 제시하므로 서버가 둘을 구분할 단서가 없어 탈취 자체를 탐지 불가. 로그아웃·비밀번호 변경 전까지 침해가 조용히 지속.
로테이션 + 재사용 감지를 도입. 세션은 리프레시 토큰의 bcrypt 해시만 저장(평문 미보관)하고, 갱신마다 새 리프레시 토큰을 발급하며 저장 해시를 교체(rotation). 제시된 토큰이 저장 해시와 불일치하면 — 이미 로테이션되어 폐기됐어야 할 옛/탈취 토큰 — 즉시 세션을 폐기하고 재로그인을 강제.
Fig 1. 로테이션으로 옛 토큰이 곧 ‘탈취 신호’가 되는 구조
// 로테이션·재사용 감지 없음 const payload = jwt.verify(refreshToken); // 탈취돼도 만료 전까지 무제한 재발급 // 정상/공격자 구분 불가 → 탐지 불가 return issueAccessToken(payload.sub);
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
탈취된 옛 토큰의 재사용이 자동 탐지 → 세션 폐기로 이어져 무제한 재발급을 원천 차단. 침해가 발생해도 다음 갱신 시점에 서버가 스스로 탈취를 인지하고 세션을 무효화하는 대응 지점을 확보.
서버 다중화 시 WebSocket이 특정 인스턴스에 고정되어 다른 인스턴스의 클라이언트가 이벤트를 누락하는 문제를, Socket.IO Redis pub/sub 어댑터로 인스턴스 간 이벤트를 브로드캐스트하도록 해결하여 스케일아웃 환경에서도 실시간 동기화 일관성 보장.
서버를 다중화하면 WebSocket 연결은 특정 인스턴스에 고정. 인스턴스 A가 발생시킨 emit은
A에 붙은 소켓에만 도달하고, 인스턴스 B에 연결된 클라이언트는 이벤트를 누락.
크로스 브라우저 북마크 동기화처럼 실시간 방송이 핵심인 기능이 스케일아웃에서 깨짐.
NestJS IoAdapter를 확장한 RedisIoAdapter로 pub/sub 클라이언트 한 쌍을
구성해
createAdapter(pubClient, subClient)를 소켓 서버에 주입. 어느 인스턴스에서 emit하든
Redis 채널로 발행되어 구독 중인 전 인스턴스가 각자의 소켓에 재방송.
Fig 1. 공유 Redis 채널이 인스턴스 경계를 넘어 emit을 전파
// 단일 프로세스 메모리에만 존재 @WebSocketGateway() // emit → 같은 인스턴스 소켓에만 도달 // 다중화 시 타 인스턴스 클라이언트 누락
// 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
클라이언트가 어느 인스턴스에 붙어도 동일 이벤트를 수신 → 스케일아웃 환경에서도 실시간 동기화 일관성 보장. 로드밸런서의 sticky 세션 의존을 제거해 인스턴스 수평 확장이 자유로워지고, 인스턴스 추가가 곧 실시간 처리량 증가로 직결.
SMTP 이메일 인증 코드의 brute-force·재전송 남용·타이밍 공격 위험을, 코드 만료 · 시도 횟수 제한 · 재전송 쿨다운 · 상수시간 비교의 다중 방어로 해결하여 안전한 SMTP 이메일 인증 시스템 구축.
이메일 인증 코드는 세 갈래로 노출. brute-force(짧은 코드를 반복 대입), 재전송 남용(메일 폭탄·발송
비용),
타이밍 공격(문자열 비교의 조기 반환 시간차로 코드 추론). 단순 === 비교와 무제한 시도는 세 위험에 모두 취약.
단일 방어가 아니라 계층별 다중 방어(defense-in-depth)를 적용. 각 계층이 서로 다른 공격 표면을 담당하도록 배치.
timingSafeEqual로 시간차 제거Fig 1. 공격 표면별로 분담하는 5계층 방어
// 타이밍 누출 + 무제한 대입 if (record.code !== code) { throw new BadRequestException('불일치'); }
// 시도 횟수 초과 → 코드 무효화 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
세 공격 경로가 각자 다른 계층에서 차단되어, 한 겹이 뚫려도 다음 계층이 방어하는 구조 확보. brute-force는 시도 제한, 남용은 쿨다운, 추론은 상수시간 비교, 분산 대입은 IP 레이트리밋이 담당.
AWS 인프라의 수동 구성으로 인한 재현 불가·드리프트 문제를, 모듈형 Terraform 코드화와 버전 고정, S3 원격 state 및 잠금으로 해결하여 재현 가능하고 드리프트 없는 인프라 시스템 구축.
인프라를 콘솔에서 손으로 구성하면, 무엇을 어떤 순서로 만들었는지 기록이 남지 않아 재현 불가. 누군가 콘솔에서 값을 바꾸면 실제 상태와 의도가 어긋나는 드리프트가 조용히 누적되고, 재해 복구·이관 시 되살릴 근거가 없음.
VPC·서브넷(network), RDS·Redis(data), SSM(secrets), 레지스트리(ecr), EC2·IAM(compute),
알람(monitoring), 배포(cicd)를 독립 모듈로 분리하고 envs/prod에서 조립.
재사용·검토 단위 확보.
required_version >= 1.11, aws ~> 6.0를 명시하고
.terraform.lock.hcl로 프로바이더 해시 고정 →
누가·언제 apply해도 동일 버전으로 동일 결과.
state를 암호화된 S3 버킷에 원격 저장하고 use_lockfile로 동시 apply 충돌을 차단 →
팀 공유 단일 진실, 병렬 변경으로 인한 state 파손 방지.
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/*
인프라 전체가 코드로 버전 관리되어, 동일 코드로 언제든 동일 환경을 재현 가능.
변경은 plan으로 사전 검토 후 apply되고, 원격 state가 실제 상태를 추적해 드리프트를
감지·수렴.
모든 리소스에 default_tags(Project·Env·ManagedBy)가 자동 부착되어 비용 추적·정리도 일관.
AWS 인프라에 CloudWatch 경보 → SNS → Lambda → Discord 웹훅 알림 파이프라인을 구축하여 ALB/EC2/RDS 이상 징후를 실시간 감지. 보안 비밀은 SSM SecureString으로 관리하며 운영 알림을 자동화.
지표를 사람이 콘솔에서 수동으로 들여다봐야만 이상을 알 수 있으면, 5xx 급증·타깃 다운·CPU 과부하·디스크 소진 같은 징후를 발생 시점에 놓치기 쉬움. 장애 인지가 늦어 대응이 사후로 밀리고, 알림 채널에 웹훅 URL 같은 비밀을 평문으로 두면 그 자체가 위험.
CloudWatch 경보가 임계값 위반 시 SNS 토픽에 발행하고, 이를 구독한 Lambda가 메시지를
Discord 임베드로 변환해 웹훅으로 중계. 상태 전이(ALARM/OK)를 색상으로 구분해 통지하며,
웹훅 URL은 SSM SecureString에 저장하고 Lambda가 WithDecryption으로 런타임에만 복호화.
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
ALB·EC2·RDS의 이상 징후가 발생 즉시 Discord로 통지되어, 콘솔을 지켜보지 않아도 장애를 조기 인지.
상태 복구 시 OK 전이도 함께 통지되어 해소 여부까지 자동 추적. 웹훅 비밀은 SecureString + KMS로 보호되어
코드·환경변수에 평문 노출 없음. 파이프라인 전체가 Terraform 모듈로 코드화되어 재현 가능.