보안은 개발이 끝난 뒤 추가하는 기능이 아니다

vx_developer·2일 전

개발하다가

목록 보기
42/45
post-thumbnail

프로그래밍을 처음 배울 때 보안은 보통 비밀번호를 암호화하거나 로그인하지 않은 사용자의 접근을 막는 기능이라고 배운다.

if (!currentUser) {
  throw new UnauthorizedError();
}

로그인하지 않은 요청을 거부하면 보호된 페이지에 접근할 수 없다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 로그인 검사 하나만으로 해결할 수 없는 문제가 생긴다.

  • 요청으로 받은 가격과 할인 금액을 믿어도 되는가?
  • 로그인한 사용자가 다른 사람의 주문을 조회할 수 있는가?
  • 레스토랑이 업로드한 파일을 그대로 공개해도 되는가?
  • 결제 서비스가 보낸 웹훅인지 어떻게 확인하는가?
  • 데이터베이스 비밀번호는 어디에 저장하고 언제 교체하는가?
  • 오류와 로그에는 어떤 정보를 남겨도 되는가?
  • 취약한 패키지와 잘못된 운영 설정은 어떻게 발견하는가?

보안은 개발이 끝난 뒤 추가하는 기능이 아니다.

보안은 입력이 들어와 처리되고 저장되며 다른 시스템으로 전달되고 운영 기록에 남는 전체 흐름에서, 허용된 주체만 허용된 행동을 수행하도록 위험을 제한하는 설계 원칙이다.


무엇을 지킬지 모르면 보호 방법도 정할 수 없다

크리스가 음식 배달 서비스를 개발한다고 생각해 보자.

서비스에는 고객, 레스토랑 운영자, 배달 기사, 관리자, 결제 제공업체가 참여한다.

각 주체가 중요하게 여기는 데이터도 다르다.

type DeliveryServiceAssets = {
  customerAddress: string;
  restaurantBankAccount: string;
  orderHistory: Order[];
  courierLocation: GeoPoint;
  paymentProviderToken: string;
  adminSession: string;
};

주소, 계좌 정보, 주문 이력, 위치, 외부 결제 토큰, 관리자 세션은 서로 다른 이유로 보호해야 한다.

보안 설계는 먼저 다음 질문에서 시작한다.

  • 무엇을 보호해야 하는가?
  • 누가 정상적으로 사용할 수 있는가?
  • 어떤 경로로 접근하는가?
  • 잘못 노출되거나 변경되면 어떤 피해가 발생하는가?
  • 시스템이 사용할 수 없게 되면 어떤 업무가 멈추는가?

이를 기밀성, 무결성, 가용성이라는 세 가지 관점으로 나눌 수 있다.

관점질문배달 서비스 예시
기밀성허용되지 않은 사람이 읽을 수 있는가다른 고객의 주소 노출
무결성허용되지 않은 방식으로 바뀔 수 있는가주문 금액이나 환불 계좌 변경
가용성필요한 때 사용할 수 있는가주문 API 과부하로 결제 불가

모든 데이터를 같은 수준으로 보호할 필요는 없다. 그러나 데이터의 중요도와 실패 영향을 분류하지 않으면 보호 비용을 어디에 집중해야 하는지도 결정하기 어렵다.


외부에서 들어온 값은 출처와 상관없이 검증해야 한다

주문 생성 API가 클라이언트의 요청을 그대로 사용한다고 생각해 보자.

await orderRepository.create({
  customerId: request.body.customerId,
  restaurantId: request.body.restaurantId,
  totalPrice: request.body.totalPrice,
  status: request.body.status,
});

사용자는 다른 사람의 customerId, 더 낮은 totalPrice, 허용되지 않은 status를 직접 보낼 수 있다.

외부 입력은 검증 전까지 신뢰할 수 없다.

브라우저 폼뿐 아니라 다음 값도 외부 입력이다.

  • URL 경로와 쿼리
  • HTTP 헤더와 쿠키
  • 모바일 애플리케이션의 요청
  • 외부 결제 서비스의 웹훅
  • 메시지 큐의 이벤트
  • 업로드 파일
  • 다른 내부 서비스의 응답
  • 운영자가 입력한 관리 데이터

먼저 요청의 구조를 검증한다.

import { z } from "zod";

