[번역] 코드가 아닌 정책을 전달하세요

Jay Freestone - 2026-06-06


오렌지색 데이터 리본이 프론트엔드 패널에 연결되어 있고, 그 뒤로 X 표시가 된 중복 코드 카드가 보이는 이미지

별도의 프론트엔드에 데이터를 공급하는 API(REST, GraphQL 등)가 있다고 가정해 봅시다.
여기 무해해 보이는 헬퍼 함수가 하나 있습니다.
function canCancelOrder(order: Order) {
return ['PENDING', 'PAID'].includes(order.status)
}
백엔드는 불변성(Invariant)이 위배될 때 POST /orders/:id/cancel 요청을 거부해야 하며, 프론트엔드는 관련 버튼을 비활성화하거나 숨겨야 합니다.
이것은 단순한 예시일 뿐이지만, 이러한 규칙들은 경계를 넘어 스며드는 습성이 있습니다.
// API
function handleCancelOrder(ctx: Context, payload: Payload) {
const order = ctx.orderRepository.get(payload.id)

if (!canCancelOrder(order)) {
throw new Error(`Cannot cancel an order in state: ${order.status}`)
}

// 기타 로직
}
// 프론트엔드
function CancelButton({ status }: { status: OrderStatus }) {
const disabled = !['PENDING', 'PAID'].includes(status);

return (
<button
disabled={disabled}
name="action"
value="cancel"
>
Cancel order
</button>
)
}
결국 서로 어긋날 수밖에 없는 동일한 규칙의 복사본이 두 개 존재하게 됩니다.
선택지는 다음과 같습니다.
  • 코드를 공유하기 (패키지 또는 모노레포의 공유 모듈 활용).
  • 규칙을 (데이터로서) 전달하고, 백엔드를 신뢰할 수 있는 단일 출처(Source of Truth)로 유지하기.

코드 공유하기

normalizePostcode()와 같이 컨텍스트에 의존하지 않는 정적 헬퍼를 공유하는 것은 괜찮습니다.
문제가 발생하는 지점은 컨텍스트가 포함된 규칙입니다. 프론트엔드와 백엔드라는 두 개의 개별적으로 배포 가능한 단위가 생기는 순간, 이들이 항상 동기화되어 있을 것이라고 실제로 신뢰하기는 어렵습니다.
  • 공유 패키지는 양측에서 서로 다른 버전을 사용할 수 있습니다.
  • 모노레포에서의 '원자적(Atomic)' 커밋이라 할지라도 결과적으로는 두 개의 서로 다른 배포가 이루어집니다. 하나가 실패하면 서로 다른 계약(Contract) 상태에 놓이게 됩니다.
이러한 문제를 완화하는 것이 불가능하지는 않지만, 실제로 관리하다 보면 차이(Drift)를 피하기가 매우 어렵습니다.
또한 양측이 언어를 공유하지 않는다면 코드 공유는 시작조차 할 수 없습니다. Go로 작성된 API와 TypeScript 프론트엔드는 함수를 (쉽게) 공유할 수 없으므로, 어차피 JSON과 같은 중립적인 스펙(Spec)으로 컴파일해야 합니다. 이 시점에 이르면 결국 데이터를 전달하고 있는 셈입니다.
이는 자연스럽게 다음 주제로 이어집니다.

코드가 아닌 데이터를 전달하세요

함수나 클래스를 공유하는 대신, 그 결과값이나 이를 구동하는 스펙을 직렬화하여 네트워크를 통해 전송하세요.

결정 사항을 전달하기

백엔드는 이미 규칙을 평가할 수 있으므로, 그 결과를 반환하기만 하면 됩니다.
{
"id": "123",
"customer": { "name": "Jay" },
"items": [{ "name": "Product one" }],
"status": "PENDING",
"allowedActions": ["CANCEL"]
}
이제 프론트엔드는 상태를 다시 유도하는 대신, 주어진 상태를 렌더링하기만 하면 됩니다.
function CancelButton({ allowedActions }: { allowedActions: Action[] }) {
const disabled = !allowedActions.includes('CANCEL');

return (
<button
disabled={disabled}
name="action"
value="cancel"
>
Cancel order
</button>
)
}
하지만 단순한 불리언(Boolean) 값만으로는 부족할 때가 많습니다. UI는 거의 항상 '왜' 안 되는지에 대한 이유(예: '이미 배송됨' 등)를 필요로 하기 때문입니다.
{
"status": "SHIPPED",
"allowedActions": [],
"disabledReasons": { "CANCEL": "Orders can't be canceled once shipped" }
}
GitHub GraphQL API의 viewerCanUpdate 필드는 정확히 동일하게 동작합니다.
이 방식이 HATEOAS와 매우 유사하다고 느껴진다면 정확히 보신 것입니다. 클라이언트가 추측하게 만드는 대신, 서버가 클라이언트에게 어떤 전이(Transition)가 가능한지 명시적으로 알려주는 것입니다.
{
"class": ["order"],
"properties": {
"id": "123",
"status": "PENDING"
},
"actions": [
{
"name": "cancel-order",
"title": "Cancel order",
"method": "POST",
"href": "https://api.example.com/orders/123/cancel"
}
]
}

평가기가 아닌 정책을 전달하기

불변성이 더 복잡한 경우에는 정책(Policy) 자체를 직렬화하고, 양측에서 작고 공유된 평가기(Evaluator)를 통해 이를 평가하세요. CASL과 같은 권한 관리 라이브러리가 이를 위해 만들어졌습니다. 규칙을 묶어서 네트워크로 보낼 수 있습니다.
import { packRules } from '@casl/ability/extra';
import { defineRulesFor } from '../services/appAbility';

app.post('/authz', (req, res) => {
res.send({ rules: packRules(defineRulesFor(req.user)) });
});
유효성 검사를 위한 JSON Schema도 비슷한 방식입니다.
{
"type": "object",
"properties": {
"name": { "type": "string" },
"credit_card": { "type": "number" },
"billing_address": { "type": "string" }
},
"required": ["name"],
"dependentRequired": {
"credit_card": ["billing_address"]
}
}
이 경우 코드를 공유하기는 하지만, 정책이 아닌 평가기(CASL ability, JSON Schema validator 등)만을 공유합니다. 평가기는 대개 안정적인 반면, 비즈니스 로직은 그렇지 않을 가능성이 높기 때문입니다.
물론 모든 것을 데이터로 쉽게 표현할 수 있는 것은 아닙니다. zod 스키마는 코드이므로(직렬화를 지원하려는 여러 시도가 있긴 하지만), 이를 공유한다는 것은 공유 모듈을 사용하거나 변환 단계를 거쳐야 함을 의미합니다.

공유는 배려입니다

어떤 방식을 선택하든, 무엇이든 공유하세요. 동일한 도메인 규칙이 두 곳에 존재하게 두지 마세요. 중복(WET, Write Everything Twice)이 괜찮을 때도 있지만, 불변성에 대해서는 그렇지 않습니다.
다음에 백엔드에서 이미 강제하고 있는 사항을 반영하기 위해 프론트엔드에서 가드(Guard) 로직을 작성하고 있는 자신을 발견한다면, 대신 데이터로서 공유할 수 없는지 자문해 보세요.
0
3

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글