본문으로 건너뛰기

Clean Architecture in React

Alex Kondovalexkondov.com ↗

Clean Architecture in React

“The Full-Stack Tao” 책의 4장. React 애플리케이션을 작은 기능에서 시작해 점진적으로 구조화하는 과정을 실전 예제로 설명한다.

처음부터 완벽한 설계를 목표로 하지 않는다. 동작하는 초안을 만들고, 편집하듯 설계를 개선해나간다.


요구사항 파악

예제 요구사항

“사용자가 현재 활성 글쓰기 프롬프트를 읽고 답변할 수 있는 페이지. 자신이 답변을 제출하기 전까지는 다른 사람의 답변을 볼 수 없어야 한다.”

가정을 짓지 말 것

  • 가정(assumption)은 엔지니어링 문제의 근원
  • 질문을 통해 가정을 줄여야 한다
  • best-guess 엔지니어링informed decision-making 패러다임 전환
  • 질문 예시:
    • 데이터를 어떻게 가져올 것인가?
    • 비인증 사용자는 무엇을 보는가?
    • SEO는 중요한가?
    • 사용자가 답변을 수정/삭제할 수 있는가?
    • 이미지는 어디에 호스팅하는가?

첫 번째 초안 (First Draft)

  • 목표: 일단 동작하게 만들기. 구조/패턴은 나중에
  • 텍스트의 첫 번째 초안처럼 — 문법, 쉼표, 반복 단어 신경 쓰지 않는다
  • React 컨텍스트에서는 하나의 컴포넌트에 모든 것을 넣고 동작 확인
export default function App() {
  return (
    <div>
      <header>
        <nav>...</nav>
      </header>
      <main>...</main>
    </div>
  )
}
  • 아이디어 검증, UI 프로토타이핑, 데모 전 크런치 상황이라면 여기서 멈춰도 된다
  • 그 외의 경우엔, 동작하는 컴포넌트는 첫 번째 단계일 뿐이다

데이터 설계

데이터 조회 방식 논의

문제: 사용자가 이미 답변했는지 여부를 어떻게 알 수 있는가?

방식 문제점
브라우저에서 answers 필터링 페이지네이션 환경에서 불가; 보여선 안 되는 데이터가 브라우저에 노출
answers: null로 신호 전달 nullable 타입 체크 남발; Chesterton’s Fence 문제 발생 가능
answered: boolean 플래그 추가 명시적이고 안전한 설계 ✓

Chesterton’s Fence: 어떤 것을 왜 만들었는지 이해하지 못한 채 변경하면 예기치 않은 문제가 생긴다. null을 빈 배열로 바꾸는 것처럼.

const prompt = {
  title: 'What is the meaning of life?',
  answers: [],
  answered: false,
}

인터페이스 우선 설계 (Design the Interface First)

  • NoSQL(DynamoDB) 경험에서 얻은 교훈: access pattern을 먼저 설계하면 도메인 중심의 이해하기 쉬운 시스템이 나온다
  • UI(사용자 접점)를 먼저 설계하고, 그에 맞게 API를 설계한다

도메인 기반으로 만들 것 (Build for the Domain)

순수 REST 방식의 문제점:

/prompts
/prompts/:id
/prompts/:id/answers
  • 최소 2번의 연속 요청이 필요 → 사용자 대기 시간 증가
  • 클라이언트가 비즈니스 로직을 직접 처리해야 함
  • 이런 문제를 해결하려다 GraphQL 레이어가 추가됨 → 불필요한 복잡도

도메인 우선 방식:

GET /prompt  (사용자 식별자 포함)
  • 백엔드가 사용자 확인, 답변 여부 체크, 적절한 데이터 반환을 모두 처리
  • 백엔드는 무엇을 반환할지 결정, 프론트엔드는 받은 데이터를 어떻게 렌더링할지 결정 → 단일 책임 원칙

Purism Leaks Logic: API를 순수하고 제네릭하게 유지하면, 결정권이 클라이언트로 넘어간다. 클라이언트가 모든 비즈니스 로직을 알게 되는 설계는 나쁜 설계다.


컴포넌트 정리 (Tidying Up)

“리팩토링”이라는 단어는 부담스럽다. 설계는 개발하면서 점진적으로 개선해야 하며, 6개월 후에 한꺼번에 하는 것이 아니다.

Layout 분리

  • 여러 페이지에서 반복되는 헤더/내비게이션 구조 → Layout 컴포넌트 추출
