> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Vue disabled는 보안이 아니다: 개발자 도구 조작에도 안전한 서버 검증 설계
- URL: https://mlog.me/vue-disabled-devtools-server-validation/
- Published: 2026-09-02T13:13:55.000Z
- Updated: 2026-09-02T13:13:55.000Z
- Description: Vue에서 disabled를 걸어 둔 버튼·콤보박스·입력값은 개발자 도구로 바꿀 수 있다. MutationObserver 실험과 디렉티브 등록 실패를 출발점으로, Vue의 UX 가드부터 Spring Boot의 권한·상태 검증, DB 동시성·멱등성까지 실제 보안 경계를 정리한다.
- Author: mLog
- Tags: 개발, vue, javascript, security, backend, web-development, #Vue.js

관리 화면을 만들다 보면 조건에 맞지 않는 버튼을 `disabled`로 잠그는 일이 많습니다. 권한이 없거나, 입력이 덜 되었거나, 이미 처리가 끝난 경우입니다. 화면에서 회색으로 바뀌고 클릭도 되지 않으니 언뜻 보면 안전해 보입니다.

그런데 크롬 개발자 도구를 열어 버튼의 `disabled` 속성을 지워 보니 바로 활성화됐습니다. `<select>`도 열렸고, 잠가 둔 `<input>`도 수정할 수 있었습니다. 처음에는 속성이 삭제되는 즉시 다시 붙이는 `MutationObserver` 디렉티브를 만들면 되겠다고 생각했습니다. 전역 등록까지 했는데 이번에는 `mounted()`에 넣은 로그조차 보이지 않았습니다.

등록 순서와 이름을 확인하는 과정에서 더 중요한 사실이 드러났습니다. 질문 자체가 잘못되어 있었습니다.

> 중요한 것은 `disabled`가 지워지지 않게 만드는 일이 아니다. `disabled`가 사라지고 프론트엔드가 전부 우회되어도 서버가 잘못된 요청을 거절하도록 만드는 일이다.

이 글은 Vue 3 화면에서 문제를 재현한 뒤, 프론트엔드의 역할과 실제 보안 경계를 분리합니다. 이어서 Spring Boot API가 인증·권한·객체 소유권·업무 상태·가격·재고를 다시 확인하는 방법, 동시 요청과 재전송을 데이터베이스에서 막는 방법까지 한 번에 연결합니다.

테스트는 반드시 자신이 관리하는 로컬 또는 스테이징 환경에서 진행해야 합니다.

## 먼저 결론부터: 브라우저는 사용자의 컴퓨터다

웹 서버가 HTML, CSS, JavaScript를 내려보내는 순간 그 코드는 사용자 브라우저에서 실행됩니다. 사용자는 개발자 도구로 DOM을 바꾸고, JavaScript 실행을 중단하고, 메모리의 상태를 수정하고, 네트워크 요청을 복사해 다시 보낼 수 있습니다.

따라서 다음 항목은 보안 경계가 될 수 없습니다.

- 버튼의 `disabled` 속성
- 보이지 않게 만든 메뉴와 입력 필드
- Vue의 `ref`, `computed`, Pinia 상태
- 클릭 핸들러 첫 줄의 조건문
- `MutationObserver`로 구현한 속성 복구
- 우클릭·F12 차단과 JavaScript 난독화

이 장치들이 쓸모없다는 뜻은 아닙니다. 정상 사용자의 실수를 줄이고, 화면 상태를 일관되게 보여주며, 중복 클릭을 줄이는 데는 꼭 필요합니다. 다만 권한을 강제하는 책임까지 맡기면 안 됩니다.

실제 경계는 요청을 받는 서버와 최종 데이터를 보존하는 데이터베이스입니다. 프론트엔드는 “현재는 실행할 수 없습니다”라고 안내하고, 서버는 “이 사용자가 이 객체에 지금 이 작업을 해도 되는가”를 매 요청마다 판단해야 합니다.

## 가장 작은 코드로 문제 재현하기

Vue에서 흔히 쓰는 코드는 다음과 같습니다.

```vue
<script setup lang="ts">
import { computed, ref } from 'vue'

const inputText = ref('')
const canSubmit = computed(() => inputText.value.trim().length >= 5)

function submit(): void {
  console.log('서버 요청 실행')
}
</script>

<template>
  <input v-model="inputText" />
  <button :disabled="!canSubmit" @click="submit">
    제출
  </button>
</template>

```

입력값이 다섯 글자보다 짧으면 Vue가 버튼에 `disabled`를 반영합니다. 개발자 도구의 Elements 탭에서 이 속성을 제거하면 브라우저는 버튼을 활성 상태로 취급하고 클릭 이벤트를 실행할 수 있습니다.

여기서 `disabled="false"`를 넣으면 풀릴 것 같지만 그렇지 않습니다. `disabled`는 불리언 속성이므로 순수 HTML에서는 **속성이 존재하는 것 자체가 참**입니다. Vue의 `:disabled="false"`는 렌더링 과정에서 속성을 제거하므로 동작이 다릅니다.

하지만 이 차이도 보안과는 무관합니다. 공격자는 버튼을 클릭할 필요조차 없습니다. Network 탭에서 정상 요청을 복사하거나 `fetch`, API 테스트 도구, 프록시로 엔드포인트를 직접 호출할 수 있기 때문입니다.

## `disabled`, `readonly`, `hidden`이 실제로 보장하는 범위

세 속성은 자주 혼동되지만 브라우저 동작이 다릅니다. 공통점은 어느 것도 요청 변조를 막지 못한다는 것입니다.

### `disabled`

- 지원되는 폼 컨트롤의 조작과 포커스를 막습니다.
- 일반적인 폼 제출에서 해당 컨트롤의 이름과 값이 빠집니다.
- 브라우저의 제약 조건 검증에도 참여하지 않습니다.
- `<button>`, `<input>`, `<select>`, `<textarea>`, `<option>` 등에 사용할 수 있습니다.

“전송되지 않으니 안전하다”는 결론은 위험합니다. 공격자는 같은 이름의 값을 요청 본문에 직접 추가할 수 있습니다. 반대로 서버가 그 필드가 항상 올 것이라고 기대하면 정상 요청에서도 누락되어 장애가 날 수 있습니다.

