problem_solving

9. 팀 프로젝트에 실제 서비스처럼 보안, 성능 입히기 — Re:Verse 하드닝 기록

점심뭐먹지 2026. 7. 26. 00:20


Cloudflare–nginx–NestJS 프록시 체인 위에서,
rate limiting, RLS 백스톱, Gemini 비용 최적화, 프론트 로딩 성능까지

 

 

안녕하세요. Re:Verse를 개발하고 있는 제가 접니다. 이 글에서는 성경 구절 필사(筆寫) 서비스 Re:Verse를 운영 배포하면서 약 2주간 진행한 보안 하드닝(security hardening)과 성능 최적화(performance optimization) 작업을 다룹니다.

 

한 명이 만드는 서비스라도 프로덕션에 올리는 순간 마주치는 문제는 규모가 큰 서비스와 다르지 않습니다. 특히 Re:Verse는 손글씨 유사도 검사에 Gemini API라는 유료 외부 의존성을 쓰고, 요청이 Cloudflare → nginx → NestJS라는 프록시 체인을 거칩니다. 이 두 가지가 겹치면서, 흔한 방어 패턴이 그대로는 동작하지 않는 지점이 여러 곳 있었습니다. 이 글은 그 지점들을 하나씩 짚고, 어떻게 해결했는지 코드와 함께 정리한 기록입니다.

먼저 서비스 구성을 짚고, 보안 작업 다섯 가지, 성능 작업 다섯 가지 순서로 다루겠습니다.


Re:Verse의 배포 구성

