프론트엔드에서 점검하는 API 인가와 응답 데이터
최근 보안 사고를 계기로 프론트엔드 개발에서 점검할 API 보안을 정리합니다. 공개 보도와 가상 예제를 구분하고, Astro와 Next.js에서 BFF의 소유권 검증·응답 필드 제한을 적용하는 방법과 효과를 살펴봅니다.
들어가며
최근 보안 사고 보도를 접하면서 프론트엔드 개발에서도 점검할 부분을 정리해보고 싶었다. 로그인 상태에 따라 화면을 나누고 API 응답을 화면에 표시하는 작업은 익숙하다. 그 과정에서 요청자의 권한과 브라우저에 전달되는 데이터까지 어떻게 확인하면 좋을까.
이 글에서는 조회 API 하나를 기준으로 요청이 서버에 도착하고 응답이 브라우저로 돌아오는 과정을 따라가 본다. 그 안에서 프론트엔드 개발자가 검토할 지점, Astro와 Next.js로 적용하는 방법, 적용 후 기대할 수 있는 효과를 연결해보려 한다.
먼저 29CM·강남언니·티빙의 사례를 공개 보도 범위에서 정리한다. 확인 기준일은 2026년 9월 14일이며, 사례 문단에는 회사 공지와 정부 조사 결과를 인용한 보도를 연결했다. 이후의 코드는 가상의 주문 조회 API다. 실제 사고의 구현이나 공격 경로를 재현한 예제는 아니다.
1. 먼저 보도에서 확인되는 내용
29CM — 주문정보 조회 API에 대한 비정상 접근
보안뉴스가 인용한 회사 공지에 따르면, 29CM는 2026년 8월 27일 주문정보 조회 API에 대한 외부의 비정상 접근으로 일부 고객 정보가 유출된 사실을 확인했다. 보도된 유출 규모는 총 159,852건이다. 이름만 유출된 대상은 138,841명, 이름·이메일 주소·휴대전화번호·배송정보가 함께 유출된 대상은 21,011명이다. 회사는 결제정보·아이디·비밀번호는 유출 항목에 포함되지 않았다고 밝혔다. 보안뉴스 — 29CM 사고 보도
회사는 해당 접근 경로를 차단하고 한국인터넷진흥원(KISA)에 신고했으며, 대상 고객에게 유출 항목을 안내했다. 이 보도에는 공격자의 인증 상태, 객체 식별자 변경 여부, 구체적인 인가 검증 구현이 제시되어 있지 않다. 보안뉴스 — 회사의 대응 및 공지 내용
강남언니 — 상담내역 조회 API에 대한 비정상 접근
ZDNet Korea가 인용한 2026년 9월 7일 회사 공지에 따르면, 강남언니는 9월 4일 상담내역 조회 API에 대한 비정상 접근으로 일부 고객 정보가 유출됐다고 밝혔다. 유출 대상은 국내외 이용자 219,665명이며, 유출 항목에는 이름·연락처·생년월일과 상담·결제·시술 관련 정보 등이 포함됐다. ZDNet Korea — 강남언니 사고 보도
회사는 최초 접근 경로를 차단한 뒤 9월 5일 다른 경로를 통한 재접근 시도를 확인했다고 설명했다. 이후 전수 점검과 인증 절차 강화, 이상 접근 탐지체계 개선, 권한 검증 로직 재정비를 실시했다고 밝혔다. 이러한 사후 조치 목록만으로 최초 침해 원인을 특정할 수는 없으며, 해당 보도에는 구체적인 취약점과 공격 요청이 제시되어 있지 않다. ZDNet Korea — 회사가 발표한 대응 내용
티빙 — 탈취한 접속키를 통한 개발·운영 환경 접근
보안뉴스가 전한 2026년 9월 3일 민관합동조사단 발표에 따르면, 티빙에서는 계정 정보 3,954만 개와 개발 프로젝트 361건, 30.35GB가 유출됐다. 조사단은 공격자가 사전에 탈취한 개발환경 접속키로 개발 프로젝트에 접근한 뒤, 소스코드에 평문으로 포함된 운영환경 접속키를 확보해 이용자 데이터베이스에 접근했다고 설명했다. 보안뉴스 — 티빙 조사 결과 보도
3,954만 개는 중복 계정을 포함한 집계다. 조사단은 동일 이용자가 보유한 복수 계정이 포함되어 있다고 밝혔으므로, 이를 피해자 3,954만 명으로 해석해서는 안 된다. 뉴스토마토 — 유출 계정 수와 실제 이용자 수의 구분
위 보도에서 29CM와 강남언니의 침해 원인이 BOLA였는지는 확인되지 않는다. 여기까지를 사례에서 확인한 사실로 두고, 다음부터는 프론트엔드 개발에서 점검할 수 있는 일반적인 API 보안 문제를 살펴보려 한다.
2. 로그인 확인 다음에 점검할 것
요청자의 신원과 리소스 접근 권한
주문 화면을 만든다고 가정하면, 먼저 로그인한 사용자만 접근하도록 구성하게 된다. 여기에 한 가지를 더 확인하고 싶다. 주문 ID를 바꿔 요청해도 그 사용자가 볼 수 있는 데이터만 반환되는지다.
인증(Authentication)은 요청자의 신원을 확인하는 절차이고, 인가(Authorization)는 해당 요청자가 특정 리소스에 특정 작업을 수행할 권한이 있는지 판단하는 절차다.
| 구분 | 확인하는 내용 | 주문 조회 예제 |
|---|---|---|
| 인증 | 요청자의 신원 | 유효한 세션 또는 토큰인가? |
| 인가 | 대상과 작업에 대한 접근 권한 | 이 사용자가 이 주문을 조회할 수 있는가? |
유효한 토큰을 확인했더라도 다른 사용자의 주문을 조회할 권한까지 부여되는 것은 아니다. OWASP API Security Top 10 2023의 API1 항목인 BOLA(Broken Object Level Authorization)는 이러한 객체 수준의 인가 실패를 다룬다. OWASP — API1:2023 BOLA
인증만 수행하는 코드
프레임워크에 관계없이 문제를 볼 수 있도록, 우선 요청을 처리하는 함수만 작성해보자. verifyToken은 토큰을 검증하고 사용자 정보를 반환하는 함수, db는 데이터베이스 클라이언트라고 가정한다.
async function getOrderResponse(request: Request, orderId: string) {
const session = await verifyToken(request);
if (!session) {
return new Response(null, { status: 401 });
}
const order = await db.order.findUnique({ where: { id: orderId } });
// 취약점: 요청자가 이 주문을 조회할 권한이 있는지 확인하지 않는다.
return Response.json(order);
}
본인 주문만 조회하는 테스트는 이 코드에서도 통과할 수 있다. 그러나 사용자 A의 토큰으로 사용자 B의 주문 ID를 요청하면, 주문이 존재하는 경우 B의 정보가 반환된다.
# 사용자 A의 유효한 토큰으로 사용자 B의 주문을 조회하는 가상 요청
curl -H "Authorization: Bearer <USER_A_TOKEN>" \
https://api.example.com/orders/10002
사용자가 전달한 객체 식별자를 변경해 권한 없는 데이터에 접근하는 문제는 IDOR(Insecure Direct Object Reference)의 예이며, 위 API에서는 BOLA에 해당한다. 식별자가 순차 정수인지 UUID인지는 객체 수준 인가의 필요성을 바꾸지 않는다. OWASP — BOLA 공격 예제와 식별자
이 요청은 경로와 토큰의 형식이 올바를 수 있다. 따라서 요청 형식 검사만으로 접근 권한 위반을 판단할 수 없다. 업무별 권한 정책을 일반적인 정적 분석만으로 완전히 검증하기도 어렵다. 본인 데이터의 정상 조회와 함께 다른 사용자의 데이터에 대한 접근 거부를 테스트해야 한다. OWASP — 인가 정책과 테스트
3. 프론트엔드에서 검토할 서버 경계
화면에서 다른 사용자의 주문으로 이동할 수 없게 만들면 충분할 것처럼 보일 수 있다. 버튼 비활성화나 라우팅 가드는 사용자가 가능한 작업을 이해하도록 돕지만, 요청자는 HTTP 요청을 직접 전송하거나 기존 요청의 식별자를 변경할 수 있다.
[브라우저 UI] ──> [서버 API: 인증·인가 검증]
↑
[직접 작성한 HTTP 요청] ┘
따라서 브라우저의 접근 제한만으로 서버 데이터의 권한을 보장할 수 없다. 서버는 클라이언트가 전달한 값과 관계없이 매 요청에 필요한 인가를 수행해야 한다. OWASP — 모든 요청의 권한 검증
이 지점에서 살펴볼 수 있는 방법이 BFF(Backend For Frontend)다. 프론트엔드 팀이 서버 계층도 담당한다면, 화면에 필요한 API를 구성하면서 권한과 응답 필드를 함께 검토할 수 있다. 기존 백엔드에서 이미 같은 정책을 제공한다면 그 구현을 활용할 수 있고, 클라이언트 전용 서버 계층이 필요하다면 BFF를 검토할 수 있다.
4. Astro와 Next.js에서 주문 조회에 적용해보기
먼저 정할 검증 위치
BFF는 특정 클라이언트의 요구에 맞춘 서버 계층이다. 이번 예제에서는 브라우저가 BFF로 주문을 요청하고, BFF가 세션과 주문 소유권을 확인한 뒤 화면에 필요한 필드만 반환하도록 구성해보려 한다.
[브라우저]
│ 세션 쿠키
↓
[BFF]
· 세션 검증
· 주문 소유권 검증
· 응답 필드 제한
│ 서버 간 인증을 적용한 요청
↓
[Backend API]
· BFF 등 허용된 호출자의 접근만 허용
· 다른 호출 경로에도 필요한 인가 정책 적용
이 구조를 사용하려면 기존 백엔드 API를 직접 호출해 검증을 우회할 수 없도록 네트워크 접근과 호출 권한을 구성해야 한다. 다른 웹·앱·내부 서비스의 호출 경로가 있다면 그 경로에도 인가 정책이 필요하다. 서비스 토큰은 서버 간 호출자를 식별하는 수단이며, 최종 사용자의 주문 조회 권한을 자동으로 보장하지 않는다.
BFF와 백엔드 중 어느 계층에서 어떤 정책을 집행할지 정하고, 사용자·조직·리소스·작업에 관한 검증이 누락되지 않도록 해야 한다. OWASP — 인가 설계 원칙
두 프레임워크에서 사용할 같은 정책
예제의 정책은 간단하다. 유효한 세션이 있어야 하고, 주문 소유자만 조회할 수 있으며, 응답에는 주문 ID·상태·상품명·수량만 포함한다. getSession은 서버에서 세션의 유효성과 만료를 확인하고, fetchOrder는 서버 간 인증을 거쳐 주문을 조회하는 별도 구현 함수로 가정한다. 두 함수는 프레임워크가 자동으로 제공하는 기능이 아니다.
| 적용 지점 | Astro | Next.js App Router |
|---|---|---|
| API 파일 | src/pages/api/orders/[id].ts | app/api/orders/[id]/route.ts |
| 쿠키 읽기 | 엔드포인트 컨텍스트의 cookies | await cookies() |
| 동적 경로 값 | params.id | await params 이후 id |
| 실행 환경 | 서버 어댑터와 요청 시 실행 설정 | 요청을 처리할 수 있는 Next.js 서버 환경 |
Next.js 예제는 15·16 버전 계열의 비동기 cookies()와 params API를 기준으로 한다. 쿠키를 읽어 사용자별 응답을 만드는 이 경로는 정적 export 배포로 처리할 수 없다. Next.js — Route Handlers, Next.js — cookies, Next.js — BFF와 export 모드
Astro: 서버 엔드포인트에 적용하기
Astro의 정적 엔드포인트는 빌드 시 실행된다. 요청마다 쿠키를 읽고 권한을 검증하려면 배포 환경에 맞는 서버 어댑터를 설치·설정해야 한다. 기본 정적 모드를 유지한다면 해당 경로에 export const prerender = false를 추가한다. output: 'server' 구성에서는 경로가 기본적으로 요청 시 실행된다. Astro — 서버 엔드포인트
이 블로그는 정적 빌드 구성이므로, 아래 API를 실제 서비스에 추가한다면 서버 실행 환경부터 마련해야 한다. 어댑터 설정을 마쳤다는 전제로 엔드포인트를 작성하면 다음과 같다.
// src/pages/api/orders/[id].ts
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = async ({ params, cookies }) => {
const session = await getSession(cookies.get('session')?.value);
if (!session) {
return new Response(null, { status: 401 });
}
if (!params.id) {
return new Response(null, { status: 400 });
}
const order = await fetchOrder(params.id);
// 개인 주문 조회 정책: 주문 소유자만 조회할 수 있다.
if (!order || order.userId !== session.userId) {
return new Response(null, { status: 404 });
}
return Response.json(
{
id: order.id,
status: order.status,
items: order.items.map((item) => ({ name: item.name, qty: item.qty })),
},
{ headers: { 'Cache-Control': 'no-store' } },
);
};
Next.js: Route Handler에 적용하기
Next.js에서는 같은 검증을 App Router의 Route Handler에 둘 수 있다. 브라우저의 요청 주소는 Astro 예제와 동일한 /api/orders/:id이고, 요청에서 쿠키와 경로 값을 읽는 부분이 달라진다.
// app/api/orders/[id]/route.ts
import { cookies } from 'next/headers';
// getSession, fetchOrder는 위에서 설명한 별도 구현 함수를 사용한다.
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const cookieStore = await cookies();
const session = await getSession(cookieStore.get('session')?.value);
if (!session) {
return new Response(null, { status: 401 });
}
const { id } = await params;
const order = await fetchOrder(id);
if (!order || order.userId !== session.userId) {
return new Response(null, { status: 404 });
}
return Response.json(
{
id: order.id,
status: order.status,
items: order.items.map((item) => ({ name: item.name, qty: item.qty })),
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
여기서 await cookies()는 쿠키를 읽을 뿐이다. 쿠키 값의 유효성과 사용자의 권한은 애플리케이션에서 검증해야 한다. 동적 경로의 params도 Promise이므로 값을 꺼내기 전에 기다린다. Next.js — cookies, Next.js — Route Handler의 params
적용 후 달라지는 동작
앞의 취약한 예제에서는 유효한 토큰만 있으면 다른 사용자의 주문도 반환됐다. 두 구현에서는 주문 소유권 검증을 통과해야 200 응답을 받을 수 있고, 원본 주문에 포함된 필드 중 지정한 항목만 브라우저로 전달된다. 이번 예제에서 기대하는 효과는 다른 사용자의 주문 접근 거부와 응답 데이터 범위 제한이다.
조직 단위 주문, 공동 구매, 관리자 조회처럼 다른 정책이 있다면 단순 소유권 비교만으로는 충분하지 않다. 해당 작업에 필요한 조직 소속과 역할, 공유 권한 등을 추가로 평가해야 한다. 데이터 접근 계층을 직접 제어할 수 있다면 조회 조건에 사용자나 조직 범위를 포함하는 방식도 사용할 수 있다.
캐시도 함께 점검할 부분이다. 두 예제의 Cache-Control: no-store는 HTTP 응답을 저장하지 않도록 지시하지만, 서버 내부의 데이터 조회 캐시까지 설정하지는 않는다. Next.js의 fetchOrder가 fetch를 사용한다면, 서버 간 인증 등을 담은 요청 옵션에 cache: 'no-store'를 명시해 조회 결과도 저장하지 않도록 구성한다. 이 설정과 별도로 적용한 데이터 캐시가 있다면 그 범위도 확인한다. Next.js — fetch의 cache 옵션
주문이 없는 경우와 백엔드 연결 실패도 구분해야 한다. 앞의 코드는 검증 흐름에 집중한 예제이므로, 실제 적용 시 네트워크 오류와 예외 처리, 배포 환경의 캐시 동작까지 확인할 필요가 있다.
Server Component에서 조회하는 경우
Next.js에서 최초 화면을 Server Component로 렌더링한다면, 자신의 Route Handler를 HTTP로 호출하는 단계가 꼭 필요한 것은 아니다. 서버의 데이터 접근 함수를 직접 호출하면 추가 HTTP 왕복을 줄일 수 있다. Next.js — Server Components와 BFF
이때 Route Handler에만 소유권 검증을 두면 다른 조회 경로에서 같은 검증을 놓칠 수 있다. 세션 검증·소유권 확인·응답 필드 선택을 수행하는 서버 전용 데이터 접근 함수로 공통화하고, Route Handler와 Server Component가 이를 사용하도록 구성할 수 있다. 화면에서 버튼을 숨기거나 상위 레이아웃에서 로그인만 확인하는 것으로 데이터 접근 검증을 대신하지 않는다. Next.js — 인증·인가와 데이터 접근 계층
Client Component에 전달하는 props도 그 함수가 반환한 공개 가능한 데이터로 제한한다. 이 구성을 택하면 검증 정책을 여러 경로에서 일관되게 적용하고, 응답 필드 변경도 한곳에서 검토할 수 있다.
404를 반환한 이유
리소스가 없을 때 404, 권한이 없을 때 403을 반환하면 요청자가 상태 코드 차이로 존재 여부를 추론할 수 있다. HTTP 명세는 접근이 금지된 리소스의 존재를 숨기기 위해 404를 반환하는 것을 허용한다. RFC 9110 — 403 Forbidden
위 예제는 두 경우 모두 본문 없는 404를 반환한다. 이는 상태 코드와 본문 차이를 통한 추론을 줄이는 선택이다. 응답 시간 등 다른 차이까지 제거하는 것은 아니며, 서비스 정책상 권한 부족을 명시해야 한다면 403을 사용할 수 있다.
응답 필드 제한을 유지하는 방법
소유권을 확인한 다음에는 응답 내용을 살펴보려 한다. 가상 주문 화면에 주문 ID·상태·상품명·수량만 필요하다면 해당 필드만 응답에 포함한다. 원본 객체에 배송지나 전화번호가 있더라도 UI에서 표시하지 않는 것만으로 응답에서 제거되지는 않는다. 객체 접근 권한을 확인한 뒤, 각 필드가 해당 사용자에게 공개 가능한지도 판단해야 한다. OWASP — 객체 속성 수준 인가
TypeScript 타입 주석은 런타임 객체를 변환하지 않는다. 아래 코드는 order가 해당 타입에 할당 가능한 객체라고 가정한 예제다.
type PublicOrder = {
id: string;
status: string;
};
const order = { id: '10001', status: 'pending', address: '배송지 주소' };
const publicOrder: PublicOrder = order;
// address도 직렬화된다. 타입 주석은 객체의 필드를 제거하지 않는다.
const response = Response.json(publicOrder);
앞의 두 엔드포인트처럼 새 객체를 만드는 방법도 충분히 사용할 수 있다. 응답 구조를 여러 곳에서 재사용하고 싶다면 런타임 스키마로 명시하는 방법을 검토할 수 있다. 아래 Zod 스키마는 Astro와 Next.js의 서버 코드에서 동일하게 사용할 수 있으며, zod를 직접 의존성으로 설치한 구성을 가정한다.
import { z } from 'zod';
const PublicOrderSchema = z.object({
id: z.string(),
status: z.enum(['pending', 'shipped', 'delivered']),
items: z.array(
z.object({
name: z.string(),
qty: z.number().int().positive(),
}),
),
});
// 소유권 검증을 통과한 뒤, 엔드포인트의 응답 구성에 사용한다.
const publicOrder = PublicOrderSchema.parse(order);
return Response.json(publicOrder, {
headers: { 'Cache-Control': 'no-store' },
});
기본 z.object()는 스키마에 없는 키를 파싱 결과에서 제거한다. 원본 객체를 수정하는 것은 아니며, 알 수 없는 키를 허용하도록 설정하면 동작이 달라진다. 필수 값의 타입이나 enum 검증이 실패하면 .parse()가 예외를 발생시키므로, 서버 오류 처리에서 민감한 원본 데이터를 응답에 포함하지 않아야 한다. Zod — 객체 스키마
화면 밖의 요청으로 효과 확인하기
구현 후에는 본인 주문이 잘 보이는지와 함께, 다른 계정의 세션으로 같은 주문을 요청했을 때의 결과도 확인하고 싶다. Astro와 Next.js에 적용한 정책이 같으므로 확인할 동작도 같다.
| 요청 조건 | 이 예제의 기대 결과 |
|---|---|
| 세션이 없거나 만료됨 | 401 |
| 본인의 주문 조회 | 200, 허용된 응답 필드만 포함 |
| 다른 사용자의 주문 조회 | 404, 주문 정보 미포함 |
| 존재하지 않는 주문 조회 | 404 |
| 원본 주문에 민감 필드 추가 | 해당 필드가 응답에 포함되지 않음 |
| BFF를 우회한 백엔드 직접 호출 | 접근 제한 또는 해당 경로의 인가 정책 적용 |
조직과 역할이 있는 서비스에서는 다른 조직의 리소스, 권한 변경 직후의 요청, 관리자 전용 작업에 대한 테스트도 정책에 맞게 추가한다.
5. 요청과 응답 주변에서 더 점검할 부분
소유권과 응답 필드를 정리했다면, 그 요청에 사용하는 세션과 빌드 결과도 살펴볼 수 있다. 여기서는 함께 검토할 항목을 짧게 정리한다. 각각 다루는 위협이 다르므로 적용 목적과 효과를 구분해두려 한다.
세션 쿠키와 CSRF 방어
BFF 예제에서는 브라우저가 세션 쿠키를 전달한다. 이 쿠키를 설정할 때는 JavaScript의 값 접근을 제한하는 옵션부터 확인할 수 있다.
HttpOnly는 JavaScript에서 쿠키 값을 읽는 것을 제한한다. 다만 XSS 코드가 해당 쿠키를 동반한 요청을 전송하는 것까지 막지는 않는다. MDN — HttpOnly
Astro에서는 인증을 완료한 서버 엔드포인트의 cookies로 설정한다. sessionId는 서버에서 생성한 세션 식별자라고 가정한다.
cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 2,
});
Next.js에서는 인증을 완료한 Route Handler나 Server Action 안에서 비동기 쿠키 저장소를 얻어 같은 옵션을 적용한다. Server Component의 렌더링 중에는 쿠키를 설정할 수 없다. 다음 코드는 해당 서버 함수 내부에 들어갈 부분이다. Next.js — 쿠키 설정
// import { cookies } from 'next/headers';
const cookieStore = await cookies();
cookieStore.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 2,
});
SameSite=Lax는 크로스 사이트 POST 요청에 쿠키가 포함되는 것을 제한하지만, 최상위 GET 탐색 등에는 쿠키가 전달될 수 있다. GET 요청으로 상태를 변경하지 않고, 상태 변경 요청에는 신뢰할 수 있는 Origin 검증이나 CSRF 토큰 등 서비스 구성에 맞는 방어를 적용해야 한다. 쿠키의 만료 설정과 별도로 서버에서도 세션 만료와 폐기를 처리해야 한다. MDN — SameSite, OWASP — CSRF 방어
요청 제한과 식별자
조회 권한이 있더라도 짧은 시간에 과도한 요청이 들어오는 상황은 별도로 다뤄야 한다. 이때 요청 제한을 검토할 수 있다.
사용자별 요청 제한은 하나의 계정으로 반복 조회하는 속도를 제한한다. IP·사용자·조직·엔드포인트별 제한을 서비스 특성에 맞게 조합하고, 임계값은 정상 트래픽과 화면의 병렬 호출·재시도를 고려해 결정해야 한다.
Redis 카운터를 사용하는 경우 증가와 만료 설정을 분리하면, 증가 후 오류가 발생했을 때 만료 없는 키가 남을 수 있다. 다음 Lua 예제는 두 작업을 원자적으로 수행한다. KEYS[1]에는 서버에서 확인한 사용자 ID와 API 범주로 구성한 키를, ARGV[1]에는 제한 구간의 초 단위 길이를 전달한다고 가정한다. Redis — Rate limiter 예제
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
애플리케이션은 반환값이 허용 횟수를 초과하면 429를 반환한다. 이 방식은 제한 구간의 경계에서 짧은 시간에 요청이 집중될 수 있으므로, 더 균일한 제한이 필요하면 다른 알고리즘을 검토한다.
추측하기 어려운 식별자는 순차 ID를 이용한 열거를 어렵게 할 수 있다. UUIDv7은 타임스탬프와 랜덤 비트 등을 결합한 시간순 식별자로, 인덱스의 삽입 지역성을 개선할 수 있지만 단편화 제거를 보장하지는 않는다. 또한 생성 시각 정보가 포함된다. 식별자가 알려진 경우의 무단 접근은 별도의 인가 검증으로 차단해야 한다. RFC 9562 — UUIDv7
시크릿과 소스맵 관리
프론트엔드 프로젝트에서 사용하는 환경변수도 확인 대상이다. 서버에서만 써야 하는 키가 어느 단계에서 브라우저로 전달되는지 살펴보려 한다.
Astro는 기본적으로 PUBLIC_ 접두사를 가진 환경변수를 클라이언트에 노출할 수 있도록 처리한다. Vite 자체의 기본 접두사는 VITE_이므로 두 도구의 기본 규칙을 구분해야 한다. 서버 전용 환경변수라도 define:vars 등으로 HTML에 직렬화하면 브라우저에 전달된다. Astro — 환경변수, Astro — define:vars, Vite — 환경변수
Next.js에서 브라우저에 노출할 환경변수의 접두사는 NEXT_PUBLIC_이다. 서버용 키는 이 접두사 없이 서버 코드에서만 사용하고, Client Component의 props나 API 응답에 담아 보내지 않아야 한다. 접두사에 따른 노출 규칙과 실제 데이터 전달 경계를 함께 확인하는 점은 두 프레임워크에서 같다. Next.js — 환경변수
Gitleaks 같은 도구는 저장소에 포함된 시크릿을 탐지하는 데 사용할 수 있다. 과거 커밋까지 검사하려면 checkout 깊이와 스캔 범위를 맞춰야 한다. 환경변수의 실제 값이 빌드 결과에 삽입되는 상황은 저장소 검사만으로 탐지되지 않을 수 있으므로, 빌드 산출물과 서버·클라이언트 데이터 전달 경계도 확인해야 한다. 이미 노출된 키는 기존 키를 폐기하고 새 키로 교체해야 한다. Gitleaks — 검사 모드와 범위
Vite의 build.sourcemap: false는 프로덕션 소스맵을 생성하지 않는다. 'hidden'은 소스맵을 생성하면서 번들의 참조 주석만 제거한다. 오류 추적 서비스에 업로드한 뒤 공개하지 않으려면 .map 파일을 배포 대상에서 제외하거나 접근을 제한해야 한다. 소스맵 비공개는 이미 클라이언트 번들에 포함된 시크릿을 보호하지 않는다. Vite — build.sourcemap
Next.js의 프로덕션 브라우저 소스맵은 기본적으로 비활성화되어 있고, productionBrowserSourceMaps: true로 활성화하면 생성된 소스맵을 제공한다. 오류 추적 도구가 별도 플러그인으로 소스맵을 생성하는 경우도 있으므로, 설정 이름뿐 아니라 최종 배포 파일의 공개 여부를 확인한다. Next.js — productionBrowserSourceMaps
CSP와 Referrer-Policy
브라우저에 전달된 뒤의 동작은 응답 헤더로도 일부 제한할 수 있다. Astro와 Next.js 모두 배포 환경에 맞게 헤더를 설정하되, 여기서는 정책이 줄여주는 위험부터 구분해보려 한다.
CSP는 허용되지 않은 스크립트 실행과 일부 외부 통신을 제한한다. connect-src는 fetch, XHR, WebSocket 등의 연결을 제어하며, 폼 제출은 별도의 form-action으로 제한한다. form-action은 default-src를 상속하지 않는다. 이러한 지시어를 설정하더라도 모든 데이터 반출 경로가 차단되는 것은 아니다. MDN — connect-src, MDN — form-action
nonce 기반 정책은 응답마다 예측하기 어려운 값을 생성하고 HTML의 스크립트 속성과 일치시켜야 한다. 정적 배포에서는 고정 nonce를 재사용하지 않고, 필요한 인라인 스크립트의 해시를 정책에 반영하는 방식 등을 사용한다. 실제 정책은 사이트의 스크립트·스타일·외부 리소스 사용을 확인해 구성해야 한다. MDN — CSP
Referrer-Policy는 다른 문서로 이동하거나 리소스를 요청할 때 Referer에 포함되는 정보를 제어한다. 현대 브라우저의 기본 정책인 strict-origin-when-cross-origin에서는 다른 출처로 HTTPS 요청을 보낼 때 경로와 쿼리 대신 origin만 전달한다. URL 전체가 항상 외부에 전달되는 것은 아니며, 실제 정책과 개별 링크 설정에 따라 동작이 달라진다. MDN — Referrer-Policy
6. 내 프로젝트에 적용한다면
한 번에 모든 설정을 추가하기보다, 사용자별 데이터를 반환하는 API 하나를 골라 요청과 응답을 따라가며 확인하는 데서 시작하려 한다. 먼저 권한을 검증하는 위치와 우회 가능한 경로를 찾고, 브라우저에 필요한 필드와 실제 응답을 비교하는 순서다. 각 방법에서 기대할 수 있는 효과를 모으면 다음과 같다.
| 통제 | 적용 목적 | 함께 확인할 조건 |
|---|---|---|
| 객체·작업별 인가 | 권한 없는 조회와 변경 방지 | 모든 접근 경로와 조직·역할 정책 |
| 응답 필드 제한 | 공개 가능한 데이터만 전달 | 인가 이후 필드 선택과 스키마 검증 |
| 세션 관리·CSRF 방어 | 세션 노출과 위조 요청 위험 완화 | 서버의 세션 만료·폐기, 상태 변경 요청 검증 |
| 요청 제한 | 반복 조회 속도와 자원 사용 제한 | 여러 계정·경로를 통한 요청과 정상 트래픽 |
| 시크릿 관리·스캐닝 | 자격증명 노출 예방과 탐지 | 접근 권한, 빌드 산출물, 노출된 키의 폐기·교체 |
| 소스맵 비공개·CSP | 소스 노출과 브라우저 공격 위험 완화 | 배포 파일 접근, 실제 리소스와 정책의 일치 |
| 추측하기 어려운 식별자 | 식별자 열거 비용 증가 | 식별자를 알고 있어도 적용되는 인가 검증 |
BFF를 도입할지는 기존 백엔드의 역할까지 확인한 뒤 결정하려 한다. 이미 필요한 인가와 응답 구성을 제공한다면 그 경로를 검증하고 테스트를 보강할 수 있다. 클라이언트별 서버 계층이 필요하다면 Astro 엔드포인트나 Next.js Route Handler를 선택지로 두되, 백엔드 접근 제한·캐시·세션·운영 비용을 함께 검토해야 한다.
마치며
이번 글에서 내 개발 작업에 가져가고 싶은 기준은 요청과 응답의 경계를 함께 보는 것이다. 화면에서 정상적으로 동작하는 기능도, 다른 계정으로 요청했을 때의 결과와 브라우저에 전달되는 필드까지 확인해보려 한다.
Astro와 Next.js는 API를 작성하는 방식이 다르지만, 예제에서 적용한 기준은 같다. 서버에서 세션과 접근 권한을 확인하고, 허용된 데이터만 반환하며, 다른 조회 경로에도 같은 정책을 적용하는 것이다. 최근 사고의 원인을 추정하기보다 이런 점검을 실제 코드와 테스트에 어떻게 반영할지 살펴보는 출발점으로 삼고 싶다.
참고 자료
- 보안뉴스 — 29CM 주문정보 조회 API 관련 사고 보도
- ZDNet Korea — 강남언니 개인정보 유출 및 대응 보도
- 보안뉴스 — 티빙 민관합동조사단 조사 결과 보도
- 뉴스토마토 — 티빙 유출 계정의 중복 집계 설명
- OWASP — API1:2023 Broken Object Level Authorization
- OWASP — Authorization Cheat Sheet
- Astro — Endpoints
- Next.js — Backend for Frontend
- Next.js — Route Handlers
- Next.js — 인증·인가 설계
- Zod — 객체 스키마
- Redis — INCR와 요청 제한 예제
- MDN — Content Security Policy