본문으로 건너뛰기

5~6장: 구현 설계 · 클래스 설계

한눈에

구현 설계와 클래스 설계를 붙여 읽으면, "무엇을 감추고 무엇을 드러낼 것인가"라는 한 질문이 보여요.

📝 묶음 요약

5~6장은 "코드의 경계를 어디에 그을 것인가"를 두 각도에서 다루는 묶음이에요. 5장은 설계 원칙(복잡도 관리·정보 은닉·반복 설계)을 제시하고, 6장은 그 원칙을 구체 단위 — 원서에서는 클래스, FE에서는 훅과 모듈 — 로 옮기는 방법을 보여줘요.

  • 5장 — "복잡도 관리가 소프트웨어의 최우선 기술적 과제"라는 전제는 컴포넌트·상태·빌드가 얽힌 2026년 FE에서 더 절실해요.
  • 5장 — 정보 은닉·반복 설계·변경 가능성 격리 같은 원칙은 프레임워크가 레일을 깐 뒤에도 "어떤 결정을 어디에 숨길지"를 고르는 판단으로 살아남아요.
  • 6장 — ADT·캡슐화·응집도·결합도는 그대로 유효하지만, 적용 단위가 class에서 hook/component로 번역되면서 의미가 달라져요.
  • 6장 — 훅은 클래스보다 경계가 느슨해서 원칙만으로는 지키기 어렵고, TypeScript 반환 타입 같은 강제 장치가 함께 가야 해요.

큰 그림을 훑었으니, 각 장의 판정과 체크, 그리고 실제 코드로 들어가 볼게요.

Chapter 5. Design in Construction

📖 원문 핵심

설계는 사악한 문제(Wicked Problem)다: 소프트웨어 설계는 풀어봐야 비로소 명확히 정의할 수 있는 문제로, 틀린 방향을 가보고 되돌아오는 과정 자체가 설계의 본질이에요.

소프트웨어의 제1 기술 명령은 복잡성 관리다: 프로젝트가 기술적 이유로 실패할 때 그 근본 원인은 대부분 통제되지 않은 복잡성이며, 좋은 설계의 목표는 한 번에 다뤄야 할 복잡성의 양을 최소화하는 것이에요.

정보 은닉(Information Hiding): 각 클래스는 설계·구현 결정을 다른 클래스로부터 숨겨야 하며, 이 원칙이야말로 복잡성을 감추는 가장 강력한 휴리스틱이에요.

느슨한 결합(Loose Coupling): 클래스 간 연결을 최소화하도록 설계해야 하며, 연결이 늘어날수록 한 부분의 변경이 전체에 미치는 영향이 커져 유지보수 비용이 올라가요.

설계 계층(Levels of Design): 소프트웨어 설계는 시스템 전체 → 서브시스템/패키지 → 클래스 → 루틴 → 루틴 내부의 5개 수준에서 이뤄지며, 각 수준에서 독립적으로 복잡성을 통제해야 해요.

✅ 체크리스트

  • "이 훅/컴포넌트가 숨기는 비밀이 무엇인가?"라는 질문에 한 문장으로 답할 수 있나요? (정보 은닉)
  • API 응답 구조나 외부 라이브러리 인터페이스처럼 변경 가능성이 높은 결정이 한 곳에 격리되어 있나요?
  • 컴포넌트 간 순환 import가 없나요?
  • 컴포넌트/훅 구조를 최소 2가지 이상 시도하고, 첫 번째가 아닌 더 나은 안을 선택했나요?
  • 설계가 lean한가요? 모든 추상화 레이어에 존재 이유가 있는지, "나중에 필요할 것 같아서" 만든 레이어는 없는지 살펴봤나요?

다섯 가지 질문을 한 화면에서 어떻게 풀어내는지, PaymentsPage 리팩터로 확인해 볼게요.

💻 React/TS 코드 예제

심화 미션 — 설계가 아쉬운 코드 분석 + 개선 (PaymentsPage)

Before ❌
function PaymentsPage() {
const [data, setData] = useState([]);

useEffect(() => {
fetch('/api/payments')
.then((r) => r.json())
.then((list) => {
const filtered = list.filter((p) => p.status === 'paid');
const sorted = filtered.sort((a, b) => b.createdAt - a.createdAt);
setData(sorted);
});
}, []);

return (
<div>
{data.map((p) => (
<div key={p.id}>{p.amount}</div>
))}
</div>
);
}