const CreateOrderSchema = z.object({
  restaurantId: z.string().uuid(),
  deliveryAddressId: z.string().uuid(),
  items: z
    .array(
      z.object({
        menuItemId: z.string().uuid(),
        quantity: z.number().int().min(1).max(20),
      })
    )
    .min(1)
    .max(50),
  couponCode: z
    .string()
    .trim()
    .max(40)
    .optional(),
});

const input =
  CreateOrderSchema.parse(request.body);

이 검증은 허용된 필드, 타입, 길이, 수량 범위를 제한한다. 고객 ID, 가격, 주문 상태는 클라이언트가 결정할 값이 아니므로 요청 스키마에 포함하지 않는다.

형식이 올바르다고 비즈니스 의미까지 올바른 것은 아니다.

const restaurant =
  await restaurantRepository.findOpenById(
    input.restaurantId
  );

if (!restaurant) {
  throw new RestaurantUnavailableError();
}

UUID 형식이 맞더라도 실제 영업 중인 레스토랑인지 확인해야 한다.

입력 검증은 두 단계로 확장된다.

  • 문법적 검증은 타입, 형식, 길이, 범위를 확인한다.
  • 의미적 검증은 현재 서비스 상태에서 허용되는 값인지 확인한다.

검증만으로 입력이 안전한 명령이 되지는 않는다

메뉴 검색어의 길이와 타입을 검증했다고 생각해 보자.

const SearchSchema = z.object({
  keyword: z.string().trim().min(1).max(100),
});

이 검증은 비정상적으로 긴 입력을 막지만 문자열이 SQL의 일부로 실행되는 문제까지 해결하지는 않는다.

다음 코드는 검색어를 SQL 문자열에 직접 연결한다.

const query = `
  SELECT *
  FROM menu_items
  WHERE name LIKE '%${keyword}%'
`;

입력값이 데이터가 아니라 SQL 문법으로 해석될 수 있다.

매개변수화된 쿼리를 사용해야 한다.

const menuItems = await database.query(
  `
    SELECT *
    FROM menu_items
    WHERE name ILIKE $1
  `,
  [`%${keyword}%`]
);

검색어는 SQL 명령이 아니라 하나의 데이터 값으로 전달된다.

화면 출력도 사용하는 위치에 맞게 처리해야 한다.

<p>{restaurant.description}</p>

React처럼 기본적으로 텍스트를 이스케이프하는 렌더링 방식을 사용하면 설명에 포함된 HTML이 코드로 실행되는 것을 줄일 수 있다.

반대로 검증되지 않은 HTML을 직접 삽입하는 방식은 위험을 만든다.

<div
  dangerouslySetInnerHTML={{
    __html: restaurant.description,
  }}
/>

입력 검증, 매개변수화된 쿼리, 출력 인코딩은 서로 다른 경계를 보호한다. 하나를 적용했다고 나머지가 필요 없어지는 것은 아니다.


가격의 Source of Truth는 사용자의 화면이 아니다

프론트엔드는 주문 예상 금액을 계산해 보여줄 수 있다.

const displayedTotal =
  cartItems.reduce(
    (total, item) =>
      total + item.price * item.quantity,
    0
  );

이 값은 사용자에게 예상 금액을 빠르게 보여주는 데 유용하다.

그러나 사용자는 브라우저의 상태와 요청 본문을 변경할 수 있다.

{
  "restaurantId": "restaurant-123",
  "totalPrice": 1
}

서버가 이 금액을 신뢰하면 사용자가 임의로 가격을 정할 수 있다.

서버가 현재 메뉴 가격과 할인 정책으로 다시 계산해야 한다.

const pricedItems =
  await menuRepository.findOrderableItems(
    input.items.map((item) => item.menuItemId)
  );

const orderTotal = calculateOrderTotal({
  requestedItems: input.items,
  pricedItems,
  coupon,
  deliveryFeePolicy,
});

서버의 메뉴 정보와 할인 정책이 주문 금액의 Source of Truth다.

프론트엔드의 계산값은 표시를 위한 파생 값이다.

보안 문제는 특수문자를 입력하는 공격만을 의미하지 않는다. 정상적인 API를 비정상적인 순서나 값으로 사용해 비즈니스 규칙을 우회하는 것도 보안 문제다.