export default function Layout({ children }) {
  return (
    <div>
      <header>
        <Navigation />
      </header>
      <main>{children}</main>
    </div>
  )
}
  • <header> 태그는 Layout의 책임, 내비게이션 로직은 <Navigation> 의 책임
  • 레이아웃은 하나일 필요 없다: MainWithAsideLayout, OneColumnLayout, AdminLayout 등 필요한 만큼

Page 컴포넌트 정리

  • JSX 내 조건문이 보이면 → 별도 컴포넌트 추출 고려
export default function PromptPage() {
  // ...
  return (
    <Layout>
      <h1>{prompt.title}</h1>
      {prompt.answered ? (
        <AnswersList answers={prompt.answers} />
      ) : (
        <AnswerForm />
      )}
    </Layout>
  )
}

데이터 페칭 레이어 분리

HTTP 클라이언트 추출

  • 컴포넌트는 fetch, axios, gRPC, WebSocket 등 전송 방식을 알 필요 없다
  • 데이터를 렌더링하는 것만이 컴포넌트의 책임
// prompt-client.ts
export function getActivePrompt() {
  return api.get(endpoints.activePrompt)
}

export function createAnswer(answer) {
  return api.post(endpoints.createAnswer, { answer })
}
  • 서버가 아직 준비되지 않았다면 클라이언트 파일에서 하드코딩 → 컴포넌트 설계에 영향 없음

데이터 페칭 라이브러리 활용

수동으로 관리하면 상태가 폭발적으로 늘어남:

const [prompt, setPrompt] = useState(emptyPrompt)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)

UI 상태 관리 로직의 상당 부분은 데이터 페칭과 관련되어 있다.

React Query 같은 라이브러리로 단순화:

const { data: prompt, isLoading, isError } = useQuery({
  queryKey: 'prompt',
  queryFn: promptClient.getActivePrompt,
  initialData: emptyPrompt,
})

커스텀 훅으로 도메인 로직 분리

컴포넌트의 이상적인 상태: 데이터를 받아 마크업을 반환하는 순수 함수

// PromptPage.tsx
export default function PromptPage() {
  const { prompt, handleSubmit } = usePrompt()

  return (
    <Layout>
      <h1>{prompt.title}</h1>
      {prompt.answered ? (
        <AnswersList answers={prompt.answers} />
      ) : (
        <AnswerForm handleSubmit={handleSubmit} />
      )}
    </Layout>
  )
}
// usePrompt.ts
export default function usePrompt() {
  const queryClient = useQueryClient()

  const prompt = useQuery({
    queryKey: queryKeys.prompt,
    queryFn: promptClient.getActivePrompt,
    initialData: emptyPrompt,
    select: formatPrompt,
  })

  const mutation = useMutation({
    mutationFn: promptClient.createAnswer,
    onSuccess: () => {
      queryClient.invalidateQueries(queryKeys.prompt)
    },
  })

  const handleSubmit = (answer) => {
    mutation.mutate(answer)
  }

  return { prompt, handleSubmit }
}

핵심: 이벤트(버튼 클릭, 폼 제출)는 UI의 관심사, 그 결과로 무슨 일이 일어나는지는 비즈니스 로직의 관심사


모듈 설계 원칙

Deep Module vs Shallow Module

구분 설명 예시
Deep Module 작은 API 표면, 내부에 많은 로직 숨김 usePrompt() → 내부에 쿼리/뮤테이션/핸들러 은닉
Shallow Module 큰 API 표면, 내부 로직 거의 없음 추상화 가치 낮음 → 인라인 고려
  • usePrompt는 빙산처럼: 외부에는 { prompt, handleSubmit }만 노출, 내부에 방대한 로직 은닉

하드코딩된 값 관리

Endpoints 분리

// endpoints.ts
export default {
  baseURL: 'https://example.com/api/v1',
  activePrompt: '/prompts',
  createAnswer: '/prompts',
}

// prompts-client.ts
const api = axios.createInstance(endpoints.baseURL)

export function getActivePrompt() {
  return api.get(endpoints.activePrompt)
}
  • 복제-붙여넣기 오류 방지 (예: /v1 누락 버그 2시간 디버깅)
  • 같은 값이 중복되어도 OK — 프로퍼티 이름이 각각 다른 의미를 부여

Query Keys 분리

// query-keys.ts
export default {
  prompt: 'prompt',
}

// usePrompt.ts
const prompt = useQuery({
  queryKey: queryKeys.prompt,
  // ...
})

// invalidate할 때도 동일
queryClient.invalidateQueries(queryKeys.prompt)

레이어 아키텍처 요약