Before의 문제

  • 관심사 혼재: 하나의 useEffect 안에 API 호출, 필터링, 정렬이 전부 섞여 있다. "어디서 데이터를 가져오는가"와 "어떤 조건으로 가공하는가"가 구분되지 않는다.
  • 타입 부재: useState([])는 any[]이며, .status, .createdAt, .id, .amount 중 어느 하나라도 오타가 나면 런타임에서야 발견된다.
  • 테스트 불가: fetch가 컴포넌트 안에 하드코딩되어 있어서, 필터/정렬 로직만 단독으로 테스트할 수 없다.
  • 변경 취약: "환불 내역도 보여달라"는 요구사항이 오면 useEffect 콜백 내부를 직접 수정해야 한다.
After ✅
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

type PaymentStatus = 'paid' | 'refunded' | 'failed';

interface Payment {
id: string;
amount: number;
status: PaymentStatus;
createdAt: number;
}

// ━━ API 레이어: 데이터 패칭만 담당 ━━━━━━━━━

async function fetchPayments(): Promise<Payment[]> {
const res = await fetch('/api/payments');
return res.json();
}

// ━━ 도메인 로직: 필터·정렬은 순수 함수 ━━━━

function filterByStatus(payments: Payment[], status: PaymentStatus): Payment[] {
return payments.filter((p) => p.status === status);
}

function sortByLatest(payments: Payment[]): Payment[] {
return [...payments].sort((a, b) => b.createdAt - a.createdAt);
}

// ━━ 데이터 훅: 패칭 + 가공 조합 ━━━━━━━━━━━

function usePaidPayments() {
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
setLoading(true);
fetchPayments()
.then((list) => {
const paid = filterByStatus(list, 'paid');
const sorted = sortByLatest(paid);
setPayments(sorted);
})
.finally(() => setLoading(false));
}, []);

return { payments, loading };
}

// ━━ UI: 데이터를 받아서 그리기만 ━━━━━━━━━━━

function PaymentsPage() {
const { payments, loading } = usePaidPayments();

if (loading) return <div>Loading...</div>;

return (
<div>
{payments.map((payment) => (
<div key={payment.id}>{payment.amount.toLocaleString()}</div>
))}
</div>
);
}

개선 포인트

  • API(fetchPayments), 도메인 로직(filterByStatus, sortByLatest), 조합(usePaidPayments), UI(PaymentsPage)가 각각 독립적.
  • filterByStatus와 sortByLatest는 순수 함수라 mock 없이 바로 단위 테스트 가능.
  • "환불 내역도 보여달라" → filterByStatus(list, "refunded")를 추가하거나, 필터 조건을 파라미터로 바꾸면 됨. 기존 함수는 건드리지 않음.

😈 Devil's Advocate

Chapter 6. Working Classes

📖 원문 핵심

추상 데이터 타입(ADT): 데이터와 그 데이터를 조작하는 연산을 하나의 단위로 묶어, 내부 구현을 숨기고 의미 있는 인터페이스만 외부에 노출하는 개념이에요.

일관된 추상화 수준: 한 클래스 인터페이스 안에 employee 수준 루틴과 list 수준 루틴이 섞이면 추상화가 무너지므로, 모든 퍼블릭 루틴은 동일한 추상화 수준을 유지해야 해요.

캡슐화: 추상화가 복잡성을 무시할 수 있게 해주는 모델을 제공한다면, 캡슐화는 구현 세부사항을 강제로 숨겨 아예 들여다볼 수 없게 막아주는 더 강한 개념이에요.

포함("has a" 관계): 클래스가 다른 객체를 멤버로 가지는 포함은 상속보다 덜 화려하지만 오류 가능성도 낮으며, 객체지향 프로그래밍의 핵심 작업 도구예요.

리스코프 치환 원칙(LSP): 파생 클래스는 기반 클래스 인터페이스를 통해 사용자가 차이를 모르고도 사용할 수 있어야 하며, 이를 위반하는 상속은 복잡성을 줄이는 것이 아니라 오히려 늘려요.