다음 규칙도 서버에서 다시 확인해야 한다.

  • 쿠폰이 현재 사용자에게 발급되었는가?
  • 쿠폰이 해당 레스토랑에 적용되는가?
  • 최소 주문 금액을 만족하는가?
  • 같은 쿠폰을 이미 사용하지 않았는가?
  • 메뉴가 주문 가능한 상태인가?
  • 요청한 수량이 구매 제한을 넘지 않는가?

보안은 입력의 모양뿐 아니라 사용자가 서비스의 약속을 우회할 수 있는지도 확인해야 한다.


인증과 권한은 모든 중요한 행동에서 다시 만난다

고객이 로그인했다는 사실만으로 모든 주문을 조회할 수 있는 것은 아니다.

const order =
  await orderRepository.findById(
    request.params.orderId
  );

다른 고객의 주문 ID를 전달하면 주소와 주문 내역이 노출될 수 있다.

현재 사용자의 범위를 함께 적용해야 한다.

const order =
  await orderRepository.findOne({
    id: orderId,
    customerId: currentUser.id,
  });

if (!order) {
  throw new OrderNotFoundError();
}

고객은 자신의 주문만 조회할 수 있다.

레스토랑 운영자는 같은 주문을 다른 관계로 조회할 수 있다.

const order =
  await orderRepository.findOne({
    id: orderId,
    restaurantId:
      currentRestaurantMembership.restaurantId,
  });

배달 기사에게는 자신에게 배정된 주문만 보여줄 수 있다.

const delivery =
  await deliveryRepository.findOne({
    orderId,
    courierId: currentCourier.id,
  });

같은 주문이라도 주체마다 허용된 행동과 필드가 다르다.

주체허용할 수 있는 행동제한할 정보
고객자신의 주문 조회·취소내부 운영 메모
레스토랑주문 접수·조리 상태 변경전체 결제 정보
배달 기사배정 주문과 배송지 확인고객의 다른 주문 이력
고객 지원문의 해결을 위한 제한 조회결제 비밀값
관리자정책상 필요한 관리 작업불필요한 비밀번호·토큰 원문

인증은 사용자가 누구인지 확인한다. 권한은 그 사용자가 해당 주문에 특정 행동을 할 수 있는지 결정한다.


최소 권한은 사고가 발생했을 때 피해 범위를 줄인다

주문 서비스가 하나의 데이터베이스 계정으로 모든 테이블을 읽고 수정한다고 생각해 보자.

order-service
  → users: read/write
  → restaurants: read/write
  → orders: read/write
  → payments: read/write
  → audit_logs: read/write

주문 서비스가 침해되면 필요하지 않은 사용자와 감사 로그 데이터까지 변경할 수 있다.

업무에 필요한 권한만 제공하는 편이 낫다.

order-service
  → menu_items: read
  → orders: read/write
  → payment_records: create/read-status
  → audit_logs: append-only

애플리케이션 코드에서도 같은 원칙이 필요하다.

type RestaurantOrderView = {
  orderId: string;
  items: OrderItem[];
  requestedDeliveryTime: string;
  deliveryAddressSummary: string;
};

레스토랑 화면에 전체 고객 프로필이나 결제 토큰을 전달하지 않는다.

최소 권한은 정상 업무를 어렵게 만드는 것이 아니다. 계정, 서비스, 기능, 데이터 필드가 실제 책임보다 넓은 접근권을 가지지 않게 하는 일이다.


민감한 데이터는 수집하기 전부터 삭제까지 책임이 생긴다

배달 서비스를 만들 때 필요할 가능성이 있다는 이유로 모든 정보를 수집할 수 있다.

type CustomerProfile = {
  fullName: string;
  dateOfBirth: string;
  homeAddress: string;
  workAddress: string;
  phoneNumber: string;
  locationHistory: GeoPoint[];
};

그러나 수집한 데이터마다 저장, 권한, 암호화, 보존, 삭제, 사고 대응 책임이 생긴다.

서비스에 생년월일과 전체 위치 이력이 필요하지 않다면 수집하지 않는 편이 안전하다.

type DeliveryAddress = {
  recipientName: string;
  phoneNumber: string;
  streetAddress: string;
  deliveryInstructions: string | null;
};

현재 배송에 필요한 정보만 저장한다.

데이터별 책임도 구분해야 한다.

