일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- go middleware
- go 맥 air
- gin logger
- go
- gin middleware
- 신입개발자
- go 캐릭터
- go channel
- go panic
- air 환경변수
- gopath 환경변수
- go디자인패턴
- go 환경변수
- go 마스코트
- go 패닉
- go clean architecture
- 좀비고루틴
- go air 환경변수
- go recover
- 개발자
- go 맥
- golang gopher
- go 맥 air 환경변수
- 골랑 고퍼
- gin recovery
- clean architecture middleware
- 고루틴 채널
- go air
- go 대기그룹
- git
- Today
- Total
뽀미의 개발노트
Go 클린 아키텍처 본문
우리 회사는 기존의 레거시 코드를 싹 걷어내고 차세대 버전으로 전부 뒤바꿈 할 예정이다. 언어로는 Go를 선택했는데 성능도 좋고 가볍고 빨라서 좋을 것 같다. 이 언어로 얼마나 멋진 서비스를 만들지 넘넘 기대된당!!!
바로 작업에 착수하기 전 먼저 디자인 패턴도 공부하고, 아키텍처 공부도 하고 시작해야 좋을 것 같다고 하셔서 백엔드 아키텍처에 대한 이론적인걸 많이 공부했다. 그동안 클린 아키텍처 라는 말은 많이 들어봤지만 제대로 공부해본 건 이번이 처음이다. 공부한거 블로그에 정리해놓고 나도 나중에 봐야지~~ 룰루
go-clean-arch 깃헙 레포 정리
- 프레임워크에 독립적일 것.
- 테스트 가능할 것
- UI에 독립적일 것
- Database에 독립적일 것
- 비즈니스 로직은 외부를 알 필요 없음
즉 모든 layer가 독립적이고 테스트 가능해야함. 엉클밥이 제시한 아키텍처에서는 (Entities, Usecase, controller, Framework&Driver)이지만 이 프로젝트에서는 (Models, Repository, Usecase, Delivery) 이렇게 4개의 layer로 나눔!
Models (Entities)
엉클밥의 Entities와 똑같음. 게시물 / 학생 / 책 같은 객체를 저장함.
Repository
Database 핸들러. DB에 대한 CRUD 제공. 여기에 비즈니스 로직이 포함되면 안되고 순수 Database 관련 함수만 있어야 함. 어떤 DB가 쓰일지에 대한 책임도 갖고 있음. Mysql/MongoDB/MariaDB/Postgresql 등 어떤 DB도 올 수 있고, DB가 바뀌어도 다른 어떤 부분도 건드릴 필요 없이 Repository만 바꾸면 됨. 만약 microservice를 다룰 때도 여기 layer에서 다루면 됨.
Usecase
비즈니스 로직 핸들러. Repository에서 받는 데이터를 어떻게 다뤄서 delivery로 넘길지, 또는 Delivery에서 받은 데이터를 어떻게 처리해서 Repository에 넘길지를 다룸. Usecase layer는 Repository에 의존함.
Delivery (Presenter, Handler)
Presenter라고 부르기도 함. 데이터가 어떻게 사용자에게 보여질 지 결정함. Res tAPI, HTML 파일, gRPC 등 어떤 타입이든. 또한 사용자에게 데이터를 받아 Usecase에 전달하기도 함. 이 프로젝트에서는 Rest API를 사용했음. Delivery layer는 Usecase에 의존함.
각 layer는 인터페이스로 소통함. 예를 들어 Usecase와 Repository가 소통하려면, 다음과 같은 인터페이스를 통해 소통함.(Repository가 제공하는 인터페이스임) article > service.go에 있음.
type ArticleRepository interface {
Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error)
GetByID(ctx context.Context, id int64) (domain.Article, error)
GetByTitle(ctx context.Context, title string) (domain.Article, error)
Update(ctx context.Context, ar *domain.Article) error
Store(ctx context.Context, a *domain.Article) error
Delete(ctx context.Context, id int64) error
}
Service는 저 인터페이스를 구현해야만 함. 그래야 Usecase가 갖다 쓸 수 있음. app > main.go에 보면
articleRepo := mysqlRepo.NewArticleRepository(dbConn)
// Build service Layer
svc := article.NewService(articleRepo, authorRepo)
rest.NewArticleHandler(e, svc)
- Repository를 생성하고,
- Usecase(Service) 레이어를 생성한 뒤,
- Handler에 Usecase를 주입하는 것!!
의존성 주입 패턴을 사용하여 Repository → Usecase → Handler 순서로 구성됨. main.go에서 모든 컴포넌트들을 조립함.
그러면 Usecase에 있는 함수를 하나 보자.
func (a *Service) Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) {
res, nextCursor, err = a.articleRepo.Fetch(ctx, cursor, num)
if err != nil {
return nil, "", err
}
res, err = a.fillAuthorDetails(ctx, res)
if err != nil {
nextCursor = ""
}
return
}
그러면 Usecase(Service)의 Fetch 함수를 호출하면? → 받아온 articleRepo를 활용하여 articleRepo.Fetch(~~)를 사용할 수 있는 것이다!
그럼 Handler와 Usecase의 소통은 어떻게 할까? rest > article.go를 보자.
type ArticleService interface {
Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error)
GetByID(ctx context.Context, id int64) (domain.Article, error)
Update(ctx context.Context, ar *domain.Article) error
GetByTitle(ctx context.Context, title string) (domain.Article, error)
Store(context.Context, *domain.Article) error
Delete(ctx context.Context, id int64) error
}
// ArticleHandler represent the httphandler for article
type ArticleHandler struct {
Service ArticleService
}
저 ArticleService를 통해서 한다. ArticleHandler가 ArticleService를 명시적 field로 가지고 있으므로
func (a *ArticleHandler) FetchArticle(c echo.Context) error {
numS := c.QueryParam("num")
num, err := strconv.Atoi(numS)
if err != nil || num == 0 {
num = defaultNum
}
cursor := c.QueryParam("cursor")
ctx := c.Request().Context()
listAr, nextCursor, err := a.Service.Fetch(ctx, cursor, int64(num))
if err != nil {
return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()})
}
c.Response().Header().Set(`X-Cursor`, nextCursor)
return c.JSON(http.StatusOK, listAr)
}
ArticleHandler의 FetchArticle 함수 안 쪽에서 a.Service.Fetch(~~)로 함수를 쓸 수 있음.
고퍼콘 아키텍처 관련 강연
Go 언어 프로젝트 가이드 A-Z (변규현) in GopherCon Korea 2024 DAY1
DDD (Domain Driven Design)
기능 위주가 아닌 비즈니스 위주의 로직. 그래서 비즈니스 관련 변경사항 발생하면 해당 부분의 로직을 중점적으로 개선할 수 있음! 각 서비스 마다 적합한 기술 스택을 따로 선택할 수도 있음.
로직 복잡도가 올라가면 구조적 문제를 어떻게 해결할까? → 에 초점을 맞춤
API Model
내부에서 돌고있는 도메인 데이터가 모두 API Model에 대응되지 않아도 됨. 예를 들어 게시글에도 사용자에 대한 모든 데이터가 보여지기 보단 필요한 정보만 보여주면 됨. 그러면 필요한 데이터만 API Model에 담아주면 됨. 내부 도메인과 다른 형태로 모델링함.
Presenter
도메인 모델을 API Model로 변형해줌. admin/web/mobile 등 다양한 client에 대해 서로 다른 별도의 presenter를 만들 수 있음. 필요한 정보만 노출하고 민감한 정보는 숨길 수 있음. 도메인 모델이 presentation 계층에 의존하지 않을 수 있음.(클린 아키텍처 원칙에 부합함!!)
Handler
API Model Serving. Handler는 HTTP/gRPC 요청 처리와 응답 생성에만 집중하고 다른 처리는 하지 않아야함. 비즈니스 로직은 Usecase만 담당함. Handler는 Presentation 계층이고 Usecase는 애플리케이션 계층임. 둘은 명확히 구분됨. Usecase를 모킹하여 Handler를 독립적으로 테스트하기 용이함.
Domain
내부로직 전반에 걸쳐 공유되는 모델링. 사용자에게 데이터가 내려가기 전에 내부 코드에서 돌아다니는 데이터.
Usecase
비즈니스 로직 핸들링. 비즈니스 로직의 최상위 레벨. Service 또는 Repository를 의존성으로 받아 사용. 그래서 느슨한 결합을 유지 가능. 순수한 도메인 객체를 사용하고 다른 모델은 사용하지 않음. DB나 API에 대해 모르도록 작성할 수 있음. 단일 메서드에 여러 Repository를 조합하여 복잡한 비즈니스 로직을 구현할 수 있음. layer가 구분되어 있으므로 Repository 인터페이스를 모킹하여 Usecase 테스트하기 용이함. Usecase 사용 예시 : 유효성 검사, 작성자 확인하여 글 수정 가능한지 확인, 공지사항 등록 후 멤버에게 알려주기 등 → 도메인 단계에서 로직을 핸들링 하는 것이 포인트임.
(Service)
비즈니스 로직 핸들링. 필수 단계는 아니고 필요한 경우에만 도입함. Usecase의 중복이거나 무거운 로직을 Service로 분리 가능. 그래서 Usecase의 복잡성을 줄일 수 있음. Usecase는 흐름제어/조정을 하고 Service는 구체적인 비즈니스 로직 구현에 집중하여 역할을 구분함.
Model
왜 Recorder 따로 존재? 왜 Domain만 있어도 충분할텐데 Model이 별도로 존재? 불필요한거 아닌가? → Model은 DB에 직접적으로 정의된 데이터 형식임. Repository에 전부 정의하지 않고 중간과정으로 또 Recorder를 분리함. DB에 저장된 날것의 정보를 보고 싶으면 Recorder를 통해 확인할 수 있음. 예를 들어 sorting만을 위해 prefix로 붙인 정보 → 내부 도메인 로직에서는 불필요하지만 데이터가 틀어졌는지 보려면 그 정보가 필요함. 이를 위해 Recorder라는 별도의 layer를 분리함.
Repository
원시 도메인 모델을 위한 CRUD. Repository는 비즈니스 로직에 접근하지 않고 데이터 접근 로직만 담당함. 상위 계층이 구체적인 데이터 저장 방식을 모르도록 함. 도메인 모델을 반환함 → Repository가 도메인 계층에 의존하여 의존성 역전 원칙을 따름. 도메인 로직이 인프라 세부사항으로부터 독립적이게 함. Repository를 쉽게 모킹하여 테스트가 용이함. 상위 layer에서 데이터 접근에 대해 고민 안 할 수 있음.
* 의존성 역전 원칙(DIP: Dependency Injection Principal)이란?
의존성 방향이 하위수준에서 상위수준 방향으로 가야한다는 것. 두 layer 모두 인터페이스에 의존해야 함. 그리고 세부사항 → 인터페이스 방향의 의존관계를 가져야 함. 즉 상위 layer와 하위 layer 모두 인터페이스에 의존해야한다는 것. 어떤 객체를 참조해서 사용해야하면 그 객체를 직접 참조하는 것이 아니라 걔의 상위요소(인터페이스)를 참조하라는 원칙. 그래서 추상성이 높은 클래스와 통신함. 왜냐하면 하위 모듈의 인스턴스를 직접 가져다 쓸 경우 하위모듈의 구체적인 내용에 클라이언트가 의존하게 되어 변화가 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 하기 때문. 그래서 상위 인터페이스 타입의 객체로 통신하라는 원칙임.
Recorder
DB 모델 핸들링. DB의 특정 API나 쿼리 언어를 추상화. 상위계층 (Repository나 Usecase)은 DB의 세부사항을 몰라도 사용할 수 있음. 데이터 모델 로직을 중앙화하여 DB의 특성을 알 필요 없는 것. 추후 다른 데이터베이스로 migration할 경우, 다른 부분 수정할 필요 없이 Recorder 레이어만 수정하면 됨. 쿼리 복잡도가 없는 가벼운 로직은 Recorder로 분리하지 않고 Repository에서 작업하기도 함.
이와 같이 클린 아키텍처는 각 layer를 분리함으로써 로직이 독립적으로 존재하고 비즈니스에 집중할 수 있도록 함. 그리고 layer를 분리하면 테스트 코드 작성에도 용이함. counterfeiter라는 것을 이용하면 모든 layer에 대한 fake implementations를 각각 생성할 수 있음. 그래서 Handler는 Usecase fakes 활용하고, Usecase는 Service fakes 활용하고, Service는 Repository fakes를 활용하는 등 아래 단계 layer의 모킹 객체를 쉽게 사용할 수 있음. 각 layer마다 테스트 케이스를 위한 fake 패키지를 소유하고 있음.
의존성 주입 (Dependency Injection)
클린 아키텍처의 철학은 “각 layer가 서로 독립적이어야 한다”임. 동작하려면 각 layer간 dependency(의존성)이 필수적임. 클린 아키텍처는 이 의존성을 아래와 같이 단방향으로 관리함. 즉, 가장 안쪽에 있는 남들이 의존하는 가장 중심에 있는 것은 비즈니스 로직이어야 함. 비즈니스 로직이 바뀌어서 바깥 부분들이 바뀌는 건 괜찮지만 다른 것들 (Handler, Repository 등)이 바뀌어서 비즈니스 로직을 바꾸는 건 안됨!!!!
의존성이란 : A가 B에 의존한다면, B가 변경되면 A에도 영향을 미치는 것.
Handler와 Repository는 Domain(비즈니스)를 의존함. 거꾸로 의존하면 안됨. 그러나 클린 아키텍처 사상에서 layer간 결합도를 최대한 줄여야 한다고 했음. 그래서 사용하는 것이 의존성 주입(Dependency Injection)임!!
의존성 주입은 내부가 아닌 ‘외부’에서 객체를 생성해서 넣어주는 것임.
articleRepo := mysqlRepo.NewArticleRepository(dbConn)
// Build service Layer
svc := article.NewService(articleRepo, authorRepo)
rest.NewArticleHandler(e, svc)
func NewService(a ArticleRepository, ar AuthorRepository) *Service {
return &Service{
articleRepo: a,
authorRepo: ar,
}
}
func NewArticleHandler(e *echo.Echo, svc ArticleService) {
handler := &ArticleHandler{
Service: svc,
}
e.GET("/articles", handler.FetchArticle)
e.POST("/articles", handler.Store)
e.GET("/articles/:id", handler.GetByID)
e.DELETE("/articles/:id", handler.Delete)
}
위 코드에서 Repository를 만들고, 이 Repository를 Service에 주입하고, Service를 Handler에 주입하고 있음.
Repository를 Service만들때 주입하여
Repository → ArticleRepository(인터페이스) ← Service
이렇게 두 layer 모두 인터페이스를 바라보고 있고,
Service를 Handler에 주입하여
Service → ArticleService(인터페이스) ← Handler
이렇게 두 layer 모두 인터페이스를 바라보기 때문에 의존성 역전 원칙에 부합하는 것!!
'아키텍처' 카테고리의 다른 글
Go 디자인패턴 - Concurrency Patterns (동시성 패턴) (0) | 2024.12.19 |
---|---|
Go 디자인패턴 - Behavioral Patterns (행동 패턴) (2) | 2024.12.19 |
Go 디자인패턴 - Structural Patterns (구조 패턴) (0) | 2024.12.19 |
Go 디자인패턴 - Creational Patterns (생성패턴) (0) | 2024.12.19 |