✅ 체크리스트

  • 훅/컴포넌트의 인터페이스(props, 반환값)가 하나의 일관된 추상화를 제공하나요? 도메인 수준과 UI 구현 수준이 섞여있지는 않나요?
  • 훅의 반환값들 사이에 논리적 연결이 있나요? 연결이 없다면 여러 개의 ADT가 하나에 뒤섞인 건 아닌지 봤나요?
  • 컴포넌트가 다른 컴포넌트/훅의 내부 구현을 알아야만 동작하는 곳은 없나요? (의미적 결합)
  • 훅/컴포넌트를 분리할 때 "코드 줄 수"가 아니라 "책임의 수"를 기준으로 판단하고 있나요?
  • 상속(extends) 대신 합성(composition)을 기본 전략으로 쓰고 있나요?

클래스를 훅과 모듈로 번역하면 어떤 모양이 나오는지, 네 개의 Before/After로 따라가 볼게요.

💻 React/TS 코드 예제

기본 미션 1 — 책임이 섞인 서비스 코드 분리 (ImageUploadService)

Before ❌
class ImageUploadService {
async upload(file) {
const formData = new FormData();
formData.append('file', file);

const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});

const data = await res.json();

logger.log('upload success');

alert('이미지 업로드 완료');

return data.url;
}
}

Before의 책임 섞임

책임코드 위치레이어
HTTP 요청 (FormData 구성, fetch)upload 메서드 내부API / 인프라
로깅logger.log(...)인프라
사용자 알림alert(...)UI
에러 처리없음 (실패 시 무시)

alert는 UI 레이어에 속한다. 서비스 코드가 UI를 직접 호출하면, 같은 업로드 로직을 토스트 알림으로 바꾸거나 서버 사이드에서 재사용할 때 alert가 터진다.

After ✅
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

interface UploadResult {
url: string;
}

interface Logger {
log(message: string): void;
}

interface Notifier {
success(message: string): void;
}

// ━━ API 레이어: 업로드 요청만 담당 ━━━━━━━━━

async function uploadImage(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);

const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});

if (!res.ok) throw new Error(`Upload failed: ${res.status}`);

return res.json();
}

// ━━ 기본 의존성 구현 ━━━━━━━━━━━━━━━━━━━━━━━

const consoleLogger: Logger = {
log: (message) => console.log(message),
};

const alertNotifier: Notifier = {
success: (message) => alert(message),
};

// ━━ 커스텀 훅: 상태 관리 + 조합 ━━━━━━━━━━━

interface UseImageUploadOptions {
logger?: Logger;
notifier?: Notifier;
}

function useImageUpload(options?: UseImageUploadOptions) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);

const logger = options?.logger ?? consoleLogger;
const notifier = options?.notifier ?? alertNotifier;

const upload = async (file: File): Promise<string | null> => {
setUploading(true);
setError(null);

try {
const result = await uploadImage(file);
logger.log('upload success');
notifier.success('이미지 업로드 완료');
return result.url;
} catch (e) {
const message = e instanceof Error ? e.message : '알 수 없는 오류';
setError(message);
return null;
} finally {
setUploading(false);
}
};

return { upload, uploading, error };
}

개선 포인트

  • API 호출(uploadImage), 로깅(Logger), 알림(Notifier)이 각각 독립. 알림을 alert → 토스트로 바꾸려면 Notifier 구현체만 교체.
  • 업로드 상태(uploading, error)가 훅에서 관리되어 UI가 로딩/에러 상태를 자연스럽게 표현 가능.
  • 테스트 시 logger와 notifier에 mock을 넘기면 alert 없이 동작 검증 가능.

심화 미션 1 — 정보 은닉 설계 (editorState)

Before ❌
const editorState = {
content: '',
lastSavedAt: null,
};

function updateContent(text) {
editorState.content = text;
}

function markSaved() {
editorState.lastSavedAt = new Date();
}

