React 클라이언트가 Express 서버와 REST API로 통신하는 구조를 학습하기 위한 예제 앱입니다. 클라이언트는 클린 아키텍처(Clean Architecture) 를 적용하여 레이어별 관심사를 분리합니다.
npm run dev # 서버 + 클라이언트 동시 실행
npm run server # 서버만 (http://localhost:3002)
npm run client # 클라이언트만 (http://localhost:5173)react-example/
├── server/ # Express + SQLite 서버
│ └── index.js
└── client/src/ # React 클라이언트 (Clean Architecture)
├── domain/ # 도메인 레이어
│ ├── entities/ # Todo, Category, TodoStats
│ └── repositories/ # 리포지토리 인터페이스
├── application/ # 애플리케이션 레이어
│ └── usecases/ # 13개 유스케이스
├── infrastructure/ # 인프라 레이어
│ ├── api/ # REST API 구현
│ ├── localstorage/ # LocalStorage 구현
│ └── di/ # 의존성 주입 컨테이너
└── presentation/ # 프레젠테이션 레이어
├── hooks/ # 커스텀 훅
└── components/ # React 컴포넌트
핵심 원칙: 의존성이 항상 안쪽(도메인) 방향으로만 흐른다.
Presentation → Application → Domain ← Infrastructure
가장 안쪽 레이어로, 외부 라이브러리나 프레임워크에 의존하지 않습니다.
domain/entities/Todo.js
export class Todo {
constructor({ id, text, completed, category_id, category_name, ... }) { ... }
toggleCompleted() { ... } // 새 인스턴스 반환 (불변성)
updateText(newText) { ... }
assignCategory(categoryId, name, color) { ... }
matchesSearch(query) { ... } // 도메인 로직: 검색 매칭
}- 앱의 핵심 데이터 구조와 비즈니스 규칙을 포함합니다.
- 불변성(Immutability): 상태를 직접 변경하지 않고 새 인스턴스를 반환합니다.
matchesSearch()처럼 엔티티 자체에 도메인 로직을 포함할 수 있습니다.
domain/entities/Category.js — 두 번째 엔티티
export class Category {
constructor({ id, name, color, created_at }) { ... }
}- 엔티티가 여러 개 존재하면 리포지토리도 여러 개 필요해지고, 유스케이스에서 조합하는 패턴이 자연스럽게 등장합니다.
domain/entities/TodoStats.js — 값 객체 (Value Object)
export class TodoStats {
constructor({ total, completed, pending, completionRate, byCategory }) { ... }
}- 식별자(id)가 없는 값 객체로, 통계 데이터를 캡슐화합니다.
domain/repositories/TodoRepository.js
export class TodoRepository {
async getAll(filters) { throw new Error('Not implemented'); }
async getStats() { throw new Error('Not implemented'); }
async create(text, categoryId) { ... }
async update(id, data) { ... }
async delete(id) { ... }
async completeAll() { ... }
async deleteCompleted() { ... }
}domain/repositories/CategoryRepository.js
export class CategoryRepository {
async getAll() { ... }
async create(name, color) { ... }
async delete(id) { ... }
}- 데이터 접근의 계약(Contract) 만 정의합니다.
- 실제 구현은 Infrastructure 레이어에서 합니다.
- 리포지토리가 2개가 됨으로써 유스케이스에서 서로 다른 리포지토리를 조합하는 패턴을 볼 수 있습니다.
유스케이스(Use Case) 를 정의합니다. 하나의 유스케이스 = 하나의 사용자 행위입니다.
| 유스케이스 | 역할 | 주입받는 리포지토리 |
|---|---|---|
AddTodo |
새 할일 추가 + 입력 검증 | TodoRepository |
GetTodos |
필터 조건으로 목록 조회 | TodoRepository |
ToggleTodo |
완료/미완료 토글 | TodoRepository |
UpdateTodo |
텍스트 수정 + 검증 | TodoRepository |
DeleteTodo |
삭제 | TodoRepository |
CompleteAllTodos — 일괄 완료 처리
export class CompleteAllTodos {
constructor(todoRepository) { this.todoRepository = todoRepository; }
async execute() {
return this.todoRepository.completeAll();
}
}- 단순 CRUD를 넘어 "모든 미완료 항목을 완료로 변경" 이라는 비즈니스 규칙을 캡슐화합니다.
DeleteCompletedTodos — 완료된 항목 일괄 삭제
- "완료된 것만 삭제"라는 조건부 로직을 유스케이스로 분리합니다.
GetTodoStats — 통계 조회
- 완료율, 카테고리별 분포 등 집계 로직을 유스케이스로 표현합니다.
SearchTodos — 서버 사이드 검색
export class SearchTodos {
constructor(todoRepository) { ... }
async execute(query, filters = {}) {
return this.todoRepository.getAll({ ...filters, search: query });
}
}- 검색어와 기존 필터를 조합하는 것이 이 유스케이스의 역할입니다.
GetTodosByCategory — 카테고리별 조회
export class GetTodosByCategory {
constructor(todoRepository) { ... }
async execute(categoryId) {
return this.todoRepository.getAll({ category_id: categoryId });
}
}- 두 도메인(Todo + Category)을 연결하는 유스케이스입니다.
| 유스케이스 | 역할 | 주입받는 리포지토리 |
|---|---|---|
GetCategories |
카테고리 목록 조회 | CategoryRepository |
AddCategory |
카테고리 생성 + 검증 | CategoryRepository |
DeleteCategory |
카테고리 삭제 | CategoryRepository |
- TodoRepository와 별도의 CategoryRepository를 주입받는 것에 주목하세요.
- 각 유스케이스가 필요한 리포지토리만 의존합니다.
도메인에서 정의한 인터페이스를 실제로 구현하는 레이어입니다.
TodoApiRepository.js — REST API로 서버와 통신
export class TodoApiRepository {
async getAll(filters = {}) {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
// ... 필터 파라미터를 쿼리스트링으로 변환
const res = await fetch(url);
return data.map(item => new Todo(item)); // JSON → 도메인 엔티티 변환
}
}CategoryApiRepository.js — 카테고리 CRUD API
TodoLocalStorageRepository.js — 브라우저 저장소 사용
export class TodoLocalStorageRepository {
_read() { return JSON.parse(localStorage.getItem('todos') || '[]'); }
_write(items) { localStorage.setItem('todos', JSON.stringify(items)); }
async getAll(filters = {}) {
let items = this._read();
if (filters.search) { /* 클라이언트 사이드 필터링 */ }
return items.map(i => new Todo(i));
}
// ... 동일한 인터페이스의 모든 메서드 구현
}핵심 포인트: 리포지토리 패턴의 가치
두 구현체는 완전히 동일한 인터페이스를 가집니다. DI 컨테이너에서 한 줄만 변경하면 서버 없이도 앱이 동작합니다:
// container.js
// 서버 통신 모드 (기본)
const todoRepository = new TodoApiRepository();
// LocalStorage 모드 (서버 불필요) — 이 줄로 교체
// const todoRepository = new TodoLocalStorageRepository();유스케이스, 컴포넌트, 훅은 아무것도 변경하지 않아도 됩니다. 이것이 리포지토리 패턴과 의존성 주입의 핵심 가치입니다.
const todoRepository = new TodoApiRepository();
const categoryRepository = new CategoryApiRepository();
export const useCases = {
getTodos: new GetTodos(todoRepository),
addTodo: new AddTodo(todoRepository),
searchTodos: new SearchTodos(todoRepository),
getCategories: new GetCategories(categoryRepository),
// ...
};- 의존성 주입(DI): 유스케이스가 구체적 구현체를 직접 생성하지 않고, 외부에서 주입받습니다.
- 테스트 시 Mock 리포지토리를 주입할 수 있습니다.
- 리포지토리 구현체 교체가 이 파일 한 곳에서만 일어납니다.
사용자에게 보이는 UI를 담당합니다.
useTodos — 메인 상태 관리
export function useTodos() {
const [filter, setFilter] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState(null);
// 필터가 바뀔 때마다 유스케이스를 다시 호출
const loadTodos = useCallback(async () => {
const filters = {};
if (searchQuery) filters.search = searchQuery;
if (categoryFilter) filters.category_id = categoryFilter;
const data = await useCases.getTodos.execute(filters);
setTodos(data);
}, [searchQuery, categoryFilter, filter]);
}useCategories — 카테고리 상태 관리
useTodoStats — 통계 데이터 관리
- 각 훅이 관련된 유스케이스만 호출하고, 관심사를 분리합니다.
| 컴포넌트 | 역할 |
|---|---|
SearchBar |
검색 입력 |
TodoForm |
할일 추가 (카테고리 선택 포함) |
FilterBar |
상태 필터 (All/Active/Completed) + 카테고리 필터 |
TodoList |
목록 렌더링 |
TodoItem |
개별 항목 (체크, 수정, 삭제, 카테고리 배지) |
BulkActions |
일괄 완료 / 완료 항목 삭제 |
TodoStats |
통계 대시보드 |
| Method | Endpoint | 설명 |
|---|---|---|
| GET | /api/todos?search=&category_id=&completed= |
목록 조회 (검색, 필터) |
| GET | /api/todos/stats |
통계 (완료율, 카테고리별 수) |
| POST | /api/todos |
새 할일 생성 |
| PATCH | /api/todos/:id |
수정 |
| DELETE | /api/todos/:id |
삭제 |
| PATCH | /api/todos/bulk/complete-all |
전체 완료 |
| DELETE | /api/todos/bulk/delete-completed |
완료 항목 일괄 삭제 |
| GET | /api/categories |
카테고리 목록 |
| POST | /api/categories |
카테고리 생성 |
| DELETE | /api/categories/:id |
카테고리 삭제 |
- 파일 기반 DB (
server/todos.db)로 별도 설치 불필요 - WAL 모드: 읽기/쓰기 동시 처리 성능 향상
todos↔categories간 외래 키(Foreign Key) 관계- 서버 시작 시 테이블/기본 카테고리 자동 생성
| 패턴 | 적용 위치 | 설명 |
|---|---|---|
| Clean Architecture | 클라이언트 전체 | 레이어별 관심사 분리, 안쪽 방향 의존성 |
| Repository Pattern | domain ↔ infrastructure | 데이터 접근을 추상화하여 구현을 교체 가능하게 함 |
| Dependency Injection | di/container.js | 의존성을 외부에서 주입하여 결합도를 낮춤 |
| Use Case Pattern | application/usecases | 비즈니스 로직을 독립적 단위로 캡슐화 |
| Custom Hook | presentation/hooks | React 상태 로직을 컴포넌트에서 분리 |
| REST API | 서버 ↔ 클라이언트 | HTTP 메서드로 자원을 조작 |
| Immutable Entity | domain/entities | 상태 변경 시 새 인스턴스를 반환하여 부수효과 방지 |
| Value Object | TodoStats | 식별자 없이 값으로만 의미를 갖는 객체 |
| Interface Segregation | 리포지토리 2개 분리 | 각 유스케이스가 필요한 인터페이스에만 의존 |
- 할일 추가 / 수정 (더블클릭) / 삭제 / 완료 토글
- 카테고리 지정 (Work, Personal, Shopping + 커스텀)
- 검색 (서버 사이드)
- 필터링 (All / Active / Completed + 카테고리별)
- 일괄 완료 / 완료 항목 일괄 삭제
- 통계 대시보드 (완료율, 카테고리별 분포)
- 리포지토리 교체 (API ↔ LocalStorage)
- Client: React, Vite
- Server: Express, better-sqlite3
- Database: SQLite
- 통신: REST API (fetch)