[FRONT]
- [FRONT] 프론트엔드 쿠키 이슈 해결하기
- [FRONT] Nuxt Proxy 설정과 활용
- [FRONT] 웹 캐시 전략과 구현
- [FRONT] Next.js와 Nuxt.js 비교 분석
- [FRONT] Monorepo vs Multi-repo vs Monolith 아키텍처
- [FRONT] mitmproxy를 활용한 디버깅
- [FRONT] Storybook 활용 가이드
- [FRONT] Vercel Turbopack 소개
- [FRONT] 캐시와 캐싱 전략
- [FRONT] SWC 컴파일러 이해하기
- [FRONT] 인터섹션 옵저버로 인터섹션 여부 감지하기
- [FRONT] BroadcastChannel 사용해서 같은 도메인 브라우저 간 통신하기
- [FRONT] DOM이벤트 버블링(Bubbling)과 캡처링(Capturing)
- [FRONT] XSS와 CSRF
- [FRONT] 웹 성능 최적화
- [FRONT] 브라우저 렌더링 과정
- [FRONT] 웹 접근성
- [FRONT] URL과 Domain 정확히 이해하자 (url구조)
- [FRONT] HTTP 헤더 이해하기
- [FRONT] 🍪 Cookie 보안의 모든 것: 탈취부터 방어까지
- [FRONT] 프론트엔드 기술은 어떻게 발전해왔는가?
- [FRONT] www.google.com 입력하면 일어나는 일 — DNS부터 렌더링까지
- [FRONT] 요즘 개인정보 유출은 왜 전부 ‘API’에서 터질까
알고있으면 너무 좋은 프론트엔드 WEB API 파헤쳐보자
2026년 들어 국내 플랫폼의 개인정보 유출 공지가 유난히 잦습니다. 그런데 공지 문구가 거의 똑같습니다. “조회하는 연동 기능(API)에 외부의 비정상적인 접근이 발생해…“
이 글은 최근 사고들을 소재로 왜 하필 API에서 터지는지, 프론트엔드 개발자가 mitmproxy로 매일 들여다보는 그 API가 서버에서 무엇을 놓치면 유출 통로가 되는지를 정리합니다.
들어가며
mitmproxy 글을 쓸 때만 해도 목적은 순수하게 디버깅이었습니다. 앱에서 나가는 요청이 안 보이니까 중간에 프록시를 끼워서 보자는 것이었죠.
그런데 최근 사고들을 보다 보니, 그때 봤던 화면이 다시 떠올랐습니다. mitmproxy를 켜면 앱이 어떤 엔드포인트를 어떤 파라미터로 호출하는지 전부 보입니다. 프론트에서 아무리 감춰도 요청 그 자체는 숨길 수 없습니다.
그러면 질문이 이렇게 바뀝니다.
“요청이 다 보인다면, 서버는 그 요청을 무엇으로 막고 있는가?”
최근 유출 사고의 본질이 정확히 여기에 있습니다.
최근 사고 요약
| 구분 | 강남언니 (힐링페이퍼) | 29CM (무신사 계열) |
|---|---|---|
| 시점 | 2026년 9월 4일 | 2026년 8월 27일 |
| 공개된 경로 | 상담 내역 조회 API | 주문 정보 조회 API |
| 규모 | 219,665명 | 159,852건 |
| 유출 항목 | 이름·연락처·생년월일 + 상담/시술 정보 | 이름 위주, 일부 이메일·전화·배송지 |
| 유출 안 된 것 | 카드번호·비밀번호 | 결제정보·아이디·비밀번호 |
두 사고 모두 회사가 밝힌 사실은 “조회 API에 비정상 접근이 있었다”까지입니다. 엔드포인트도, 파라미터도, 실제 요청 형태도 공개되지 않았습니다.
⚠️ 이 글에서 다루는 원인 분석은 공개된 공지와 보도를 근거로 한 일반론입니다. 수사 결과가 나오기 전까지 특정 취약점으로 단정할 수 없습니다.
그래서 mitmproxy로 털린 건가?
아닙니다. 이건 짚고 넘어가야 합니다.
mitmproxy 같은 중간자(MITM) 도구가 성립하려면 피해자 기기에 프록시 설정과 가짜 CA 인증서가 설치돼야 합니다. 설치 글에서 인증서를 키체인에 등록하고, 폰에서 mitm.it 접속해서 프로파일을 깔았던 그 과정이죠.
22만 명 폰에 그 짓을 할 수는 없습니다.
| 구분 | MITM (mitmproxy) | 이번 사고 |
|---|---|---|
| 위치 | 클라이언트 ↔ 서버 사이 | 서버의 API 앞 |
| 전제 조건 | 대상 기기에 CA 설치 | 없음 (그냥 호출) |
| 볼 수 있는 것 | 내 트래픽 | 남의 데이터 |
| 확장성 | 1명씩 | 수십만 건 |
유출된 게 다른 사람의 상담 기록, 다른 사람의 주문 내역이라는 점이 결정적입니다. 도청이 아니라 서버가 남의 데이터를 그냥 내준 쪽에 가깝습니다.
토큰이 털린 건가?
그것도 공개적으로는 아닙니다.
토큰이나 세션이 대량으로 유출된 사고라면 비밀번호 변경 강제, 전체 세션 만료 같은 조치가 먼저 나옵니다. 그런데 두 회사 모두 비밀번호와 결제정보는 안전하다고 선을 그었습니다.
다만 “토큰과 무관하다”는 뜻은 아닙니다. 가능성은 이 정도 층위로 나뉩니다.
- 인가(Authorization) 실패 — 토큰은 유효했지만, 서버가 “이 데이터가 이 사람 것인지”를 확인하지 않은 경우
- 인증(Authentication)이 약한 조회 API — 내부용·파트너용 연동이 외부에서 호출 가능했던 경우
- 대량 조회 제어 부재 — 정상 토큰 하나로 무한히 긁어도 아무도 못 막은 경우
업계에서 가장 많이 거론되는 건 1번, 혹은 1 + 3 조합입니다.
핵심: 인증과 인가는 다른 것이다
여기서 개념을 확실히 갈라야 합니다. 이 둘을 섞으면 위 1번 문제가 그대로 생깁니다.