Before의 문제

  • 직접 변이 가능: 어디서든 editorState.content = "해킹"처럼 함수를 거치지 않고 상태를 바꿀 수 있다. 변경 경로를 추적할 수 없으므로, "누가 상태를 바꿨는지" 디버깅이 불가능하다.
  • 파생 상태 누락: "수정되었는데 아직 저장 안 됨(dirty)" 같은 파생 상태를 외부에서 매번 직접 비교해야 한다.
  • 변경 알림 없음: 상태가 바뀌어도 UI가 알 수 없다. React와 연결하려면 결국 별도의 연결 코드가 필요.
After ✅
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

interface EditorSnapshot {
readonly content: string;
readonly lastSavedAt: Date | null;
readonly isDirty: boolean;
}

type EditorListener = (snapshot: EditorSnapshot) => void;

// ━━ 캡슐화된 에디터 상태 ━━━━━━━━━━━━━━━━━━

function createEditorStore() {
let content = '';
let lastSavedAt: Date | null = null;
let savedContent = '';
const listeners: Set<EditorListener> = new Set();

function getSnapshot(): EditorSnapshot {
return {
content,
lastSavedAt,
isDirty: content !== savedContent,
};
}

function notify() {
const snapshot = getSnapshot();
listeners.forEach((fn) => fn(snapshot));
}

return {
getSnapshot,

subscribe(listener: EditorListener) {
listeners.add(listener);
return () => listeners.delete(listener);
},

updateContent(text: string) {
content = text;
notify();
},

markSaved() {
lastSavedAt = new Date();
savedContent = content;
notify();
},
};
}

// 사용
const editorStore = createEditorStore();

개선 포인트

  • 클로저로 캡슐화: content, lastSavedAt, savedContent는 클로저 안에 갇혀 있어 외부에서 직접 접근 불가. 반드시 updateContent(), markSaved()를 통해서만 변경 가능.
  • 읽기 전용 스냅샷: getSnapshot()이 readonly 프로퍼티 객체를 반환하여, 외부에서 반환값을 수정해도 내부 상태에 영향 없음.
  • 파생 상태 내장: isDirty가 스냅샷에 포함되어, 외부에서 "현재 내용 ≠ 저장된 내용"을 직접 비교할 필요 없음.
  • 구독 패턴: subscribe로 변경 알림을 받을 수 있어 React의 useSyncExternalStore와 바로 연결 가능.

심화 미션 2 — 클래스 응집도 판단 (EditorManager vs 분리 구조)

Before ❌
class EditorManager {
saveDocument() {}
autoSave() {}
sendAnalytics() {}
showNotification() {}
}
After ✅
// ━━ 문서 저장: 하나의 관심사 ━━━━━━━━━━━━━━

class DocumentService {
saveDocument() {
/* ... */
}
autoSave() {
/* ... */
}
}

// ━━ 분석 이벤트: 하나의 관심사 ━━━━━━━━━━━━

class AnalyticsService {
sendAnalytics() {
/* ... */
}
}

// ━━ 사용자 알림: 하나의 관심사 ━━━━━━━━━━━━

class NotificationService {
showNotification() {
/* ... */
}
}

응집도 비교

관점EditorManager (Before)분리 구조 (After)
응집도낮음 — 저장, 분석, 알림이 한 클래스에 혼재높음 — 각 클래스가 하나의 관심사만 담당
변경 영향알림 로직을 바꾸면 저장 로직도 같은 파일에서 수정 위험알림을 바꿔도 DocumentService는 영향 없음
테스트알림 테스트를 위해 EditorManager 전체를 인스턴스화NotificationService만 단독 테스트 가능
의존성클래스가 커질수록 내부 의존성이 보이지 않게 엉킴클래스 간 의존이 명시적 (필요할 때만 주입)

프론트엔드에서의 선택: 클래스 대신 훅/모듈

실제 React 프로젝트에서는 위 분리를 클래스가 아닌 다음 구조로 구현하는 것이 자연스럽다.

After ✅
// ━━ 커스텀 훅: 상태 + 사이드이펙트 ━━━━━━━

function useDocumentSave(docId: string) {
const [saving, setSaving] = useState(false);

const save = async (content: string) => {
setSaving(true);
await saveDocument(docId, content);
setSaving(false);
};

return { save, saving };
}

// ━━ 서비스 모듈: 상태 없는 로직 ━━━━━━━━━━━

// analytics.ts
export function trackEvent(name: string, data: Record<string, unknown>) {
/* ... */
}