### `readonly`

- 텍스트 계열 `<input>`과 `<textarea>`에서 사용자가 값을 편집하지 못하게 합니다.
- 포커스와 복사가 가능하고 값도 폼에 제출됩니다.
- `<select>`, 버튼, 체크박스, 라디오에는 적용되지 않습니다.
- 개발자 도구나 직접 만든 HTTP 요청에서는 값을 얼마든지 바꿀 수 있습니다.

### `hidden`과 숨겨진 UI

- `type="hidden"`, `v-if`, `v-show`, CSS 숨김은 화면 노출 방식을 바꿀 뿐입니다.
- 숨김 필드의 값, 현재 단계, 사용자 ID, 역할을 서버 판단 근거로 삼으면 안 됩니다.
- 화면에서 삭제한 관리자 버튼도 API 권한 검사 없이는 아무 의미가 없습니다.

가격을 `disabled` 입력에 표시하거나, `ownerId`를 hidden 필드로 보내거나, `role="ADMIN"`을 Vue 상태에 저장했다고 해서 그 값이 신뢰 가능한 것은 아닙니다.

## Vue에서 해야 할 일: 하나의 상태로 UX를 일관되게 만들기

보안 경계가 아니라고 해서 프론트엔드 가드를 없애면 안 됩니다. 정상 사용자는 왜 버튼이 눌리지 않는지 알아야 하고, Enter 제출과 버튼 클릭이 서로 다른 경로로 동작해서도 안 됩니다.

주문 확정 화면이라면 `computed` 하나를 UI와 핸들러가 함께 사용하도록 만들 수 있습니다.

```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { orderApi } from '@/api/orderApi'

type AccountStatus = 'PENDING' | 'APPROVED' | 'SUSPENDED'
type OrderStatus = 'DRAFT' | 'CONFIRMED' | 'CANCELLED'

interface CurrentUser {
  id: string
  status: AccountStatus
}

interface OrderSummary {
  id: string
  ownerId: string
  status: OrderStatus
  version: number
}

interface ConfirmOrderPayload {
  quantity: number
  optionCode: string
  expectedVersion: number
}

interface PendingAttempt {
  key: string
  payload: ConfirmOrderPayload
}

const props = defineProps<{
  currentUser: CurrentUser
  order: OrderSummary
}>()

const emit = defineEmits<{
  confirmed: [order: OrderSummary]
}>()

const quantity = ref(1)
const optionCode = ref('STANDARD')
const submitting = ref(false)
const completed = ref(false)
const errorMessage = ref('')
const pendingAttempt = ref<PendingAttempt | null>(null)

const canConfirm = computed(() =>
  props.currentUser.status === 'APPROVED' &&
  props.currentUser.id === props.order.ownerId &&
  props.order.status === 'DRAFT' &&
  quantity.value >= 1 &&
  quantity.value <= 10 &&
  !submitting.value &&
  !completed.value
)

const fieldsLocked = computed(() =>
  submitting.value || completed.value || pendingAttempt.value !== null
)

watch(
  () => props.order.id,
  () => {
    pendingAttempt.value = null
    completed.value = false
    errorMessage.value = ''
  }
)

function isDefinitiveHttpFailure(error: unknown): boolean {
  if (typeof error !== 'object' || error === null || !('status' in error)) {
    return false
  }

  const status = (error as { status?: unknown }).status
  return typeof status === 'number' &&
    status >= 400 &&
    status < 500 &&
    ![408, 425, 429].includes(status)
}

async function confirmOrder(): Promise<void> {
  // DOM 속성 제거와 중복 클릭을 줄이는 UX 가드다.
  // 이 조건문 자체가 서버 권한 검사를 대신하지는 않는다.
  if (!canConfirm.value) return

  submitting.value = true
  errorMessage.value = ''

  const attempt = pendingAttempt.value ?? {
    key: crypto.randomUUID(),
    payload: {
      quantity: quantity.value,
      optionCode: optionCode.value,
      expectedVersion: props.order.version
    }
  }

  // 결과가 불명확한 재시도에서 키와 본문이 함께 유지된다.
  pendingAttempt.value = attempt

  try {
    const confirmedOrder = await orderApi.confirm(
      props.order.id,
      attempt.payload,
      attempt.key
    )

    pendingAttempt.value = null
    completed.value = true
    emit('confirmed', confirmedOrder)
  } catch (error) {
    // 서버가 재시도 대상이 아닌 4xx로 결과를 확정했다면 새 키를 사용한다.
    // 네트워크 단절·5xx처럼 결과가 불명확하면 같은 키와 본문을 유지한다.
    if (isDefinitiveHttpFailure(error)) {
      pendingAttempt.value = null
    }

    errorMessage.value = '처리 결과를 확인하지 못했습니다. 다시 시도해 주세요.'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="confirmOrder">
    <label>
      수량
      <input
        v-model.number="quantity"
        type="number"
        min="1"
        max="10"
        :disabled="fieldsLocked"
      />
    </label>

    <label>
      배송 방식
      <select v-model="optionCode" :disabled="fieldsLocked">
        <option value="STANDARD">일반 배송</option>
        <option value="EXPRESS">빠른 배송</option>
      </select>
    </label>

    <button
      type="submit"
      :disabled="!canConfirm"
      :aria-busy="submitting"
    >
      {{
        submitting
          ? '처리 중…'
          : pendingAttempt
            ? '같은 요청 다시 확인'
            : '주문 확정'
      }}
    </button>

    <p v-if="errorMessage" role="alert">
      {{ errorMessage }}
    </p>
  </form>
</template>

```

이 패턴의 장점은 분명합니다.

- `computed`가 버튼 상태와 제출 가능 조건을 한곳에 모읍니다.
- `<form @submit.prevent>`가 클릭과 Enter 제출을 같은 함수로 보냅니다.
- 핸들러 가드가 단순 DOM 조작과 실수성 호출을 한 번 더 걸러냅니다.
- `submitting`이 정상 사용자의 빠른 중복 클릭을 줄입니다.
- 결과가 불명확한 재시도에는 같은 멱등키와 같은 요청 본문을 함께 재사용합니다.
- 서버가 성공을 확정하면 완료 상태와 최신 주문을 부모 화면에 반영해 새 키로 다시 누르는 일을 막습니다.