| 구분 | 인증 (Authentication) | 인가 (Authorization) |
|---|---|---|
| 질문 | 누구세요? | 이거 볼 권한 있어요? |
| 확인 대상 | 토큰이 유효한가 | 이 리소스의 주인이 맞는가 |
| 실패 시 응답 | 401 Unauthorized | 403 Forbidden |
| 보통 구현 위치 | 미들웨어 (전역) | 각 핸들러 (리소스 단위) |
문제는 인증은 미들웨어로 한 번에 깔리는데, 인가는 엔드포인트마다 개발자가 직접 챙겨야 한다는 점입니다. 100개 API 중 99개를 잘 막아도, 빠진 1개가 22만 건의 통로가 됩니다.
1
2
3
4
5
6
7
// ❌ 인증만 하고 인가를 빠뜨린 코드
router.get("/api/consultations/:id", authMiddleware, async (req, res) => {
// authMiddleware가 "로그인한 사용자"인 건 확인해 줬다.
// 그런데 이 상담 기록이 "그 사용자의 것"인지는 아무도 안 봤다.
const consultation = await db.consultation.findById(req.params.id);
res.json(consultation);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 리소스 소유권까지 검증
router.get("/api/consultations/:id", authMiddleware, async (req, res) => {
const consultation = await db.consultation.findById(req.params.id);
if (!consultation) {
return res.status(404).json({ code: "NOT_FOUND" });
}
// 핵심: 이 리소스가 요청자의 것인가
if (consultation.userId !== req.user.id) {
return res.status(404).json({ code: "NOT_FOUND" });
}
res.json(toPublicConsultation(consultation));
});
💡 권한 없는 리소스에 403 대신 404를 주는 이유는, 403이 “그 ID는 존재한다”는 사실을 알려주기 때문입니다. 존재 여부 자체가 정보가 되는 도메인(의료·금융)에서는 404가 안전합니다.
OWASP API Top 10 1위: BOLA
위의 잘못된 코드에 정식 이름이 있습니다. BOLA(Broken Object Level Authorization), 예전엔 IDOR(Insecure Direct Object Reference)라고 부르던 것입니다.
OWASP는 이 취약점을 API 보안 위험 1위로 올려두고, 악용 난이도를 Easy로 평가합니다. 이유는 단순합니다.
1
2
3
4
5
# 공격이라고 부르기도 민망한 수준
GET /api/orders/10001 → 내 주문 (정상)
GET /api/orders/10002 → 남의 주문 (여기서 데이터가 나오면 끝)
GET /api/orders/10003 → 남의 주문
...
반복문 하나면 됩니다. 특별한 도구도, 제로데이도 필요 없습니다.
왜 이렇게 자주 뚫리나
1) 순차 ID를 그대로 노출한다
1
/api/orders/10001, 10002, 10003...
다음 값을 추측할 수 있으면 전수 조회의 시작점이 됩니다. UUID나 ULID를 쓰면 추측은 어려워집니다. 다만 UUID는 추측 방지일 뿐 인가를 대체하지 않습니다. ID가 어딘가로 새면 그대로 열립니다.
2) 목록 API는 막고 상세 API는 잊는다
1
2
3
4
5
// 목록은 보통 잘 막힌다 — userId로 필터링하니까
GET /api/orders → WHERE user_id = ? ✅
// 상세에서 구멍이 난다 — id만 보고 꺼내니까
GET /api/orders/:id → WHERE id = ? ❌
3) 내부용 API가 외부에 열려 있다
어드민·CS·파트너 연동용으로 만든, 조건 없이 조회되는 API가 방화벽 밖에 노출된 경우입니다. 강남언니가 한 경로를 막은 다음 날 다른 경로로 재시도가 들어왔다는 대목은, 공격자가 이런 표면을 훑고 있었다는 정황으로 읽힙니다.
4) 응답에 필요 없는 필드가 다 실려 있다
화면에는 이름만 쓰는데 API는 주소·전화번호·생년월일을 통째로 내려주는 경우입니다. 프론트가 안 그리면 안 보인다고 착각하기 쉽지만, mitmproxy를 켜면 그대로 보입니다. 유출 규모를 키우는 건 대개 이 부분입니다.
mitmproxy로 내 서비스 점검해보기
여기서 mitmproxy 글이 다시 쓰입니다. 공격 도구가 아니라 내가 만든 API의 응답을 확인하는 용도로요.
1
$ mitmweb --listen-port 8080
프록시를 물리고 우리 앱을 한 바퀴 돌려본 다음, 이 세 가지만 확인해도 많은 게 보입니다.
① 응답에 화면에서 안 쓰는 개인정보가 실려 있는가
1
2
3
4
5
6
7
8
9
10
11
12
13
// 주문 목록 화면엔 상품명과 상태만 필요한데...
{
"orderId": "10001",
"productName": "티셔츠",
"status": "배송중",
"user": {
"name": "임척",
"phone": "010-1234-5678", // 화면에서 안 씀
"email": "test@example.com", // 안 씀
"address": "서울시 ...", // 안 씀
"birthDate": "1990-01-01" // 왜 있지?
}
}
② 파라미터를 바꿔도 200이 오는가
캡처한 요청을 mitmproxy에서 수정해 다시 보내보고, 내 계정 토큰으로 남의 리소스 ID를 조회했을 때 무엇이 오는지 확인합니다. 데이터가 나오면 그게 BOLA입니다.
③ 같은 요청을 반복하면 어디서 막히는가
100번, 1000번 호출했을 때 429가 나오는지, 아니면 계속 200이 나오는지 봅니다. 안 막힌다면 대량 수집이 가능하다는 뜻입니다.
⚠️ 반드시 본인이 권한을 가진 개발·스테이징 환경에서만 하세요. 타인의 서비스에 시도하면 정보통신망법 위반입니다.
그래서 뭘 해야 하나
서버 사이드
1) 모든 리소스 조회에 소유권 검증을 강제한다
개별 핸들러에 맡기면 반드시 빠집니다. 쿼리 레벨에서 강제하는 편이 안전합니다.
1
2
3
4
5
6
// 조회 헬퍼가 소유자 조건을 항상 포함하도록 강제
async function findOrderForUser(orderId: string, userId: string) {
return db.order.findFirst({
where: { id: orderId, userId }, // userId 조건이 빠질 수 없는 구조
});
}
2) 응답 필드를 화이트리스트로 관리한다
1
2
3
4
5
6
7
8
9
10
11
12
13
interface PublicOrder {
orderId: string;
productName: string;
status: OrderStatus;
}
function toPublicOrder(order: Order): PublicOrder {
return {
orderId: order.id,
productName: order.productName,
status: order.status,
};
}
엔티티를 res.json(order)로 그대로 던지지 않는 것만으로도, 스키마에 필드가 추가될 때 개인정보가 따라 나가는 사고를 막습니다.
3) Rate Limit과 이상 조회 탐지를 건다
정상 사용자는 1분에 주문 상세를 300번 열지 않습니다. 계정 단위·IP 단위 호출량 제한과, 단시간 대량 조회에 대한 알림이 있어야 합니다. 인가에 구멍이 나도 피해 규모를 줄여주는 마지막 방어선입니다.
4) 안 쓰는 API를 닫는다
리뉴얼 후 남은 구버전 엔드포인트, 테스트용으로 열어둔 경로가 가장 위험합니다. 문서에 없는 API는 점검 대상에서도 빠지기 때문입니다.
프론트엔드가 할 수 있는 것
프론트는 인가를 대신할 수 없습니다. 다만 문제를 가장 먼저 발견할 수 있는 위치에 있습니다.
- 과도한 응답을 발견하면 백엔드에 알린다 — 화면에서 안 쓰는 필드가 내려오면 그건 프론트가 제일 먼저 봅니다.
- 민감 데이터를 localStorage에 쌓지 않는다 — XSS 한 방에 통째로 나갑니다. (XSS와 CSRF)
- 토큰은
HttpOnly+Secure+SameSite쿠키로 — (Cookie 보안의 모든 것) - 프론트의 숨김은 보안이 아니다 — 버튼을 안 그려도 API는 그대로 열려 있습니다. 화면에서 지웠으니 안전하다는 말은 성립하지 않습니다.
정리
1
2
3
4
5
6
7
8
9
[요청] ──▶ 인증: 누구인가? ← 미들웨어로 한 번에 처리됨
↓
인가: 이 데이터의 주인인가? ← ⚠️ 여기가 자주 빠진다
↓
응답 필터: 필요한 필드만 ← ⚠️ 여기서 규모가 커진다
↓
Rate Limit: 대량 조회 차단 ← ⚠️ 마지막 방어선
↓
[데이터]
- 최근 유출 사고들은 MITM도, 토큰 대량 탈취도 아닙니다. 공개된 사실은 “조회 API가 비정상 접근에 뚫렸다”까지입니다.
- 업계가 공통으로 지목하는 유형은 API 접근통제, 특히 객체 단위 인가(BOLA)의 실패입니다.
- 인증은 보통 잘 되어 있습니다. 문제는 인가가 엔드포인트마다 수작업이라는 것입니다.
- 사고 규모를 키우는 건 응답에 과하게 실린 필드와 없는 호출량 제한입니다.
- 프론트엔드의 숨김은 보안이 아닙니다. mitmproxy 한 번이면 다 보입니다.
디버깅하려고 깔았던 도구가, 결국 우리 API가 얼마나 솔직하게 다 내주고 있는지를 보여주는 거울이었던 셈입니다.