클린 아키텍처, "노트북과 여행용 어댑터"로 이해하기
TL;DR
- 문제: 비즈니스 로직이 DB나 프레임워크에 강하게 의존해서 변경이 어렵고 테스트도 느림
- 원인: 레이어드 아키텍처(Controller-Service-Repository)는 외부 환경과 도메인이 너무 가까움
- 해결: 클린 아키텍처 - 도메인을 중심에 두고, 외부(DB, 웹)는 어댑터로 감싸기
- 효과: DB 교체 쉬움, 테스트 빠름 (외부 의존 없이), 비즈니스 로직 명확해짐
- 한계: 보일러플레이트 증가 (인터페이스, 어댑터 등), 학습 곡선, 초기 속도 저하, 간단한 CRUD엔 오버
글 머리말
“Service에서 JPA Repository를 바로 쓰면 안 되나요?”
예전에 코드 리뷰 때 동료에게 받은 질문입니다. 솔직히 저도 이전 회사에서 레이어드 아키텍처(Controller-Service-Repository)로 개발할 때는 별 문제를 못 느꼈거든요.
그런데 시간이 지나면서 불편한 점들이 하나둘 생겼습니다.
- Service 테스트하려면 H2 DB를 띄워야 해서 테스트가 느려짐
- DB를 MySQL에서 MongoDB로 바꾸려니 Service 코 드를 전부 수정해야 함
- 비즈니스 로직이 JPA 엔티티에 섞여서 어디까지가 “진짜 로직”인지 헷갈림
결국 “비즈니스 로직이 외부 환경(DB, 프레임워크)에 너무 의존하고 있다”는 문제였습니다.
그래서 클린 아키텍처를 공부했는데, 처음엔 용어들이 너무 추상적이었습니다. 엔티티, 유즈케이스, 인터페이스 어댑터… 뭐가 뭔지 모르겠더군요.
하지만 개인 프로젝트에 직접 적용하면서, 이 아키텍처가 결국 “노트북과 여행용 어댑터”와 똑같다는 걸 깨달았습니다. 이 비유로 풀어보겠습니다.
핵심 개념: 노트북과 어댑터
클린 아키텍처의 핵심은 한 문장으로 요약됩니다.
“노트북(도메인)은 전기가 어디서 오는지 몰라도 된다.”
무슨 말인지 풀어보겠습니다.
1. 도메인 (노트북 본체)
카페에서 노트북으로 작업하고 있다고 상상해보세요. 노트북 안에는 CPU, 메모리, 하드디스크가 있고, 여러분이 작성 중인 문서(비즈니스 로직)가 돌아가고 있습니다.
이 노트북은 한국에 있든, 미국에 있든, 일본에 있든 똑같이 동작해야 합니다. 벽에 있는 콘센트가 돼지코(220V)인지, 110V인지는 노트북 본체가 알 필요 없습니다.
이게 바로 도메인(Domain)입니다. 웹 프레임워크가 스프링이든 아니든, DB가 오라클이든 MySQL이든 상관없이 순수한 비즈니스 규칙은 변하면 안 됩니다.
2. 포트 (충전 단자)
하지만 노트북도 전기는 필요합니다. 그래서 옆면에 충전 단자(Port)가 있죠. 노트북은 이렇게 말합니다.
“전기가 어떻게 만들어지는지는 모르겠고, 그냥 이 C타입 구멍(Interface)으로 20V만 넣어줘.”
이게 바로 포트(Port)입니다. 도메인 계층은 외부와 대화하기 위한 인터페이스(규격)만 정의해둡니다. 실제로 전기가 어디서 오는지는 관심 없습니다.
3. 어댑터 (여행용 변환 플러그)
이제 현실 세계로 나가봅시다. 한국 카페 벽에는 220V 구멍이 있고, 미국 호텔 벽에는 110V 구멍이 있습니다. 이 벽의 구멍들이 바로 DB나 웹 프레임워크 같은 외부 환경입니다.
외부 환경을 노트북의 C타입 구멍에 맞게 연결하려면? 중간에 변환 플러그(Adapter)가 필요합니다.
- 220V 콘센트 → 어댑터 → C타입 포트 → 노트북
- 110V 콘센트 → 어댑터 → C타입 포트 → 노트북
이게 바로 어댑터(Adapter) 계층입니다. JPA로 데이터를 가져오든, 파일에서 읽어오든, 어댑터 가 알아서 도메인이 원하는 모양(Port)으로 바꿔줍니다.
핵심은 노트북(도메인)이 어댑터를 모른다는 겁니다. 노트북은 그냥 C타입 포트만 바라보고, 어댑터가 알아서 변환해줍니다.
실제 코드로 보기
비유는 이해했는데, 실제 코드로는 어떻게 구현할까요? “예약 시스템”을 예시로 들어보겠습니다.
전체 구조도
graph LR
External["외부 세상<br/>(DB, Web)"] --> Adapter["어댑터<br/>(변환 플러그)"]
Adapter --> Port["포트<br/>(충전 단자)"]
Port --> Domain["도메인<br/>(노트북 본체)"]
style External fill:#ffebee
style Adapter fill:#f3e5f5
style Port fill:#e8f5e9
style Domain fill:#fff3e01. 포트 정의 (충전 단자 규격)
먼저 도메인 쪽에서 “나는 이런 모양으로 데이터를 원해”라고 인터페이스를 선언합니다.
// application/port/out/SaveReservationPort.java
public interface SaveReservationPort {
// "저장이 어떻게 되는지는 모르겠고,
// Reservation 객체를 주면 저장해줘."
void save(Reservation reservation);
}이게 바로 C타입 충전 단자입니다. “20V 전기만 주면 돼. 어디서 오는지는 관심 없어.”
2. 유즈케이스 (노트북 본체)
비즈니스 로직은 이 포트(인터페이스)만 믿고 코드를 짭니다. 실제로 저장이 DB에 되는지 파일에 되는지는 관심 없습니다.
// application/service/CreateReservationService.java
@Service
public class CreateReservationService implements CreateReservationUseCase {
// JPA가 아니라 인터페이스(포트)만 바라봅니다
private final SaveReservationPort saveReservationPort;
public CreateReservationService(SaveReservationPort saveReservationPort) {
this.saveReservationPort = saveReservationPort;
}
@Override
public void create(CreateReservationCommand command) {
// 순수한 비즈니스 로직
Reservation reservation = Reservation.create(command);
// 어디에 저장되는지는 모름. 포트로 내보낼 뿐.
saveReservationPort.save(reservation);
}
}여기서 중요한 건 SaveReservationPort가 인터페이스라는 점입니다. JPA Repository가 아니라요.
3. 어댑터 구현 (변환 플러그)
이제 바깥 세상(JPA)을 포트에 맞게 연결해주는 어댑터를 만듭니다.
// adapter/out/persistence/ReservationRepositoryAdapter.java
@Component
public class ReservationRepositoryAdapter implements SaveReservationPort {
private final JpaRepository<ReservationEntity, Long> jpaRepository;
@Override
public void save(Reservation reservation) {
// 1. 도메인 객체를 받아서
// 2. DB 엔티티(JPA 규격)로 변환
ReservationEntity entity = ReservationEntity.fromDomain(reservation);
// 3. 실제 DB에 저장
jpaRepository.save(entity);
}
}이게 220V 콘센트를 C타입으로 바꿔주는 어댑터입니다.
나중에 DB를 MongoDB로 바꾸고 싶다면? 그냥 어댑터만 새로 만들면 됩니다.
// MongoDB용 어댑터 (새로 추가)
@Component
public class MongoReservationAdapter implements SaveReservationPort {
private final MongoTemplate mongoTemplate;
@Override
public void save(Reservation reservation) {
mongoTemplate.save(ReservationDocument.from(reservation));
}
}도메인(유즈케이스) 코드는 한 줄도 안 바뀝니다. 그냥 어댑터만 갈아끼우면 되니까요.
개인 프로젝트에 적용하며 느낀 점
이론은 그럴싸한데, 실제로 적용해보니 어땠을까요?
좋았던 점: 테스트가 놀라울 정도로 쉬 워졌다
가장 크게 와닿았던 건 테스트였습니다. 예전에는 서비스 로직 하나 테스트하려면 H2 DB 띄우고, 데이터 넣고, 롤백하고… 번거로웠거든요. 테스트 하나 실행하는 데 5초는 걸렸습니다.
그런데 포트(인터페이스)가 있으니까, 그냥 가짜 어댑터(Fake Adapter)를 하나 만들어서 끼우면 끝이었습니다.
// 테스트용 가짜 어댑터 (마치 보조배터리 같은 존재)
class InMemoryReservationAdapter implements SaveReservationPort {
private final Map<String, Reservation> store = new HashMap<>();
@Override
public void save(Reservation reservation) {
store.put(reservation.getId(), reservation);
}
}이렇게 하면 DB 없이도 비즈니스 로직이 완벽하게 돌아가는지 검증할 수 있습니다. 마치 노트북을 콘센트 없는 야외에서 보조배터리로 켜서 작업하는 것과 같죠.
실제로 테스트 실행 시간이 5초 → 0.3초로 줄었습니다. 테스트를 자주 돌리게 되니까 버그도 일찍 잡히더군요.
힘들었던 점: 단순 CRUD에는 과하다
솔직히 말하면, 간단한 기능을 만들 때는 “현타”가 오기도 했습니다.
Controller에서 Repository 바로 부르면 10분이면 끝날 코드를…
- 포트 인터페이스 만들고
- 어댑터 클래스 만들고
- 도메인 모델 만들고
- 엔티티 변환 로직 짜고…
마치 “라면 하나 끓이는데 최고급 셰프용 주방도구를 세팅하는 느낌”이었습니다. 단순한 CRUD(저장/조회)만 있는 기능에는 확실히 과한 구조입니다.
언제 적용하면 좋을까?
개인 프로젝트로 연습하면서 내린 결론입니다.
레이어드 vs 클린 아키텍처 비교
| 항목 | 레이어드 아키텍처 | 클린 아키텍처 | 선택 기준 |
|---|---|---|---|
| 개발 속도 | ✅ 빠름 (10분) | ⚠️ 느림 (1시간+) | 빠른 출시 필요 시 레이어드 |
| 코드 양 | ✅ 적음 | ❌ 많음 (인터페이스, 어댑터) | 간단한 기능은 레이어드 |
| DB 교체 | ❌ 어려움 (Service 수정 필요) | ✅ 쉬움 (어댑터만 교 체) | DB 변경 가능성 있으면 클린 |
| 테스트 속도 | ❌ 느림 (H2 DB 필요) | ✅ 빠름 (Mock 객체) | 테스트 중요하면 클린 |
| 학습 곡선 | ✅ 낮음 (Spring 기본) | ❌ 높음 (개념 이해 필요) | 팀 역량 고려 |
| 유지보수 | ⚠️ 중간 (도메인 복잡해지면 힘듦) | ✅ 쉬움 (도메인 독립) | 장기 프로젝트면 클린 |
적용하면 좋은 경우
- 비즈니스 로직이 복잡할 때: 결제, 정산, 예약처럼 규칙이 많은 도메인
- DB 변경 가능성이 있을 때: MySQL → MongoDB, 혹은 외부 API로 전환
- 테스트가 중요한 도메인: 핵심 로직을 DB 없이 빠르게 검증하고 싶을 때
- 장기 프로젝트: 2년 이상 운영할 시스템
굳이 안 해도 되는 경우
- 단순 CRUD: 데이터 넣고 빼는 게 전부라면 레이어드가 훨씬 빠름
- 관리자 페이지: 빠르게 만들고 끝내야 하는 기능
- 프로토타입: 일단 동작하는 게 중요한 초기 단계
- 팀이 개념을 모를 때: 혼자 알아서 쓰면 유지보수 지옥
실제로 쓰는 방식: 하이브리드
저는 지금 이렇게 씁니다.
프로젝트 구조:
├── domain/ # 클린 아키텍처 (핵심 도메인)
│ ├── reservation/ # 예약 - 규칙 복잡, 변경 잦음
│ └── payment/ # 결제 - 정합성 중요
│
└── admin/ # 레이어드 아키텍처 (관리자 기능)
├── dashboard/ # 단순 조회
└── settings/ # 설정 관리핵심 도메인은 클린 아키텍처로 보호하고, 단순 기능은 레이어드로 빠르게 개발합니다. 한 프로젝트에 두 아키텍처가 공존해도 괜찮습니다.
결국 아키텍처도 도구입니다. 상황에 맞게 골라 쓰면 됩니다.
이 접근의 아쉬운 점
1. 보일러플레이트 코드 폭발
클린 아키텍처의 가장 큰 단점입니다.
간단한 CRUD 하나 만드는 데 필요한 파일:
- 도메인 모델 (Reservation.java)
- 포트 인터페이스 (ReservationPort.java)
- 유즈케이스 (CreateReservationUseCase.java)
- 어댑터 (ReservationJpaAdapter.java)
- JPA 엔티티 (ReservationEntity.java)
- 컨트롤러 (ReservationController.java)
총 6개 파일 vs 레이어드는 3개 (Controller, Service, Repository)
문제:
- 파일이 너무 많아서 처음 보는 사람은 혼란
- 단순 조회 기능도 6개 파일을 거쳐야 함
- “왜 이렇게 복잡하게 해?” 질문 매일 받음
2. 학습 곡선
팀 전체가 이해해야 합니다.
| 개념 | 이해도 | 소요 시간 |
|---|---|---|
| 레이어드 아키텍처 | ✅ 쉬움 | 1~2일 |
| 클린 아키텍처 | ⚠️ 어려움 | 2~3주 |
| + 의존성 역전 원칙 (DIP) | ⚠️ 추상적 | 1주 |
| + 포트와 어댑터 패턴 | ⚠️ 헷갈림 | 1주 |
실제 경험:
- 신입: “도메인이랑 엔티티가 뭐가 달라요?”
- 3년 차: “왜 Service를 유즈케이스라고 부르나요?”
- 5년 차: “이거 레이어드랑 똑같은데 이름만 바꾼 거 아닌가요?”
해결책:
팀 전체 교육 (1~2주) + 멘토링 + 레퍼런스 코드 제공
3. 초기 속도 저하
프로젝트 초기에 느립니다.
레이어드:
- 기능 1개 개발: 30분
- 10개 기능: 5시간
클린 아키텍처:
- 기능 1개 개발: 2시간 (폴더 구조, 인터페이스 등)
- 10개 기능: 20시간 (하지만 점점 빨라짐)딜레마:
- 스타트업: “속도가 생명인데 이렇게 느려도 되나?”
- 대기업: “일단 빠르게 만들고 나중에 리팩토링하자”
우리 선택:
핵심 도메인만 클린 아키텍처, 나머지는 레이어드 (하이브리드)
4. 팀 전체 합의 필요
혼자 쓰면 재앙입니다.
실제 경험:
- 나: 클린 아키텍처로 예약 기능 개발
- 동료: 레이어드로 결제 기능 개발
- 결과: 프로젝트 구조가 뒤죽박죽, 유지보수 지옥
필수 조건:
- ✅ 팀 전체가 개념 이해
- ✅ 코드 리뷰로 구조 강제
- ✅ 레퍼런스 코드 공유
- ✅ 폴더 구조 표준화
5. 모든 기능에 적용하려는 실수
처음 배우면 모든 기능에 적용하고 싶어집니다.
나의 실수:
- 간단한 조회 API도 6개 파일로 쪼갬
- 관리자 페이지 CRUD도 클린 아키텍처
- 결과: 개발 속도 3배 느림, 팀원 불만 폭발
깨달음:
“라면 끓이는 데 최고급 주방도구가 필요 없다”
6. 결국 “적재적소”를 선택했다
클린 아키텍처는 만능 해결책이 아닙니다. 하지만:
- ✅ 복잡한 도메인 로직 → 클린 아키텍처
- ✅ 간단한 CRUD/조회 → 레이어드
- ✅ 프로토타입 → 레이어드 (빠르게)
- ✅ 2년 이상 운영 → 클린 아키텍처 (유지보수)
만약 팀이 개념을 이해 못 한다면?
레이어드로 가세요. 구조가 아무리 좋아도 팀이 못 쓰면 소용없습니다.
지금은 이 정도로 충분합니다.
시스템 점검 체크리스트
저도 클린 아키텍처를 적용할 때 이 항목들을 확인합니다. 도메인 중심 설계를 고려한다면 참고하시면 좋을 것 같습니다.
- 도메인 독립성: 도메인 레이어가 외부 라이브러리(JPA, Spring 등)에 의존하지 않는가?
- 인터페이스 방향: 도메인이 인터페이스(Port)를 정의하고, 어댑터가 구현하는 방향이 맞는가?
- 복잡도 판단: 이 기능이 정말 클린 아키텍처가 필요한가? 단순 CRUD면 레이어드로 충분하지 않은가?
- 팀 이해도: 팀원 전체가 Port와 Adapter 개념을 이해하고 있는가?
- 테스트 독립성: 도메인 로직을 DB 없이 테스트할 수 있는가?
마무리
클린 아키텍처를 처음 봤을 때는 “왜 이렇게 복잡하게 해?”라는 생각이 들었습니다.
하지만 직접 적용해보니, 복잡해 보이는 이유가 있더군요. 비즈니스 로직을 보호하기 위해서입니다. 노트북(도메인)이 어떤 콘센트(DB, 프레임워크)에 꽂히든 똑같이 동작하게 만드는 거죠.
물론 모든 기능에 적용할 필요는 없습니다. 라면 끓이는 데 최고급 주방 도구가 필요 없듯이요.
저는 이렇게 정리했습니다.
- 복잡한 도메인 로직 → 클린 아키텍처로 보호
- 단순 CRUD/조회 → 레이어드로 빠르게
이 정도만 기억해두면, 상황에 맞게 선택할 수 있을 겁니다.
참고 :
https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
https://herbertograca.com/2017/11/16/package-by-component-and-domain-driven-design/