위 예제는 `orderApi`가 프로젝트의 중앙 HTTP 클라이언트로 구현되어 있고, 실패 객체에 HTTP `status`를 보존한다는 전제입니다. `400`·`401`·`403`·`404`·`409`처럼 서버가 결과를 명확히 판단한 응답에서는 키를 폐기합니다. 연결 끊김, `408`, `425`, `5xx`처럼 서버 처리 여부가 불명확하거나 재시도가 요구되는 경우에는 입력을 잠근 채 동일한 키와 본문으로 재시도합니다. `429`의 재시도 시점은 `Retry-After` 등 서버 정책에 맞춰 조정합니다.

하지만 이 코드의 `currentUser`, `ownerId`, `status`, 허용 수량은 모두 브라우저에 있습니다. 공격자는 바꿀 수 있습니다. 그러므로 이 계산은 **화면을 위한 사전 판단**일 뿐, 승인 결정이 아닙니다.

또한 쿠키 기반 세션 인증을 사용한다면 `orderApi` 같은 중앙 HTTP 클라이언트에서 프로젝트 설정과 일치하는 CSRF 토큰을 상태 변경 요청에 포함해야 합니다. “SPA니까 CSRF를 끈다”는 식으로 결정하면 안 됩니다. 브라우저가 인증 쿠키를 자동으로 보내는 구조인지 먼저 확인해야 합니다.

## 버튼보다 더 위험한 `select`와 `input` 값 변조

버튼은 주로 동작 실행 여부가 문제지만 `<select>`와 `<input>`은 서버로 전달되는 값 자체가 바뀔 수 있습니다.

예를 들어 품절된 옵션에 `disabled`를 붙여도 사용자는 속성을 지우거나 요청의 `optionCode`를 직접 바꿀 수 있습니다. Vue에서 허용 목록을 검사하면 정상 사용자의 입력 오류는 줄일 수 있지만, 보안 검증은 아닙니다.

서버는 다음을 다시 확인해야 합니다.

- 전달된 옵션 코드가 실제로 존재하는가
- 현재 판매 가능한 옵션인가
- 이 사용자와 주문에서 선택 가능한가
- 현재 시각·지역·재고 조건에도 허용되는가

가격은 더 명확합니다. 프론트엔드가 보낸 `price`, `discount`, `total`을 결제 기준으로 사용하면 안 됩니다. 화면에는 가격을 보여주되, 요청에는 주문 식별자·수량·옵션처럼 사용자가 선택할 수 있는 최소 정보만 담고 서버가 DB의 현재 가격, 할인 정책, 세금, 재고로 최종 금액을 다시 계산해야 합니다.

요청 DTO에 `userId`, `role`, `ownerId`, `targetStatus`, `price`를 아예 두지 않는 것이 가장 이해하기 쉬운 출발점입니다.

## `MutationObserver`를 붙여 본 결과: 잘 작동해도 보안은 아니다

`disabled`가 삭제되는 순간 다시 붙이는 모습은 꽤 그럴듯합니다. `MutationObserver`는 DOM 속성 변경을 감지할 수 있으므로 데모에서는 실제로 속성이 복구됩니다.

그러나 사용자는 다음 방법으로 우회할 수 있습니다.

- 페이지 JavaScript 실행을 일시 중단하거나 변경한다.
- Observer의 `disconnect()`를 호출하거나 관찰 코드를 제거한다.
- Vue 상태나 이벤트 핸들러를 바꾼다.
- 버튼을 건드리지 않고 API를 직접 호출한다.

따라서 `v-secure-disabled`, “침입 차단 성공”, “절대 못 뚫는 버튼” 같은 이름은 잘못된 확신을 줍니다. 클릭 가드가 “99%를 막는다”는 식의 수치도 근거가 없습니다.

DOM 상태가 다른 라이브러리나 레거시 위젯 때문에 자주 어긋나서 **UI 복구 자체가 필요한 경우**라면 다음처럼 한계를 드러내는 이름을 사용하는 편이 낫습니다.

```ts
// src/directives/vRestoreDisabled.ts
import type { Directive } from 'vue'

type Disableable =
  | HTMLButtonElement
  | HTMLInputElement
  | HTMLSelectElement
  | HTMLTextAreaElement
  | HTMLOptionElement
  | HTMLOptGroupElement
  | HTMLFieldSetElement

interface RestoreState {
  expected: boolean
  observer: MutationObserver
}

const states = new WeakMap<Disableable, RestoreState>()
const observeOptions: MutationObserverInit = {
  attributes: true,
  attributeFilter: ['disabled']
}

export const vRestoreDisabled: Directive<Disableable, boolean> = {
  mounted(el, binding) {
    const observer = new MutationObserver(() => {
      const current = states.get(el)
      if (!current || el.disabled === current.expected) return

      observer.disconnect()
      el.disabled = current.expected
      observer.observe(el, observeOptions)
    })

    const state: RestoreState = {
      expected: Boolean(binding.value),
      observer
    }

    states.set(el, state)
    el.disabled = state.expected
    state.observer.observe(el, observeOptions)
  },

  updated(el, binding) {
    const state = states.get(el)
    if (!state) return

    state.expected = Boolean(binding.value)
    state.observer.disconnect()
    el.disabled = state.expected
    state.observer.observe(el, observeOptions)
  },

  unmounted(el) {
    states.get(el)?.observer.disconnect()
    states.delete(el)
  }
}

```

이 구현은 처음 전달된 `binding.value`를 콜백이 계속 기억하는 문제를 피하려고 최신 예상 상태를 별도 객체에 저장합니다. DOM 요소에 `el._observer` 같은 임의 속성을 추가하지 않고 `WeakMap`으로 수명도 관리합니다. `pointer-events: none`과 반복 `alert()`도 쓰지 않습니다.

그럼에도 이 코드는 보안 모듈이 아닙니다. DOM 상태 동기화 도구일 뿐입니다. 단순한 Vue 폼이라면 커스텀 디렉티브보다 `:disabled="조건"`이라는 선언형 바인딩이 우선입니다. Vue 공식 문서도 커스텀 디렉티브는 일반 요소에 대한 저수준 DOM 접근이 필요한 경우로 범위를 좁힙니다.