Re:Verse는 사용자가 성경 구절을 손으로 필사한 사진을 올리면, 원문과의 유사도를 채점해 주는 서비스입니다. 스택은 다음과 같습니다.

  • 백엔드: NestJS 11 + TypeScript. DB는 Supabase PostgreSQL(ORM 없이 @supabase/supabase-js 직접 사용), 인증은 Supabase Auth + JWT(JWKS 검증).
  • 프론트엔드: React 18 + Vite 6 + Tailwind CSS v4. 인증은 Supabase anon 키만 사용.
  • 손글씨 채점: Google Gemini API. 이미지를 받아 원문과의 유사도를 구조화된 JSON으로 반환.
  • 인프라: 백엔드·프론트 모두 Docker 이미지로 빌드해 GCP에 배포. 앞단에 Cloudflare, 오리진에 nginx가 정적 자산을 서빙하고 /api/**만 백엔드로 리버스 프록시합니다.

여기서 핵심은 두 가지입니다. 하나는 모든 요청이 프록시 뒤에서 들어온다는 점, 다른 하나는 핵심 기능이 요청당 비용이 발생하는 유료 API라는 점입니다. 이 전제가 이후 설계 결정 대부분을 좌우했습니다.


1부. 보안

프록시 뒤에서 무력화되는 rate limiting

가장 먼저 부딪힌 문제는 rate limiting이었습니다. NestJS의 @nestjs/throttler는 기본적으로 클라이언트 IP를 버킷 키(bucket key)로 사용해 요청 수를 셉니다. 그런데 Re:Verse의 요청은 Cloudflare와 nginx를 거쳐 들어옵니다. 이 경우 애플리케이션이 보는 원격 주소는 실제 사용자가 아니라 프록시의 IP입니다.

결과적으로 모든 사용자가 같은 IP 하나로 묶입니다. 한 명이 요청을 많이 보내면 전체 사용자의 공용 한도가 소진됩니다. Gemini 호출처럼 요청당 비용이 드는 경로에서는, 이 구조가 곧 한 사용자가 전체 서비스의 비용 한도를 태워버릴 수 있는 취약점이 됩니다.

해결책은 버킷 키를 IP가 아니라 인증 토큰의 사용자 식별자(sub)로 잡는 것입니다. ThrottlerGuard를 상속해 getTracker만 재정의했습니다.

// src/common/throttler/user-throttler.guard.ts
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
  protected getTracker(req: Record<string, unknown>): Promise<string> {
    const header = req.headers as Record<string, unknown> | undefined;
    const auth = header?.authorization;
    if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
      const sub = this.decodeSub(auth.slice(7));
      if (sub) {
        return Promise.resolve(`u:${sub}`); // 사용자별 버킷
      }
    }
    // 토큰 없는 공개 라우트는 IP로 폴백
    const ips = req.ips as string[] | undefined;
    const ip = ips && ips.length > 0 ? ips[0] : (req.ip as string | undefined);
    return Promise.resolve(`ip:${ip ?? 'unknown'}`);
  }
  // ...
}

여기서 한 가지 설계상의 판단이 있습니다. 이 Guard는 토큰을 검증하지 않습니다. sub만 base64로 디코딩해 꺼내 쓸 뿐입니다. 언뜻 위험해 보이지만, 이 값은 신뢰 경계(trust boundary)가 아니라 버킷을 나누는 라벨일 뿐입니다. 위조한 sub로 새 버킷을 만들어도, 실제 유료 핸들러에 도달하려면 별도의 AuthGuard가 JWKS로 토큰을 검증하는 관문을 통과해야 합니다. 통과하지 못하면 401이므로, 검증되지 않은 요청은 애초에 Gemini까지 가지 못합니다. 즉 스로틀링 키는 가볍게, 실제 인증은 무겁게 분리한 구조입니다.

IP 폴백이 실제 클라이언트를 가리키게 하려면 프록시 신뢰 설정도 필요합니다. 전역 부팅 설정에서 trust proxy를 켜서 X-Forwarded-For를 신뢰하게 했습니다.

// src/app.setup.ts
app.set('trust proxy', 1);

가장 비싼 경로인 Gemini 채점 라우트에는 분당 20회처럼 별도의 낮은 한도를 두어, 인증되지 않은 비용 남용의 상한을 확실히 잡았습니다.

응답 헤더 하드닝 — helmet과 nginx의 역할 분담

다음은 브라우저 방어선입니다. XSS·클릭재킹·MIME 스니핑 같은 공격을 막는 보안 응답 헤더를 어디서 붙일지가 문제였습니다. 백엔드(NestJS)와 프론트를 서빙하는 nginx가 둘 다 헤더를 붙일 수 있어서, 잘못하면 헤더가 중복되거나 서로 다른 값으로 충돌합니다.

역할을 명확히 나누는 것으로 정리했습니다. 브라우저에 HTML을 내려주는 주체는 nginx이므로, CSP와 HSTS는 nginx가 소유합니다. JSON만 반환하는 백엔드에는 CSP가 필요 없고 HSTS는 중복되므로, helmet에서는 그 둘을 끄고 나머지 안전 헤더만 적용했습니다.

// src/app.setup.ts — 백엔드는 CSP·HSTS를 끄고 나머지만
app.use(helmet({ contentSecurityPolicy: false, hsts: false }));

nginx 쪽에서는 브라우저 방어 헤더를 한데 모아 관리합니다.

# nginx.conf — 보안 응답 헤더
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
add_header Cross-Origin-Opener-Policy "same-origin-allow-popups" always; # OAuth 팝업 격리
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; ... frame-ancestors 'none'; object-src 'none'" always;

CSP는 한 번에 강제(enforce)로 켜지 않았습니다. 처음에는 Report-Only 모드로 배포해(커밋 abc4e57, 2026-07-14) 실제 위반 리포트를 지켜봤습니다. 빌드된 index.html에 인라인 스크립트가 없고 외부 이미지 의존이 없다는 것을 확인한 뒤에야 enforce로 승격했습니다(커밋 4dd7081, 2026-07-20).
프로덕션에서 CSP를 처음 켜는 서비스라면, 이 Report-Only → enforce 2단계를 반드시 권합니다. 곧바로 강제로 켜면 정상 리소스까지 차단되어 화면이 깨지기 쉽습니다.

RLS 백스톱 — 정책 없는 deny-by-default

Supabase는 클라이언트가 anon 키로 DB에 직접 붙을 수 있는 구조입니다. Re:Verse 백엔드는 service_role 키로만 DB에 접근하지만, 만약 anon 키가 실수로 노출되거나 클라이언트가 테이블에 직접 접근을 시도하면 어떻게 될까요? 이 시나리오에 대한 최후의 방어선으로 RLS(Row-Level Security) 백스톱을 두었습니다.

핵심은 정책(policy)을 하나도 만들지 않고 RLS만 켜는 것입니다.

-- supabase/migrations/20260722000000_rls_backstop.sql
alter table public.users enable row level security;
alter table public.verses enable row level security;
alter table public.writing_sessions enable row level security;
-- ... 총 10개 테이블

service_role 키에는 bypassrls 속성이 있어 RLS를 우회합니다. 따라서 이 마이그레이션을 적용해도 서버 동작은 전혀 바뀌지 않습니다. 반면 정책이 하나도 없으므로, anon·authenticated 같은 그 외 역할은 기본 거부(deny-by-default) 상태가 됩니다. 즉 anon 키가 유출되어도 전체 사용자 데이터가 새지 않습니다.

운영 부담이 없으면서 사고 시 피해를 원천 차단하는, 비용 대비 효과가 아주 좋은 방어입니다. 나중에 특정 테이블을 anon 키로 직접 읽어야 할 일이 생기면, 그때 해당 테이블에 한해 auth.uid() = user_id 같은 최소 권한 정책을 명시적으로 추가하면 됩니다.

입력 검증과 5xx 에러 마스킹

두 가지 방어를 함께 정리합니다. 먼저 입력 검증입니다. 전역 ValidationPipe에 화이트리스트를 켜서, DTO에 선언되지 않은 필드는 아예 거부하도록 했습니다.

// src/app.setup.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,           // DTO에 없는 필드는 제거
    forbidNonWhitelisted: true, // 알 수 없는 필드가 있으면 요청 거부
    transform: true,
  }),
);

각 DTO는 class-validator로 값의 형태까지 좁힙니다. 예를 들어 감정 추천 파라미터는 @IsIn([...EMOTION_CODES])로 enum에 대해 검증하고, DB의 외래 키가 두 번째 방어선이 됩니다. 날짜 필드는 @Matches(/^\d{4}-\d{2}-\d{2}$/)로 형식을 강제합니다.

다음으로 5xx 에러 마스킹입니다. 서버 오류의 스택 트레이스나 내부 메시지가 클라이언트로 새면, 그 자체가 정보 노출입니다. 전역 예외 필터에서 5xx는 상세를 서버 로그에만 남기고, 클라이언트에는 일반 메시지만 보냅니다.

// src/common/filters/all-exceptions.filter.ts
if (status >= 500) {
  // 5xx: 상세는 서버 로그에만, 클라이언트에는 'Internal server error'만
  this.logger.error(
    `${request.method} ${request.url} -> ${status}`,
    exception instanceof Error ? exception.stack : String(exception),
  );
}
// 4xx(검증 실패·401·403·404·409 등)는 의도된 안전한 메시지라 그대로 유지

4xx는 클라이언트가 알아야 하는 의도된 메시지이므로 그대로 통과시키고, 5xx만 가립니다. 이 동작은 유닛 테스트로 4xx 통과 / 5xx 마스킹 / 비밀 미노출을 검증했습니다(커밋 f36a7ea, 2026-07-22).

공급망 하드닝 — CI 파이프라인까지 방어선을 넓히다

마지막 보안 작업은 코드가 아니라 배포 파이프라인입니다(커밋 6954455, 2026-07-21). 요즘 공격은 애플리케이션이 아니라 빌드 과정을 노리는 경우가 많습니다. 세 가지를 적용했습니다.

  • GitHub Actions를 커밋 SHA로 고정(pin): actions/checkout@v4 같은 태그 대신 actions/checkout@11d5960...처럼 커밋 해시로 못박았습니다. 태그는 재작성될 수 있어, 악의적 버전이 같은 태그로 치환되는 공격을 막기 위함입니다.
  • Trivy 이미지 취약점 스캔: 배포 이미지에 대해 CRITICAL·HIGH 취약점을 스캔합니다. 지금은 리포팅 단계(exit-code 0)이고, 이후 빌드 실패로 승격할 계획입니다.
  • 컨테이너 non-root 실행 + 헬스체크: 백엔드 Docker 런타임을 USER node로 내려 권한 상승 위험을 줄이고, /api/health 헬스체크를 추가했습니다.

여기에 더해, 배포와 분리된 테스트 CI 게이트를 두었습니다(커밋 ce6ff17). 인증 경로를 포함한 E2E 18개(토큰 없음 401, 만료 토큰 401, @Public 우회, 유효 토큰 시 req.user 세팅 등)가 main 푸시와 모든 PR에서 돌고, 이 워크플로에는 시크릿을 주지 않습니다.


2부. 성능

Gemini 비용·지연 최적화 — 가장 비싼 경로부터

성능 작업의 우선순위는 명확했습니다. 요청당 돈이 나가는 Gemini 경로가 1순위입니다. 여기서는 지연(latency)뿐 아니라 비용이 직접적인 성능 지표입니다.

먼저 이미지 다운스케일입니다(커밋 3cb431c). Gemini는 입력 토큰에 비례해 과금하는데, 손글씨 사진은 원본 해상도가 큽니다. sharp로 전송 전 최대 1024px JPEG로 축소했습니다. OCR·유사도 판정에는 그 정도 해상도면 충분해서, 품질 손실 없이 입력 토큰을 크게 줄였습니다.

다음으로 토큰 예산(token budgeting)입니다(커밋 bb3a699). Gemini 호출에 다음을 적용했습니다.

// Gemini 채점 요청 설정(개념 정리)
{
  generationConfig: {
    thinkingBudget: 0,        // 확장 사고 비활성화 — 토큰 낭비 제거
    responseSchema: {...},    // 구조화 응답 강제 — 파싱 모호성 제거
    maxOutputTokens: N,       // 응답 길이 상한
    temperature: 0,           // 결정론적 채점 — 유사도 점수 재현성 확보
  },
  // 요청 타임아웃 적용
}

특히 temperature: 0은 성능이자 정확성 이슈였습니다(커밋 cd50961). 온도가 0이 아니면 같은 이미지에 대해 유사도 점수가 매번 달라져, 비결정적(non-deterministic) 채점이 됩니다. 온도를 0으로 고정해 재현성을 확보했습니다. thinkingBudgetmaxOutputTokens는 지연과 비용을 함께 줄였습니다. 물론, 이 방법이 100% 정확성을 잡을 수는 없습니다만, 어느정도 보정은 가능합니다.

백그라운드 잡 동시성 상한

Re:Verse의 유사도 검사는 비동기입니다. 사용자가 사진을 올리면 즉시 세션 상태를 돌려주고, 채점은 백그라운드에서 진행합니다. 사용자 경험은 좋아지지만, 여기에 함정이 있습니다. 업로드가 몰리면 백그라운드 Gemini 호출이 무제한으로 쌓여 할당량과 비용이 통제 불능이 될 수 있습니다.

그래서 백그라운드 잡에 동시성 상한을 두었습니다(커밋 70ba3d3). 동시에 진행되는 Gemini 작업 수를 제한해, 순간적인 업로드 폭주가 곧바로 비용 폭주로 이어지지 않도록 막았습니다. 비동기 처리를 도입할 때는 "즉시 응답"만큼이나 "동시성 상한"을 같이 설계해야 한다는 것을 보여주는 사례입니다.

프론트 초기 로딩 — 라우트 코드 스플리팅과 지연 로드

프론트에서는 로그인 초기 번들을 줄이는 데 집중했습니다(커밋 33d0efd, fa440b7). 로그인 화면을 여는 사용자가 필사·히트맵·프로필 화면의 코드까지 한꺼번에 받을 이유가 없습니다.

모든 라우트를 React.lazy로 분리했습니다. 특히 MainLayout을 지연 로드하면서, @tanstack/react-query 컨텍스트가 인증 화면 청크에만 포함되도록 했습니다. 로그인 초기 번들에서 React Query가 통째로 빠집니다.

// src/App.tsx
const MainLayout = lazy(() => import("./layouts/MainLayout")); // React Query 컨텍스트 포함
const LoginPage = lazy(() => import("./pages/Login/LoginPage"));
const PilsaPage = lazy(() => import("./pages/Pilsa/PilsaPage"));
// ...
<Suspense fallback={<RouteFallback />}>
  <Routes>
    <Route path="/login" element={<LoginPage />} />
    <Route element={<ProtectedRoute />}>
      <Route element={<MainLayout />}>
        <Route path="/pilsa" element={<PilsaPage />} />
        {/* ... */}
      </Route>
    </Route>
  </Routes>