데이터보호 방법보존 기준
비밀번호전용 비밀번호 해싱계정 자격 증명 갱신까지
배송지암호화·접근 통제사용자 설정과 법적 요구에 따라
결제 카드제공업체 토큰 사용원문 카드 저장 회피
주문 내역권한·무결성·감사거래 및 법적 보존 정책
실시간 기사 위치최소 접근·짧은 보존배송 수행에 필요한 기간
세션 토큰안전한 저장·만료·철회세션 생명주기

데이터를 보호하는 가장 단순한 방법 중 하나는 필요하지 않은 데이터를 보유하지 않는 것이다.


통신 경계마다 상대방과 메시지를 검증해야 한다

고객의 브라우저와 서버 사이에서 HTTPS를 사용하더라도 서버와 다른 시스템 사이의 통신이 자동으로 안전해지는 것은 아니다.

flowchart TD
    A[고객 앱] --> B[배달 서비스 API]
    B --> C[결제 제공업체]
    B --> D[알림 서비스]
    B --> E[레스토랑 시스템]

각 연결에는 별도의 인증, 암호화, 권한, 시간 제한이 필요하다.

결제 웹훅이 들어왔다고 생각해 보자.

await paymentService.markPaid(
  request.body.orderId
);

요청을 보낸 주체를 확인하지 않으면 누구나 주문을 결제 완료 상태로 바꿀 수 있다.

웹훅 서명을 검증해야 한다.

const event =
  paymentProvider.verifyWebhook({
    rawBody: request.rawBody,
    signature:
      request.headers["payment-signature"],
  });

이 코드는 결제 제공업체의 공식 검증 방식으로 요청의 출처와 변경 여부를 확인한다.

그다음 이벤트의 의미를 검증한다.

if (event.type !== "payment.completed") {
  return;
}

const payment =
  await paymentRepository.findByProviderId(
    event.paymentId
  );

if (
  !payment ||
  payment.expectedAmount !== event.amount
) {
  throw new PaymentVerificationError();
}

서명이 유효해도 이벤트 유형, 주문과의 관계, 금액, 통화, 중복 처리 여부를 확인해야 한다.

외부 서비스에서 왔다는 사실과 현재 비즈니스 작업에 사용할 수 있다는 사실은 다르다.


비밀값은 코드에 숨기는 문자열이 아니라 생명주기를 가진 자격 증명이다

데이터베이스 비밀번호와 외부 API 키를 코드에 작성하면 저장소와 배포 결과에 남는다.

const paymentApiKey =
  "live_payment_secret";

환경변수로 옮기면 코드와 설정을 분리할 수 있다.

const paymentApiKey =
  process.env.PAYMENT_API_KEY;

하지만 환경변수라는 위치만으로 비밀 관리가 완성되지는 않는다.

다음 질문도 필요하다.

  • 값은 누가 생성하는가?
  • 개발자 개인에게 원문이 필요한가?
  • 어느 서비스와 환경에서 사용할 수 있는가?
  • 언제 만료되고 교체되는가?
  • 노출되었을 때 어떻게 철회하는가?
  • 사용 기록을 확인할 수 있는가?
  • 이전 값에서 새 값으로 어떻게 전환하는가?

비밀 관리 시스템에서 실행 시점에 값을 받을 수 있다.

const paymentApiKey =
  await secretManager.getSecret(
    "production/payment-api-key"
  );

애플리케이션은 필요한 비밀만 읽을 수 있어야 한다.

await authorizationPolicy.assertServiceCanRead({
  service: "payment-worker",
  secret:
    "production/payment-api-key",
});

비밀값을 로그에 남기지 않아야 한다.

logger.info("Payment client configured", {
  provider: "payment-provider",
  keyVersion,
});

어떤 제공업체와 키 버전을 사용했는지는 기록하되 원문 키는 기록하지 않는다.


오류는 내부 구조를 숨기면서도 복구 가능해야 한다

데이터베이스 오류를 그대로 사용자에게 반환하면 내부 정보가 노출될 수 있다.

return response.status(500).json({
  error: error.stack,
  query: error.query,
});

스택, SQL, 테이블 이름, 서버 경로가 외부 응답에 포함될 수 있다.

외부에는 안정적인 오류 형식을 제공한다.

return response.status(500).json({
  code: "ORDER_PROCESSING_FAILED",
  message:
    "주문을 처리하지 못했다. 잠시 후 다시 시도해 달라.",
  requestId,
});