## 전역 등록, 로컬 등록, 플러그인 중 무엇을 선택할까

보안 수준의 차이는 없습니다. 적용 범위와 유지보수 방식의 차이입니다.

### `<script setup>`에서 로컬 등록

한두 컴포넌트에서만 필요하다면 의존성이 드러나는 로컬 등록이 단순합니다. `<script setup>`에서는 `v`로 시작하는 camelCase 변수명이 템플릿의 로컬 디렉티브로 인식됩니다.

```vue
<script setup lang="ts">
import { vRestoreDisabled } from '@/directives/vRestoreDisabled'
</script>

<template>
  <button v-restore-disabled="true">
    처리 불가
  </button>
</template>

```

export 이름이 `restoreDisabled`라면 다음처럼 별칭을 주면 됩니다.

```ts
import { restoreDisabled as vRestoreDisabled } from '@/directives/restoreDisabled'

```

### `main.ts`에서 전역 등록

여러 화면에서 공통으로 사용한다면 `mount()` 전에 등록합니다.

```ts
import { createApp } from 'vue'
import App from './App.vue'
import { vRestoreDisabled } from './directives/vRestoreDisabled'

const app = createApp(App)

app.directive('restore-disabled', vRestoreDisabled)
app.mount('#app')

```

### 플러그인으로 포장

여러 디렉티브, 공통 옵션, 주입할 서비스가 함께 있을 때 `install(app)`을 가진 플러그인이 유용합니다. 디렉티브 하나를 `app.use()`로 감쌌다고 더 안전해지거나 더 전문적인 구조가 되는 것은 아닙니다. 작은 기능이라면 `app.directive()`가 더 읽기 쉽습니다.

## 전역 등록했는데 로그조차 안 보였던 이유를 찾는 순서

원래 실험에서는 `main.js`에 전역 등록했는데 `mounted()` 로그도 보이지 않았습니다. 이때 “등록 순서 문제일 확률이 90%”처럼 추측하기보다 실행 경로를 위에서부터 확인하는 것이 빠릅니다.

### 1\. 첫 빌드·모듈 오류부터 확인한다

named export와 default export가 맞지 않거나 경로가 틀리면 보통 모듈 로딩 또는 빌드 오류가 먼저 납니다. Console의 첫 번째 오류와 Network에서 실패한 모듈 요청을 확인합니다. 뒤에 따라오는 연쇄 오류부터 고치면 시간을 낭비하기 쉽습니다.

### 2\. 실제 진입 파일인지 확인한다

프로젝트가 `main.ts`를 사용하는데 `main.js`를 수정했거나, 페이지마다 서로 다른 Vue 앱을 마운트하는 구조일 수 있습니다. 등록 코드를 넣은 파일이 실제 번들 진입점인지 확인합니다.

### 3\. 반드시 `mount()` 전에 등록한다

```ts
const app = createApp(App)

app.directive('restore-disabled', vRestoreDisabled)
console.log(app.directive('restore-disabled'))

app.mount('#app')

```

인자를 하나만 넘긴 `app.directive('restore-disabled')`는 등록된 디렉티브를 반환하는 공개 API입니다. `app._context.directives` 같은 내부 속성에 의존할 필요가 없습니다.

### 4\. 템플릿 이름과 조건을 최소화한다

다음처럼 조건을 `true`로 고정한 테스트 요소를, 처음부터 렌더링되는 위치에 둡니다.

```vue
<button v-restore-disabled="true">
  디렉티브 연결 확인
</button>

```

- `restore-disabled`가 아니라 `v-restore-disabled`인지 확인합니다.
- `secure`와 `scure` 같은 철자 오류를 확인합니다.
- `v-if="false"` 아래에 있어 아직 마운트되지 않은 요소인지 확인합니다.
- 콘솔 필터, 프로덕션 로그 제거 설정, 캐시와 HMR 상태도 확인합니다.

연결이 확인되면 테스트 버튼을 지우고 원래 조건을 다시 붙입니다. 이 디버깅은 디렉티브를 정상화하는 작업이지 보안을 완성하는 작업은 아닙니다.

## 실제 보안 경계: Spring Boot에서 다시 판단하기

이제 주문 확정 API를 서버 관점에서 설계해 보겠습니다. 서버 검증은 크게 두 층입니다.

첫째, 형식 검증입니다. 필수값·타입·길이·범위·문자열 형식을 확인합니다. 둘째, 업무 검증입니다. 인증된 사용자가 해당 주문을 소유하는지, 현재 상태에서 확정할 수 있는지, 옵션과 재고가 유효한지 확인합니다.

`@Valid`를 통과했다고 안전한 요청이 되는 것은 아닙니다. 형식이 완벽한 다른 사용자의 주문 ID도 있을 수 있고, 이미 확정된 주문을 다시 확정하려는 요청도 있을 수 있습니다.

### 요청 DTO는 사용자가 결정할 값만 받는다

```java
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;

public record ConfirmOrderRequest(
    @NotNull
    @Min(1)
    @Max(10)
    Integer quantity,

    @NotBlank
    @Size(max = 30)
    String optionCode,

    @NotNull
    @PositiveOrZero
    Long expectedVersion
) {
}

```

여기에는 `ownerId`, `userId`, `role`, `price`, `discount`, `total`, `targetStatus`가 없습니다. 행위자의 신원은 인증 객체에서 가져오고, 나머지는 서버가 조회하거나 계산합니다.

### 컨트롤러에서 인증과 형식 검증을 연결한다

```java
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

// 실제 프로젝트의 인증 필터가 만드는 Principal 타입을 사용한다.
record AuthenticatedUser(UUID userId) {
}

@RestController
@RequestMapping("/api/orders")
public class OrderConfirmationController {

    private final OrderConfirmationService service;

    public OrderConfirmationController(OrderConfirmationService service) {
        this.service = service;
    }

    @PostMapping("/{orderId}/confirmations")
    @PreAuthorize("hasAuthority('order:confirm')")
    public ResponseEntity<ConfirmOrderResponse> confirm(
        @AuthenticationPrincipal AuthenticatedUser user,
        @PathVariable UUID orderId,
        @RequestHeader("Idempotency-Key") UUID idempotencyKey,
        @Valid @RequestBody ConfirmOrderRequest request
    ) {
        return ResponseEntity.ok(
            service.confirm(
                user.userId(),
                orderId,
                idempotencyKey,
                request
            )
        );
    }
}

```