</Suspense>

로그인 화면은 인증 셸(shell)의 무게를 지지 않고, 무거운 상태 관리 라이브러리는 로그인 이후로 미룹니다. 첫 화면이 빨라집니다.

정적 자산 장기 캐시와 폰트 자체 호스팅

로딩 성능의 나머지 절반은 캐시입니다. Vite는 콘텐츠 해시가 붙은 파일명(main.a3f2b1c4.js)을 생성하므로, 파일이 바뀌면 이름도 바뀝니다. 즉 파일명이 곧 버전이라 장기 캐시가 안전합니다. nginx에서 정적 자산에 1년 만료를 부여했습니다.

# nginx.conf
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|svg|png|jpe?g|gif|webp|ico)$ {
    expires 1y;
    access_log off; # 정적 자산 접근 로그는 끔
}

폰트는 자체 호스팅으로 전환했습니다(커밋 ba2256a). Pretendard를 jsDelivr CDN에서 받던 것을 없애고, 콘텐츠 해시가 붙은 woff2로 번들에 포함시켰습니다. 이렇게 하면 외부 script-src 의존이 사라져 CSP도 더 좁게 조일 수 있습니다. 성능 최적화가 보안 강화로 이어진 지점입니다. 폰트는 동적 import로 렌더를 막지 않게 하고, font-display: swap으로 대체 글꼴을 즉시 보여줘 CLS(누적 레이아웃 이동)를 줄였습니다.