requestId를 사용하면 사용자는 내부 세부정보 없이 고객 지원에 문제를 전달할 수 있다.

내부 로그에는 조사에 필요한 정보를 남긴다.

logger.error("Order processing failed", {
  requestId,
  orderId,
  errorName: error.name,
});

그러나 다음 값은 로그에서 제외해야 한다.

  • 비밀번호와 인증 토큰
  • 결제 카드 원문
  • API 키와 암호화 키
  • 전체 배송지와 불필요한 개인정보
  • 쿠키와 세션 ID 원문
  • 요청 본문 전체

보안 오류 처리는 정보를 모두 숨기는 작업이 아니다. 사용자에게는 안전한 복구 정보를, 운영자에게는 민감정보를 제외한 조사 정보를 제공하는 일이다.


로그는 공격을 발견할 수 있어야 하지만 새로운 유출 경로가 되어서는 안 된다

정상 요청만 기록하면 공격 시도를 추적하기 어렵다.

다음 사건은 보안 로그의 후보가 된다.

  • 반복된 로그인 실패
  • 다른 고객 주문에 대한 접근 거부
  • 관리자 역할 변경
  • 쿠폰 중복 사용 시도
  • 웹훅 서명 검증 실패
  • 비정상적으로 많은 주문 생성
  • 비밀값 접근과 교체
  • 민감한 데이터 내보내기

구조화된 보안 이벤트를 남길 수 있다.

securityLogger.warn(
  "order_access_denied",
  {
    actorId: currentUser.id,
    orderId,
    requestId,
    ipAddressHash:
      hashForSecurityAnalysis(ipAddress),
    occurredAt: new Date().toISOString(),
  }
);

이 로그는 누가 어떤 주문에 접근하려 했는지 조사할 수 있게 한다. 원본 인증 토큰과 전체 요청은 포함하지 않는다.

로그도 보호 대상 데이터다.

  • 로그를 읽을 수 있는 사람을 제한한다.
  • 로그의 변경과 삭제를 통제한다.
  • 서버 시간을 동기화한다.
  • 보존 기간을 정한다.
  • 경고 기준과 대응 담당자를 정한다.
  • 민감정보 마스킹을 테스트한다.

기록만 하고 아무도 확인하지 않는 로그는 탐지 체계가 아니다.


파일 업로드는 저장 기능이 아니라 새로운 실행 경계를 여는 기능이다

레스토랑 운영자가 메뉴 이미지를 업로드할 수 있다고 생각해 보자.

await fileStorage.save({
  fileName: upload.originalName,
  content: upload.buffer,
});

사용자가 정한 파일명을 그대로 사용하고 파일 내용을 확인하지 않으면 경로 조작, 실행 파일 업로드, 과도한 크기의 파일 같은 문제가 생길 수 있다.

허용 범위를 명시해야 한다.

const MenuImageSchema = z.object({
  size: z.number().max(5 * 1024 * 1024),
  detectedMimeType: z.enum([
    "image/jpeg",
    "image/png",
    "image/webp",
  ]),
});

확장자나 요청 헤더만 믿지 않고 실제 파일 형식을 검사해야 한다.

서버가 새 저장 이름을 만든다.

const storageKey =
  `menu-images/${crypto.randomUUID()}`;

사용자가 전달한 파일명은 화면 표시용 메타데이터로만 제한해서 사용할 수 있다.

파일 업로드에는 다음 판단이 필요하다.

  • 허용할 파일 종류는 무엇인가?
  • 최대 크기와 이미지 해상도는 얼마인가?
  • 악성 콘텐츠 검사가 필요한가?
  • 애플리케이션 실행 경로와 분리해 저장하는가?
  • 다운로드 응답의 Content-Type을 올바르게 설정하는가?
  • 공개 파일과 비공개 파일의 접근 정책이 다른가?
  • 삭제된 메뉴의 파일도 함께 정리되는가?

파일은 단순한 문자열 입력보다 더 넓은 처리 경로를 통과하므로 별도의 보안 경계로 다뤄야 한다.


의존성을 설치하는 순간 외부 코드가 서비스 안으로 들어온다

라이브러리는 개발 속도를 높이지만 서비스가 실행하는 코드의 일부가 된다.