// ━━ 유틸 함수: 순수 변환 ━━━━━━━━━━━━━━━━━━

// format.ts
export function formatDocTitle(title: string, maxLen: number): string {
return title.length > maxLen ? `${title.slice(0, maxLen)}` : title;
}

구조 비교

구조용도장점
커스텀 훅상태 + 사이드이펙트가 함께 필요할 때 (useDocumentSave)React 생명주기와 자연스럽게 연결, 상태를 컴포넌트에 바인딩
서비스 모듈상태 없이 외부 호출만 할 때 (analytics.ts)프레임워크 무관, 어디서든 import해서 사용 가능
유틸 함수순수 변환 로직 (format.ts)테스트가 가장 쉬움, 의존성 없음

클래스가 유리한 경우는 장기 생명주기 + 내부 상태 + 복잡한 초기화/정리가 동시에 필요할 때다. 예를 들어 WebSocket 연결 관리, Canvas/WebGL 렌더러, 복잡한 상태 머신 등은 클래스의 constructor/destroy 패턴이 훅의 useEffect cleanup보다 명확할 수 있다.

😈 Devil's Advocate

코드는 결론이 아니에요. 같은 주제를 7명이 어떻게 겪고 결정했는지 이어서 들어 볼게요.

💬 토론 질문 & 멤버 의견

질문 1. 구현 과정에서 설계를 바꾸게 되는 대표적인 상황은? 다시 나눌 때의 기준은 "감"이 아니라 무엇이어야 하나?

Alice2년차 프론트엔드 개발자 (F-pretence)
초기에 특정 기능에 맞춰 구현했지만 기획이 바뀌면서 유사한 기능이 여러 곳에서 재사용되어야 할 때 공통 컴포넌트/공통 로직으로 분리하는 방향으로 설계 수정. 기준은 UI 역할 / 비즈니스 로직 / 데이터 관리로 책임을 명확히 나누는 것. 공통 컴포넌트나 유틸을 만들 때는 도메인 지식이 들어가지 않도록 설계함.
zinii클로드에게 직장을 빼앗기게 생긴 고꼬마 개발자
기획이 바뀌거나 UI가 크게 변경될 때. 데이터 기준으로 구조를 다시 나눔 — 주는 데이터와 받는 데이터에 따라 화면에 보여야 할/숨겨야 할 요소가 달라지기 때문.
Leo아침 밥 안먹는 4년차 프론트엔드 개발자
세 가지 상황에서 다시 나눔. ① 한 번에 인지해야 하는 정보의 양이 많아졌다고 느낄 때 ② 요구사항 변경에 따라 관심사가 많아졌을 때 ③ 중복 코드가 많아졌을 때. 단, DRY를 지킬 때 가장 중요한 것은 섣부른 추상화를 피하는 것 — 거짓된 중복, 우발적 중복에 속지 않기. 구조를 다시 나눌 땐 역할과 책임 기준으로.

질문 2. 결제 내역 목록 화면(조회/검색/필터/정렬/페이지네이션)을 어떤 단위로 코드를 나눌 것인가? 각자 선호하는 분리 방식은?

Alice2년차 프론트엔드 개발자 (F-pretence)
검색/필터/정렬/페이지네이션은 공통 목록 기능이라 특정 도메인에 결합되지 않도록 UI와 기능을 분리. 이 네 가지는 서로 영향을 주는 상태이기 때문에 하나의 상태 관리 훅으로 묶음. 도메인 지식이 포함된 로직은 상위 레벨에서 관리.
zinii클로드에게 직장을 빼앗기게 생긴 고꼬마 개발자
어렵게 느껴짐 — 연관 항목들이 서로 엮여있으니까. 데이터 단위로 분리하는 게 맞을 것 같음.
Leo아침 밥 안먹는 4년차 프론트엔드 개발자
네 단위로 나눔. ① UI를 그리는 코드 ② UI 관련 로직(상태 필터, 정렬, 페이지네이션) ③ 유저 행위(검색) ④ 서버 API 호출(결제 목록 조회, 검색).

의견은 저마다 다르지만, 두 장이 한 흐름으로 이어진다는 감각은 공유돼요.