1. CI란 무엇이며 품질에 어떤 영향을 주는가
혼자 코드를 작성할 때는 “로컬에서는 다 초록색이야”라는 세계에 살기 쉽습니다. 문제는 “로컬”이 하나의 별도 행성이라는 점입니다. 여러분에게는 Go 버전도 하나, 도구 모음도 하나, 캐시도 하나, 운영체제도 하나, 그리고 gofmt를 “나중에 언젠가” 실행해도 된다고 믿는 믿음의 정도도 하나뿐입니다. CI (continuous integration)는 품질 검사를 중립적인 환경으로 옮겨, 모두에게 동일한 규칙이 자동으로 적용되게 하는 방법입니다.
CI는 대부분의 팀에서 두 가지 일을 동시에 해결합니다. 첫째, 언제나 동일한 방식으로 검사를 실행합니다. 즉, 같은 단계 순서, 같은 명령, 같은 “깨끗한” 환경입니다. 둘째, 품질을 “누군가의 의견”이 아니라 “계약”으로 바꿉니다. 브랜치가 quality gate를 통과하거나, 아니면 통과하지 못하거나 둘 중 하나입니다.
CI는 마치 피곤하지도 않고, 딴생각하지도 않으며, “에이, 그냥 두고 나중에 고치자”를 변명으로 받아들이지 않는 “검사 로봇”처럼 생각하면 편합니다.
미니 도식: CI가 프로세스 안에서 어디에 있는가
flowchart TD
A[코드를 작성함] --> B[로컬에서 검사를 실행함]
B --> C[변경 사항을 푸시함]
C --> D[CI가 quality gate를 실행함]
D -->|성공| E[변경을 받아들일 수 있음]
D -->|실패| F[수정하고 반복함]
이 도식은 아주 당연해 보이지만, 실제 상황을 만나기 전까지만 그렇습니다. go test가 “우연히” 실행되지 않았고, gofmt를 “깜빡”했으며, 린터는 IDE에서만 설정되어 있었다는 사실을 마주하면 말입니다. CI가 필요한 이유는 바로 기억력과 기분에 품질을 맡기지 않기 위해서입니다.
2. CI의 Quality gate: 타협 없는 동일한 단계
“CI에 품질을 넣자”라고 말하면 초보자들은 종종 마법을 떠올립니다. 마치 CI가 코드를 스스로 “좋게 만들어 주는” 새로운 도구인 것처럼 말이죠. 실제로 CI는 그저 명령을 실행하는 도구일 뿐입니다. 진짜 핵심은 다른 곳에 있습니다. 우리는 하나의 단계 목록을 고정하고 그 목록을 건너뛰지 못하게 하는 것입니다.
중요한 점은, CI에서 우리는 거의 절대 “자동으로 코드를 수정”하고 싶어하지 않는다는 것입니다. 예를 들어 gofmt -w를 실행하고 대신 커밋까지 하게 만드는 식은 피해야 합니다. CI는 편집자가 아니라 심판이어야 합니다. 코드가 이미 올바른 형태로 들어왔는지 확인하는 역할만 해야 합니다.
기본 원칙을 분명히 해두겠습니다. 여러분의 quality gate는 익숙한 도구들로 구성되지만, 이제 그것들은 깨끗한 환경에서 실행되며 명확한 “OK/FAIL” 신호를 줍니다.
권장되는 단계 순서
아래 순서는 “유일한 정답”은 아니지만, 매우 실용적인 흐름입니다:
- 포맷과 import(잡음과 작은 빌드 오류를 제거하기 위해),
- 정적 분석(go vet, 린터),
- 테스트(동작 확인).
왜 이 순서가 더 편할까요? 포맷팅과 import는 가장 저렴하고 가장 결정적인 검사이기 때문입니다. 문제를 빠르게 찾아내고, 단지 불필요한 import 하나 때문에 go test 로그 수십 줄을 읽게 만들지 않습니다.
3. 재현 가능한 명령: 로컬과 CI에서 동일하게
특히 학습용 프로젝트에서는 “로컬에서는 IDE의 초록색 삼각형을 누르고, CI는 알아서 하게 두자”라는 생각이 아주 유혹적입니다. 하지만 그 결과는 “왜 내 컴퓨터에서는 통과했는데 CI에서는 실패하지?” 같은 이상한 버그로 이어집니다. 더 안정적인 방법은 하나의 명령 집합을 정하고, 로컬과 CI에서 똑같이 실행하는 것입니다.
실제 프로젝트에서는 이를 위해 Makefile이나 작은 스크립트, 혹은 단순한 “항상 이 명령들을 실행한다”는 약속을 사용합니다. 여기서 중요한 것은 자동화 도구 자체가 아니라 원칙입니다. CI는 “다른 세계”가 되어서는 안 됩니다. CI는 같은 검사를 중립적인 환경에서 실행하는 것입니다.
미니 예제: Go 코드로 표현한 “품질 스크립트”
셸 스크립트에 대해 논쟁하지 않기 위해, quality gate를 Go의 단계 순서로 마음속에 그려볼 수도 있습니다:
package main
import "fmt"
type Step func() error
func run(steps []Step) error {
for i, s := range steps {
if err := s(); err != nil {
return fmt.Errorf("step %d failed: %w", i, err)
}
}
return nil
}
func main() {}
여기서 중요한 것은 구현이 아니라 생각입니다. 단계는 순서대로 진행되고, 첫 번째 실패가 전체 과정을 멈춥니다. 보통 CI도 정확히 이렇게 동작합니다: “fail fast”.
4. 학습용 애플리케이션 예제
CI를 추상적인 철학처럼 보이게 두지 않기 위해, 실제적인 무언가에 연결해 봅시다. 이 시점에서의 강의 커리큘럼에 작은 콘솔 애플리케이션이 있고(tasker라고 부르기로 합시다), 작업의 비즈니스 로직을 담은 패키지가 있다고 상상해 봅시다.
아이디어는 단순합니다. CI는 프로젝트를 빌드하고 검사를 실행할 수 있어야 합니다. 따라서 저장소에는 다음이 있어야 합니다:
- 컴파일 가능한 패키지,
- 최소한 하나의 테스트,
- go vet/린터가 잠재적으로 도움을 줄 수 있는 상황 최소 하나.
예제: task 패키지
package task
import "strings"
type Task struct {
Title string
Done bool
}
func New(title string) Task {
return Task{Title: strings.TrimSpace(title)}
}
여기에는 “어려운” 것은 없지만, 이미 검사할 것은 있습니다. 포맷팅, import, 테스트입니다.
예제: 동작 테스트
package task
import "testing"
func TestNew_TrimsSpace(t *testing.T) {
got := New(" buy milk ")
if got.Title != "buy milk" {
t.Fatalf("Title=%q", got.Title) // Title="buy milk"
}
}
이 테스트는 CI의 의미를 아주 잘 보여줍니다. 누군가 실수로 TrimSpace를 제거하면, CI는 똑똑해서 잡아내는 것이 아니라 규율을 지키기 때문에 이를 잡아냅니다.
5. 과하지 않은 최소 검증 파이프라인
이 절에서는 그날의 도구들을 하나의 “제어판”으로 묶어 보겠습니다. 파이프라인은 거대하고 무서운 것이 아니라, 최소한이고 반복 가능해야 한다는 점을 머릿속에 두는 것이 중요합니다. 검사가 너무 많고 시끄러우면 사람들은 그것을 존중하지 않게 됩니다. 꺼버리거나, 무시하거나, “일단 조용하게만 하자” 식으로 고치게 됩니다. 우리는 정반대를 원합니다. 신호가 깨끗해야 합니다.
아래는 테스트를 이미 사용하는 Go 프로젝트, 특히 학습용 프로젝트에 적합한 실용적인 최소 구성입니다.
기본 표: “도구 → CI에서의 역할”
| CI 단계 | 명령 | 이유 |
|---|---|---|
| 포맷 | gofmt (보통 gofmt -l로) | 코드가 이미 포맷되었는지 확인 |
| import | goimports (보통 goimports -l로) | import 순서/최신 상태 확인 |
| Vet | go vet ./... | 실행 전에 “거의 버그”를 잡기 |
| 린터 | staticcheck 또는 golangci-lint | 추가적인 품질 신호 |
| 테스트 | go test ./... | 시나리오에 대한 동작 확인 |
주의하세요: “포맷”과 “import”는 “수정”이 아니라 “무엇이 잘못되었는지 보여주기” 모드로 확인합니다. 로컬에서는 수정 버전(-w)을 실행할 수 있지만, CI는 반드시 검증만 해야 합니다.
참고로 go vet도 계속 발전하고 있습니다. 예를 들어 Go 1.24에서는 vet에 test 분석기가 추가되어, 테스트/벤치마크/예제 선언에서 흔한 오류를 찾아냅니다.
Go 1.25에서는 이것이 자연스러운 진화로 받아들여집니다. “내장된 품질 관리”가 더 풍부해지고, CI는 추가 설정 없이도 더 많은 이점을 얻게 됩니다.
6. 실전: 설정, 버전, 그리고 CI 디버깅
CI 설정 예제: GitHub Actions
여기서 중요한 점이 하나 있습니다. YAML은 Go가 아닙니다. 여러분이 좋아할 필요도 없습니다. YAML의 역할은 한 번 프로세스를 명확하게 적어 두어, 이후 모든 커밋마다 그걸 다시 논의하지 않게 하는 것입니다. “YAML이 싫다”라는 생각이 들었다면, 축하합니다. 이제 여러분은 계속 신경 쓰지 않아도 돌아가게 만들고 싶은 엔지니어처럼 생각하기 시작한 것입니다.
아래는 아이디어를 보여주는 아주 작은 workflow 예제입니다. 학습 맥락에서는 모든 플래그를 외우는 것보다 구조를 이해하는 것이 더 중요하므로 일부러 짧게 만들었습니다.
name: quality
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.25.x" }
- run: go vet ./...
- run: go test ./...
네, 여기에는 gofmt/goimports와 린터가 없습니다. 그건 그냥 “뼈대”일 뿐입니다. 뼈대만 이해되면, 단계 추가는 기계적인 작업이 됩니다.
gofmt 검사를 자동 수정 없이 추가하는 방법
아이디어는 이렇습니다. gofmt -l은 포맷해야 하는 파일을 출력합니다. 출력이 비어 있지 않으면 오류입니다.
CI에서의 명령은 종종 다음처럼 보입니다(개념적으로):
test -z "$(gofmt -l .)"
test -z가 마음에 들지 않으면, “출력이 있으면 출력하고 실패한다”는 방식으로 더 쉽게 이해하도록 만들 수도 있습니다. 하지만 메커니즘 자체는 핵심이 아닙니다. 핵심은 CI가 포맷하지 않고, CI가 포맷을 요구한다는 점입니다.
도구 버전: 왜 CI에서 “못 박아”야 하는가
가장 짜증나는 CI 문제 중 하나는 이렇게 보입니다. 어제는 잘 되었는데 오늘은 실패했고, 여러분은 “아무것도 바꾸지 않았다”는 상황입니다. 흔한 원인은 어딘가에서 도구가 업데이트되었기 때문입니다. 그래서 CI에서는 goimports, staticcheck, golangci-lint 같은 도구의 버전을 고정해 두는 것이 유용합니다.
Go에서는 보통 go install ...@version 같은 형식으로 특정 버전을 설치합니다. 이 설치 방식은 Go 문서에서도 특정 버전의 실행 파일을 설치하고 “우발적인 업데이트”를 피하는 방법으로 강조됩니다.
예시(특정 버전에 묶지 않은 개념 예제):
go install golang.org/x/tools/cmd/goimports@v0.1.0
go install honnef.co/go/tools/cmd/staticcheck@v0.4.7
CI에서 이것은 다음을 의미합니다. 오늘이든 내일이든 일주일 뒤든 파이프라인은 같은 분석기를 실행하게 되며, “도구 개발자가 무엇을 배포했는지”에 흔들리지 않습니다.
CI 실패를 읽는 법: 당황하지 않는 빠른 전략
CI 로그는 가끔 “고통의 두루마리”처럼 보입니다. 그건 정상입니다. 사람은 아니니까, 너무 많이 적어도 부끄러워하지 않습니다. 하지만 우리는 이 로그를 빨리 읽는 법을 배워야 합니다. 그렇지 않으면 CI는 도우미가 아니라 스트레스의 원천이 됩니다.
가장 실용적인 읽기 전략은 이렇습니다. 먼저 처음 실패한 단계를 찾습니다. 파이프라인은 보통 fail-fast이기 때문입니다. 만약 gofmt가 실패했다면 go test를 분석할 필요가 없습니다. 테스트는 아예 실행되지 않았을 수도 있고, 실행되더라도 어차피 지금 수정할 코드 위에서 돌고 있을 수 있기 때문입니다.
그 다음에는 메시지의 성격을 봅니다.
만약 gofmt/goimports라면, 문제는 거의 항상 기계적입니다. 포맷과 import입니다. 이런 실패는 가장 반가운 종류입니다. 빠르게 고칠 수 있고, “숨은 의미”가 거의 없기 때문입니다.
만약 go vet라면, 이를 “버그의 징후”로 받아들여야 합니다. 이때는 경고를 “없애는” 것이 아니라, 도구가 정확히 무엇을 발견했는지 이해하는 것이 유용합니다. Go에서 Printf 관련 오류는 특히 흔합니다. fmt.Printf는 ...any를 받기 때문에, 컴파일러가 형식 문자열과 인자의 일치를 항상 보장할 수는 없습니다.
만약 린터라면, 신호의 강도를 파악해야 합니다. “가능한 버그”는 강한 신호입니다. “더 간단하게 할 수 있음”은 그보다 약합니다. CI에서는 린터가 자잘한 모든 것을 빨간 표시로 바꾸지 않도록 적절히 조정하는 것이 합리적입니다. 그렇지 않으면 품질이 아니라 “린터를 입막음하는 게임”이 됩니다.
CI가 “체크박스”가 아니라 품질을 돕도록 하는 법
미묘한 경계가 있습니다. CI를 지나치게 “아름다움”과 “정답”만 검사하게 만들면, 사람들은 프로세스를 싫어하게 됩니다. 반면 CI를 지원하는 골격처럼 만들면, 명백한 문제는 걸러내고, 의미에 대한 논의는 리뷰에서 하게 됩니다.
좋은 CI는 세 가지를 합니다.
첫째, gofmt와 goimports가 코드 논의 전에 공백과 우발적인 import를 정리하므로 리뷰의 잡음을 줄입니다.
둘째, 저렴한 버그를 일찍 잡습니다. go vet과 린터는 흔한 오류를 테스트보다 더 빨리, 그리고 사용자보다 훨씬 더 빨리 찾아낼 수 있습니다.
셋째, 프로젝트를 예측 가능하게 만듭니다. 파이프라인이 초록색이면, 적어도 코드가 컴파일되고 기본 검사가 통과했다는 사실은 확신할 수 있습니다.
7. CI에 품질을 도입할 때 흔한 실수
실수 1: CI가 개발자 대신 코드를 “고친다”.
가끔 CI가 gofmt -w를 실행하고 “모든 걸 자동으로 고치게” 만들려 합니다. 실제로는 금세 혼란으로 이어집니다. CI가 파일을 바꾸지만, 그 변경을 여러분의 브랜치에 의미 있게 정리할 수는 없기 때문입니다. 훨씬 안정적인 방법은 CI가 검사만 하고, 수정은 로컬에서 의식적으로 하게 만드는 것입니다.
실수 2: 로컬과 CI에서 다른 명령을 실행한다.
로컬에서는 “IDE 테스트”를 실행하고, CI에서는 “어떤 다른 명령 집합”을 실행하면, 여러분은 반드시 “내 컴퓨터에서는 되는데요” 상황을 만나게 됩니다. 올바른 접근은 동일한 검증 명령 집합을 가지고, CI가 그것을 깨끗한 환경에서 그대로 실행한다고 보는 것입니다.
실수 3: 린터가 너무 많고, 너무 시끄럽다.
집계 도구를 붙이고 “전부 다” 켜는 것은 전형적인 함정입니다. 결과적으로 수백 개의 메시지가 쏟아지고, 우선순위를 잃으며, 진짜 문제는 사소한 꾸밈성 메시지에 묻힙니다. 린터는 유용한 신호를 줘야 합니다. 신호가 잡음이 되면 품질은 올라가는 것이 아니라 떨어집니다.
실수 4: 도구 버전이 고정되지 않는다.
오늘은 goimports가 이렇게 동작하고, 내일은 조금 다르게 동작해서, 갑자기 코드 변경 없이 CI가 실패할 수 있습니다. 대부분은 도구 버전을 고정하지 않았다는 뜻입니다. CI에서는 go install ...@version을 통해 특정 버전의 실행 파일로 도구를 설치하는 것이 유용합니다.
실수 5: go vet를 “잔소리”로 무시한다.
go vet는 스타일이 아니라 오류의 징후를 다룹니다. CI가 계속 vet에서 실패하는데 팀이 이를 무시하는 데 익숙해졌다면, 가장 저렴한 안전벨트 하나를 꺼버린 셈입니다. 특히 많은 vet 문제는 실제 런타임 버그로 이어지기 때문에 더 아쉽습니다.
GO TO FULL VERSION