콘텐츠로 이동

에러 처리

Hurum에서 에러는 다른 모든 것과 동일한 경로를 따라요: Executor가 에러를 포착하고 Event를 emit해요. 프레임워크 레벨에서 특별한 에러 채널이나 try/catch 래퍼는 없어요. 이렇게 하면 에러가 상태에 어떻게 나타나는지 완전히 제어할 수 있어요.

두 가지 패턴이 대부분의 시나리오를 커버해요: 순차적 Intent를 통한 유효성 검증Executor의 에러 Event.

패턴 1: 순차적 Intent를 통한 유효성 검증

섹션 제목: “패턴 1: 순차적 Intent를 통한 유효성 검증”

사용자가 폼을 제출할 때, 보통 먼저 유효성을 검증한 다음 저장하고 싶어요. 유효성 검증이 실패하면 저장은 절대 실행되어서는 안 돼요.

순차적 Intent가 이걸 자연스럽게 처리해요. Executor가 throw하면, Intent 내의 나머지 Command는 건너뛰어요.

import { Events, Event } from '@hurum/core'
interface ValidationError {
field: string
message: string
}
const PurchaseEvent = Events('Purchase', {
validated: Event<{ purchase: Purchase }>(),
validationFailed: Event<{ errors: ValidationError[] }>(),
saveRequested: Event<{ id: string }>(),
saved: Event<{ purchase: Purchase }>(),
saveFailed: Event<{ error: SaveError }>(),
})

유효성 검증 Executor는 validated 또는 validationFailed를 emit해요. 실패 시 Intent 체인을 중지하기 위해 throw해요:

import { CommandExecutor } from '@hurum/core'
const [ValidateCommand, ValidateExecutor] = CommandExecutor<
{ purchase: Purchase }
>((command, { emit }) => {
const errors = validate(command.purchase)
if (errors.length > 0) {
emit(PurchaseEvent.validationFailed({ errors }))
throw new Error('Validation failed') // Stops the intent chain
}
emit(PurchaseEvent.validated({ purchase: command.purchase }))
})

throw가 핵심이에요. Hurum에게 이 Intent의 나머지 Command 실행을 중지하라고 알려줘요. SaveCommand는 절대 실행되지 않아요.

저장 Executor는 유효성 검증이 통과한 경우에만 실행돼요:

const [SavePurchaseCommand, SavePurchaseExecutor] = CommandExecutor<
{ purchase: Purchase },
{ purchaseRepository: PurchaseRepository }
>(async (command, { deps, emit, signal }) => {
emit(PurchaseEvent.saveRequested({ id: command.purchase.id }))
if (signal.aborted) return
const result = await deps.purchaseRepository.save(command.purchase)
if (signal.aborted) return
result.match(
(saved) => emit(PurchaseEvent.saved({ purchase: saved })),
(error) => emit(PurchaseEvent.saveFailed({ error })),
)
})

Intent는 Command를 순서대로 나열해요. 먼저 유효성 검증, 그다음 저장:

import { Intents, Intent } from '@hurum/core'
const PurchaseIntents = Intents('Purchase', {
submitButtonClicked: Intent(ValidateCommand, SavePurchaseCommand),
})

사용자가 제출을 클릭하면:

  1. ValidateCommand가 먼저 실행돼요
  2. 유효성 검증 실패 시: validationFailed가 emit되고, throw로 체인이 중지되며, SavePurchaseCommand는 절대 실행되지 않아요
  3. 유효성 검증 통과 시: validated가 emit되고, SavePurchaseCommand가 정상적으로 실행돼요

모든 Event를 한 곳에서 처리해요:

import { Store } from '@hurum/core'
const PurchaseStore = Store({
state: {
purchase: null as Purchase | null,
saving: false,
validationErrors: [] as ValidationError[],
saveError: null as SaveError | null,
},
})
.on(PurchaseEvent, {
validated: (state, { purchase }) => ({
...state,
purchase,
validationErrors: [],
}),
validationFailed: (state, { errors }) => ({
...state,
validationErrors: errors,
}),
saveRequested: (state) => ({
...state,
saving: true,
saveError: null,
}),
saved: (state, { purchase }) => ({
...state,
purchase,
saving: false,
}),
saveFailed: (state, { error }) => ({
...state,
saving: false,
saveError: error,
}),
})
.intents(PurchaseIntents)
.executors(ValidateExecutor, SavePurchaseExecutor)
.deps<{ purchaseRepository: PurchaseRepository }>()

단일 Executor 내에서 발생하는 에러(예: 네트워크 장애)의 경우, 에러를 포착하고 Event를 emit해요. 예외가 처리되지 않은 채 전파되지 않도록 하세요.

const [LoadCommand, LoadExecutor] = CommandExecutor<
{ id: string },
{ purchaseRepository: PurchaseRepository }
>(async (command, { deps, emit, signal }) => {
emit(PurchaseEvent.loadRequested({ id: command.id }))
if (signal.aborted) return
try {
const purchase = await deps.purchaseRepository.findById(command.id)
if (signal.aborted) return
emit(PurchaseEvent.loaded({ purchase }))
} catch (error) {
if (signal.aborted) return
emit(PurchaseEvent.loadFailed({
error: { code: 'LOAD_ERROR', message: (error as Error).message },
}))
}
})

try/catch는 Executor 내부에 있어요. Store는 예외를 절대 보지 않아요 — 깔끔하게 타입이 지정된 에러 페이로드를 가진 loadFailed Event만 봐요.

Executor가 에러를 포착하지 않고 throw하면, Hurum의 middleware onError 훅이 이걸 잡아내요. 이것은 처리되지 않은 에러를 위한 안전망이에요:

const errorLoggingMiddleware: Middleware = {
onError(error, context) {
console.error('Unhandled executor error:', error.message)
console.error('Intent:', context.intent)
console.error('Command:', context.command)
// Report to error tracking service
errorTracker.report(error, { intent: context.intent })
},
}
const MyStore = Store({ state: { ... } })
.middleware(errorLoggingMiddleware)
// ...

onError는 로깅과 모니터링에 사용하세요. 상태 전환에는 사용하지 마세요. 상태는 항상 Event를 통해 변경되어야 해요.

에러 Event는 일반 Event와 같아요. on 핸들러에서 처리한 다음, 컴포넌트에서 상태를 읽으면 돼요:

function PurchaseForm() {
const store = useStore(PurchaseStore)
const validationErrors = store.use.validationErrors()
const saveError = store.use.saveError()
const saving = store.use.saving()
return (
<form onSubmit={(e) => {
e.preventDefault()
store.send.submitButtonClicked({ purchase })
}}>
{validationErrors.length > 0 && (
<ul className="errors">
{validationErrors.map((err) => (
<li key={err.field}>{err.field}: {err.message}</li>
))}
</ul>
)}
{saveError && (
<div className="error">Save failed: {saveError.message}</div>
)}
<button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Submit'}
</button>
</form>
)
}
  • 순차적 Intent는 throw 시 중지돼요. 이것이 유효성 검증 패턴이에요. SaveCommand 앞에 ValidateCommand를 나열하고, 유효성 검증기에서 throw해서 저장을 차단해요.
  • 에러 Event는 일반 Event예요. 특별한 에러 타입은 없어요. Events() 그룹에 정의하고 다른 Event와 마찬가지로 on에서 처리해요.
  • Executor 내부에서 에러를 포착하세요. 예외가 밖으로 새어나가지 않도록 하세요. 타입이 지정된 에러 Event로 변환해서 Store가 예측 가능하게 처리할 수 있도록 해요.
  • Middleware onError는 안전망이에요. 상태 관리가 아닌 로깅에 사용하세요. onError에서 에러를 처리하고 있다면, 해당 로직을 Executor의 에러 Event로 옮기세요.
  • 적절한 시점에 에러 상태를 초기화하세요. 사용자가 액션을 재시도할 때, “requested” 핸들러에서 이전 에러를 초기화해서 오래된 에러가 남아있지 않도록 해요.