배포 후 Cloudflare 엣지 캐시 퍼지

마지막은 배포 순간의 문제입니다. 앞단에 Cloudflare가 있으면, 새 이미지를 배포해도 사용자가 엣지에 캐시된 예전 HTML을 받을 수 있습니다. 그래서 배포 파이프라인 마지막에 Cloudflare API로 캐시를 즉시 퍼지하는 스텝을 넣었습니다(커밋 81460f5).

HTML은 새로 받고, 콘텐츠 해시가 붙은 정적 자산은 새 해시로 필요할 때만 다시 받습니다. 배포 직후에도 사용자가 항상 최신 화면을 보게 됩니다.


2주간의 작업 타임라인

날짜 영역 작업 커밋
07-14 보안 nginx 보안 헤더 + CSP(Report-Only) abc4e57
07-15 성능 라우트 코드 스플리팅 + API 클라이언트 견고화 33d0efd
07-16 성능 폰트 렌더 비차단 + 정적 자산 장기 캐시 5d1b990
07-16 성능 유사도 검사 백그라운드 동시성 상한 70ba3d3
07-18 성능 Pretendard 자체 호스팅(jsDelivr 제거) ba2256a
07-19 성능 Gemini 토큰·지연 최적화 bb3a699
07-19 성능 Gemini 이미지 다운스케일(sharp) 3cb431c
07-20 보안 사용자별 rate limiting + helmet + RLS 252ff15
07-20 보안 CSP enforce 승격 + COOP + HSTS 4dd7081
07-20 성능 배포 후 Cloudflare 캐시 퍼지 81460f5
07-21 보안 공급망 하드닝(SHA 핀 + Trivy + non-root) 6954455
07-22 보안 5xx 에러 마스킹 + 동시성 테스트 f36a7ea
07-22 보안 RLS 백스톱 프로덕션 반영 51dc757
07-22 정확성 Gemini 온도 0 고정 cd50961

마치며

작업을 돌아보면 관통하는 원칙이 두 가지였습니다.

첫째, 방어심층(defense-in-depth)입니다. rate limiting 키와 실제 인증을 분리하고, helmet과 nginx가 헤더를 나눠 맡고, RLS를 최후 방어선으로 두고, 5xx를 가리는 것 모두 "한 겹이 뚫려도 다음 겹이 막는" 설계입니다.

둘째, 비용이 곧 성능 지표라는 점입니다. Re:Verse에서 가장 중요한 최적화는 CPU나 메모리가 아니라 Gemini 토큰이었습니다.

 

유료 외부 API에 의존하는 서비스라면, 이미지 다운스케일·토큰 예산·동시성 상한처럼 호출당 비용을 줄이는 작업이 곧 성능 최적화입니다.

 

인프라와 클라우드를 처음 구성해보는 작업이었고, vm 웨어도 처음 구성해보던지라 조금 헤맸던 것 같습니다.

이 글이 프록시 뒤에서 서비스를 운영하거나, 유료 AI API를 제품에 붙이는 분들께 도움이 되길 바랍니다. 

'problem_solving' 카테고리의 다른 글

0. I don't have a mouth but...  (0) 2026.03.09