브라우저에서 온 것은 전부 사용자가 쓴 것이다. 우리가 쓴 JavaScript가 보낸 값도 마찬가지다.
이 경계는 무엇을 청구하는가
데이터의 경계 마지막 편입니다. 앞의 세 편이 “이 값이 어디 있고, 몇 개이고, 서로 맞는가”였다면, 이번 편은 “이 값을 믿어도 되는가” 입니다.
ep.03 계약 경계와 헷갈리기 쉬우므로 먼저 구분합니다.
- 계약 경계는 형태를 묻습니다. 이 값이
{ productId: string, quantity: number }모양인가. - 신뢰 경계는 권한을 묻습니다. 이 사람이 그 상품을 그 수량만큼 살 자격이 있는가.
형태가 맞아도 권한이 없을 수 있습니다. { userId: "other-person", amount: 1 }은 스키마를 완벽히 통과하지만, 남의 계정으로 요청한 것일 수 있습니다.
이 경계가 청구하는 것은 중복 검증입니다. 같은 규칙을 클라이언트와 서버 양쪽에 씁니다. 낭비처럼 보이지만 낭비가 아닙니다. 두 검증은 목적이 다릅니다.
- 클라이언트 검증 → 사용자가 실수를 빨리 알게 하는 것. UX입니다.
- 서버 검증 → 시스템이 잘못된 상태가 되지 않게 하는 것. 보안입니다.
클라이언트 검증만 있으면 보안이 없어집니다. 서버 검증만 있으면 UX가 나빠집니다.
신뢰 경계가 답하는 질문은 하나입니다. 이 입력은 어디서 검증되어야 하는가.
이 경계에 붙어 있는 이름들
서버 권위 원칙
결과에 영향을 주는 값은 서버가 정합니다. 다른 어떤 원칙보다 먼저 세워야 합니다.
// 위험 - 클라이언트가 보낸 가격을 그대로 씁니다
app.post('/orders', async (req, res) => {
const { productId, quantity, price } = req.body
const total = price * quantity // 사용자가 price를 조작할 수 있습니다
await createOrder({ productId, quantity, total })
})
// 안전 - 가격은 서버가 조회합니다
app.post('/orders', async (req, res) => {
const { productId, quantity } = OrderSchema.parse(req.body)
const product = await db.product.findUnique({ where: { id: productId } })
if (!product) return res.status(404).json({ code: 'PRODUCT_NOT_FOUND' })
const total = product.price * quantity // 서버가 아는 가격만 씁니다
await createOrder({ productId, quantity, total, userId: req.user.id })
})
두 번째 코드에서 userId도 눈여겨볼 만합니다. 요청 바디가 아니라 인증된 세션에서 가져옵니다. 바디의 userId를 믿으면 남의 계정으로 주문할 수 있습니다.
클라이언트가 보내도 되는 것과 서버가 정해야 하는 것을 구분하는 기준은 단순합니다.
| 클라이언트가 보내도 되는 것 | 서버가 정해야 하는 것 |
|---|---|
| 무엇을 원하는지 (상품 ID, 수량) | 그것의 값 (가격, 할인율) |
| 표시 설정 (정렬, 페이지) | 누가 요청했는지 (userId) |
| 사용자가 입력한 내용 | 지금이 언제인지 (createdAt) |
| 멱등키 | 권한과 역할 |
인증과 인가
두 단어가 자주 섞입니다.
인증(authentication) 은 “누구인가”입니다. 로그인, 토큰 검증, 세션 확인이 그 예입니다. 한 번 통과하면 신원이 확정됩니다.
인가(authorization) 는 “무엇을 해도 되는가”입니다. 이 사용자가 이 문서를 볼 수 있는가, 이 주문을 취소할 수 있는가.
인증은 한 번, 인가는 요청마다 자원마다 확인해야 합니다. 이 구분을 놓치면 흔한 취약점이 생깁니다.
// 인증만 있고 인가가 없음
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.id } })
res.json(order) // 로그인만 했으면 남의 주문도 볼 수 있습니다
})
// 인가까지 — 자원 단위로 확인합니다
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.id } })
if (!order) return res.status(404).json({ code: 'NOT_FOUND' })
// 이 사용자의 주문인가
if (order.userId !== req.user.id && !req.user.roles.includes('admin')) {
return res.status(404).json({ code: 'NOT_FOUND' }) // 403이 아니라 404
}
res.json(order)
})
403은 “그 주문은 존재하지만 당신 것이 아닙니다”를 알려줍니다. ID를 하나씩 바꿔가며 요청하면 어느 ID가 실재하는지 알아낼 수 있습니다. 존재 자체를 숨겨야 하는 자원에는 403 대신 404를 씁니다.
이 취약점의 이름이 IDOR(Insecure Direct Object Reference) 입니다. 웹 취약점 중 가장 흔한 축에 듭니다.
최소 권한
최소 권한 원칙(principle of least privilege) 은 각 주체가 자기 일에 필요한 만큼만 권한을 갖는다는 규칙입니다.
이 원칙은 사용자 권한만의 이야기가 아닙니다.
- 애플리케이션이 쓰는 DB 계정이
DROP TABLE권한을 가질 필요가 있는가. - 이미지 리사이즈 워커가 결제 테이블을 읽을 수 있어야 하는가.
- 프론트엔드에 내려주는 사용자 객체에
passwordHash가 들어 있지는 않은가.
마지막 항목이 특히 자주 발생합니다.
// 위험 - 필요 없는 필드까지 내려갑니다
const user = await db.user.findUnique({ where: { id } })
return res.json(user) // passwordHash, internalNotes, ...
// 안전 - 내보낼 것을 명시합니다
return res.json({
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
})
내보낼 필드를 고르는 방식(allowlist) 이 안전합니다. 뺄 필드를 고르면(denylist), 나중에 추가된 필드가 자동으로 새어 나갑니다.
토큰을 어디에 둘 것인가
프론트엔드에서 반복되는 결정입니다. 완벽한 답은 없고 트레이드오프가 있습니다.
| 저장 위치 | XSS에 안전 | CSRF에 안전 | 비고 |
|---|---|---|---|
| localStorage | 아니오 | 예 | 스크립트가 읽을 수 있습니다 |
| 메모리 (JS 변수) | 부분적 | 예 | 새로고침하면 사라집니다 |
| httpOnly 쿠키 | 예 | 아니오 | SameSite로 CSRF를 막습니다 |
실무에서 널리 쓰이는 조합은 httpOnly + Secure + SameSite 쿠키입니다.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly가 JavaScript의 접근을 막고, SameSite가 다른 사이트에서의 요청에 쿠키가 붙는 것을 막습니다. 이 조합이 두 공격 벡터를 모두 다룹니다.
localStorage에 토큰을 두는 것이 위험한 이유는 XSS가 한 번 성공하면 토큰이 통째로 털리기 때문입니다. 그리고 XSS는 우리 코드가 아니라 광고 스크립트, 분석 도구, npm 의존성 등 서드파티 스크립트에서 올 수도 있습니다.
안티패턴
클라이언트에서만 권한 판단: if (user.role === 'admin')으로 화면을 감추는 것은 UI 편의입니다. 서버가 같은 검사를 하지 않으면, API를 직접 호출하는 것으로 우회됩니다.
응답에 과다 정보: 에러 메시지에 SQL, 스택 트레이스, 내부 경로가 들어가면 공격자에게 지도를 주는 것입니다. 개발 환경과 프로덕션의 에러 응답을 분리합니다.
계정 존재 여부를 알려주는 로그인 에러: “존재하지 않는 아이디입니다”와 “비밀번호가 틀렸습니다”를 구분하면, 어떤 아이디가 가입되어 있는지 알아낼 수 있습니다. “아이디 또는 비밀번호가 올바르지 않습니다” 하나로 통일합니다.
개인 데이터를 공유 캐시에: ep.08 캐시 경계에서 짚은 문제입니다. Cache-Control: public이 붙은 응답에 사용자 이름이 들어 있으면 CDN이 그것을 다른 사람에게 줍니다.
검증을 프론트엔드 라이브러리에만: zod 스키마를 프론트엔드에서만 쓰고 서버는 그냥 받는 코드가 의외로 흔합니다. 스키마를 공유하되, 서버에서도 반드시 실행합니다.
이 경계가 화면에 드러나는 자리
권한은 화면에서 무엇을 보여줄 것인가의 문제가 됩니다. 그리고 여기에는 세 가지 선택지가 있습니다.
숨김, 비활성화, 그리고 안내
관리자만 쓸 수 있는 삭제 버튼이 있습니다. 일반 사용자에게 어떻게 보여야 할까요.
숨김(hide): 아예 렌더하지 않습니다. 화면이 깔끔합니다. 대신 사용자는 그 기능의 존재를 모릅니다.
비활성화(disable): 회색으로 보여주되 누를 수 없게 합니다. 기능의 존재는 알지만, 왜 못 쓰는지는 모릅니다. 사용자는 이유 없는 비활성화에 좌절합니다.
안내(explain): 보여주고, 누를 수 있게 하고, 누르면 이유를 설명합니다. 또는 비활성화하되 툴팁으로 이유를 답니다.
선택 기준을 세울 수 있습니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 권한 자체가 없음 (일반 사용자에게 관리자 기능) | 숨김 | 존재를 알 필요가 없습니다 |
| 조건이 안 맞음 (배송 시작 후 취소) | 비활성화 + 이유 | 왜 안 되는지 알아야 합니다 |
| 상위 플랜에서 가능 | 보여주고 안내 | 업그레이드 경로로 유도합니다 |
| 지금은 안 되지만 나중엔 됨 | 비활성화 + 시점 안내 | 포기나 기약 없는 기다림을 막아야 합니다 |
권한이 없는 것과 조건이 안 맞는 것은 다릅니다. 전자는 숨기고, 후자는 설명합니다.
// 권한 없음 - 렌더하지 않습니다
{canDelete && <DeleteButton onClick={handleDelete} />}
// 조건 안 맞음 - 보여주고 이유를 답니다
<DeleteButton
disabled={!isCancellable}
title={!isCancellable ? '배송이 시작된 주문은 취소할 수 없습니다' : undefined}
/>
이 모든 화면 처리는 UI 편의이지 보안이 아니라는 점을 다시 강조합니다. 버튼을 숨겨도 API는 열려 있습니다. 화면 처리와 서버 검사는 같은 규칙을 다른 목적으로 구현한 것입니다.
에러 메시지가 흘리면 안 되는 것
에러 화면은 도움이 되어야 하지만, 너무 많이 말하면 안 됩니다.
[나쁨] Error: relation "users" does not exist at Query.run (/app/db/client.js:42)
[나쁨] 해당 이메일로 가입된 계정이 없습니다
[나쁨] 관리자 권한이 필요합니다 (현재 권한: viewer)
[좋음] 요청을 처리할 수 없습니다. 문제가 계속되면 아래 번호로 문의해 주세요.
요청 ID: 7f3a-2b81
[좋음] 아이디 또는 비밀번호가 올바르지 않습니다
[좋음] 이 작업을 수행할 수 없습니다
사용자에게는 요청 ID를, 로그에는 전부가 원칙입니다. 사용자는 문의할 때 붙잡을 것이 있고, 우리는 그 ID로 전체 맥락을 찾습니다. 이 설계는 ep.13 관측 경계에서 자세히 다룹니다.
다만 균형이 필요합니다. 모든 에러를 “요청을 처리할 수 없습니다”로 만들면, 사용자가 고칠 수 있는 문제까지 감춰집니다.
- 사용자가 고칠 수 있는 것 → 구체적으로 말합니다. “비밀번호는 8자 이상이어야 합니다.”
- 사용자가 고칠 수 없는 것 → 요청 ID와 함께 일반화합니다.
- 보안에 민감한 것 → 존재 여부를 흘리지 않습니다.
위험한 행동 앞의 마찰
ep.09 일관성 경계에서 되돌릴 수 없는 지점에 확인 화면을 둔다고 했습니다. 신뢰 경계에서는 여기에 강도가 더해집니다.
위험도에 따라 마찰의 종류가 달라집니다.
- 확인 다이얼로그: 되돌릴 수 있지만 번거로운 작업에 씁니다. “이 항목을 삭제하시겠습니까?”가 그 예입니다.
- 타이핑 확인: 되돌릴 수 없는 작업에 씁니다. 저장소 이름을 직접 입력하게 하는 방식입니다.
- 재인증: 계정 자체에 영향을 주는 작업에 씁니다. 비밀번호 변경, 결제수단 등록, 계정 삭제가 그 예입니다.
- 2단계 인증: 금전이 오가거나 권한이 이양되는 작업에 씁니다.
// 3단계 - 민감 작업 전 재인증
async function changeEmail(newEmail: string) {
// 세션이 있어도 최근 인증을 다시 요구합니다
const reauth = await requestReauthentication()
if (!reauth.ok) return
await api.changeEmail(newEmail, { reauthToken: reauth.token })
}
재인증의 목적은 사용자를 의심하는 것이 아니라, 자리를 비운 사이 누군가 그 화면을 만졌을 가능성이나 세션이 탈취되었을 가능성을 있어서 그런 것입니다. 로그인한 지 오래된 세션일수록 이 확인의 의미가 큽니다.
마찰은 비용입니다. 되돌릴 수 없는 것과 계정에 영향을 주는 것에만 씁니다. 모든 곳에 마찰을 두면 사용자는 읽지 않고 통과하는 법을 익히고, 그 순간 마찰은 보안이 아니라 장식이 됩니다.
참고 자료
- OWASP. Top 10:2021 - A01 Broken Access Control. https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP. Cheat Sheet Series: Authorization. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Saltzer, J., & Schroeder, M. (1975). The Protection of Information in Computer Systems. Proceedings of the IEEE.
- MDN. Set-Cookie: SameSite. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
- W3C. WAI-ARIA Authoring Practices: Disabled controls. https://www.w3.org/WAI/ARIA/apg/
다음 편 예고
데이터의 경계가 끝났습니다. 이제 시스템이 시간 속에서 살아갑니다. 첫 질문은 “무엇이 함께 배포되어야 하는가”입니다. 배포 단위는 코드 구조가 아니라 릴리스 속도가 결정합니다.