{
  "dependencies": {
    "web-framework": "1.2.3",
    "image-parser": "4.5.6",
    "payment-sdk": "7.8.9"
  }
}

직접 작성하지 않은 코드도 애플리케이션의 권한으로 실행된다.

의존성 관리에는 다음 작업이 필요하다.

  • 사용하지 않는 패키지를 제거한다.
  • 잠금 파일로 설치 결과를 고정한다.
  • 알려진 취약점을 자동 검사한다.
  • 보안 업데이트를 검토하고 적용한다.
  • 패키지 이름과 출처를 확인한다.
  • 설치 스크립트와 공급망 위험을 고려한다.
  • 프레임워크와 런타임의 지원 종료 일정을 확인한다.
npm audit

자동 검사는 알려진 문제를 찾는 출발점이다. 결과의 실제 영향과 실행 경로를 검토해야 한다.

취약점이 없다고 보고된 사실이 안전을 증명하지는 않는다. 아직 알려지지 않은 문제, 잘못된 설정, 애플리케이션의 비즈니스 로직 결함은 별도로 남아 있다.


안전한 기본값은 빠진 설정을 허용보다 거부로 만든다

새로운 관리자 기능이 추가되었는데 권한 설정이 누락될 수 있다.

function canPerformAdminAction(
  action: string
) {
  const permission =
    adminPermissions[action];

  return permission ?? true;
}

알 수 없는 행동이 기본 허용된다.

명시되지 않은 행동은 거부하는 편이 안전하다.

function canPerformAdminAction(
  action: AdminAction,
  admin: AdminUser
) {
  const permission =
    adminPermissions[action];

  return permission
    ? permission(admin)
    : false;
}

보안 관련 기본값에는 다음 원칙을 적용할 수 있다.

상황안전한 기본값
알 수 없는 권한거부
검증되지 않은 입력처리 중단
서명 검증 실패이벤트 무시·기록
비밀값을 찾지 못함안전하게 시작 실패
보안 설정 누락기능 비활성화
예상하지 못한 파일 형식업로드 거부
외부 호출 시간 초과무제한 대기 금지
민감한 응답 캐시 여부 불명확캐시하지 않음

안전한 기본값은 오류가 발생하지 않게 만드는 것이 아니다. 불확실한 상태가 데이터 노출이나 무단 변경으로 이어지지 않게 만드는 것이다.


보안 검사는 배포 전에 끝나지 않는다

코드 검토와 테스트를 통과해도 운영 환경에서는 새로운 상황이 생긴다.

  • 실제 사용자 수에 따라 자동화 공격이 증가한다.
  • 새로운 취약점이 의존성에서 발견된다.
  • 관리자 계정이 탈취될 수 있다.
  • 접근 정책과 비즈니스 요구가 바뀐다.
  • 예상하지 못한 데이터가 로그에 기록된다.
  • 방화벽이나 저장소 설정이 변경된다.

보안 상태를 운영 중에도 확인해야 한다.

type SecuritySignal =
  | "login_failure_spike"
  | "webhook_signature_failure"
  | "cross_account_access_attempt"
  | "admin_role_changed"
  | "secret_access_anomaly"
  | "bulk_data_export";

신호마다 임계값과 대응 방법을 정할 수 있다.

if (
  failedLoginCount >
  securityPolicy.loginFailureThreshold
) {
  await securityAlertService.notify({
    type: "login_failure_spike",
    accountId,
  });
}

경고가 발생한 뒤의 절차도 필요하다.

  1. 실제 공격인지 확인한다.
  2. 영향받은 계정과 데이터를 식별한다.
  3. 세션, 키, 토큰을 필요한 범위에서 철회한다.
  4. 추가 피해를 막는다.
  5. 안전하게 서비스를 복구한다.
  6. 원인을 분석하고 재발 방지를 적용한다.

보안은 취약점을 한 번 제거하는 완료 상태가 아니다. 변경과 사고를 계속 발견하고 대응하는 운영 능력이다.


보안은 서비스 흐름 전체에 배치되어야 한다

음식 배달 주문의 흐름을 정리하면 다음과 같다.

flowchart TD
    A[외부 요청] --> B[검증·인증·권한]
    B --> C[서버의 가격·쿠폰 규칙 적용]
    C --> D[결제·알림 시스템과 안전하게 통신]
    D --> E[최소 데이터 저장과 감사 기록]
    E --> F[모니터링·탐지·대응]