예제의 `AuthenticatedUser`는 설명을 위한 최소 타입입니다. 실제로는 인증 과정에서 검증한, 변경 불가능한 사용자 subject ID를 가진 Principal을 사용합니다. 멀티테넌트 서비스라면 신뢰된 테넌트 ID도 Principal에서 가져와 모든 조회 조건에 포함해야 합니다. 요청 본문의 사용자명이나 테넌트 ID로 대체하면 안 됩니다.

메서드 보안을 사용하려면 명시적으로 활성화합니다. 보안 의존성을 추가한 것만으로 메서드 보안이 자동으로 켜진다고 가정하면 안 됩니다.

```java
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

```

### 서비스에서 기능·객체·상태·가격을 모두 검사한다

다음 코드는 핵심 판단 순서를 보여주는 예시입니다. 엔티티, 응답 DTO, 요청 지문 생성기와 도메인 예외의 세부 구현은 생략했습니다. 운영용 멱등 저장소에는 원자적 키 예약과 요청 지문 비교가 추가되어야 합니다.

```java
import java.math.BigDecimal;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderConfirmationService {

    private final AccountRepository accountRepository;
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final InventoryRepository inventoryRepository;
    private final ConfirmationRepository confirmationRepository;

    public OrderConfirmationService(
        AccountRepository accountRepository,
        OrderRepository orderRepository,
        ProductRepository productRepository,
        InventoryRepository inventoryRepository,
        ConfirmationRepository confirmationRepository
    ) {
        this.accountRepository = accountRepository;
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
        this.inventoryRepository = inventoryRepository;
        this.confirmationRepository = confirmationRepository;
    }

    @Transactional
    public ConfirmOrderResponse confirm(
        UUID actorId,
        UUID orderId,
        UUID idempotencyKey,
        ConfirmOrderRequest request
    ) {
        Account account = accountRepository.findById(actorId)
            .orElseThrow(AccountForbiddenException::new);

        if (account.getStatus() != AccountStatus.APPROVED) {
            throw new AccountForbiddenException();
        }

        // 메서드 이름만으로 잠기지는 않는다. Repository에 @Lock이 필요하다.
        // 소유권도 조회 조건에 넣고, 정책에 따라 존재하지 않는 것처럼 응답한다.
        PurchaseOrder order = orderRepository
            .findOwnedForUpdate(orderId, actorId)
            .orElseThrow(OrderNotFoundException::new);

        String requestFingerprint = ConfirmationFingerprint.create(
            orderId,
            request.quantity(),
            request.optionCode(),
            request.expectedVersion()
        );

        // 주문 행을 잠근 뒤 다시 조회하므로 같은 주문의 동시 재시도도
        // 첫 요청이 커밋한 결과를 확인할 수 있다.
        var previous = confirmationRepository
            .findByActorIdAndIdempotencyKey(actorId, idempotencyKey);

        if (previous.isPresent()) {
            if (!previous.get().matches(requestFingerprint)) {
                throw new IdempotencyConflictException();
            }

            return ConfirmOrderResponse.from(previous.get());
        }

        if (order.getStatus() != OrderStatus.DRAFT) {
            throw new OrderStateConflictException();
        }

        if (order.getVersion() != request.expectedVersion()) {
            throw new StaleOrderVersionException();
        }

        ProductOption option = productRepository
            .findActiveOption(order.getProductId(), request.optionCode())
            .orElseThrow(InvalidOptionException::new);

        if (request.quantity() > option.getMaxPerOrder()) {
            throw new InvalidQuantityException();
        }

        // 클라이언트 가격이 아니라 신뢰 가능한 DB 값으로 계산한다.
        BigDecimal unitPrice = option.getCurrentUnitPrice();
        BigDecimal total = unitPrice.multiply(
            BigDecimal.valueOf(request.quantity())
        );

        int updated = inventoryRepository.decreaseIfAvailable(
            order.getProductId(),
            request.quantity()
        );

        if (updated != 1) {
            throw new OutOfStockException();
        }

        order.confirm(option.getCode(), request.quantity(), total);

        Confirmation confirmation = confirmationRepository.saveAndFlush(
            Confirmation.success(
                actorId,
                idempotencyKey,
                requestFingerprint,
                order.getId(),
                total
            )
        );

        return ConfirmOrderResponse.from(confirmation);
    }
}

```

`findOwnedForUpdate`라는 메서드 이름만으로는 JPA 잠금이 걸리지 않습니다. 저장소에 잠금 모드를 명시해야 합니다.

```java
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.Param;

public interface OrderRepository extends JpaRepository<PurchaseOrder, UUID> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("""
        select o
          from PurchaseOrder o
         where o.id = :orderId
           and o.ownerId = :actorId
        """)
    Optional<PurchaseOrder> findOwnedForUpdate(
        @Param("orderId") UUID orderId,
        @Param("actorId") UUID actorId
    );
}

```

낙관적 버전 검사를 함께 쓴다면 주문 엔티티의 버전 필드는 JPA가 관리하도록 합니다.

```java
@Version
@Column(nullable = false)
private long version;

```

확인 순서를 요약하면 다음과 같습니다.

- **기능 권한:** 이 사용자가 주문 확정 기능 자체를 실행할 수 있는가
- **객체 권한:** 이 주문이 해당 사용자 또는 테넌트 범위에 속하는가
- **상태:** 현재 `DRAFT`이며 `DRAFT → CONFIRMED` 전이가 허용되는가
- **옵션:** 서버의 활성 옵션 목록에 있고 지금 선택 가능한가
- **가격:** DB와 서버 정책으로 다시 계산했는가
- **재고:** 확인과 차감이 경쟁 조건 없이 처리되는가
- **재전송:** 같은 작업이 두 번 실행되지 않는가

