12.over-engineering
과잉 설계 5종: 너무 많이 하면 생기는 일
"완벽한 설계"를 추구하다 아무것도 못 만드는 사람들
지금까지 다뤘던 안티패턴들은 대부분 "설계를 안 해서" 또는 "잘못 해서" 생기는 문제였다. 이번에는 정반대다. 설계를 너무 많이 해서 생기는 문제. 아이러니하게도, 좋은 설계를 추구하는 개발자일수록 더 빠지기 쉬운 함정이다.
"나중에 확장할 수 있게 해두자", "혹시 필요할 수도 있으니까", "좀 더 일반적으로 만들어두면..."이라는 말이 반복되기 시작하면 위험 신호임. 이번 편에서는 과잉 설계가 낳는 다섯 가지 안티패턴을 한번에 살펴본다. 각각은 짧지만, 모아 놓으면 하나의 큰 그림이 보인다.
1. Swiss Army Knife (스위스 아미 나이프)
정의
인터페이스나 클래스가 너무 많은 기능을 노출하는 패턴. God Object와 비슷하지만, Swiss Army Knife는 인터페이스 설계 관점의 문제다. 사용자가 어떤 메서드를 써야 하는지 알 수 없게 만든다.
God Object가 "내부적으로 너무 많은 걸 하는" 문제라면, Swiss Army Knife는 "외부에 너무 많은 걸 노출하는" 문제다. 스위스 아미 나이프에 칼, 가위, 드라이버, 코르크 따개, 톱, 캔 오프너가 다 달려있는 것처럼. 편리해 보이지만 정작 쓰려고 하면 "이 중에 뭘 써야 하지?"가 된다.
// "만능" 데이터 처리 인터페이스
interface DataProcessor {
// 읽기
readFromFile(path: string): Promise<Data>;
readFromURL(url: string): Promise<Data>;
readFromStream(stream: ReadableStream): Promise<Data>;
readFromBuffer(buffer: Buffer): Promise<Data>;
readFromString(str: string): Data;
// 변환
toJSON(): string;
toXML(): string;
toCSV(): string;
toYAML(): string;
toProtobuf(): Buffer;
toMsgPack(): Buffer;
// 검증
validate(schema: Schema): boolean;
validateStrict(schema: Schema): ValidationResult;
sanitize(rules: SanitizeRules): Data;
// 암호화
encrypt(key: string, algorithm?: string): Buffer;
decrypt(key: string, algorithm?: string): Data;
hash(algorithm?: string): string;
sign(privateKey: string): string;
// 압축
compress(format?: "gzip" | "brotli" | "zstd"): Buffer;
decompress(format?: "gzip" | "brotli" | "zstd"): Data;
// 전송
sendToAPI(endpoint: string, options?: RequestOptions): Promise<Response>;
sendToQueue(queueName: string): Promise<void>;
sendToWebSocket(ws: WebSocket): void;
// 기타
clone(): Data;
diff(other: Data): DiffResult;
merge(other: Data): Data;
transform(fn: (data: Data) => Data): Data;
}
이 인터페이스를 처음 본 개발자는 뭘 할 거냐면, 문서를 찾아볼 거다. "파일 하나 읽고 싶은데 이 30개 메서드 중에 뭘 써야 하지?" 그리고 이 인터페이스를 구현하는 클래스를 만드는 개발자는? 30개 메서드를 전부 구현해야 한다. 그중 실제로 쓰이는 건 5개 정도일 텐데.
해결법: 인터페이스 분리 원칙(ISP)을 적용한다. DataReader, DataSerializer, DataValidator, DataEncryptor로 쪼개서, 필요한 것만 골라 사용하게 만든다.
2. Poltergeist (폴터가이스트)
정의
자체적인 상태나 책임 없이, 다른 클래스의 메서드 호출을 중계하기 위해서만 존재하는 단명(短命) 클래스. 유령처럼 잠깐 나타났다 사라진다.
폴터가이스트는 독일어로 "시끄러운 유령"이라는 뜻이다. 코드에서의 폴터가이스트 클래스는 유령처럼 존재감이 없고, 하는 일도 별로 없다. 생성되고, 메서드 하나 호출하고, 바로 가비지 컬렉션 대상이 된다. "이 클래스 왜 있는 거지?" 싶으면 폴터가이스트일 확률이 높다.
// 폴터가이스트: OrderProcessor
class OrderProcessor {
// 자체 상태 없음. 필드도 없음.
processOrder(order: Order): ProcessResult {
const validator = new OrderValidator();
const isValid = validator.validate(order); // 다른 클래스에 위임
if (!isValid) return { success: false, error: "Invalid order" };
const calculator = new PriceCalculator();
const total = calculator.calculate(order.items); // 또 다른 클래스에 위임
const repository = new OrderRepository();
repository.save({ ...order, total }); // 또 위임
return { success: true };
}
}
// 사용하는 쪽
function handleCheckout(order: Order) {
const processor = new OrderProcessor(); // 생성!
const result = processor.processOrder(order); // 호출!
// processor는 이제 쓸모없음. GC 대상.
}
OrderProcessor가 하는 일이 뭐냐고? 아무것도 안 한다. 자기는 로직이 전혀 없고, 다른 세 클래스를 순서대로 호출할 뿐이다. 이건 클래스가 아니라 함수로 충분함:
// 폴터가이스트 제거: 그냥 함수로
async function processOrder(order: Order): Promise<ProcessResult> {
if (!validateOrder(order)) return { success: false, error: "Invalid" };
const total = calculateTotal(order.items);
await saveOrder({ ...order, total });
return { success: true };
}
모든 것이 클래스일 필요는 없다. 특히 TypeScript/JavaScript에서는 함수가 일급 객체이니까, 상태가 없는 로직은 그냥 함수로 만들면 된다. "class를 만들어야 한다"는 Java적 사고방식에서 벗어나자.
3. Golden Hammer (황금 망치)
정의
익숙한 기술이나 패턴을 부적합한 문제에도 반복 적용하는 패턴. "내가 가진 게 망치뿐이면 모든 게 못으로 보인다." (Abraham Maslow)
이건 코드 예시보다 사례가 더 와닿는다:
-
모든 상태 관리에 Redux: 로컬 폼 상태도 Redux에 넣고, 모달 열림/닫힘도 Redux에 넣고, API 캐시도 Redux에 넣는다.
useState로 3줄이면 끝날 걸 action, reducer, selector, thunk로 50줄을 쓰고 있음. -
모든 데이터베이스에 MongoDB: "스키마리스가 유연하니까"라는 이유로 결제 데이터, 재고 관리, 회계 데이터까지 전부 MongoDB에 넣는다. 관계형 데이터를 NoSQL에 억지로 맞추면서
$lookup파이프라인이 100줄이 넘는 괴물이 됨. -
모든 서비스에 마이크로서비스: MAU 1000명인 사내 도구를 쿠버네티스 위에 10개 마이크로서비스로 배포한다. 서비스 간 통신, 서비스 디스커버리, 분산 트레이싱, 사가 패턴... 개발자 2명이 인프라 관리에 시간의 80%를 쓰고 있음.
-
모든 통신에 GraphQL: 단순한 CRUD API도 GraphQL로 만든다. 쿼리 1개, 뮤테이션 3개짜리 API인데 스키마 정의, 리졸버 작성, 코드젠 설정에 하루를 쓴다. REST로 하면 30분이면 끝남.
-
모든 CSS에 CSS-in-JS: 정적인 마케팅 페이지의 스타일도 styled-components로 작성한다. 런타임 오버헤드가 생기고, SSR에서 스타일이 깜빡이고, 빌드 결과물이 불필요하게 커짐.
왜 이렇게 되는 걸까?
학습 비용의 매몰 비용 효과때문이다. Redux를 열심히 공부했으니 Redux를 써야 본전을 뽑는 기분이 드는 것. 새로운 도구를 배우는 것보다 익숙한 도구를 쓰는 게 편하니까. 하지만 "이 문제에 가장 적합한 도구가 뭔가?"를 먼저 물어봐야 한다. 못에는 망치를, 나사에는 드라이버를.
4. Premature Optimization (성급한 최적화)
정의
실제 병목을 측정하기 전에 성능을 이유로 코드의 명확성과 설계를 희생하는 패턴. Donald Knuth: "Premature optimization is the root of all evil."
"이게 느릴 것 같아서"라는 직감만으로 최적화를 시작하면, 대부분의 경우 읽기 어렵고 유지보수하기 힘든 코드만 남는다. 실제 병목은 전혀 다른 곳에 있는 경우가 대부분이고.
// ❌ "문자열 연결이 느리니까" 최적화
function buildQuery_bad(conditions: string[]): string {
// "+" 연산자가 느리다며 배열로 조합
const parts: string[] = [];
parts[parts.length] = "SELECT * FROM users WHERE 1=1"; // push 대신 직접 인덱싱 (더 빠르다고?)
for (let i = 0; i < conditions.length; i++) { // for...of가 느리다며 인덱스 루프
parts[parts.length] = " AND ";
parts[parts.length] = conditions[i];
}
return parts.join(""); // 마지막에 join
}
// ❌ "비트 연산이 더 빠르니까" 최적화
function isEven_bad(n: number): boolean {
return (n & 1) === 0; // n % 2 === 0 대신 비트 연산
}
function divideByTwo_bad(n: number): number {
return n >> 1; // n / 2 대신 비트 시프트
}
// ❌ "혹시 느릴 수 있으니까" 모든 것을 캐싱
class UserService_bad {
private cache = new Map<string, User>();
private cacheTimestamps = new Map<string, number>();
private readonly CACHE_TTL = 5 * 60 * 1000;
async getUser(id: string): Promise<User> {
const cached = this.cache.get(id);
const timestamp = this.cacheTimestamps.get(id);
if (cached && timestamp && Date.now() - timestamp < this.CACHE_TTL) {
return cached;
}
const user = await this.db.findUser(id);
this.cache.set(id, user);
this.cacheTimestamps.set(id, Date.now());
return user;
}
// getUser가 1초에 1번 호출되는 관리자 페이지인데
// 이 캐싱 로직이 필요했을까?
}
// ✅ 먼저 읽기 쉽게 작성하고, 프로파일링 후 필요한 곳만 최적화
function buildQuery(conditions: string[]): string {
const base = "SELECT * FROM users WHERE 1=1";
return conditions.reduce((q, c) => `${q} AND ${c}`, base);
}
function isEven(n: number): boolean {
return n % 2 === 0; // 의도가 명확함
}
// 캐싱이 진짜 필요한지는 프로파일링으로 확인한 후에 추가
class UserService {
async getUser(id: string): Promise<User> {
return this.db.findUser(id); // 일단 단순하게
}
}
현대 JavaScript 엔진(V8)은 이미 충분히 빠르다. for...of와 인덱스 for 루프의 성능 차이? 마이크로초 단위다. 병목은 거의 항상 I/O(DB 쿼리, API 호출, 파일 읽기)에 있다. n & 1이 n % 2보다 빠르다고 해서 그게 사용자 경험에 영향을 미치는 경우는 거의 없다.
5. Premature Abstraction (성급한 추상화)
정의
구체적인 요구사항이 충분히 확보되기 전에 일반화하는 패턴. DRY(Don't Repeat Yourself)를 맹목적으로 따르다가 잘못된 추상화를 만들어버린다.
"이 코드랑 저 코드가 비슷해 보이니까 공통화하자" — 이 문장이 프로젝트를 망치는 시발점이 되는 경우가 정말 많다. 두 코드가 지금은 비슷해 보여도, 나중에 완전히 다른 방향으로 발전해야 할 수 있다. 그때 공통 추상화가 양쪽 모두를 방해하게 됨.
// 상황: 유저 등록 폼과 상품 등록 폼이 비슷해 보임
// "둘 다 폼이니까 공통 컴포넌트로 만들자!"
// ❌ 성급한 추상화
interface FormConfig {
fields: Array<{
name: string;
type: "text" | "number" | "email" | "select" | "file" | "rich-text";
label: string;
required?: boolean;
validation?: (value: any) => string | null;
options?: string[]; // select용
accept?: string; // file용
maxLength?: number; // text용
min?: number; max?: number; // number용
placeholder?: string;
dependsOn?: string; // 조건부 렌더링
customRenderer?: () => JSX.Element; // 커스텀 렌더링이 필요하면...
}>;
onSubmit: (data: Record<string, any>) => Promise<void>;
layout?: "vertical" | "horizontal" | "grid";
showReset?: boolean;
submitLabel?: string;
}
function GenericForm({ config }: { config: FormConfig }) {
// 200줄짜리 분기 로직...
// if (field.type === "select") ... else if (field.type === "file") ...
// if (field.dependsOn) ... if (field.customRenderer) ...
}
유저 등록 폼은 점점 이메일 인증, 비밀번호 강도 표시기, 소셜 로그인 버튼이 필요해지고, 상품 등록 폼은 다중 이미지 업로드, 옵션 조합 생성기, 에디터 연동이 필요해진다. GenericForm은 양쪽의 요구사항을 다 수용하려고 config 옵션이 끝없이 늘어나고, 결국 아무도 이해할 수 없는 괴물이 됨.
// ✅ 그냥 각각 만들면 됨
function UserRegistrationForm() {
// 유저 등록에 필요한 것만. 30줄.
// 나중에 이메일 인증이 필요하면 여기에만 추가.
}
function ProductForm() {
// 상품 등록에 필요한 것만. 40줄.
// 나중에 이미지 업로드가 필요하면 여기에만 추가.
}
// 진짜 공통인 것만 추출 (예: 입력 필드 컴포넌트)
function TextField({ label, value, onChange, error }: TextFieldProps) {
// 이건 진짜 공통이다. 10줄.
}
DRY의 진짜 의미
DRY는 "코드를 복사하지 말라"가 아니다. "지식(knowledge)을 중복하지 말라"는 뜻이다. 유저 등록 폼과 상품 등록 폼은 코드가 비슷해 보여도 다른 지식을 담고 있다. 다른 도메인, 다른 요구사항, 다른 변경 이유를 가진 코드를 억지로 합치면 Wrong Abstraction이 되는 것.
과잉 설계의 신호
다섯 가지 안티패턴을 관통하는 공통 패턴이 있다. 아래 신호가 보이면 한 발 물러서서 생각해보자.
이런 신호가 보이면 멈춰라
- "나중에 필요할 수도 있으니까" 라는 말이 회의에서 반복됨
- 실제 사용되는 기능보다 "확장성"을 위한 코드가 더 많음
- 간단한 기능 추가에 3일 이상 설계 논의를 하고 있음
- 인터페이스에 구현체가 딱 하나뿐인데 인터페이스가 존재함
- 프로파일링 없이 "이게 느릴 것 같아서" 최적화하고 있음
- 코드를 처음 보는 사람이 "이게 왜 이렇게 복잡한 거예요?" 라고 물어봄
- GenericSomething, AbstractSomethingFactory 같은 이름의 클래스가 눈에 띔
- 설정 옵션이 20개가 넘는 함수나 컴포넌트가 있음
과잉 설계 방지법
- YAGNI (You Aren't Gonna Need It): 지금 필요한 것만 만들어라. "나중에 필요할 수도"의 80%는 영원히 필요 없다.
- Rule of Three: 비슷한 코드가 3번 반복될 때 추상화를 고려해라. 2번은 우연의 일치일 수 있다.
- 프로파일링 먼저: 측정 없는 최적화는 도박이다.
console.time()이든 Chrome DevTools든, 먼저 측정하고 나서 최적화해라. - 단순함이 최고의 설계: "이거 더 단순하게 할 수 있나?"를 항상 자문하자. 가장 좋은 코드는 없는 코드이고, 그다음은 읽기 쉬운 코드다.
- 삭제하기 쉬운 코드 작성: "이 코드를 나중에 쉽게 지울 수 있는가?"라는 질문이 "이 코드를 나중에 쉽게 확장할 수 있는가?"보다 더 유용한 경우가 많다.
정리
과잉 설계의 아이러니는, 좋은 개발자가 되려는 노력이 오히려 나쁜 코드를 만든다는 거다. 디자인 패턴을 공부하면 모든 곳에 패턴을 적용하고 싶어지고, SOLID 원칙을 배우면 인터페이스를 남발하고, DRY를 알면 모든 중복을 제거하려 한다. 하지만 원칙은 도구이지 교리가 아니다.
Swiss Army Knife는 "너무 많은 기능 노출", Poltergeist는 "존재 이유 없는 클래스", Golden Hammer는 "익숙한 것만 반복 사용", Premature Optimization은 "측정 없는 최적화", Premature Abstraction은 "시기상조의 일반화". 이 다섯 가지는 모두 "지금 필요하지 않은 것을 만들었다"는 공통점이 있다.
Kent Beck의 말을 빌리자면: "Make it work, make it right, make it fast." 이 순서를 지키면 과잉 설계의 대부분은 피할 수 있다. 먼저 돌아가게 만들고, 그다음에 깔끔하게 만들고, 마지막에 빠르게 만들자. 거꾸로 하면 안 됨.