각 단계는 서로 다른 보안 책임을 가진다.

  • 요청 경계에서는 입력 형식과 요청자를 확인한다.
  • 비즈니스 계층에서는 가격, 쿠폰, 상태 전이 규칙을 다시 계산한다.
  • 외부 통신에서는 TLS, 자격 증명, 서명, 타임아웃을 적용한다.
  • 저장 단계에서는 최소 수집, 암호화, 접근 통제를 적용한다.
  • 로그에서는 추적 가능성과 민감정보 제외를 함께 고려한다.
  • 운영 단계에서는 이상 행동과 새로운 취약점을 발견한다.

마지막 단계에서 보안 미들웨어 하나를 붙이는 방식으로는 이 책임을 모두 표현할 수 없다.


보안을 설계하기 전에 물어봐야 할 질문

무엇을 보호해야 하는가

  1. 서비스에서 가장 중요한 데이터와 기능은 무엇인가?
  2. 데이터가 노출되면 누구에게 어떤 피해가 발생하는가?
  3. 데이터가 변경되면 금전과 운영에 어떤 영향이 생기는가?
  4. 서비스를 사용할 수 없게 되면 어떤 업무가 멈추는가?
  5. 모든 데이터를 같은 등급으로 취급하고 있지 않은가?

어디에서 입력이 들어오는가

  1. 브라우저와 모바일 요청을 모두 검증하는가?
  2. 헤더, 쿠키, URL, 파일도 외부 입력으로 보는가?
  3. 내부 서비스와 외부 제공업체의 데이터도 검증하는가?
  4. 타입·길이·범위 같은 문법적 검증이 있는가?
  5. 현재 비즈니스 상태에 맞는 의미적 검증도 수행하는가?

Source of Truth를 서버가 통제하는가

  1. 가격과 할인 금액을 서버가 다시 계산하는가?
  2. 현재 사용자 ID를 인증 결과에서 가져오는가?
  3. 주문 상태를 클라이언트가 임의로 정할 수 없는가?
  4. 결제 완료를 제공업체의 검증된 이벤트로 확인하는가?
  5. 화면의 계산값을 영구적인 사실로 저장하고 있지 않은가?

인증과 권한을 구분했는가

  1. 로그인 여부와 자원 접근 권한을 따로 확인하는가?
  2. 고객, 레스토랑, 기사, 관리자의 범위가 구분되는가?
  3. 목록과 상세 API에 같은 범위 정책이 적용되는가?
  4. 프론트엔드의 버튼 숨김과 별개로 서버가 검사하는가?
  5. 정의되지 않은 행동은 기본적으로 거부되는가?

민감한 데이터를 최소화했는가

  1. 실제 기능에 필요하지 않은 정보를 수집하고 있지 않은가?
  2. 데이터별 저장 목적과 보존 기간이 정해져 있는가?
  3. 비밀번호와 복원이 필요한 개인정보를 다르게 보호하는가?
  4. 결제 카드 원문 대신 제공업체 토큰을 사용하는가?
  5. 삭제 요청이 백업과 파생 데이터에 어떻게 반영되는가?

통신과 외부 연동을 검증하는가

  1. 모든 민감한 통신에 TLS를 사용하는가?
  2. 외부 API 자격 증명에 최소 권한이 적용되는가?
  3. 웹훅의 서명과 이벤트 의미를 모두 검증하는가?
  4. 외부 요청에 타임아웃과 재시도 제한이 있는가?
  5. 같은 이벤트를 다시 받아도 중복 작업이 발생하지 않는가?

비밀값의 생명주기를 관리하는가

  1. 비밀값이 코드, 로그, 이미지에 포함되지 않는가?
  2. 환경과 서비스별로 접근 범위가 분리되는가?
  3. 비밀값을 정기적으로 교체할 수 있는가?
  4. 노출 시 즉시 철회할 수 있는가?
  5. 누가 언제 비밀값에 접근했는지 확인할 수 있는가?

로그와 오류가 안전한가

  1. 외부 오류에 내부 스택과 SQL이 노출되지 않는가?
  2. 로그에 비밀번호, 토큰, 키, 카드 정보가 없는가?
  3. 권한 거부와 비정상 행동을 추적할 수 있는가?
  4. 로그 자체의 접근과 변경이 통제되는가?
  5. 경고가 발생했을 때 실제 대응 절차가 있는가?

