
요즘 LLM한테 "게시글 CRUD 만들어줘" 하면 5분이면 나오잖아요. 근데 그렇게 만든 코드로 3개월 버텨본 적 있으신가요? 제 경험상, 사람이 구조를 안 잡아주면 코드는 결국 산으로 가더라고요.
제 생각에 LLM이 아무리 발전해도 코드의 구조를 설계하는 건 여전히 사람의 몫인 것 같아요. LLM은 "이렇게 짜줘"라고 하면 잘 짜주는데, "어떻게 짜야 하는지"를 결정하는 건 못 하거든요. 적어도 아직까지는요.
그리고 저는 클린코드를 "보기 좋은 코드"보다는 변경에 강한 코드에 가깝다고 생각하는 편이에요. 이 감각은 이직 면접에서도 꽤 차이를 만들더라고요. "상태관리 어떻게 하세요?"라는 질문에 "zustand이요"가 아니라, 어떤 설계 원칙 위에서 관리하는지를 말할 수 있으면 인상이 확 달라지거든요.
오늘은 React에서 데코레이터 패턴이 어떻게 클린코드로 이어질 수 있는지, 제가 경험한 것들을 코드와 함께 공유해보려고 해요.
React에서 비동기 로직 짜면 이런 코드 많이 만들어보셨을 거예요.
async function createPost(title: string) {
try {
const created = await api.post.create({ title })
setPosts((prev) => [...prev, created])
toast.success('작성 완료!')
router.push('/posts')
} catch (error) {
toast.error(error instanceof Error ? error.message : '실패했습니다')
console.error(error)
}
}
async function deletePost(postId: string) {
try {
await api.post.delete(postId)
setPosts((prev) => prev.filter((p) => p.id !== postId))
toast.success('삭제 완료!')
} catch (error) {
toast.error(error instanceof Error ? error.message : '실패했습니다')
console.error(error)
}
}
두 함수를 나란히 놓고 보면, 뭔가 이상하지 않나요?
에러 처리 코드가 똑같아요. toast.error(...) 부분이 그대로 복붙이죠. 성공 시 토스트 띄우는 패턴도 같고요. 함수가 2개일 때는 괜찮지만, 10개, 20개로 늘어나면 이 반복이 프로젝트 전체에 퍼져요. 나중에 에러 처리 방식을 바꾸고 싶으면? 20군데를 다 고쳐야 해요.
이렇게 여러 함수에 걸쳐서 반복되는 부가 로직을 "횡단 관심사(Cross-Cutting Concern)"라고 불러요. 에러 처리, 로깅, 권한 체크, 로딩 상태 관리 같은 것들이 대표적이에요.
각 함수의 핵심 비즈니스 로직은 분명히 다른데, 그 주변을 감싸는 부가 로직은 계속 똑같은 거죠. 문제는 이 둘이 한 함수 안에 뒤섞여 있다는 거예요.
백엔드, 특히 Spring을 써보신 분이면 여기서 바로 느낌이 오실 거예요. "이거 AOP로 빼면 되는 거 아니야?" @Transactional, @Cacheable 같은 어노테이션으로 횡단 관심사를 깔끔하게 분리하잖아요. 근데 프론트엔드에서는 이런 접근이 잘 안 보이죠. React 생태계에서는 보통 커스텀 훅으로 해결하려고 하는데, 훅은 컴포넌트 레벨의 관심사 분리이지 함수(액션) 레벨의 관심사 분리는 아니거든요.
오늘 이 문제를 데코레이터 패턴으로 풀어볼 건데, 그 전에 먼저 짚고 넘어가야 할 게 있어요. 데코레이터는 클래스 메서드에 붙이는 거잖아요. 그러면 React에서 상태와 액션을 클래스로 다루는 패턴부터 알아야 이야기가 이어져요.
React 하면 함수형 컴포넌트, 훅, 불변 상태가 먼저 떠오르잖아요. 근데 상태 관리 영역에서는 OOP적 접근이 꽤 오래 전부터 쓰여왔어요.
MobX가 대표적이에요. 클래스에 상태를 정의하고, 메서드로 상태를 변경하는 패턴이죠.
class PostStore {
posts: Post[] = []
async create(title: string) {
const created = await api.post.create({ title })
this.posts.push(created)
}
async delete(postId: string) {
await api.post.delete(postId)
this.posts = this.posts.filter((p) => p.id !== postId)
}
}
함수형으로 작성한 처음 코드와 비교해보면, 핵심 로직이 훨씬 깔끔하게 드러나죠? this.posts에 직접 push하고, 직접 filter하고. setPosts((prev) => ...) 같은 함수형 업데이트 패턴이 필요 없어요.
zustand도 비슷한 접근을 지원해요.
const usePostStore = create((set, get) => ({
posts: [],
create: async (title: string) => {
const created = await api.post.create({ title })
set((s) => ({ posts: [...s.posts, created] }))
},
}))
valtio는 더 직관적이에요. proxy 기반이라 직접 mutation이 가능하거든요.
const postState = proxy({
posts: [] as Post[],
async create(title: string) {
const created = await api.post.create({ title })
postState.posts.push(created)
},
})
이런 라이브러리들의 공통점이 보이시나요? 상태(state)와 그 상태를 변경하는 액션(action)을 하나의 단위로 묶는 거예요. 데이터와 행동이 함께 있으니, 관련된 코드를 찾아 돌아다닐 필요가 없죠.
상태와 액션을 묶는 건 좋은데, 처음에 봤던 횡단 관심사 문제는 여전해요. MobX든 valtio든, 에러 처리를 하려면 결국 try-catch를 각 메서드 안에 넣어야 하거든요.
class PostStore {
posts: Post[] = []
async create(title: string) {
try { // 👈 여전히 이게 필요해요
const created = await api.post.create({ title })
this.posts.push(created)
toast.success('작성 완료!')
} catch (error) {
toast.error(error instanceof Error ? error.message : '실패했습니다')
}
}
}
상태와 액션은 클래스로 깔끔하게 묶었는데, 에러 처리·성공 알림·권한 체크 같은 부가 로직은 여전히 메서드 안에 섞여 있어요.
여기서 데코레이터가 등장합니다. 클래스 메서드가 있으니까, 그 메서드에 데코레이터를 붙일 수 있거든요.
데코레이터 패턴은 GoF 디자인 패턴 중 하나로, 핵심 아이디어는 이래요.
원래 객체를 수정하지 않고, 감싸서 기능을 추가한다.
커피를 생각해보면 직관적이에요. 아메리카노가 원래 객체라면, 시럽 추가, 샷 추가, 휘핑 추가는 각각 데코레이터예요. 아메리카노 자체를 바꾸는 게 아니라, 바깥에서 감싸서 기능을 얹는 거죠.
코드로 보면 이런 느낌이에요.
// 원래 함수
async function createPost(title: string) {
const created = await api.post.create({ title })
posts.push(created)
}
// 데코레이터로 감싸기
const createPostWithErrorHandling = withErrorHandler(createPost)
const createPostFull = withSuccessToast(createPostWithErrorHandling, '작성 완료!')
원래 함수는 핵심 로직만 갖고 있고, 에러 처리와 성공 토스트는 바깥에서 감싸서 추가했어요. 원래 함수를 건드리지 않고 기능을 덧붙인 거죠.
@ 문법: 선언적으로 표현하기이 패턴을 언어 차원에서 지원하는 게 데코레이터 문법이에요. @ 기호를 사용해서 함수를 감싸는 걸 선언적으로 표현할 수 있죠.
class PostStore {
posts: Post[] = []
@withSuccessToast('작성 완료!')
@withErrorHandler
async create(title: string<) {
const created = await api.post.create({ title })
this.posts.push(created)
}
}
앞에서 클래스로 상태와 액션을 묶어뒀으니까, 이렇게 메서드 위에 @로 데코레이터를 얹을 수 있는 거예요. 위에서 함수를 감싸던 걸 @ 문법으로 선언한 것뿐인데, 읽는 사람 입장에서는 훨씬 직관적이죠. 메서드 본문을 읽기도 전에 "이 메서드는 에러 처리가 되어 있고, 성공하면 토스트가 뜨는구나"를 바로 알 수 있으니까요.
💡 TC39 데코레이터 프로포절은 현재 Stage 3이고, TypeScript 5.0부터 사용할 수 있어요.
tsconfig.json에서"experimentalDecorators": true로 켜면 됩니다.
원리를 이해하기 위해 간단한 데코레이터를 직접 만들어볼게요.
// 에러 핸들링 데코레이터
function OnError(handler: (error: unknown) => void) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value
descriptor.value = async function (...args: any[]) {
try {
return await original.apply(this, args)
} catch (error) {
handler(error)
throw error
}
}
return descriptor
}
}
별거 아니죠? 원래 메서드를 try-catch로 감싸는 새 함수로 바꿔치기하는 거예요. 핵심은 원래 메서드의 코드를 건드리지 않는다는 거예요. 감싸기만 할 뿐이죠.
이 원리를 알면, 어떤 횡단 관심사든 데코레이터로 만들 수 있다는 감이 와요.
앞에서 다룬 두 가지를 정리하면 이래요.
저는 이 두 가지를 결합한 comwit이라는 상태관리 라이브러리를 만들었어요. 바이브코딩 플랫폼 서비스에서 내부적으로 쓰다가 최근에 오픈소스로 공개했습니다.
comwit에서 액션은 action() 팩토리 함수로 정의해요.
import { action, OnError, OnSuccess } from 'comwit'
export const postActions = action<Pick<PostActions, 'create' | 'delete'>, AppContext>(
({ state, context }) => {
class PostActions {
private model = state(post)
@OnSuccess(() => {
toast.success('작성 완료!')
context.router.push('/posts')
})
@OnError((e) => toast.error(e instanceof Error ? e.message : '실패했습니다'))
async create(title: string) {
const created = await api.post.create({ title })
this.model.posts.data.push(created)
}
@OnSuccess(() => toast.success('삭제 완료!'))
@OnError((e) => toast.error(e instanceof Error ? e.message : '실패했습니다'))
async delete(postId: string) {
await api.post.delete(postId)
this.model.posts.data = this.model.posts.data.filter((p) => p.id !== postId)
}
}
return new PostActions()
}
)
앞에서 다뤘던 내용이 전부 여기에 녹아 있어요. 클래스 안에 상태(this.model)와 액션(create, delete)이 함께 있고, 횡단 관심사는 @OnSuccess, @OnError 데코레이터로 분리되어 있죠.
action()은 ({ state, context })를 인자로 받아요. state는 다른 도메인의 상태에 접근하는 함수이고, context는 라우터 같은 외부 의존성을 주입받는 곳이에요. this.model = state(post)로 상태 인스턴스를 가져오는 부분이 좀 특이한데, 이건 뒤에서 SSR 이야기할 때 다시 나올 거예요.
create 메서드 본문에는 "게시글 만들고 목록에 추가한다"라는 핵심 로직만 남아 있어요. 성공하면 뭘 하는지, 에러나면 어떻게 처리하는지는 데코레이터가 선언적으로 말해주고 있죠. try-catch가 사라졌어요.
comwit이 제공하는 데코레이터들을 좀 더 살펴볼게요.
@OnError(handler) — 에러 발생 시 handler를 실행해요. 에러는 그대로 전파됩니다.
@OnError((e) => toast.error(e instanceof Error ? e.message : '에러 발생'))
async save() { /* ... */ }
@OnSuccess(handler) — 성공 시 handler를 실행해요.
@OnSuccess(() => router.push('/list'))
async create() { /* ... */ }
@Debounce(ms) — 검색 입력처럼 연속 호출을 제한할 때요.
@Debounce(300)
async search(keyword: string) {
await this.model.posts.query(keyword)
}
@Throttle(ms) — 스크롤 이벤트처럼 일정 간격으로만 실행하고 싶을 때요.
@Throttle(1000)
async trackScroll(position: number) { /* ... */ }
@Authorized({ when, onDeny }) — 권한 체크. 조건 실패 시 대체 동작을 실행합니다.
@Authorized({
when: () => Boolean(user.me),
onDeny: () => router.push('/login'),
})
async create(title: string) { /* ... */ }
하나하나 보면 별것 아닌 것 같은데, 이걸 조합해서 쌓을 수 있다는 게 진짜 포인트예요.
@LoginRequired
@OnSuccess(() => toast.success('작성 완료!'))
@OnError((e) => toast.error(e.message))
async create(title: string) {
const created = await api.post.create({ title })
this.model.posts.data.push(created)
}
메서드 본문을 읽기도 전에 이 메서드의 전체 흐름이 보여요. 로그인이 필요하고, 성공하면 토스트가 뜨고, 실패하면 에러 토스트가 뜨고, 핵심 로직은 게시글을 만들고 목록에 추가하는 것. 코드가 자기 자신을 설명하는 거예요. 개인적으로 데코레이터의 가장 큰 가치가 여기에 있다고 생각해요.
내장 데코레이터만으로 끝이 아니에요. createInterceptor로 나만의 데코레이터를 만들 수 있어요. Spring 커스텀 어노테이션이랑 비슷한 개념이에요.
예를 들어 "로그인 필수" 체크를 매번 if (!this.user.me) return으로 하고 있었다면:
const LoginRequired = createInterceptor(({ state, context }) => {
const u = state(user)
return onAuthorized({
when: () => Boolean(u.me),
onDeny: () => context.router.push('/login'),
})
})
이제 프로젝트 어디서든 @LoginRequired 한 줄로 끝이에요. @AdminOnly, @RateLimited, @WithAnalytics 같은 것도 같은 방식으로 만들 수 있고요. 팀의 규칙을 코드로 표현하는 거죠.
위 코드에서 한 가지 눈에 걸리는 부분이 있었을 거예요.
class PostActions {
private model = state(post)
// ...
}
왜 post를 직접 import해서 쓰지 않고, 굳이 state()로 감싸서 가져올까요? 이게 좀 생뚱맞아 보일 수 있는데, 여기에 꽤 중요한 이유가 있어요. 그리고 이 이유를 알면, SSR에서 전역 상태를 다룰 때 왜 조심해야 하는지까지 이해할 수 있어요.
zustand의 일반적인 사용법을 볼게요.
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
useStore는 모듈 레벨에서 한 번 생성돼요. 클라이언트에서는 상관없죠. 브라우저 탭 하나에 사용자 하나니까요.
근데 SSR에서는 얘기가 달라져요. Node.js 서버는 모듈을 한 번 로드하고 모든 요청에서 공유하거든요. 사용자 A의 요청이 전역 스토어에 데이터를 넣으면, 바로 다음에 들어온 사용자 B의 요청이 그 데이터를 볼 수 있어요. 인증 토큰이나 개인 정보가 다른 사람에게 새어나갈 수 있는 거죠.
물론 zustand도 createStore + Context로 해결할 수 있고, jotai도 Provider 스코프로 나눌 수 있어요. 근데 핵심은 그렇게 안 해도 코드가 돌아간다는 거예요. 기본 사용법이 위험한 패턴을 허용하고, 안전한 패턴은 추가 설정으로 가야 하거든요.
comwit은 다른 접근을 해요. 상태 인스턴스를 모듈 레벨이 아니라 Provider 안에서 생성해요.
<StateProvider models={[post, user, comment]}>
{children}
</StateProvider>
model()로 정의한 건 템플릿이지 인스턴스가 아니에요. "설계도"라고 보시면 돼요.
// 이건 "설계도"예요. 상태가 아닙니다.
export const post = model({
posts: query({ initialData: [], queryFn: () => api.post.findAll() }),
current: null,
})
실제 상태는 Provider가 마운트될 때 생성되고, 언마운트되면 사라져요. SSR에서 요청마다 Provider가 새로 마운트되니까, 상태 오염이 구조적으로 불가능해지는 거예요.
"조심해서 쓰면 된다"가 아니라 "잘못 쓸 수가 없다"에 가까운 거죠. 이 차이가 꽤 크다고 느꼈어요.
이제 아까 질문의 답이 나와요. 모듈에서 post를 직접 import하면 그건 설계도예요. 현재 Provider 안에서 생성된 실제 인스턴스를 가져오려면 state(post)를 통해야 해요.
class PostActions {
// ❌ 이러면 모듈 레벨의 설계도를 가져옴
// private model = post
// ✅ 현재 Provider의 실제 인스턴스를 가져옴
private model = state(post)
}
Spring의 의존성 주입(DI)과 비슷한 개념이에요. 직접 new로 만들지 않고, 컨테이너(Provider)가 관리하는 인스턴스를 주입받는 거죠. 다른 도메인의 상태도 state(user) 이렇게 바로 접근할 수 있고요.
전역 상태처럼 어디서든 접근할 수 있으면서도, 생명주기는 Provider에 묶여 있어요. 편의성과 안전성을 동시에 잡는 구조예요.
서버에서 받은 초기 데이터를 클라이언트 상태에 넣을 때는 silent()를 써요.
init(data: Post) {
silent(() => {
this.model.current = data
})
}
silent() 안의 상태 변경은 리렌더를 트리거하지 않아요. 서버 컴포넌트에서 받은 데이터를 안전하게 주입할 수 있는 거죠.
function PostDetail({ initialPost }: { initialPost: Post }) {
const { actions } = usePost((s) => ({ actions: s.actions }))
actions.init(initialPost) // useEffect 없이 직접 호출해도 안전해요
return <PostView />
}
데코레이터가 메서드 수준의 클린코드라면, 도메인 구조는 프로젝트 수준의 클린코드라고 할 수 있어요.
React 프로젝트를 처음 시작하면 보통 이렇게 나누잖아요.
src/
components/
hooks/
utils/
types/
api/
이 구조의 문제는, 기능을 하나 수정하려면 여러 폴더를 돌아다녀야 한다는 거예요. 게시글 기능을 고치려면 components/PostList.tsx, hooks/usePost.ts, types/post.ts, api/post.ts를 다 열어야 하죠.
도메인 단위로 나누면 관련된 모든 것이 한 폴더에 모여요.
state/
post/
types.ts ← 상태 + 액션 타입 (이게 계약서 역할이에요)
model.ts ← 초기 상태 + query 정의
actions/
crud.ts ← 생성, 수정, 삭제
load.ts ← 데이터 페칭
init.ts ← SSR 하이드레이션
index.ts ← 훅 + re-export
user/
types.ts
model.ts
actions/
index.ts
types.ts 하나를 열면 그 도메인이 뭘 하는지 전부 보여요. 어떤 상태를 가지고 있고, 어떤 액션이 가능한지. 이게 사람한테도 좋은데, LLM한테는 특히 효과가 큰 것 같아요. "댓글 기능 추가해줘" 했을 때 LLM이 타입 파일 하나만 읽으면 전체 맥락을 파악할 수 있거든요.
재밌는 건 데이터 페칭이 상태 모델 안에 녹아있다는 거예요. 모델에서 query()로 필드를 선언하면:
export const post = model({
posts: query({
initialData: [],
queryFn: () => api.post.findAll(),
}),
current: null,
})
액션에서는 .query()를 호출하기만 하면 돼요.
async loadPosts() {
await this.model.posts.query()
}
이 한 줄이 실행되면 isLoading, isFetching이 자동으로 true가 되고, 성공하면 isSuccess가 켜지고 data에 결과가 들어가요. 에러나면 isError랑 error가 세팅되고요. 별도로 로딩 상태를 관리하는 코드를 짤 필요가 없어요.
const { posts } = usePost((s) => ({ posts: s.posts }))
if (posts.isLoading) return <Skeleton />
if (posts.isError) return <e>{posts.error}</e>
return posts.data.map((p) => <PostCard key={p.id} {...p} />)
TanStack Query 써보신 분이면 staleTime, cacheTime, placeholderData 같은 옵션도 익숙하실 거예요. 비슷한 인터페이스를 상태 모델 안에서 바로 쓸 수 있다는 게 포인트입니다.
오늘 네 가지를 살펴봤어요.
상태와 액션을 클래스로 묶는 것. OOP적 상태관리로 데이터와 행동을 하나의 단위로 만들면, 관련 코드가 흩어지지 않아요.
데코레이터로 횡단 관심사를 분리하는 것. try-catch, 권한 체크, 디바운스 같은 부가 로직을 핵심 비즈니스 로직에서 떼어내면, 코드가 자기 자신을 설명하기 시작해요.
Provider 스코프로 SSR 안전성을 확보하는 것. 모듈 레벨 상태의 위험을 "조심해서 쓰기"가 아니라 "구조적으로 막기"로 해결하면, 실수할 여지 자체가 사라져요.
도메인 단위로 코드를 조직하는 것. 기능별로 관련 코드를 모아두면 사람도 LLM도 맥락 파악이 빨라지고, 변경에 강한 구조가 돼요.
제 경험상, 이런 구조적인 판단은 LLM이 대신해주기 어려운 영역이에요. 사람이 설계하고, LLM은 그 안에서 코드를 채우는 거죠. 이 감각을 가지고 있으면 이직 시장에서도 분명 차이가 나지 않을까 생각합니다.
comwit은 지금 1000개 넘는 프로젝트에서 돌아가고 있어요. 관심 있으시면 직접 코드를 한번 까보세요.
GitHub: https://github.com/meursyphus/comwit
Claude Code나 Cursor 쓰고 계시다면, LLM에게 아래 URL을 알려주고 "이걸 읽고 세팅해줘"라고 해보세요:
comwit.io/llm.txt
위에서 본 도메인 구조부터 데코레이터 패턴까지, 그대로 잡아줄 거예요.
공통 에러 핸들러나 공통 서버요청문의 생성자를 매번 생성하고 라이프 사이클에 집어넣어줬거든요... 요건 따라해볼만 하겠네요
상태가 어디에 종속적인지만 잘구분해 쓴다면요.
백엔드 동료가 매번 쓰던 스프링코드에서 보면서 왜 이 생각을 못했나 싶네용.