graph TD
  A[PromptPage 컴포넌트] -->|호출| B[usePrompt 커스텀 훅]
  B -->|호출| C[promptClient HTTP 레이어]
  C -->|참조| D[endpoints.ts]
  B -->|참조| E[query-keys.ts]
  B -->|데이터 변환| F[formatPrompt 유틸]
  C -->|스키마 검증| G[promptSchema Zod]
레이어 파일 책임
렌더링 PromptPage.tsx, AnswersList.tsx, AnswerForm.tsx 데이터를 받아 마크업 반환
도메인 로직 usePrompt.ts 상태 관리, 데이터 변환, 이벤트 처리
전송 레이어 prompts-client.ts HTTP 요청, 스키마 검증
설정 endpoints.ts, query-keys.ts 하드코딩 값 중앙 관리

레이어를 나누는 이유: 각 레이어는 변경 빈도가 다르다. HTTP 핸들러는 거의 안 바뀌고, JSX는 자주 바뀌고, 도메인 로직은 그 중간이다.


변경에 대응하기

날짜 포맷 추가 예시

나쁜 예 — 컴포넌트에 도메인 로직 혼입:

// AnswersList.tsx ← 여기에 있으면 안 됨
import dayjs from 'dayjs'
<div>{dayjs(answer.createdAt).fromNow(true)}</div>

좋은 예 — 훅의 select에서 처리:

// usePrompt.ts
const prompt = useQuery({
  queryKey: queryKeys.prompt,
  queryFn: promptClient.getActivePrompt,
  initialData: emptyPrompt,
  select: formatPrompt, // 데이터 변환은 여기서
})
  • 컴포넌트는 answer.createdAt을 그냥 렌더링
  • 라이브러리 변경 시 formatPrompt 함수만 수정

유효성 검사 (Schema Validation)

신뢰 경계에서 검증하라

  • TypeScript 타입 캐스팅은 런타임 보호가 없다
  • 외부 소스(API 응답, 메시지 브로커, 파일)에서 데이터를 받을 때는 반드시 검증
// schemas.ts
export const promptSchema = z.object({
  title: z.string(),
  answers: z.array({
    text: z.string(),
    author: z.object({ name: z.string() }),
    createdAt: z.string().date(),
  }),
  answered: z.boolean(),
})

// prompts-client.ts
export function getActivePrompt() {
  const { data } = api.get(endpoints.activePrompt)
  return promptSchema.parse(data) // 런타임 검증
}

“가짜 안전에 안주하지 마라.” — TypeScript만으로는 런타임 오류를 막을 수 없다.

  • 검증은 오류의 발생 지점(전송 레이어) 에서 처리 → 컴포넌트와 훅은 신뢰할 수 있는 데이터만 받음

Empty States & Guard Clauses

중첩 조건문 처리

문제: 데이터 로딩 중 undefined 상태 추가 → 중첩 삼항 연산자 발생

{prompt ? (
  prompt.answered ? <AnswersList /> : <AnswerForm />
) : (
  <RandomInspirationalQuote />
)}

해결 1: 조건별 컴포넌트 추출

// PromptPage.tsx
{prompt ? <PromptContent prompt={prompt} /> : <RandomInspirationalQuote />}

해결 2: Guard Clause 패턴으로 else 제거

// PromptContent.tsx
export default function PromptContent({ prompt }) {
  if (!prompt.answered) {
    return <AnswerForm />
  }
  return <AnswersList answers={prompt.answers} />
}
  • Guard Clause: 오류/예외 상태를 먼저 체크하고 빠르게 반환 → 나머지 로직은 기본 들여쓰기 유지
  • 복잡도를 줄이는 가장 실용적인 도구 중 하나

핵심 원칙 정리

Evergreen 프로그래밍 원칙의 React 적용

일반 원칙 React 적용
레이어 분리 (Hexagonal Architecture) 컴포넌트 / 훅 / HTTP 클라이언트 분리
단일 책임 원칙 각 컴포넌트, 훅, 클라이언트가 하나의 역할만
함수 분리 (복잡한 조건문) JSX 조건 → 컴포넌트 추출
추상화 / 캡슐화 usePrompt 훅이 상세 구현 은닉

소프트웨어 설계는 실용적이어야 한다

  • 좋은 구조는 유지보수 가능성을 위한 것이지, 코드를 예쁘게 보이려는 것이 아님
  • 레이어를 나누는 이유: 다른 종류의 로직은 다른 속도로 변한다
    • HTTP 핸들러 → 변경 빈도 낮음
    • 도메인 로직 → 중간
    • JSX/마크업 → 변경 빈도 높음
  • 코드가 보기 좋은 것은 유지보수 가능성의 부산물