본문으로 건너뛰기

신뢰 경계와 검증의 위치 - 디자인 패턴, 시스템 수준 편 ep.10

브라우저에서 온 것은 전부 사용자가 쓴 것이다. 우리가 쓴 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) 입니다. 웹 취약점 중 가장 흔한 축에 듭니다.

신뢰 경계를 넘는 요청의 흐름 다이어그램. 위쪽 브라우저 레인에는 폼 입력, 클라이언트 검증, 요청 조립 세 단계가 왼쪽에서 오른쪽으로 화살표로 이어지고, 레인 머리에 전부 사용자가 조작할 수 있으며 우리가 쓴 JavaScript가 보낸 값도 마찬가지라는 경고가 붙어 있습니다. 브라우저 레인과 아래쪽 서버 레인 사이에는 굵은 가로선으로 신뢰 경계가 그어져 있고, 요청 조립에서 나온 주황색 화살표가 이 경계를 한 번 넘어 서버 레인의 첫 단계로 들어갑니다. 서버 레인에는 네 개의 검증 단계가 순서대로 이어집니다. 첫째 형태 검증은 스키마 파싱이며 실패 시 400, 둘째 인증은 누구인지 한 번 확인하며 실패 시 401, 셋째 인가는 무엇을 해도 되는지 확인하며 실패 시 403이 아니라 404, 넷째 비즈니스 규칙은 재고, 잔액, 상태 전이를 검사하며 실패 시 409 또는 422를 반환합니다. 레인 아래에는 인증은 한 번, 인가는 요청마다 자원마다 확인하고 네 관문을 모두 통과해야 처리한다는 설명이 있습니다. 그 아래에는 인가 실패에 403 대신 404를 쓰는 이유가 설명됩니다. 403은 그 주문은 존재하지만 당신 것이 아니라는 사실을 알려주므로 ID를 바꿔가며 어느 ID가 실재하는지 알아낼 수 있고, 존재 자체를 숨겨야 하는 자원에는 404가 안전하며 이 취약점의 이름이 IDOR라는 내용입니다. 맨 아래에는 클라이언트 검증과 서버 검증이 대비됩니다. 클라이언트 검증의 목적은 UX로, 사용자가 실수를 빨리 알게 하고 서버 왕복 없이 즉시 피드백하지만 이것만 있으면 보안이 없습니다. 서버 검증의 목적은 보안으로, 시스템이 잘못된 상태가 되지 않게 하는 우회할 수 없는 유일한 검증이며 이것만 있으면 UX가 나빠서 둘 다 필요합니다.

최소 권한

최소 권한 원칙(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}
/>

숨김, 비활성화, 안내 세 가지 표현 방식과 선택 기준 다이어그램. 위쪽에 세 장의 카드가 나란히 있고 각 카드에는 화면 예시가 들어 있습니다. 첫째 숨김 카드는 주문 8842 화면에 상세와 영수증 버튼만 있고 삭제 버튼이 아예 없으며, 존재를 알 필요가 없을 때 쓴다는 설명이 붙습니다. 둘째 비활성화와 이유 카드는 같은 화면에 회색으로 비활성화된 취소 버튼이 있고 그 아래에 배송이 시작된 주문은 취소할 수 없다는 이유가 적혀 있으며, 왜 못 쓰는지 알 수 있고 이유 없는 비활성화는 좌절만 남긴다는 설명이 붙습니다. 셋째 보여주고 안내 카드는 대량 내보내기 화면에 CSV 내보내기 버튼과 Pro 배지가 있고, 누르면 업그레이드 경로로 유도하며 기능의 가치를 먼저 보여준다는 설명이 붙습니다. 그 아래 표에는 상황별 권장이 네 줄로 정리되어 있습니다. 권한 자체가 없는 경우는 숨김이며 존재를 알 필요가 없고, 조건이 안 맞는 경우는 비활성화와 이유이며 왜 안 되는지 알아야 하고, 상위 플랜에서 가능한 경우는 보여주고 안내이며 업그레이드 경로로 유도하고, 지금은 안 되지만 나중엔 되는 경우는 비활성화와 시점 안내이며 포기나 기약 없는 기다림을 막아야 합니다. 그 아래 두 박스에는 권한이 없는 것과 조건이 안 맞는 것은 다르므로 전자는 숨기고 후자는 설명한다는 원칙과, 화면 처리는 UI 편의이지 보안이 아니므로 버튼을 숨겨도 API는 열려 있고 서버가 같은 검사를 반드시 해야 한다는 경고가 나란히 있습니다. 맨 아래에는 위험한 행동 앞의 마찰이 위험도에 비례한다는 제목 아래 확인 다이얼로그는 되돌릴 수 있지만 번거로운 작업, 타이핑 확인은 되돌릴 수 없는 작업, 재인증은 계정에 영향을 주는 작업, 2단계 인증은 금전이 오가거나 권한이 이양되는 작업에 쓴다는 네 단계가 나열됩니다.

이 모든 화면 처리는 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 일관성 경계에서 되돌릴 수 없는 지점에 확인 화면을 둔다고 했습니다. 신뢰 경계에서는 여기에 강도가 더해집니다.

위험도에 따라 마찰의 종류가 달라집니다.

  1. 확인 다이얼로그: 되돌릴 수 있지만 번거로운 작업에 씁니다. “이 항목을 삭제하시겠습니까?”가 그 예입니다.
  2. 타이핑 확인: 되돌릴 수 없는 작업에 씁니다. 저장소 이름을 직접 입력하게 하는 방식입니다.
  3. 재인증: 계정 자체에 영향을 주는 작업에 씁니다. 비밀번호 변경, 결제수단 등록, 계정 삭제가 그 예입니다.
  4. 2단계 인증: 금전이 오가거나 권한이 이양되는 작업에 씁니다.
// 3단계 - 민감 작업 전 재인증
async function changeEmail(newEmail: string) {
  // 세션이 있어도 최근 인증을 다시 요구합니다
  const reauth = await requestReauthentication()
  if (!reauth.ok) return

  await api.changeEmail(newEmail, { reauthToken: reauth.token })
}

재인증의 목적은 사용자를 의심하는 것이 아니라, 자리를 비운 사이 누군가 그 화면을 만졌을 가능성이나 세션이 탈취되었을 가능성을 있어서 그런 것입니다. 로그인한 지 오래된 세션일수록 이 확인의 의미가 큽니다.

마찰은 비용입니다. 되돌릴 수 없는 것과 계정에 영향을 주는 것에만 씁니다. 모든 곳에 마찰을 두면 사용자는 읽지 않고 통과하는 법을 익히고, 그 순간 마찰은 보안이 아니라 장식이 됩니다.


참고 자료


다음 편 예고

ep.11 - 배포 경계와 마이크로 프론트엔드

데이터의 경계가 끝났습니다. 이제 시스템이 시간 속에서 살아갑니다. 첫 질문은 “무엇이 함께 배포되어야 하는가”입니다. 배포 단위는 코드 구조가 아니라 릴리스 속도가 결정합니다.