클린 아키텍처를 거꾸로 알고 있었어요 — 험블 객체라는 렌즈로 다시 본 테스트 전략
— 험블 객체라는 렌즈로 다시 본 테스트 전략
---
들어가며: 한 번도 의심하지 않았던 그림
최근에 클린 아키텍처를 다시 읽기 전까지, 저는 클린 아키텍처와 어니언 아키텍처를 한 문장으로 요약할 수 있다고 생각했어요.
"DB가 중심에 있고, 그 위에 도메인이 올라가고, 그 위에 서비스가 올라가고, 가장 바깥에 UI가 있다."
전형적인 계층형(layered) 아키텍처의 그림이었죠. 위에서 아래로 흐르고, 아래에서 위로 의존하는. 그런데 책을 다시 읽으면서 이 그림이 어딘가 묘하게 어긋나 있었다는 걸 알게 됐어요.
진짜 그림은 이렇더라고요.
중심에는 비즈니스 규칙이 있고, 테두리에는 "테스트하기 어려운 것들"이 있다. DB도, UI도, 외부 API도 — 모두 같은 자리에 있어요. 가장 바깥의 같은 테두리에요.
이 한 줄이 머릿속에서 풀리는 데 시간이 좀 걸렸어요. 그리고 풀리고 나니, 오래 갈피를 못 잡고 있던 프론트엔드 테스트의 자리가 같이 또렷해졌습니다.
---
방향이 반대였다
클린 아키텍처가 그리는 동심원을 한 번 더 들여다볼게요.
┌─────────────────────────────┐
│ Frameworks & Drivers │ ← 웹·UI·DB·장치
│ ┌───────────────────────┐ │
│ │ Interface Adapters │ │ ← 컨트롤러·프레젠터·게이트웨이
│ │ ┌─────────────────┐ │ │
│ │ │ Use Cases │ │ │ ← 애플리케이션 업무 규칙
│ │ │ ┌───────────┐ │ │ │
│ │ │ │ Entities │ │ │ │ ← 엔터프라이즈 업무 규칙
│ │ │ └───────────┘ │ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
핵심은 의존성의 방향이에요. 의존성은 언제나 안쪽을 향한다.
- 엔티티(이자 계산 공식)는 유스케이스(대출 신청 플로우)를 모르고요.
- 유스케이스는 UI · DB · 프레임워크를 모릅니다.
- 안쪽이 바깥을 모를수록, 업무 규칙은 기술 결정에 얽매이지 않아요.
여기서 제가 오래 헷갈려 있던 지점이 드러났어요. 저는 DB를 "가장 아래의 기반"으로 그렸는데, 책은 DB를 "가장 바깥의 디테일"로 그리고 있었던 거죠. 위치는 다르지 않았어요. 의미가 달랐던 거예요.
그러면 자연스럽게 다음 질문이 따라옵니다 — 안쪽의 업무 규칙은 바깥 세계(DB·UI·네트워크)와 어떻게 연결되는 걸까요? 그 연결 지점에 어떤 구조가 들어가는 걸까요? 이 질문에 가장 깔끔한 답을 준 것이 험블 객체 패턴이었어요.
---
어댑터, 또는 험블 객체
클린 아키텍처, 어니언 아키텍처, 육각형 아키텍처(Ports & Adapters) — 이름은 다르지만 결국 같은 이야기를 해요. 안쪽에는 변하지 않는 업무 규칙이 있고, 바깥 세계와 만나는 자리에는 어댑터가 있다는 거죠. 어댑터는 안쪽이 이해하는 말과 바깥이 쓰는 말 사이를 번역해주는 얇은 껍질이에요.
이 어댑터의 본질을 테스트의 관점에서 가장 또렷하게 잡아낸 표현이 험블 객체(Humble Object) 패턴이라고 생각해요.
핵심은 단순합니다. 모듈을 두 조각으로 가르는 거예요. 한쪽에는 테스트하기 어려운 부분 — 화면 렌더링, 네트워크 호출, DB 접근, 메시지 디코딩 — 을 다 모아서 얇게 가둡니다. 이쪽이 "험블"이에요. 그리고 그 바깥에 모든 의미 있는 결정을 둡니다. 그러면 결정은 빠르고 풍부하게 단위 테스트할 수 있고, 험블 껍질은 통합 테스트로 가볍게 확인하면 되죠.
이 구조는 어디서 본 모양이에요. 클린 아키텍처 동심원의 가장 바깥 두 층 — Frameworks & Drivers, Interface Adapters — 이 정확히 험블의 자리거든요. 그리고 그 안쪽 — Use Cases와 Entities — 이 풍부하게 테스트되어야 할 자리고요.
다시 말해, 어댑터의 자리가 곧 험블 객체의 자리예요. 아키텍처가 그어둔 동심원의 경계와, 테스트 가능성으로 그은 경계가 같은 선이라는 뜻이죠.
책은 이 구조의 세 가지 사례를 보여줘요. 표면상으로는 전혀 다른 문제처럼 보이는데도, 모양은 똑같아요.
| 영역 | 험블 (테두리) | 그 외 (안쪽) |
|---|---|---|
| GUI | 뷰 (화면 렌더만) | 프레젠터 (무엇을·어떻게 보여줄지) |
| DATA | DB + ORM | 유스케이스 (SQL 없이 동작) |
| MESSAGE | 메시지 수신·디코드 | 비즈니스 결정 |
바깥 세계와 닿는 얇은 껍질, 그리고 그 안쪽의 결정 — 이 한 줄이 클린/어니언/육각형이 공통으로 말하는 패러다임의 실용적 모습입니다.
---
한 줄로 줄이면: "비즈니스 로직만 테스트하라"
험블 객체 패턴이 실무에 주는 처방은 결국 한 줄이에요.
테두리는 통합 테스트로 가볍게 두고, 안쪽 비즈니스 로직만 단위 테스트로 풍부하게 한다.
이렇게 적고 나면, 사실 새삼스러운 이야기는 아니에요. DB와 외부 API에서는 이미 자연스럽게 그렇게 해 오고 있었으니까요. 진짜 DB로 돌리면 느리고 불안정하니까 인터페이스 뒤로 숨기고 가짜 구현을 주입한다, 그리고 그 위의 유스케이스를 단위 테스트로 검증한다 — 패턴의 이름을 몰랐을 뿐, 이미 험블 껍질을 만들고 그 안쪽을 테스트하고 있었던 거죠.
문제는 프론트엔드였습니다.
저는 오랫동안 프론트엔드 테스트의 감을 못 잡았어요. "디자인이 자주 바뀌니까, 테스트까지 따라가는 비용이 크다"는 결론으로 매번 넘어갔거든요. DB·API에 대해선 "여기는 모킹, 저기는 단위 테스트"라는 분해선이 명확했는데, 프론트엔드에 대해선 그 분해선이 어디에 있는지 보이지 않았어요. 그래서 "프론트엔드는 테스트하기 애매하다"가 일종의 결론처럼 굳어 있었고요.
험블 객체의 렌즈는 그 분해선을 드러냅니다. 프론트엔드도 같은 구조거든요.
- 화면(View)이 험블 껍질이에요. 사용자와 닿은 자리, 자주 바뀌는 자리.
- 그 안쪽에는 — 잘 안 보였지만 분명히 존재하는 — 결정 로직이 있어요. 도메인 모델을 화면이 그릴 수 있는 모양으로 변환하는 자리, 즉 프레젠터.
그러니까 "프론트엔드는 자주 바뀌어서 테스트가 애매하다"는 명제는, 정확히는 "화면은 자주 바뀐다"가 맞아요. 그 안의 결정 로직은 의외로 안정적이고, 단위 테스트의 효용도 분명하죠.
그동안 안 보이던 분해선이, 이제는 보여요.
---
그래서, 어떻게 테스트할 것인가
험블 객체의 핵심 기술은 단순해요.
- 인터페이스로 경계를 긋는다.
- 얇은 껍질(험블)은 통합 테스트로 최소한만 확인한다.
- 안쪽 로직은 Fake/Stub으로 주입받아, 단위 테스트로 풍부하게 검증한다.
세 영역에 차례로 적용해 볼게요. DB와 외부 API는 이미 비슷한 감각으로 해 오던 일을 패턴의 언어로 다시 정리해보는 셈이고, 프론트엔드는 같은 패턴이 어디에 분해선을 긋는지 확인해보는 자리예요.
1) DB 테스트: 인터페이스 뒤에 ORM을 가둔다
// 경계 = 인터페이스
interface UserRepository {
findById(id: string): Promise
}
// 얇은 껍질 (험블)
class PrismaUserRepo implements UserRepository {
async findById(id: string) {
return prisma.user.findUnique({ where: { id } })
}
}
// 테스트 대상 = 안쪽 로직
test('비활성 유저 차단', () => {
const fake: UserRepository = {
findById: async () => ({ id: 'u1', active: false } as User),
}
const svc = new AuthService(fake)
expect(() => svc.login('u1')).toThrow('inactive')
})
PrismaUserRepo 자체는 통합 테스트로 "쿼리가 의도대로 도느냐"만 최소 확인합니다. 진짜 풍부한 테스트는 AuthService에 몰려요. Fake UserRepository를 주입해서, 비활성 유저, 차단된 유저, 권한이 부족한 유저 — 모든 분기를 빠르고 안정적으로 검증할 수 있죠.
2) 외부 API 테스트: 네트워크를 어댑터 뒤로 숨긴다
// 경계 = 인터페이스
interface PaymentGateway {
charge(amount: number): Promise
}
// 얇은 껍질 (험블)
class HttpPaymentGateway implements PaymentGateway {
async charge(amount: number) {
const res = await fetch(/* ... */)
return res.json()
}
}
// 테스트 대상 = 안쪽 유스케이스
test('결제 실패 시 주문 취소', async () => {
const stub: PaymentGateway = {
charge: async () => { throw new Error('declined') },
}
const uc = new OrderUseCase(stub)
await uc.place(order)
expect(order.status).toBe('cancelled')
})
fetch 호출은 HttpPaymentGateway 안에만 존재하고, 그 바깥에서는 흔적조차 보이지 않아요. OrderUseCase는 결제가 어떤 PG사를 통해 처리되는지조차 모릅니다. 테스트는 "결제가 실패했을 때 우리가 무엇을 하는가"라는 비즈니스 결정에 집중할 수 있고요.
3) 프론트엔드 테스트: 화면은 그리기만, 결정은 프레젠터에서
이게 저한테는 가장 새로운 관점이었어요. 위 두 예시는 어댑터를 인터페이스로 분리했는데, 프론트엔드에서는 어댑터를 출력 모델(View Model)로 분리해요. View가 받아서 그리기만 할 데이터의 모양을 먼저 정의하고, 그 모양을 만드는 로직(Presenter)을 안쪽에 두는 거죠.
// 경계 = 출력 모델
type OrderView = {
totalLabel: string // '₩12,000'
statusBadge: '결제 대기' | '배송 중' | '취소'
primaryAction: '결제하기' | '배송 조회' | null
}
// 안쪽 = Presenter (테스트 대상)
function presentOrder(order: Order): OrderView {
return {
totalLabel: `₩${order.total.toLocaleString()}`,
statusBadge: toBadge(order.status),
primaryAction: pickAction(order),
}
}
// 얇은 껍질 (험블) = View
function OrderCard({ view }: { view: OrderView }) {
return (
{view.totalLabel}
{view.statusBadge}
{view.primaryAction && {view.primaryAction}}
)
}
// 테스트 = Presenter의 결정
test('미결제 주문은 결제 버튼을 노출한다', () => {
const order = { total: 12000, status: 'pending', paid: false }
expect(presentOrder(order).primaryAction).toBe('결제하기')
})
test('취소된 주문은 액션 버튼을 노출하지 않는다', () => {
const order = { total: 12000, status: 'cancelled', paid: false }
const view = presentOrder(order)
expect(view.primaryAction).toBeNull()
expect(view.statusBadge).toBe('취소')
})
OrderCard는 받은 OrderView를 그리기만 해요. JSX가 view.primaryAction의 존재 여부에 따라 분기하긴 하지만, 이건 "어떤 결정"이 아니라 "결정의 결과를 옮겨 그리는 일"입니다. 진짜 결정 — 주문 상태와 결제 여부에 따라 어떤 액션을 노출할 것인가 — 는 pickAction 안에 있어요. 그리고 그 결정은 React도 DOM도 모르는 순수 함수라, 가장 빠르고 풍부하게 단위 테스트할 수 있고요.
프론트엔드가 자주 바뀌는 진짜 부분은 OrderCard의 마크업·스타일이지, "취소된 주문에는 액션을 숨긴다"는 정책이 아니에요. 후자는 비즈니스가 바뀔 때만 바뀌는, 의외로 안정적인 영역이고요. 그러니까 "프론트엔드는 자주 바뀌어서 테스트하기 애매하다"는 명제는, 정확히는 "화면은 자주 바뀐다"가 맞고 — 그 안에는 자주 바뀌지 않는 결정 로직이 분명히 숨어 있다는 거죠.
---
같은 모양의 세 사례 — 정리
세 영역은 전혀 다른 문제처럼 보였지만, 결국 한 문장으로 묶여요.
테두리는 얇게, 안쪽은 풍부하게.
테두리는 통합 테스트로 "정말 잘 연결됐는지"만 가볍게 확인하고, 안쪽 비즈니스 결정은 단위 테스트로 빠르고 풍부하게 검증한다 — 이게 험블 객체 패턴이 우리에게 주는 실용적인 처방이에요.
---
닫는 말
플라톤은 우리가 사는 세계를 동굴 속 그림자에 비유했어요. 사람들은 동굴 벽에 비친 그림자만 보며 평생을 살아가지만, 그 너머의 빛 아래에는 변하지 않는 진짜 형상 — 이데아 — 이 따로 있다는 거예요. 눈앞의 나무는 진짜가 아니에요. 진짜는 '나무 그 자체'라는 형상이고, 우리가 보는 나무는 그 형상의 불완전한 그림자일 뿐이라는 이야기죠.
소프트웨어를 두고 생각해 보면, 이 비유가 의외로 잘 들어맞아요.
우리가 매일 손에 쥐는 것들 — React 컴포넌트, Prisma 쿼리, fetch 호출, Tailwind 클래스, JWT 토큰 — 은 모두 그림자예요. 시간이 지나면 React는 다른 프레임워크로, Prisma는 다른 ORM으로, fetch는 다른 클라이언트로 교체될 수 있어요. 모두 변하는 것들이고, 결국에는 변하더라고요.
진짜는 그 너머에 있어요. 이자는 어떻게 계산되는가, 취소된 주문에는 어떤 액션이 노출되어야 하는가, 비활성 유저는 어떻게 다루는가 — 이런 비즈니스의 결정들이요. 어떤 기술 스택을 고르든 흔들리지 않아야 하는 것들이죠. 서비스가 존재하는 이유 그 자체니까요.
그래서 소프트웨어에서 정말 중요한 일은 비즈니스 규칙을 지켜내는 거예요. 현실의 세부 사항이 그것을 흐리게 하지 않도록요. 험블 객체 패턴은 그 지켜냄의 기술이라고 생각합니다 — 변동성을 가장 바깥 테두리에 가두고, 안쪽에는 변하지 않아야 할 것만 남겨두는, 이데아를 그림자로부터 분리하는 작업이요.
클린 아키텍처가 동심원으로, 어니언이 양파 껍질로, 육각형이 포트와 어댑터로 그린 풍경이 결국 같은 그림이라는 점도, 모두 같은 직관에 도달한 흔적처럼 보여요. 이름은 다르지만 모두 한 가지를 말하고 있는 거죠. 진짜를 지키기 위해, 그림자를 한쪽으로 몰아두자.
저한테는 그 분해선이 가장 안 보이던 자리가 프론트엔드였어요. 이번에 다시 들여다보면서, 그 선이 View와 Presenter 사이에 있다는 걸 좀 더 또렷하게 잡게 됐습니다.