UUID처럼 추측하기 어려운 식별자를 써도 객체 권한 검사를 생략할 수 없습니다. 인증되었다는 사실도 모든 객체에 대한 권한을 의미하지 않습니다.

## `@Transactional`만으로 동시 요청이 해결되지는 않는다

두 요청이 동시에 재고 1개를 읽으면 둘 다 “재고 있음” 조건을 통과할 수 있습니다. 그 뒤 각각 1개를 차감하면 초과 판매가 일어납니다. 조회와 수정을 하나의 메서드에 넣고 `@Transactional`을 붙였다는 사실만으로 모든 check-then-act 경쟁이 사라지는 것은 아닙니다.

재고는 조건부 갱신으로 원자적으로 줄일 수 있습니다.

```sql
UPDATE product_inventory
   SET stock = stock - :quantity
 WHERE product_id = :productId
   AND stock >= :quantity;

```

영향받은 행이 1이면 성공, 0이면 재고 부족 또는 경쟁 실패로 처리합니다. 업무 특성에 따라 비관적 행 잠금, 낙관적 버전, 더 강한 격리 수준을 선택할 수도 있습니다.

Spring Data JPA에서 위 SQL을 사용한다면 수정 쿼리임을 명시하고 반환된 행 수를 검사합니다.

```java
@Modifying
@Query(value = """
    UPDATE product_inventory
       SET stock = stock - :quantity
     WHERE product_id = :productId
       AND stock >= :quantity
    """, nativeQuery = true)
int decreaseIfAvailable(
    @Param("productId") UUID productId,
    @Param("quantity") int quantity
);

```

멱등성도 별도 방어선입니다. 같은 사용자가 같은 `Idempotency-Key`로 같은 요청을 재전송하면 최초 결과를 돌려주고, 같은 키에 다른 주문·수량·옵션을 붙이면 충돌로 거절해야 합니다. 이를 위해 처리 기록에는 요청의 핵심 필드나 정규화한 요청 해시를 함께 저장합니다. 최종적으로는 데이터베이스 고유 제약을 두어 동시에 들어온 중복 요청까지 막고, 고유 제약 충돌도 일관된 재조회 또는 `409` 응답으로 변환합니다.

```sql
CREATE UNIQUE INDEX uq_confirmation_actor_key
    ON order_confirmation (actor_id, idempotency_key);

CREATE UNIQUE INDEX uq_confirmation_order
    ON order_confirmation (order_id);

```

첫 번째 인덱스는 한 사용자가 같은 멱등키를 여러 작업에 재사용하지 못하게 합니다. 두 번째 인덱스는 “주문 하나는 한 번만 확정된다”는 별도의 업무 불변식을 지킵니다. 멱등키는 재전송을 다루는 수단이지 주문당 1회 실행 규칙을 대신하지 않습니다.

위 서비스 예제는 같은 주문 행을 먼저 잠그므로 동일 주문에 대한 동시 재시도를 직렬화합니다. 서로 다른 주문에 같은 키가 동시에 들어오는 경우에는 고유 제약이 마지막 방어선입니다. 운영 구현에서는 키를 작업 전에 원자적으로 예약하거나, 해당 제약 이름의 충돌만 식별해 기존 처리 기록을 안전한 별도 경계에서 재조회하고 동일 요청은 원래 결과로, 다른 요청은 `409`로 변환해야 합니다. 고유 제약 예외를 그대로 흘려 `500`으로 만들면 안 됩니다.

`saveAndFlush()`는 제약 위반을 메서드 안에서 빨리 드러내기 위한 예시입니다. 도메인 예외는 기본 롤백 대상인 `RuntimeException` 계열로 두거나 `rollbackFor`를 명시해야 재고 차감과 주문 상태 변경이 함께 롤백됩니다. `@Valid`를 사용하려면 프로젝트에 Bean Validation 구현과 Spring Boot의 validation starter도 포함되어 있어야 합니다.

외부 결제 호출은 로컬 DB 트랜잭션만으로 원자성을 보장할 수 없습니다. 결제사의 멱등 기능을 사용하고, 로컬 처리 기록 또는 outbox 패턴과 함께 설계해야 합니다. 행 잠금을 잡은 채 느린 외부 네트워크 호출을 오래 기다리는 구조도 피하는 편이 좋습니다.

확정 시점의 가격이 사용자가 마지막으로 본 가격과 달라질 수 있는 서비스라면 서버가 발급한 `quoteId` 또는 가격 버전을 함께 검증하고, 변경 시 `409`로 재확인을 요구하는 정책도 필요합니다. 서버 가격을 신뢰한다는 원칙이 사용자 몰래 더 높은 금액을 확정해도 된다는 뜻은 아닙니다.

## 상태 전이는 화면 순서가 아니라 서버가 강제한다

화면이 `작성 → 검토 → 확정` 순서로 이동한다고 해서 API도 그 순서로만 호출되는 것은 아닙니다. 마지막 확정 API 주소를 아는 사용자는 앞 단계를 건너뛰어 직접 요청할 수 있습니다.

서버에는 명시적인 현재 상태가 있어야 합니다.

- 각 요청에서 현재 상태를 다시 읽습니다.
- 허용된 다음 상태인지 검사합니다.
- 완료된 일회성 단계를 다시 실행하지 못하게 합니다.
- 숨김 필드나 클라이언트 쿠키를 현재 단계의 근거로 쓰지 않습니다.
- 오래 멈춘 중간 상태에는 업무에 맞는 만료 시간을 둡니다.

승인, 환불, 쿠폰 지급, 비밀번호 재설정처럼 한 번만 실행되어야 하는 작업일수록 이 원칙이 중요합니다.

## 응답 코드를 나누면 프론트와 로그가 선명해진다

모든 실패를 `200 OK`와 `success: false`로 돌리거나 전부 `500`으로 처리하면 원인 분석과 모니터링이 어려워집니다.

### `400 Bad Request`

JSON 형식, 필수값, 타입, 길이, 범위처럼 요청 형식이 잘못된 경우입니다. 존재하지 않는 옵션 코드를 입력 오류로 취급하는 정책도 여기에 둘 수 있습니다.

### `401 Unauthorized`