변경 이후에도 안전한가

  1. 의존성 취약점을 계속 확인하는가?
  2. 보안 회귀 테스트가 자동화되어 있는가?
  3. 운영 설정 변경을 추적하는가?
  4. 보안 사고 시 철회·격리·복구 절차가 있는가?
  5. 새로운 기능을 만들 때 위협과 데이터 흐름을 다시 검토하는가?

흔한 실수는 보안을 별도의 기능 목록으로 보는 것이다

프론트엔드 검증만 적용한다

사용자는 브라우저 코드를 우회해 API를 직접 호출할 수 있다.

사용자 경험을 위한 클라이언트 검증과 별개로 서버가 모든 외부 입력을 검증해야 한다.

로그인하면 모든 요청이 안전하다고 생각한다

인증된 사용자도 다른 사용자의 데이터에 접근하거나 비즈니스 규칙을 악용할 수 있다.

각 자원과 행동에 권한 검사를 적용해야 한다.

요청으로 받은 가격을 저장한다

브라우저의 금액은 사용자가 바꿀 수 있다.

서버의 메뉴와 할인 정책을 기준으로 최종 금액을 다시 계산해야 한다.

데이터를 암호화하면 노출되지 않는다고 생각한다

애플리케이션은 정상 기능을 위해 데이터를 복호화하며 로그와 응답에 원문을 남길 수도 있다.

암호화와 함께 권한, 최소 노출, 로그 정책을 적용해야 한다.

비밀값을 환경변수로 옮기면 관리가 끝났다고 생각한다

환경변수도 노출, 접근, 교체, 철회 문제가 있다.

비밀의 생성부터 폐기까지 전체 생명주기를 관리해야 한다.

오류를 숨기기 위해 아무 기록도 남기지 않는다

사용자에게 내부 구조를 공개할 필요는 없지만 운영자는 원인을 조사할 수 있어야 한다.

요청 식별자와 민감정보를 제외한 구조화된 오류 정보를 남겨야 한다.

보안 도구가 모든 문제를 찾는다고 생각한다

취약점 검사는 알려진 패키지 문제를 찾을 수 있지만 할인 중복, 가격 조작 같은 서비스 규칙 우회는 이해하지 못할 수 있다.

도구 검사와 비즈니스 흐름 검토를 함께 수행해야 한다.

출시 직전에 한 번 점검한다

코드, 의존성, 설정, 사용자 행동은 출시 후에도 계속 바뀐다.

모니터링, 업데이트, 사고 대응까지 보안 설계에 포함해야 한다.


보안의 핵심은 서비스 전체에서 허용된 흐름을 유지하는 데 있다

프로그래밍을 처음 배울 때는 로그인 검사와 비밀번호 보호로 보안의 기본 역할을 이해할 수 있다.

if (!currentUser) {
  throw new UnauthorizedError();
}

입문 단계에서는 충분한 설명이다.

하지만 실제 서비스에서 보안은 특정 코드 한 줄이나 하나의 미들웨어에 머물지 않는다.

  • 무엇을 누구로부터 보호해야 하는가?
  • 외부 입력은 어디에서 들어오는가?
  • 서버가 가격과 상태의 Source of Truth를 유지하는가?
  • 인증된 사용자의 자원별 권한을 확인하는가?
  • 필요한 데이터만 수집하고 필요한 기간만 보관하는가?
  • 외부 시스템의 신원과 메시지를 검증하는가?
  • 비밀값과 키의 생성·접근·교체·철회를 관리하는가?
  • 오류와 로그가 조사 가능하면서 민감정보를 노출하지 않는가?
  • 의존성과 운영 설정의 변화를 계속 확인하는가?
  • 사고가 발생했을 때 피해를 제한하고 복구할 수 있는가?

보안은 개발이 끝난 뒤 추가하는 기능이 아니다.

보안은 입력이 들어와 처리되고 저장되며 다른 시스템으로 전달되고 운영 기록에 남는 전체 흐름에서, 허용된 주체만 허용된 행동을 수행하도록 위험을 제한하는 설계 원칙이다.


참고 자료

profile
Vision eXperience Developer

0개의 댓글