Go의 Layered 설계 방식
(jerf.org)- Go 언어는 패키지 간 순환 참조를 엄격히 금지하기 때문에, 이는 자연스럽게 계층적 설계(layered design) 를 유도함
- 이 글은 Go 프로젝트를 의무적으로 갖게 되는 계층 구조를 설명하고, 그 위에 별도의 아키텍처를 강제하지 않아도 충분히 유효하다고 주장함
- 순환 의존이 발생할 경우, 이를 해결하기 위한 구체적이고 실용적인 리팩터링 전략을 단계별로 제시함
- 각 패키지는 독립적으로 의미 있는 기능 단위를 가지도록 설계되어, 테스트, 유지보수, 마이크로서비스 분리에도 유리함
- 결과적으로 이 방식은 실제 코드 설계에서 흔히 발생하는 "바나나를 원했는데 정글을 들고 온다"는 문제를 방지함
Go에서의 레이어드 설계 접근법
기본 원칙
- Go는 패키지 간 순환 참조 금지
- 모든 Go 프로그램의 import 관계는 유향 비순환 그래프(DAG) 를 구성해야 함
- 이 구조는 선택이 아닌 언어 레벨에서 강제되는 설계 규칙
패키지 레이어링의 자동 형성
- 외부 패키지를 제외한 프로젝트 내부 패키지들은 참조 깊이에 따라 자동으로 레이어링 가능
- 아래 그림처럼 최하단에는 metrics, logging, 공용 자료구조 등 핵심 유틸리티 패키지가 위치
- 이후 상위 패키지들이 점점 기능을 조합하면서 위로 쌓이는 구조를 형성
이 설계 방식의 특성
- 레이어는 계층적 추상화가 아닌 참조 방향성에 기반
- 하나의 패키지는 다수 하위 레벨 패키지를 참조 가능
- MVC, 헥사고날 아키텍처 등 기존 설계 방식도 이 구조 위에 "적용" 가능함
→ 단, Go의 구조적 제약을 반드시 고려해야 함
순환 참조 해결 전략
순환 참조 발생 시 아래 순서대로 리팩터링을 시도:
1. 기능 이동
- 가장 추천되는 방식
- 순환을 유발하는 기능을 정확히 분석해, 논리적으로 적절한 위치로 옮김
- 자주 쓰이진 않지만 개념적 명확성을 가장 많이 향상시킴
2. 공용 기능을 별도 패키지로 분리
- 양쪽에서 공통으로 쓰는 타입이나 함수(
Username
등)를 제3 패키지로 이동 -
패키지가 작아 보일지라도 과감히 분리
→ 시간이 지나며 해당 패키지가 커질 가능성이 높음
3. 상위 조합 패키지 생성
- 순환하는 두 패키지를 조합하는 제3 패키지를 생성
- 예:
Category
,BlogPost
양방향 의존을 상위 패키지로 분리
→ 하위 패키지는 dumb struct로 유지, 실제 기능은 상위 패키지에서 조합
4. 인터페이스 도입
- 구조체나 함수가 필요한 메서드만 가진 인터페이스로 의존성 치환
- 불필요한 의존성 제거 및 테스트 편의성 확보
- 단, 지나치게 사용하면 오히려 설계가 복잡해질 수 있음
5. 복사(Copy)
- 의존 대상이 매우 작을 경우, 간단히 복사해서 사용
- DRY 위반처럼 보일 수 있지만 실제로는 설계 명확화에 도움되는 경우 많음
6. 하나의 패키지로 합치기
- 위 방법이 모두 불가능하면 두 패키지를 병합
- 너무 큰 패키지가 되지 않는다면 수용 가능
→ 단, 무조건적인 병합은 지양하고 신중히 결정
이 설계 방식의 실용적 장점
- 각 패키지는 스스로 의미 있는 기능 단위를 가지며 독립 테스트 가능
- 패키지 내 참조가 제한되어, 전체 코드 이해 없이도 개별 패키지 이해가 가능
- 의도하지 않은 전체 의존성 연결(=정글 문제)을 피하고, 필요한 것만 사용하는 코드 작성 유도
-
마이크로서비스 분리 시에도 손쉽게 추출 가능
→ 대부분의 종속성이 명확히 정의되어 있음
결론
- Go의 패키지 설계 제약은 귀찮은 제약이 아닌 좋은 설계 유도 장치
- 특별한 아키텍처 없이도 패키지 간 참조 구조만으로도 견고한 설계 구현 가능
- 순환 참조에 대한 정교한 분석과 리팩터링 전략은 Go뿐만 아니라 타 언어에도 유효
스프링 개발할때 가장 힘든것들 중 하나가 의존성 순환이었던거 같아요..
무한하게 서로 초기화하면서 메모리 누수로 뻗어버리는 그 답답함이란...
Hacker News 의견
-
순환 종속성을 허용하지 않는 것은 대규모 프로그램을 구축할 때 훌륭한 설계 선택임
- 이는 관심사를 적절히 분리하도록 강제함
- 순환 종속성이 발생하면 설계에 문제가 있는 것이며, 기사는 이를 해결하는 방법을 잘 설명함
- 가끔 다른 패키지가 재정의하는 함수 포인터를 사용하여 순환 종속성을 해결함
- Go 컴파일러가 순환 종속성을 만들 때 더 유용한 출력을 제공했으면 좋겠음
- 현재는 루프에 관련된 모든 패키지 목록을 제공하는데, 이는 꽤 길 수 있으며 일반적으로 문제를 일으킨 것은 마지막으로 변경한 것임
-
훌륭한 블로그 게시물임
- 이 웹사이트에는 놀라운 게시물이 많으며, 함수형 프로그래밍에 대해 배우는 것을 좋아한다면 확인해보길 권장함
- 링크
-
"세 번째 패키지로 이동" 조언과 관련된 보너스 기술
- 많은 모델 구조(SQL, Protobuf, GraphQL 등)를 생성하면 생성된 계층 간의 명확한 방향성을 설정할 수 있음
- 모든 생성된 코드를 애플리케이션 코드에 "기본 패키지"로 제공하여 모든 것을 함께 구성함
- 이 기술 도입 전에는 "모델이 모델을 순환적으로 가져오는" 문제가 있었지만, 구조적 추가 계층 도입으로 완전히 사라짐
-
Yourdon 구조적 방법에 관한 책을 읽고 있는 것 같음
-
패키지가 서로 순환 참조할 수 없음
- 실제로 Go에서는 go:linkname을 사용하여 가능함
-
랜덤라이저의 구체 개념을 떠올리게 함
-
Golang의 재미있는 특징은 패키지 수준에서 순환 종속성을 가질 수 없지만, go.mod에서는 가질 수 있음
- 요약하자면, 그것도 하지 말아야 함
-
Jerf가 패키지를 어떻게 생각하고 순환 종속성을 어떻게 처리하는지에 대한 멋진 설명임