인증 정보가 없거나 유효하지 않은 경우입니다. 이름과 달리 의미는 “인증되지 않음”에 가깝습니다.

### `403 Forbidden`

인증은 되었지만 기능 또는 객체 권한이 없는 경우입니다. 객체 존재를 숨겨야 하는 시스템은 잘못된 소유자의 조회·변경을 `404`로 통일하기도 합니다. 어느 쪽이든 전 엔드포인트에서 일관되어야 합니다.

### `409 Conflict`

이미 완료된 주문, 허용되지 않는 현재 상태, 오래된 버전, 재고 경쟁, 같은 멱등키에 다른 요청처럼 **현재 리소스 상태와 충돌**한 경우에 잘 맞습니다.

커스텀 도메인 예외는 이름만 만든다고 자동으로 이 상태가 되지 않습니다. `@RestControllerAdvice`나 `@ResponseStatus`로 명시하지 않으면 `500`으로 처리될 수 있습니다. 예를 들어 다음처럼 정책을 한곳에 모읍니다.

```java
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler({
        InvalidOptionException.class,
        InvalidQuantityException.class
    })
    ProblemDetail handleBadRequest(RuntimeException exception) {
        return problem(
            HttpStatus.BAD_REQUEST,
            "요청 값 오류",
            "허용되지 않은 요청 값입니다."
        );
    }

    @ExceptionHandler(AccountForbiddenException.class)
    ProblemDetail handleForbidden(AccountForbiddenException exception) {
        return problem(
            HttpStatus.FORBIDDEN,
            "작업 권한 없음",
            "이 작업을 수행할 수 없습니다."
        );
    }

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException exception) {
        return problem(
            HttpStatus.NOT_FOUND,
            "주문을 찾을 수 없음",
            "요청한 주문을 찾을 수 없습니다."
        );
    }

    @ExceptionHandler({
        OrderStateConflictException.class,
        StaleOrderVersionException.class,
        OutOfStockException.class,
        IdempotencyConflictException.class
    })
    ProblemDetail handleConflict(RuntimeException exception) {
        return problem(
            HttpStatus.CONFLICT,
            "요청 상태 충돌",
            "이미 처리됐거나 현재 상태에서 수행할 수 없습니다."
        );
    }

    private ProblemDetail problem(
        HttpStatus status,
        String title,
        String detail
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            status,
            detail
        );
        problem.setTitle(title);
        return problem;
    }
}

```

Bean Validation 실패는 Spring MVC의 검증 예외 처리로 `400`에 연결하고, 인증되지 않은 요청은 Spring Security의 `AuthenticationEntryPoint`에서 `401`, 필터 또는 `@PreAuthorize`의 인가 실패는 `AccessDeniedHandler`에서 `403`으로 처리합니다. 데이터베이스 고유 제약 위반은 모든 무결성 오류를 뭉뚱그리지 말고, 예상한 제약을 식별한 멱등 처리 계층에서 `IdempotencyConflictException` 같은 도메인 예외로 바꿉니다.

Spring의 `ProblemDetail`을 사용하면 프론트에 일관된 오류 형식을 제공할 수 있습니다. 내부 예외 메시지와 스택 트레이스는 응답에 노출하지 않고, 상세 정보는 접근이 통제된 서버 로그에 남깁니다.

## 접근성: 마우스만 막으면 비활성화가 아니다

네이티브 폼 컨트롤에는 가능하면 HTML `disabled`를 사용합니다. 브라우저가 의미, 포커스, 이벤트, 기본 스타일을 함께 처리하기 때문입니다.

`aria-disabled="true"`는 보조 기술에 비활성 상태라는 **의미만 전달**합니다. 클릭, 키보드 활성화, 포커스, 스타일을 자동으로 막지 않습니다. 포커스 순서에 남겨 두어야 하는 사용자 정의 컨트롤이라면 JavaScript 동작 차단과 시각적 표현을 함께 구현해야 합니다.

`pointer-events: none`은 마우스 포인터 입력만 막습니다. 키보드 Enter·Space나 프로그램 호출까지 차단하지 않으므로 비활성화 또는 보안 수단으로 단독 사용하면 안 됩니다.

## 직접 우회해 보는 검증 시나리오

프론트 버튼이 회색인지 확인하는 테스트로 끝내면 핵심을 놓칩니다. 로컬·스테이징 환경에서 아래 요청이 서버와 DB 불변식을 깨뜨리지 못하는지 확인해야 합니다.

### 화면 우회

- 개발자 도구에서 `disabled`와 `readonly`를 제거합니다.
- 숨겨진 버튼을 표시하고 클릭합니다.
- Vue 상태와 요청 본문을 서로 다르게 만들어 봅니다.
- 마우스뿐 아니라 키보드와 폼 Enter 제출도 확인합니다.

### API 우회

- UI를 거치지 않고 같은 API를 직접 호출합니다.
- `orderId`를 다른 테스트 계정의 값으로 바꿉니다.
- 요청에 `ownerId`, `role`, `price`, `targetStatus`를 임의로 추가합니다.
- 허용되지 않은 옵션, 수량 0, 지나치게 큰 수량을 보냅니다.
- 인증 없음, 권한 없음, 쿠키 인증에서 CSRF 토큰 없음의 결과를 각각 확인합니다.

### 순서와 동시성

- 검토 단계를 거치지 않고 확정 엔드포인트를 바로 호출합니다.
- 이미 끝난 요청을 다시 보냅니다.
- 같은 멱등키로 같은 요청과 다른 요청을 각각 보냅니다.
- 서로 다른 키로 동일 주문을 동시에 여러 번 확정합니다.
- 재고가 1개일 때 여러 요청을 병렬로 보냅니다.

### 확인할 최종 결과

- 권한 없는 객체가 바뀌지 않았는가
- 재고가 음수가 되지 않았는가
- 주문·결제·쿠폰이 한 번만 처리됐는가
- 실패한 트랜잭션이 중간 상태를 남기지 않았는가
- `400`, `401`, `403` 또는 `404`, `409`가 정책대로 구분되는가
- 로그에 행위자·대상·동작·결과·상관관계 ID가 남는가
- 토큰, 비밀번호, 전체 개인정보가 로그에 남지 않는가

