25P by GN⁺ 5일전 | ★ favorite | 댓글 4개
  • 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가 패키지를 어떻게 생각하고 순환 종속성을 어떻게 처리하는지에 대한 멋진 설명임