오버엔지니어링 탈출 — YAGNI·KISS·Rule of Three로 아키텍처 복잡성 줄이기
솔직히 고백하자면, 저도 한때 마이크로서비스에 완전히 빠져있었습니다. 팀 규모가 3명인데도 Kubernetes 클러스터를 올리고, 서비스 메시를 붙이고, 이벤트 버스로 서비스를 연결하면서 "이게 맞는 방향이야"라고 확신했죠. Netflix처럼 만들면 언젠가 Netflix처럼 될 것 같은 막연한 기대감도 있었습니다.
그러다 어느 날 아침, 간단한 버그 하나를 잡으러 들어갔다가 오전 내내 날려버렸습니다. 원인은 단 두 줄짜리 로직 오류였는데, 그걸 찾기 위해 다섯 개 서비스의 로그를 뒤지고, 세 개의 모니터링 대시보드를 열어두고, 분산 트레이싱 도구와 씨름해야 했습니다. 그때 처음으로 "이게 정말 우리한테 필요한 복잡성인가?"라는 질문이 떠올랐습니다.
과잉 아키텍처(Over-Architecture)는 나쁜 개발자가 만드는 게 아닙니다. 오히려 열정 있는 개발자가, 미래를 대비한다는 선한 의도로, 현재 팀과 서비스가 감당할 수 없는 복잡성을 쌓아올릴 때 발생합니다. 그리고 그 복잡성의 청구서는 언제나 나중에, 그것도 가장 바쁜 시점에 날아옵니다.
이 원칙들을 이해하고 나면, 다음 설계 회의에서 "나중에 필요할 것 같아서"라는 말이 얼마나 비싼 문장인지 본능적으로 느끼게 됩니다.
핵심 개념
복잡성은 기능이 아니라 부채다
소프트웨어 아키텍처를 이야기할 때 "복잡함 = 정교함"이라는 착각이 팀 내에 은근히 퍼져있는 경우가 많습니다. 복잡한 시스템을 설계한 사람이 기술적으로 뛰어나 보이고, 단순한 해결책은 어딘지 부족해 보이는 것처럼요. 하지만 실제로 복잡성은 모든 팀원이 매일 지불해야 하는 운영 비용입니다.
복잡한 아키텍처를 설계한 사람이 팀 내에서 더 높은 평가를 받는 구조, 이력서에 "MSA 설계 경험"을 추가하고 싶은 개인적 동기, "나중에 필요할 것 같아서"라는 막연한 불안감이 복합적으로 작용합니다. 조직의 인센티브 구조 자체가 복잡성을 미덕으로 오인하게 만드는 거죠.
과잉 아키텍처(Over-Architecture): 현재 팀 규모와 요구사항에 비해 지나치게 복잡한 시스템 설계를 도입하는 안티패턴. 불필요한 추상화 레이어, 조기 마이크로서비스 분리, 검증되지 않은 최신 기술 스택 도입이 대표적이다.
YAGNI — 지금 필요하지 않은 건 만들지 않습니다
YAGNI(You Aren't Gonna Need It)는 XP(Extreme Programming)에서 유래한 원칙인데, 처음 접하면 "당연한 말 아닌가?"라고 생각하기 쉽습니다. 그런데 실무에서 YAGNI를 위반하는 순간은 대부분 이런 식으로 찾아옵니다.
// "나중에 다른 결제 방식도 추가될 것 같아서" 만들어둔 추상화
interface PaymentProcessor {
processPayment(amount: number, currency: string): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<RefundResult>;
getTransactionHistory(userId: string, dateRange: DateRange): Promise<Transaction[]>;
}
class StripeProcessor implements PaymentProcessor { /* ... */ }
class PaypalProcessor implements PaymentProcessor { /* 아직 요구사항 없음 */ }
class TossProcessor implements PaymentProcessor { /* 아직 요구사항 없음 */ }이 코드의 문제는 PaypalProcessor나 TossProcessor가 존재한다는 것 자체가 아닙니다. "아직 없는 요구사항"을 위해 인터페이스를 설계하고, 구현체를 만들고, 팀 전체가 그 추상화에 맞춰 코딩해야 하는 인지적 부담이 문제입니다. 실제로 1년이 지나도 Stripe만 사용하는 경우가 대부분이고요.
YAGNI를 적용하면 이렇게 됩니다.
// 지금 필요한 것만: Stripe로 결제 처리
async function processStripePayment(
amount: number,
currency: string,
customerId: string
): Promise<{ transactionId: string; status: 'success' | 'failed' }> {
const charge = await stripe.charges.create({
amount,
currency,
customer: customerId,
});
return { transactionId: charge.id, status: 'success' };
}나중에 Toss가 필요해진다면? 그때 리팩터링하면 됩니다. 테스트가 잘 갖춰져 있다면 변경이 두렵지 않습니다.
주의할 점: YAGNI는 "테스트와 CI/CD가 충분히 갖춰진 상태"에서 유효합니다. 리팩터링 자신감 없이 YAGNI만 따르면, 나중에 변경 비용이 오히려 폭증할 수 있습니다.
KISS — 복잡성은 명시적으로 정당화되어야 합니다
KISS(Keep It Simple, Stupid)는 단순히 "코드를 짧게 써라"는 게 아닙니다. 복잡성을 도입하려면 그에 상응하는 명확한 가치가 있어야 한다는 사고방식입니다. 저는 개인적으로 "복잡성이 가져오는 가치가 비용을 3배 이상 초과해야 한다"는 기준을 쓰는데, 이건 공식 정의가 아니라 실무에서 스스로 세운 경험칙입니다.
의사결정 시 이런 질문을 던져보는 것도 도움이 됩니다.
| 질문 | 판단 기준 |
|---|---|
| 이 추상화를 화이트보드에서 60초 안에 설명할 수 있는가? | 불가능하면 단순화 고려 |
| 신규 팀원이 이 코드를 이틀 안에 이해할 수 있는가? | 불가능하면 복잡도 과다 |
| 이 복잡성이 제거할 수 있는 비즈니스 리스크가 명확한가? | 불명확하면 YAGNI 적용 |
| 운영 비용(모니터링, 장애 대응)을 현재 팀이 감당할 수 있는가? | 불가능하면 단계 조정 |
주의할 점: KISS는 "설계 없이 코딩하라"는 의미가 아닙니다. 클린 코드, 테스트, 적절한 디자인 패턴은 KISS와 별개로 유지하는 게 맞습니다. "단순함"이 "구조 없음"을 뜻해서는 안 됩니다.
Rule of Three — 추상화의 타이밍을 정하는 실용적 기준
"코드 중복은 나쁘다"는 말은 모든 개발자가 처음 배우는 규칙 중 하나입니다. 그런데 이게 지나치면 "중복이 두 번만 보여도 즉시 추상화"로 이어지는데, 이게 오히려 문제가 됩니다. Martin Fowler의 《Refactoring》에서 소개된 Rule of Three는 간단합니다. 같은 패턴이 세 번 반복될 때까지 추상화를 미루는 거죠.
# 첫 번째 발생 — 그냥 씁니다
def send_welcome_email(user_email: str, user_name: str) -> None:
subject = f"환영합니다, {user_name}님"
body = "가입을 축하드립니다!"
email_client.send(to=user_email, subject=subject, body=body)
# 두 번째 발생 — 비슷하지만, 아직 추상화하지 않습니다
def send_password_reset_email(user_email: str, reset_token: str) -> None:
subject = "비밀번호 재설정 안내"
body = f"다음 링크를 클릭하세요: /reset?token={reset_token}"
email_client.send(to=user_email, subject=subject, body=body)
# 세 번째 발생 — 패턴이 보이고, 숨어있던 엣지 케이스도 드러납니다
def send_order_confirmation_email(
user_email: str,
order_id: str,
locale: str = "ko" # 다국어 지원 필요성이 여기서 드러남
) -> None:
subject = f"주문 #{order_id} 확인"
body = "주문이 접수되었습니다."
email_client.send(to=user_email, subject=subject, body=body, locale=locale)
# 세 번의 반복이 공통 파라미터와 엣지 케이스를 명확히 드러냈습니다
def send_email(to: str, subject: str, body: str, locale: str = "ko") -> None:
email_client.send(to=to, subject=subject, body=body, locale=locale)두 번째 사례에서 바로 추상화했다면 locale 파라미터를 놓쳤을 겁니다. 세 번째 반복을 보고 나서야 실제 필요한 인터페이스가 무엇인지 명확해지는 경우가 실무에서 자주 있습니다.
주의할 점: Rule of Three는 "무조건 세 번 복사하라"는 게 아닙니다. 패턴이 명확하게 보인다면 두 번 만에 추상화해도 됩니다. "아직 패턴을 확신하기 어렵다면 기다리자"는 판단의 기준으로 쓰는 게 핵심입니다.
실전 적용
개념을 파악했으니, 실제 팀 상황에서 이 원칙들이 어떻게 작동하는지 두 가지 사례로 살펴보겠습니다.
예시 1: 마이크로서비스에서 모듈러 모놀리스로
Amazon Prime Video의 사례는 지금도 자주 회자됩니다. 비디오 품질 모니터링 서비스를 마이크로서비스로 운영했는데, 단일 프로세스로 전환하자 인프라 비용이 90% 감소했죠. 과도한 서비스 분리가 네트워크 I/O와 운영 오버헤드를 폭증시킨 대표적인 사례입니다.
그렇다고 "그냥 모놀리스로 돌아가면 되나?"라고 묻는다면, 더 좋은 선택지가 있습니다. 모듈러 모놀리스입니다.
# 잘못된 방향: 경계 없는 단순 모놀리스
src/
├── controllers/ # 모든 도메인 컨트롤러 혼재
├── services/ # 모든 서비스 혼재
├── models/ # 모든 모델 혼재
└── utils/ # 모든 유틸 혼재
# 권장 방향: 명확한 내부 경계를 가진 모듈러 모놀리스
src/
├── modules/
│ ├── users/ # 사용자 도메인
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── users.module.ts
│ ├── orders/ # 주문 도메인
│ │ ├── orders.controller.ts
│ │ ├── orders.service.ts
│ │ └── orders.module.ts
│ └── payments/ # 결제 도메인
│ ├── payments.controller.ts
│ ├── payments.service.ts
│ └── payments.module.ts
└── shared/ # 공유 유틸 (최소화)모듈러 모놀리스(Modular Monolith): 단일 배포 단위이지만 내부적으로 명확한 도메인 경계를 가진 아키텍처. 모놀리스의 운영 단순함과 마이크로서비스의 구조적 명확성을 함께 가져갈 수 있다.
모듈 간 의존성 규칙을 코드로 강제하고 싶다면, Node.js/TypeScript 기반이라면 Dependency-cruiser를 활용해볼 수 있습니다.
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "orders-cannot-import-payments-internals",
comment: "orders 모듈은 payments의 public API만 사용할 수 있습니다",
from: { path: "^src/modules/orders" },
to: { path: "^src/modules/payments/(?!index)" }
}
]
};CI 파이프라인에서는 이렇게 실행됩니다.
npx depcruise --config .dependency-cruiser.js src이 규칙을 CI에 추가해두면 모듈 경계 위반이 PR 단계에서 자동으로 감지됩니다. 나중에 실제로 마이크로서비스 분리가 필요해졌을 때, 경계가 이미 명확하기 때문에 분리가 훨씬 수월해집니다.
예시 2: 혁신 포인트는 하나만 허용하기
서비스 메시(Service Mesh)를 도입했다가 8개월 후 제거한 헬스케어 스타트업 사례가 있습니다. 인상적인 건 기술이 아니라 그 결과였습니다. 엔지니어링 시간의 30%를 서비스 메시 관리에 쏟다 보니 실제 제품 개발이 뒷전이 됐고, 결국 제거 후 Kubernetes 노드 비용이 40% 줄었습니다.
이런 상황을 예방하기 위해 저는 팀에 "프로젝트에서 기술적으로 새로운 시도는 한 번에 하나만"이라는 기준을 제안합니다. 새로운 기술은 학습 비용과 운영 리스크를 동반하는데, 동시에 여러 개를 도입하면 문제 발생 시 원인 파악 자체가 어려워집니다.
ADR(Architecture Decision Record, 아키텍처 결정과 그 이유를 문서화하는 관행)을 활용하면 이 판단을 팀 전체와 명확하게 공유할 수 있습니다.
## 컨텍스트
신규 서비스 개발. 팀 규모 5명, DevOps 경험 보통 수준.
## 이번 프로젝트의 새로운 시도 (하나만)
- [ ] 새로운 데이터베이스 (예: TiDB, PlanetScale)
## 검증된 기술로 채우는 영역
- 언어: TypeScript (팀 전원 경험 있음)
- 프레임워크: NestJS (이미 사용 중)
- 인프라: 단일 서버 → ECS (익숙한 도구)
- 모니터링: Datadog (기존 계약)
- CI/CD: GitHub Actions (기존 파이프라인)
## 결정
데이터베이스만 새로운 기술을 도입. 나머지는 기존 스택 유지.
## 이유
동시에 여러 기술을 새로 도입하면 문제 발생 시 원인 파악이 어렵다.이 템플릿을 docs/adr/ 폴더에 Markdown으로 보관해두면, 나중에 "왜 이 선택을 했지?"라는 질문에 명확하게 답할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 개발·배포 속도 | 단순한 시스템은 변경 비용이 낮아 피처 개발 속도가 빨라집니다 |
| 온보딩 시간 단축 | 새 팀원이 아키텍처를 파악하는 데 걸리는 시간이 크게 줄어듭니다 |
| 운영 비용 절감 | 서비스·인프라 수가 줄면 모니터링과 장애 대응 비용도 줄어듭니다 |
| 버그 감소 | 불필요한 추상화 레이어는 버그 유입 경로를 늘립니다. 레이어를 줄이면 버그도 줄어듭니다 |
| 디버깅 시간 단축 | 실제 사례에서 추상화 레이어를 단순화한 후 배포 오류 디버깅 시간이 4.2시간에서 0.8시간으로 줄어들었습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 리팩터링 부채 | YAGNI 적용 후 나중에 리팩터링 비용이 올라갈 수 있습니다 | 테스트 커버리지와 CI/CD를 충분히 갖춰두면 리팩터링 자신감이 생깁니다 |
| 팀 규모 임계점 | 팀원 10명 초과 시 마이크로서비스가 실질적 이득을 가져올 수 있습니다 | 현재 팀 규모와 DevOps 성숙도에 맞게 단계적으로 복잡성을 높여갑니다 |
| 구조 없음으로 오해 | "단순함 = 구조 없음"으로 오해하면 스파게티 코드가 됩니다 | 모듈러 모놀리스처럼 내부 경계는 명확하게 유지합니다 |
| 설계 생략 오해 | YAGNI를 "설계 없이 코딩하라"로 오해하기 쉽습니다 | 클린 코드, 테스트, 적절한 디자인 패턴은 YAGNI와 별개로 유지합니다 |
복잡성을 수치로 확인하고 싶다면 SonarQube나 CodeScene 같은 도구를 활용해볼 수 있습니다. **Cyclomatic Complexity(순환 복잡도)**는 함수 내 독립적인 실행 경로 수를 측정하며, 10을 초과하면 리팩터링을 고려해볼 시점입니다. 최근에는 사람이 코드를 읽을 때 느끼는 어려움을 수치화한 **Cognitive Complexity(인지 복잡도)**가 더 선호되는 추세입니다.
실무에서 가장 흔한 실수
-
팀 규모와 아키텍처 불일치: 개발자 3명이 마이크로서비스 10개를 운영하는 상황. 실제로 상당수의 마이크로서비스 팀이 여전히 모놀리스처럼 일괄 배포하고 있어, 아키텍처 이점을 전혀 누리지 못하고 있습니다.
-
운영 비용을 고려하지 않은 기술 도입: 서비스 메시, GraphQL Federation, 분산 트레이싱 같은 도구는 도입 자체보다 유지 비용이 훨씬 크다는 점을 간과하기 쉽습니다.
-
복잡성 도입의 정당성을 측정하지 않음: "나중에 필요할 것 같아서"라는 이유로 도입한 추상화가 실제로는 거의 사용되지 않는 경우가 많습니다. 복잡성을 도입하기 전에 "이게 해결하는 구체적인 비즈니스 문제가 무엇인가?"를 명확히 정의하는 것을 권장합니다.
마치며
그 오전, 버그 하나를 잡으러 다섯 개 서비스의 로그를 뒤지던 저는 결국 문제가 복잡성 그 자체에 있다는 걸 가장 비싼 방법으로 배웠습니다. 두 줄짜리 버그를 찾는 데 오전 내내 걸렸던 건 제 실력 문제가 아니라, 아무도 감당할 수 없을 만큼 복잡해진 시스템의 청구서였습니다.
복잡성은 기능이 아니라 모든 팀원이 매일 지불해야 하는 비용입니다. 그 비용을 명시적으로 정당화할 수 없다면, 지금 당장 단순하게 유지하는 것이 최선입니다.
지금 바로 시작해볼 수 있는 3단계:
-
복잡성부터 측정해봅니다. SonarQube 또는 CodeScene을 연결하고, 인지 복잡도 15를 초과하는 함수 목록을 뽑아보시면 좋습니다.
-
다음 기술 도입 시 ADR을 하나 써봅니다.
docs/adr/폴더를 만들고, 컨텍스트·결정·이유를 Markdown으로 기록하는 것에서 시작됩니다. -
모듈 경계를 코드로 강제해봅니다. Node.js/TypeScript라면
npx dependency-cruiser --init으로, Spring이라면 Spring Modulith 1.4로 경계 위반을 자동 테스트할 수 있습니다.
참고 자료
- Scaling up the Prime Video audio/video monitoring service and reducing costs by 90% | Amazon Prime Video Tech Blog
- Bliki: Yagni | Martin Fowler
- Refactoring: Improving the Design of Existing Code | Martin Fowler
- Stick to Boring Architecture for as Long as Possible | Addy Osmani
- Choose Boring Technology | Dan McKinley
- Avoiding Premature Software Abstractions | Better Programming
- The Fractal Trap: A Visual Model for Premature Complexity in Software Architecture | Ecliptec Mobile
- Microservices vs. Modular Monoliths in 2025: When Each Approach Wins | Java Code Geeks
- The Modular Monolith 2026 Complete Guide | DEV Community
- Stop Overengineering in 2025 | DEV Community