## 하지 말아야 할 대응

### F12와 우클릭을 막기

키보드 단축키와 컨텍스트 메뉴를 막아도 브라우저 메뉴, 원격 디버깅, 프록시, 별도 HTTP 클라이언트로 우회할 수 있습니다. 정상 사용자의 접근성과 디버깅만 방해하기 쉽습니다.

### 난독화를 권한 통제로 보기

난독화는 소스 이해 비용을 높일 수 있지만 서버 요청 형식과 실행 결과까지 숨기지는 못합니다. 지식재산 보호 보조 수단과 접근 제어를 혼동하면 안 됩니다.

### DOM 변경을 곧바로 침입으로 기록하기

브라우저 확장, 번역 도구, 접근성 도구, 테스트 자동화도 DOM을 바꿀 수 있습니다. `MutationObserver` 이벤트만으로 사용자를 공격자로 판정하거나 반복 경고창을 띄우면 오탐과 나쁜 UX가 생깁니다.

### 클라이언트 화이트리스트로 끝내기

Vue에서 옵션 목록을 검사하는 것은 좋은 UX 검증입니다. 같은 허용 목록과 사용자별 조건을 서버가 다시 적용하지 않으면 보안 효과는 없습니다.

### `@Transactional` 하나로 안심하기

트랜잭션은 작업 경계를 만듭니다. 그러나 기본 격리 수준에서 모든 중복 실행과 경쟁 조건을 자동으로 없애지는 않습니다. 잠금, 조건부 갱신, 버전, 고유 제약, 멱등성 중 업무에 맞는 장치를 함께 선택해야 합니다.

## 적용 체크리스트

### Vue

- UI 조건을 하나의 `computed`로 모았는가
- 클릭과 Enter 제출이 같은 핸들러를 거치는가
- 핸들러 안에도 UX 가드가 있는가
- 처리 중 상태로 정상적인 중복 클릭을 줄였는가
- 오류를 화면 읽기 도구가 인식할 수 있게 알리는가
- `MutationObserver`를 보안 기능으로 설명하지 않는가

### API

- 인증 사용자 ID를 세션 또는 검증된 토큰에서 가져오는가
- 기능 권한과 객체 소유권을 요청마다 확인하는가
- 현재 업무 상태와 허용 전이를 서버가 검사하는가
- 클라이언트의 역할·가격·할인·합계·상태를 신뢰하지 않는가
- 쿠키 인증이라면 CSRF 보호를 유지하는가
- 실패 유형에 맞는 HTTP 상태와 안전한 오류 응답을 쓰는가

### 데이터베이스와 외부 연동

- 재고 확인과 차감이 원자적인가
- 유일해야 할 규칙에 DB 고유 제약이 있는가
- 중복 제출과 불명확한 재시도를 멱등하게 처리하는가
- 동시 요청에서도 최종 불변식이 유지되는가
- 외부 결제·메시지 발행의 재시도와 중복 처리 정책이 있는가

## 마치며

개발자 도구로 `disabled`를 지웠을 때 버튼이 눌리는 것은 Vue의 취약점이 아닙니다. 브라우저가 사용자에게 속한 실행 환경이라는 자연스러운 결과입니다.

처음에는 속성을 되붙이는 디렉티브를 만들고, 전역 등록이 왜 안 되는지 로그부터 쫓았습니다. 그 실험은 UI 상태 관리와 Vue 디버깅을 이해하는 데는 도움이 됐습니다. 하지만 보안 해답은 아니었습니다.

프론트엔드는 정상 사용자에게 가능한 행동을 명확히 안내해야 합니다. 서버는 화면을 믿지 않고 인증·기능 권한·객체 소유권·현재 상태·입력값을 다시 확인해야 합니다. 데이터베이스는 동시 요청과 재전송 속에서도 재고, 금액, 처리 횟수 같은 불변식을 지켜야 합니다.

`disabled`가 제거되어도 안전한 시스템은 속성을 더 집요하게 붙잡는 시스템이 아닙니다. 속성이 없어도 잘못된 상태 전이가 절대로 커밋되지 않는 시스템입니다.

## 공식 문서

- [Vue 공식 문서: Computed Properties](https://vuejs.org/guide/essentials/computed.html?ref=mlog.me)
- [Vue 공식 문서: Event Handling](https://vuejs.org/guide/essentials/event-handling.html?ref=mlog.me)
- [Vue 공식 문서: Custom Directives](https://vuejs.org/guide/reusability/custom-directives.html?ref=mlog.me)
- [Vue 공식 문서: <script setup>의 로컬 디렉티브](https://vuejs.org/api/sfc-script-setup.html?ref=mlog.me#using-custom-directives)
- [Vue 공식 문서: Security와 백엔드 협업](https://vuejs.org/guide/best-practices/security.html?ref=mlog.me#backend-coordination)
- [MDN: disabled HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/disabled?ref=mlog.me)
- [MDN: readonly HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/readonly?ref=mlog.me)
- [MDN: aria-disabled](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled?ref=mlog.me)
- [MDN: MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver?ref=mlog.me)
- [OWASP: Input Validation Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Input%5FValidation%5FCheat%5FSheet.html?ref=mlog.me)
- [OWASP: Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization%5FCheat%5FSheet.html?ref=mlog.me)
- [OWASP: IDOR Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure%5FDirect%5FObject%5FReference%5FPrevention%5FCheat%5FSheet.html?ref=mlog.me)
- [OWASP: Business Logic Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Business%5FLogic%5FSecurity%5FCheat%5FSheet.html?ref=mlog.me)
- [OWASP: REST Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/REST%5FSecurity%5FCheat%5FSheet.html?ref=mlog.me)
- [OWASP: CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=mlog.me)
- [Spring Framework: Bean Validation](https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html?ref=mlog.me)
- [Spring MVC: Controller Validation](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-validation.html?ref=mlog.me)
- [Spring MVC: Error Responses](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html?ref=mlog.me)
- [Spring Security: Method Security](https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html?ref=mlog.me)
- [Spring Security: CSRF](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html?ref=mlog.me)
- [Spring Data JPA: Locking](https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html?ref=mlog.me)