Go는 왜 컴파일 속도가 빠를까?

Go는 왜 컴파일 속도가 빠를까?

김태홍 (bluemiv)
최종 수정일: 2025-04-07 14:33:18
작성일: 2025-04-07 13:21:14
AD

1. Go의 컴파일 속도가 빠른 이유

Go 언어는 다른 컴파일 언어에 비해 매우 빠르게 컴파일을 합니다. 이는 철저하게 프로그래밍 언어 설계와 컴파일러 구조를 최적화하기 위해 의도한 것이며, 속도 향상을 목표로 개발한 언어입니다.

1.1. 간단한 의존성 관리

Go는 의존성 관계를 그래프로 단순화하였습니다. 패키지 간의 순환 의존성을 허용하지 않도록 하였고, 명시적으로 import된 패키지만을 읽고 분석합니다. 이런 구조는 간결하게 의존성 탐색을 할 수 있게 하고, 예측 가능하게 만들어 전체 컴파일 단계에서 불필요한 재컴파일을 방지합니다.

예를 들어, SNS 애플리케이션에서 user 패키지가 post 패키지를 참조한다고 가정해보면,

// user/user.go
package user
 
import "sns/post"
 
func CreateUser(name string) {
    post.LogUserCreation(name)
}

이 때 post 패키지가 다시 userimport하면, "순환 의존성 방지" 규칙에 의해 컴파일 에러가 발생합니다.

또한, Go는 패키지를 DAG(Directed Acyclic Graph, 방향 비순환 그래프) 형태로 구성함으로써, 모든 패키지를 병렬적으로 컴파일 할 수 있습니다.

1.2. 컴파일 속도에 최적화된 컴파일러 구조

다음과 같이 Go의 컴파일러(gc)는 속도에 초점을 맞추어 개발되었습니다.

  • 전처리(preprocessing) 과정이 없음
  • 한 번의 패스(pass)로 대부분의 분석과 최적화를 수행
  • 단일 바이너리로 동작하며, 외부 도구에 의존하지 않음
Go 컴파일러의 내부 구조 흐름을 단순화한 그림
Go 컴파일러의 내부 구조 흐름을 단순화한 그림

이와 같이, Go는 다단계로 나누기보다는 가능한 한번에 많은 처리를 끝내는 구조로 설계되어, 컴파일 속도가 빠릅니다.

1.3. 패키지 단위로 캐싱

Go는 패키지 단위로 캐싱(해시 기반)을 수행합니다. 한번 컴파일된 패키지는 다시 컴파일되지 않습니다.

  • 변경되지 않은 패키지는 매번 새로 빌드하지 않음
  • 빌드 시간이 변경된 패키지의 수에 거의 선형적으로 비례함

예를 들어, 다음과 같은 구조의 프로젝트가 있다고 할때,

sns-app/
├── user/
│   └── user.go
├── post/
│   └── post.go
└── main.go

post 패키지만 수정하고 go build를 실행하면, post만 재컴파일되고 user는 캐시된 결과를 사용합니다. 이와 같이 캐시 덕분에 빠른 컴파일 속도를 유지할 수 있습니다.

1.4. 헤더 파일이 없음

C나 C++과 달리, Go는 헤더 파일(header file) 개념이 존재하지 않습니다. Go에서는 모든 정의가 .go 파일 내에서 이루어지며, 필요한 시점에 import를 통해 내부 구조를 알 수 있게 됩니다.

C++의 경우, 다음과 같이 헤더와 구현 파일을 분리해서 작성해야 한다.

// user.h
class User {
public:
    void CreateUser(std::string name);
};
 
// user.cpp
#include "user.h"
void User::CreateUser(std::string name) {
    // ...
}

cpp의 방식은 컴파일러가 user.h를 포함하는 모든 파일을 재컴파일하게 됩니다. 반면 Go는 인터페이스와 구현부를 명확히 분리하지 않고, 하나의 소스 파일로 모든 것을 표현하여 불필요한 재컴파일을 피할 수 있습니다.

1.5. 정적 링크 기반 실행 파일 생성

Go는 기본적으로 정적 링크(static linking)를 사용하여 실행 파일을 생성합니다. 정적 링크 방식은 외부 라이브러리를 동적으로 불러오는 대신, 컴파일 시점에 모든 코드가 실행 파일에 포함되는 방식입니다.

정적 링크와 동적 링크 비유 예시

  • 정적 링크(static linking): 모든 반찬을 도시락에 다 싸준 상태 → 그냥 도시락 하나 들고 가서 먹으면 끝!
  • 동적 링크(dynamic linking): 밥은 도시락에 있고 반찬은 식당 가서 받아야 하는 상태 → 실행할 때, 시스템이 "반찬 어디 있어?" 하고 찾아다님

모든 코드가 실행 파일에 포함이 되면, 다음과 같은 이점이 있습니다.

  • 컴파일 이후 실행 파일은 다른 의존성 없이 독립적으로 실행 가능
  • 링커 단계에서 복잡한 의존성 탐색을 하지 않아 속도 향상
  • 배포가 간편하며 실행 환경 제약이 적다

예를 들어,

// main.go
package main
 
import (
    "sns/user"
    "sns/post"
)
 
func main() {
    user.CreateUser("bluemiv")
    post.CreatePost("첫 글", "안녕하세요!")
}

main.go에서 userpostimport하고 go build 명령어를 실행하면, 외부 라이브러리 설치 없이 main에서 참조하는 모든 코드가 단일 실행 파일로 생성됩니다.

다른 글 "Go 언어의 장단점과 brew를 사용하여 MacOSX 환경에서 설치"하는 방법에 대해서도 참고하시면 좋습니다.

AD