Go(Golang) 프로젝트 폴더 구조 컨벤션 — cmd · internal · pkg 제대로 쓰는 법 (실전 예시 포함)
Go 프로젝트의 표준 폴더 구조 컨벤션을 cmd, internal, pkg를 중심으로 정리했습니다. golang-standards/project-layout이 권장하는 디렉터리의 의미, internal 내부를 domain · platform · transport로 분리하는 4계층 아키텍처, 패키지·파일 네이밍 규칙, 그리고 실제 OAuth2 서버 프로젝트의 트리 예시까지 한 번에 살펴봅니다.
Go로 어느 정도 규모 있는 서비스를 만들기 시작하면 가장 먼저 부딪히는 질문이 있습니다. “폴더는 어떻게 나눠야 하지?” 다른 언어에서 넘어온 분은 src/를 만들고 싶고, 마이크로서비스 책을 본 분은 entities/usecases/adapters로 잘게 쪼개고 싶어집니다. 그런데 막상 유명 Go 오픈소스를 열어 보면 대부분 비슷한 모양 — cmd/, internal/, pkg/, migrations/, web/ — 으로 정리돼 있습니다.
이 글은 Go 커뮤니티에서 사실상의 표준으로 자리잡은 golang-standards/project-layout 컨벤션을 기준으로, 각 디렉터리가 왜 그렇게 생겼는지 그리고 internal/ 내부를 어떻게 나눠야 유지보수가 쉬워지는지를 정리합니다. 추상적인 설명만 늘어놓지 않기 위해 실제 운영 중인 OAuth2 Authorization Server(Fosite + Redis 기반) 프로젝트의 트리를 그대로 가져와 예시로 사용합니다.
목차
- 왜 폴더 구조부터 잡아야 하는가
- Go 표준 프로젝트 레이아웃 한눈에 보기
/cmd— 바이너리 진입점만 둔다/internalvs/pkg— 가장 헷갈리는 두 디렉터리/internal안을 4계층으로 나누기 — domain · platform · transport · oauth/migrations,/configs,/web,/docs— 그 외 디렉터리- 패키지·파일 네이밍 규칙
- 실전 예시: OAuth2 서버 전체 트리
- 피해야 할 안티패턴
- FAQ
1. 왜 폴더 구조부터 잡아야 하는가
Go는 패키지 단위로 가시성·의존성·테스트가 결정되는 언어입니다. 즉 폴더를 어떻게 자르느냐가 곧 아키텍처입니다. 폴더가 잘못 잡히면 다음과 같은 문제가 줄줄이 따라옵니다.
- 순환 import:
package A → B → A가 발생하는 순간 컴파일 자체가 안 됩니다. Go에는 자바의 forward declaration이 없습니다. - 테스트가 무거워짐: HTTP 핸들러 안에 DB 쿼리를 직접 박아 두면 단위 테스트가 통합 테스트가 되어 버립니다.
- 재사용 불가:
internal/안에 모든 것을 넣으면 다른 저장소에서 못 갖다 씁니다. 반대로 전부pkg/에 넣으면 외부에 노출하고 싶지 않은 코드까지 공개됩니다. - 신규 합류자 진입 장벽: 새 동료가 들어왔을 때 “이 코드는 어디에 추가해야 하나요?”라는 질문이 반복됩니다.
폴더 구조 컨벤션은 결국 “이 코드는 어디에 둘 것인가”라는 질문에 대한 미리 합의된 답입니다. 일관된 규칙이 있으면 코드 리뷰가 빨라지고, AI 코드 어시스턴트도 더 정확한 위치에 파일을 만들어 줍니다.
2. Go 표준 프로젝트 레이아웃 한눈에 보기
golang-standards/project-layout이 권장하는 최상위 디렉터리는 대략 다음과 같습니다.
myproject/
├── cmd/ # 빌드 가능한 바이너리 진입점
├── internal/ # 외부 import를 막고 싶은 내부 코드
├── pkg/ # 외부에서 import해도 되는 라이브러리 코드
├── api/ # OpenAPI/Proto 정의, gRPC 스키마 등
├── configs/ # yaml/keys 등 런타임 설정 파일
├── migrations/ # DB 마이그레이션 SQL
├── deploy/ # Dockerfile, k8s manifest, nginx 설정 등
├── web/ # 템플릿, 정적 파일
├── docs/ # 설계 문서, 다이어그램
├── scripts/ # 일회성 셸 스크립트
├── go.mod
└── go.sum
⚠️ 이 레이아웃은 공식 표준은 아닙니다. Go 팀이 만든 게 아니라 커뮤니티 컨벤션입니다. 하지만 Docker, Kubernetes, etcd, Prometheus 같은 대형 프로젝트가 모두 비슷한 형태를 따르고 있어 사실상 표준처럼 동작합니다.
전부 다 만들 필요는 없습니다. 작은 프로젝트라면 cmd/, internal/, go.mod 셋만 있어도 충분합니다. 필요해질 때마다 하나씩 추가하면 됩니다.
3. /cmd — 바이너리 진입점만 둔다
cmd/<binary-name>/main.go 는 딱 한 가지 역할만 합니다. 의존성을 조립(wiring)해서 실행하는 것.
// cmd/api/main.go
package main
func main() {
cfg := config.MustLoad()
db := platformdb.MustConnect(cfg.DB)
rdb := platformredis.MustConnect(cfg.Redis)
srv := httpapi.New(cfg, db, rdb)
if err := srv.Run(); err != nil {
log.Fatal(err)
}
}
여기에 비즈니스 로직을 작성하지 않습니다. main.go는 의존성 그래프의 루트일 뿐, 모든 도메인 로직은 internal/ 아래에 있어야 합니다. 이 규칙을 지키면 다음이 자연스럽게 따라옵니다.
- 여러 바이너리를 만들기 쉬워집니다. 예를 들어
cmd/api/(외부 API),cmd/console/(관리자),cmd/hashpw/(CLI 유틸) 처럼 진입점만 추가하면 됩니다. main패키지는 테스트하지 않아도 됩니다. 어차피 wiring이 전부이므로 통합 테스트로 대체합니다.
실전 예시 — OAuth2 서버는 네 개의 바이너리를 갖습니다.
cmd/
├── api/ # 공개 REST API 서버
├── web/ # OAuth2 로그인/동의 화면 렌더링
├── console/ # 내부 관리자 콘솔
└── hashpw/ # 비밀번호 해싱 CLI 도구
각각의 main.go는 50줄 안팎이고, 모두 internal/ 의 같은 도메인 패키지를 공유합니다.
4. /internal vs /pkg — 가장 헷갈리는 두 디렉터리
두 디렉터리의 차이는 Go 컴파일러가 직접 강제하는 가시성 규칙에서 옵니다.
/internal
internal/은 Go 언어가 인식하는 특별한 디렉터리 이름입니다. myproject/internal/foo 패키지는 myproject/ 트리 안에서만 import할 수 있고, 다른 모듈에서는 임포트 자체가 컴파일 에러가 됩니다.
// myproject 외부 모듈에서
import "github.com/me/myproject/internal/foo" // ❌ 컴파일 에러
→ 공개 API가 되어선 안 되는 코드는 전부 internal/에 넣으세요. 비즈니스 로직, 핸들러, 도메인, 인프라 래퍼 거의 모든 것이 여기에 들어갑니다.
/pkg
pkg/는 언어 규칙이 아니라 컨벤션입니다. “여기에 있는 패키지는 외부에서 import해도 안전합니다”라는 약속의 표시입니다. 보통 다음 같은 경우에만 만듭니다.
- OpenAPI/Swagger 등에서 자동 생성된 클라이언트 SDK (
pkg/api/) - 공개해도 좋은 유틸리티 라이브러리 (예: 사내 다른 서비스가 함께 쓰는 에러 코드 상수)
💡 확신이 없다면
internal/에 넣으세요. 나중에 외부 공개가 필요해지면internal/에서pkg/로 옮기는 건 쉽지만, 반대로 회수하는 건 (이미 누가 import 해 갔을 수 있으므로) 어렵습니다.
pkg/ 자체를 만들지 말자는 의견도 많습니다. Russ Cox(Go 팀 리드)도 “필요하면 만들고, 없어도 된다”는 입장입니다. 모놀리식 모듈이라면 굳이 pkg/를 두지 않고 루트에 패키지를 두는 것도 정상입니다.
5. /internal 안을 4계층으로 나누기 — domain · platform · transport · oauth
여기가 진짜 본론입니다. internal/ 안을 어떻게 더 자르느냐가 프로젝트 수명을 결정합니다. 실전에서 가장 잘 작동하는 패턴은 4계층 분리입니다.
internal/
├── domain/ # 도메인 로직 (HTTP/DB/Redis를 모름)
├── platform/ # 인프라 어댑터 (DB·Redis·로거·암호화 래퍼)
├── transport/ # 외부 표면 (HTTP 핸들러·미들웨어)
└── <feature>/ # 도메인 한 덩어리에 안 맞는 기능 패키지
가장 중요한 규칙은 단 하나입니다.
의존성은 안쪽으로만 흐릅니다.
transport→domain/platform은 OK.domain→transport는 절대 금지.
이게 클린 아키텍처/헥사고날 아키텍처의 핵심이고, Go에서는 폴더로 그대로 표현됩니다.
5-1. internal/domain/ — 도메인 패키지
비즈니스 모델 한 덩어리(=Aggregate)마다 폴더 하나입니다. HTTP, Fosite, Redis 같은 인프라 디테일을 몰라야 합니다.
internal/domain/
├── user/
│ ├── entity.go # User struct, 도메인 메서드
│ ├── repository.go # Repository 인터페이스 (구현체 아님)
│ └── service.go # 유스케이스 로직
├── company/
├── department/
├── role/
├── rank/
├── authority/
├── service/
├── contract/
├── attachment/
└── oauthclient/
entity.go / repository.go / service.go 세 개로 나누는 게 무난한 출발점입니다.
entity.go— 도메인 타입과 그 타입에 붙는 메서드.repository.go— 영속화 인터페이스. 인터페이스는 사용하는 쪽(=domain)에서 정의하고 구현체는platform또는 어댑터 패키지에서 합니다. 이게 “Accept interfaces, return concrete types” 원칙입니다.service.go— 트랜잭션 스크립트 또는 유스케이스.Repository를 주입받아 사용합니다.
5-2. internal/platform/ — 인프라 래퍼
DB 연결, Redis 클라이언트, 로거, 암호화처럼 외부 시스템과 닿는 코드는 여기서 한 번 감싸서 다른 패키지에 노출합니다.
internal/platform/
├── config/ # env/config 로딩 (다른 패키지는 직접 os.Getenv 금지)
├── db/ # DB 커넥션 + 마이그레이션 러너
├── redis/ # go-redis 래퍼 (다른 패키지는 go-redis 직접 import 금지)
├── logger/ # slog 설정
├── crypto/ # 키 관리·서명
├── hashing/ # bcrypt/argon2 래퍼
├── ratelimit/ # 레이트리밋 어댑터
└── objectstore/ # S3/MinIO 래퍼
왜 한 번 감쌀까요?
- 테스트하기 쉬워집니다.
platform/redis를 인터페이스로 두면domain테스트에서miniredis로 갈아끼울 수 있습니다. - 라이브러리 교체 비용이 줄어듭니다.
go-redisv8 → v9 마이그레이션이platform/redis/한 폴더에서 끝납니다. - 금지 규칙을 강제할 수 있습니다. “환경변수는
platform/config만 읽는다”, “Redis는platform/redis만 잡는다”는 규칙이 골랑시린트의depguard같은 도구로 자동 검증됩니다.
5-3. internal/transport/ — 외부 표면
HTTP, gRPC, CLI, 메시지 큐 컨슈머처럼 외부와 닿는 입출력 어댑터가 모이는 곳입니다.
internal/transport/
├── httpapi/ # 공개 REST API
│ ├── handler/ # 리소스별 핸들러 (login.go, user.go, oauth.go …)
│ ├── middleware/ # 인증·로깅·레이트리밋
│ └── humaerr/ # 공통 에러 매핑 헬퍼
└── httpconsole/ # 관리자 콘솔용 (api와 분리)
핵심 원칙:
- 핸들러에 비즈니스 로직 금지. 핸들러는 요청을 파싱 → 도메인 서비스 호출 → 응답 직렬화. 끝.
- 에러 매핑은 한 곳에. 핸들러마다
if errors.Is(err, ...) { c.JSON(...) }을 반복하면 일관성이 깨집니다.humaerr/같은 공유 헬퍼로 모읍니다. - 공개 API와 관리자 API는 폴더부터 분리. 인증 정책·CORS·로깅이 다르므로 미들웨어 체인이 섞이는 걸 막아야 합니다.
5-4. 그 외 기능 패키지 (internal/oauth/, internal/session/, internal/onboarding/)
도메인 Aggregate에 깔끔하게 안 들어가는 횡단 기능은 별도 feature 패키지로 둡니다.
internal/oauth/— Fosite OAuth2 프로바이더 와이어링 (provider.go,keys.go,session.go). 토큰 스토어 구현체는internal/oauth/fositestore/로 한 단계 더 내려갑니다.internal/session/— Redis 기반 사용자 세션. OAuth 토큰 스토어와는 다른 개념이므로 일부러 분리.internal/onboarding/— 회원 가입 코드/유형/서비스를 묶은 기능 패키지.
새 최상위 디렉터리 추가는 보수적으로. 먼저 domain/platform/transport로 들어갈 수 있는지 검토하고, 정 안 맞을 때만 새 폴더를 팝니다.
6. /migrations, /configs, /web, /docs — 그 외 디렉터리
/migrations
migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_oauth_clients.up.sql
└── 000002_add_oauth_clients.down.sql
- 번호 + 이름 +
.up.sql/.down.sql쌍이 컨벤션입니다. (golang-migrate/migrate포맷) - 이미 적용된 마이그레이션은 절대 수정하지 않습니다. 잘못된 마이그레이션은 새 번호로 정정합니다. 이걸 어기면 운영/스테이징/개발 DB가 따로 놀게 됩니다.
/configs
런타임 설정 파일(yaml, OAuth 서명용 키 등)을 둡니다. 비밀값은 절대 커밋하지 않습니다. .gitignore로 막고, 샘플(.env.example)만 추적합니다.
/web
web/
├── templates/ # Go html/template (로그인·동의 화면 등)
└── static/ # css, image, js
서버 사이드 렌더링 화면이 있는 경우만 만듭니다. SPA를 별도 저장소로 두는 구조라면 web/ 자체가 없어도 됩니다.
/docs
설계 메모, 시퀀스 다이어그램, 운영 런북을 두는 곳입니다. 권위 있는 스펙(authoritative spec)이 아닙니다. 진실의 원천은 코드입니다. docs/는 그 코드를 이해하는 보조 자료입니다.
/deploy
Dockerfile, k8s 매니페스트, nginx conf 등 배포 산출물. 운영 환경별로 더 나누고 싶으면 deploy/nginx/, deploy/k8s/prod/ 처럼 한 단계 더 내립니다.
7. 패키지·파일 네이밍 규칙
폴더 구조만큼 중요한 게 이름 규칙입니다. Go는 패키지 이름이 곧 호출 시 접두사라 어색한 이름은 코드 전반의 가독성을 떨어뜨립니다.
| 항목 | 규칙 | 예시 |
|---|---|---|
| 패키지 이름 | 단수, 소문자, 언더스코어 없음 | session ✅, sessions/session_store ❌ |
| 파일 이름 | 소문자, snake_case 지양 | store.go ✅, login_handler.go ❌ → login.go ✅ |
| 한 파일 책임 | 한 가지. 300줄 넘으면 분리 | user/entity.go, user/service.go |
| 스터터링 금지 | 패키지명을 타입명에 반복하지 않음 | session.Store ✅, session.SessionStore ❌ |
| 인터페이스 위치 | 사용하는 쪽에서 정의 | domain/user.Repository (구현은 platform/db/userrepo) |
| 약어 | URL, ID, HTTP는 모두 대문자 또는 모두 소문자 | userID ✅, userId ❌ |
net/http, database/sql 같은 표준 라이브러리가 살아 있는 모범 사례입니다. 짧고, 단수형이며, 호출부에서 http.Get, sql.Open처럼 자연스럽게 읽힙니다.
8. 실전 예시: OAuth2 서버 전체 트리
지금까지 설명한 규칙을 한 프로젝트에 다 적용하면 이런 모양이 됩니다. (실제 운영 중인 OAuth2 Authorization Server)
oc-atrium-api-go/
├── cmd/
│ ├── api/
│ ├── console/
│ ├── hashpw/
│ └── web/
├── configs/
│ └── keys/
├── deploy/
│ └── nginx/
├── docs/
├── internal/
│ ├── domain/
│ │ ├── attachment/
│ │ ├── authority/
│ │ ├── company/
│ │ ├── contract/
│ │ ├── department/
│ │ ├── oauthclient/
│ │ ├── rank/
│ │ ├── role/
│ │ ├── service/
│ │ ├── user/
│ │ └── validation/
│ ├── oauth/
│ │ └── fositestore/
│ ├── onboarding/
│ ├── platform/
│ │ ├── ad/
│ │ ├── config/
│ │ ├── crypto/
│ │ ├── db/
│ │ ├── hashing/
│ │ ├── logger/
│ │ ├── objectstore/
│ │ ├── ratelimit/
│ │ └── redis/
│ ├── session/
│ └── transport/
│ ├── httpapi/
│ └── httpconsole/
├── migrations/
├── pkg/
│ └── api/ # OpenAPI에서 자동 생성된 클라이언트
├── web/
│ ├── static/
│ └── templates/
├── go.mod
├── go.sum
├── Dockerfile
├── Makefile
└── CLAUDE.md # AI 어시스턴트용 컨벤션 명세
핵심 관찰 포인트:
cmd/는 4개의 바이너리 진입점만. 비즈니스 로직은 한 줄도 없습니다.domain/아래에 11개의 Aggregate 폴더. 각각이 자기 모델·리포지토리 인터페이스·서비스를 갖습니다.platform/아래에 9개의 단일 책임 어댑터.redis,db,config,logger같은 게 전부 한 폴더씩.transport/가httpapi(공개)와httpconsole(관리)로 분리. 인증/로깅 미들웨어가 섞이지 않습니다.- OAuth/세션/온보딩은 도메인이 아니라 feature 패키지로 격리. 도메인 Aggregate에 억지로 욱여넣지 않습니다.
pkg/api/만 외부 공개. 그 외 모든 코드는internal/에 들어가 외부 import가 컴파일러 단에서 막힙니다.
9. 피해야 할 안티패턴
마지막으로 자주 보는 실수들을 정리합니다.
❌ utils/, common/, helpers/ 같은 쓰레기 폴더
이름 자체가 “여기에 뭐가 들었는지 모릅니다”라는 자백입니다. 시간이 지나면 모든 게 다 들어와 거대한 의존성 핫스팟이 됩니다. 기능별로 이름을 붙이세요. hashing/, crypto/, ratelimit/처럼.
❌ models/, controllers/, services/ 같은 계층별 그룹화
Rails나 Spring에서 익숙한 구조지만 Go에서는 안티패턴입니다. 사용자 기능 하나를 고치려면 세 폴더를 왔다 갔다 해야 합니다. Go에서는 기능별로 그룹화합니다(user/, order/ 폴더 안에 entity/repo/service가 함께).
❌ /src 디렉터리
다른 언어 출신이 가장 먼저 만드는 폴더지만 Go에서는 불필요합니다. Go 모듈은 go.mod가 있는 곳이 곧 모듈 루트입니다. cmd/, internal/을 루트에 바로 두세요.
❌ 너무 이른 인터페이스 (premature interface)
“나중에 mock 할 수도 있으니까”라며 모든 타입에 인터페이스를 붙이지 마세요. 두 번째 구현이 실제로 등장할 때가 인터페이스를 추출할 타이밍입니다. YAGNI.
❌ internal/ 안에 또 pkg/ 만들기
보통은 의미가 없습니다. internal/ 자체가 비공개라는 표시입니다. 그 안에서 또 공개/비공개를 나누고 싶다면 그건 다른 신호 — 모듈을 쪼개야 할 시점인지 검토하세요.
❌ 패키지 단위 mutable 상태와 init()
var defaultClient *http.Client 같은 패키지 전역과 init() 함수는 테스트와 동시성을 망칩니다. 모든 의존성은 생성자 함수로 주입하세요(New(cfg, db, rdb)).
FAQ
Q. pkg/를 꼭 만들어야 하나요?
A. 아닙니다. 단일 모듈이고 외부에 노출할 라이브러리가 없다면 만들지 마세요. 모든 코드를 internal/에 두는 게 안전합니다.
Q. 도메인 폴더 하나에 파일이 많아지면 어떻게 하나요?
A. 책임 단위로 더 쪼개세요. user/entity.go가 비대해지면 user/profile.go, user/password.go처럼 한 도메인 안에서 파일을 나눕니다. 패키지를 더 쪼개는 건 마지막 수단입니다 — 한 Aggregate를 여러 패키지로 찢으면 내부 메서드를 자꾸 Exported로 올려야 합니다.
Q. DDD의 Aggregate와 폴더는 1:1인가요?
A. 그렇게 맞추는 게 가장 깔끔합니다. user/, company/, order/처럼 Aggregate 루트 단위로 폴더 하나. 다만 작은 프로젝트라면 굳이 DDD 용어에 얽매일 필요 없이 “이 데이터들은 같이 변한다” 기준으로 묶어도 됩니다.
Q. 마이크로서비스라면 구조가 달라지나요?
A. 거의 같습니다. 서비스 하나가 작아지면 internal/ 안의 레이어 수가 줄어들 뿐입니다. 정말 작은 서비스라면 cmd/, internal/server/, go.mod 만으로도 충분합니다.
Q. AI 코드 어시스턴트와 이 구조를 어떻게 결합하나요?
A. 프로젝트 루트에 CLAUDE.md(또는 AGENTS.md) 같은 컨벤션 문서를 두고 “이 코드는 어디에 둔다”, **“어떤 패키지가 어떤 패키지를 import해도 되는가”**를 명문화해 두면 됩니다. 새 파일을 만들 때 AI가 잘못된 위치에 두는 사고가 크게 줄어듭니다.
정리
Go 폴더 구조의 정답은 한 줄로 요약됩니다.
cmd/는 진입점만,internal/은 4계층(domain · platform · transport · feature)으로 잘 자르고,pkg/는 정말 외부에 줄 게 있을 때만 만든다.
이 규칙만 지켜도 6개월 뒤의 자신이, 새 동료가, 그리고 AI 어시스턴트가 코드를 훨씬 빨리 이해할 수 있습니다. 처음부터 완벽한 구조를 잡으려 하지 말고, cmd/와 internal/ 두 폴더로 시작해서 코드가 자라는 만큼 platform/, transport/, migrations/를 하나씩 추가하는 점진적 방식을 추천합니다.
폴더 구조는 한 번 잘못 잡으면 되돌리기 어렵지만, 컨벤션을 한 번 합의해 두면 그 다음부터는 “어디에 둬야 하지?”라는 질문이 사라집니다. 그게 좋은 구조의 진짜 가치입니다.