- #1 Clean Architecture — 지금 이 글
- #2 Hexagonal & MessageQueue
- #3 Event-Driven Architecture
- #4 Microservices(MSA)
서버를 Go로 짜다 보면 늘 같은 질문에 부딪힌다. “이 코드는 어느 계층에 둬야 하지?” Gin 핸들러에서 바로 db.Where(...)를 부르면 편하지만, 그 순간 비즈니스 로직이 HTTP·DB에 묶여 테스트도 교체도 어려워진다. 이 시리즈는 그 질문에 Clean Architecture + Hexagonal이라는 하나의 뼈대로 답한다. 1편은 그 뼈대의 바탕, 의존성 규칙이다.
1. 한 장으로 보는 그림

핵심은 단 하나, “소스코드 의존성은 항상 바깥에서 안쪽으로만 향한다”는 의존성 규칙(Dependency Rule)이다. 안쪽 원은 바깥 원의 존재를 전혀 모른다. 안쪽에서 바깥의 이름(구조체·함수)을 언급하는 순간 규칙 위반이다.
2. 네 개의 레이어
| 레이어 | 무엇 | Go에서 |
|---|---|---|
| Entities | 가장 안정적인 도메인 규칙 | 순수 struct + 메서드. 외부 패키지 import 없음 |
| Use Cases | 애플리케이션 고유의 흐름 | 흐름·트랜잭션·규칙 적용. outbound port(인터페이스)만 의존 |
| Interface Adapters | 안팎 형식 변환 | Gin 핸들러, GORM 리포지토리, DTO 매퍼 |
| Frameworks & Drivers | 구체 기술 | Gin, GORM, PostgreSQL, Kafka SDK … |
3. 규칙을 지키는 Go의 방법 — 의존성 역전
흐름(제어)은 UseCase에서 DB로 가는데, 의존성은 반대로 바깥이 안쪽을 향하게 만들어야 한다. Go에서는 인터페이스(port)를 안쪽에 정의해 이 역전을 이룬다.
// internal/usecase/order_repository.go ← 포트를 UseCase가 "소유"한다
package usecase
type OrderRepository interface { // outbound port
FindByID(ctx context.Context, id string) (*domain.Order, error)
Save(ctx context.Context, o *domain.Order) error
}
// internal/usecase/order_usecase.go
type orderUC struct { repo OrderRepository } // 인터페이스에만 의존
func (u *orderUC) Place(ctx context.Context, id string) error {
o, err := u.repo.FindByID(ctx, id) // GORM을 전혀 모른다
if err != nil { return err }
o.Confirm() // 도메인 규칙
return u.repo.Save(ctx, o)
}
// internal/adapter/repository/order_repo.go ← 바깥이 구현
package repository
type orderRepo struct { db *gorm.DB } // 여기서만 GORM import
func (r *orderRepo) FindByID(...) (*domain.Order, error) { /* SQL */ }
포인트는 인터페이스가 usecase 패키지에 있다는 것. 리포지토리 패키지에 두면 UseCase가 리포지토리를 import 하게 되어 의존성 방향이 다시 바깥으로 뒤집힌다. 초보자가 가장 자주 놓치는 지점이다.
3-1. usecase 의 repo vs adapter 의 repo — ‘계약’과 ‘구현’
여기서 자주 나오는 혼동을 짚고 넘어가자. 폴더에 리포지토리가 두 개 있는 것처럼 보이지만, 둘은 전혀 다른 물건이다.
usecase/order_repository.go |
adapter/repository/order_repo.go |
|
|---|---|---|
| 정체 | 인터페이스(port) — 계약 | 구현체(adapter) — 실제 코드 |
| 내용 | 메서드 시그니처만 (FindByID, Save) |
진짜 SQL·GORM 코드 |
| DB 쿼리 | 실행 안 함 — “필요하다”는 선언만 | 여기서 실행 |
| 기술 import | 없음(순수) | gorm 등 여기서만 |
즉 DB 쿼리를 실제로 담당하는 건 adapter 의 repo 가 맞다. usecase 의 repo 는 쿼리를 실행하지 않는다 — 그저 “FindByID·Save 를 해줄 누군가가 필요하다”는 요구 명세(계약)다. UseCase 는 이 인터페이스에만 대고 u.repo.FindByID(...) 를 호출하고, 실행 시점에 그 자리에 꽂힌 adapter 구현이 실제 SQL 을 돌린다.
비유하면 usecase 의 repo 는 구인 공고(직무 기술서)이고, adapter 의 repo 는 실제로 그 일을 하는 사람이다. 콘센트로 치면 인터페이스는 포트(규격), 어댑터는 그 규격에 꽂히는 플러그다. 계약을 UseCase 쪽에 두기에 의존성이 안쪽을 향하고(§3 위), UseCase·Domain 은 끝까지 DB 를 모른 채 남는다.
4. 폴더 구조 — 레이어가 곧 디렉터리
의존성 규칙은 결국 어떤 코드가 어떤 코드를 import 하느냐의 문제다. 그래서 폴더를 레이어에 맞춰 갈라 두면, 규칙 위반이 import 경로에서 바로 드러난다. 앞의 코드들을 하나의 트리로 모으면 이렇게 된다.
project/
├─ cmd/
│ └─ server/
│ └─ main.go # 조립·의존성 주입 (가장 바깥에서 안쪽을 엮는다)
├─ internal/
│ ├─ domain/ # ① Entities — 순수 struct + 규칙, 외부 import 0
│ │ └─ order.go
│ ├─ usecase/ # ② Use Cases — 흐름 + 포트(인터페이스)를 "소유"
│ │ ├─ order_usecase.go
│ │ └─ order_repository.go # ★ outbound port 는 여기(안쪽)에 둔다
│ └─ adapter/ # ③ Interface Adapters — 안팎 형식 변환
│ ├─ http/ # 구동: Gin 핸들러 + DTO 매퍼
│ │ └─ order_handler.go
│ └─ repository/ # 피구동: GORM 구현
│ └─ order_repo.go
└─ go.mod # ④ Frameworks & Drivers — 기술 경계
이 트리에서 지켜야 할 규칙은 하나, import 는 바깥에서 안으로만 향한다는 것이다.
domain/은 아무것도 import 하지 않는다(표준 라이브러리 정도만).usecase/는domain/만 안다.gorm·gin을 절대 import 하지 않는다.adapter/만 바깥 기술(GORM·Gin)을 import 한다. 안쪽으로는usecase의 포트를 구현할 뿐이다.
그래서 domain/ 이나 usecase/ 파일 상단 import 에 gorm.io/gorm 이 보이면, 그 한 줄이 곧 의존성 규칙 위반의 신호다. 리뷰에서 눈으로 잡을 수도, go-arch-lint 같은 도구로 import 경계를 CI에서 강제할 수도 있다. §3에서 강조했듯 포트(order_repository.go)를 adapter/ 가 아닌 usecase/ 에 두는 것이 이 폴더 구조의 핵심이다.
5. 무엇을 얻나
- 테스트 — UseCase 테스트에서 GORM 없이 fake repo를 주입한다. DB 없이 빠르게 돈다.
- 교체 — Gin→Echo, GORM→sqlc로 바꿔도 UseCase·Entity는 그대로다.
- 재사용 — 같은 UseCase를 HTTP·CLI·큐 컨슈머 어디서든 호출한다.
다음 편에서는 이 그림을 육각형으로 다시 그린 Hexagonal(Ports & Adapters)을 보고, 특히 MessageQueue의 Consumer·Publisher를 어느 레이어에 두는지를 파고든다.
답글 남기기