CodeGym /행동 /C++ SELF /CI 및 파이프라인 configure → build → test

CI 및 파이프라인 configure → build → test

C++ SELF
레벨 37, 레슨 4
사용 가능

1. CI란 무엇이며 왜 '예의 바른 로봇'과 비슷한가

커밋할 때마다 말없이 세 가지를 해주는 친구가 있다고 상상해보세요: 프로젝트를 설정하고, 빌드하고, 테스트를 실행한 다음 '괜찮음' 또는 '깨짐'을 알려줍니다. 이것이 일상적인 의미의 CI입니다. 마법도 아니고 '또 다른 언어'도 아니며 단지 규율입니다: 검증을 동일하게, 반복 가능하게, 수동 작업 없이 실행하는 것.

CI (Continuous Integration)는 저장소 변경 시 프로젝트 검증이 자동으로 실행되는 접근 방식입니다. 보통 별도의 머신(서버)에서 실행되지만 여기서는 '어디서'보다 '무엇을'이 더 중요합니다. CI는 거의 항상 로컬에서도 실행할 수 있는 몇 개의 명령으로 이루어진 파이프라인으로 귀결됩니다. 터미널에서 한 번의 명령 시퀀스로 실행할 수 있다면 — GitHub Actions나 Jenkins를 단 한 번도 보지 않았더라도 CI의 본질을 이미 이해한 것입니다.

CI의 주요 언어는 YAML이 아니라 프로세스 종료 코드입니다

프로그래밍을 배우는 동안 사람들은 성공을 이렇게 평가하는 습관이 있습니다: "실행해봤더니 원하는 출력이 나온 것 같다". 사람에게는 괜찮은 휴리스틱이지만 기계에게는 전혀 쓸모가 없습니다. 기계에는 짧고 모호하지 않은 규칙이 필요합니다: 성공 또는 실패.

명령줄 세계에서 그런 규칙은 exit code(프로세스 반환 코드)입니다. 프로그램이 0으로 종료하면 보통 '모두 정상'을 의미합니다. 0이 아닌 코드로 종료하면 '오류'를 의미합니다. 그리고 이 규칙은 여러분의 프로그램뿐만 아니라 빌드 유틸리티나 테스트 러너, CTest에도 적용된다는 점에 주목하세요.

다음은 CI 아이디어를 잘 설명하는 최소한의 '수동 테스트 러너'입니다:

#include <iostream>

bool test_add() {
    return (2 + 3) == 5;
}

int main() {
    if (!test_add()) {
        std::cerr << "test_add FAILED\n";
        return 1; // 0이 아님 = 실패
    }
    return 0;     // 0 = 성공
}

CI가 이런 프로그램을 실행한다면, CI는 FAILED라는 문자열을 '눈으로 읽을' 필요가 없습니다. 종료 코드 1이면 충분합니다.

2. 파이프라인 configure → build → test: 세 단계의 의미

configure, build, test라는 단어는 마법 주문처럼 들릴 수 있습니다. 하지만 실제로는 서로 다른 문제를 잡아내는 세 가지 검사입니다. 각 단계가 무슨 역할을 하는지 이해하는 것이 중요합니다. 초보자는 종종 한 단계에 기대해야 할 것을 다른 단계에 기대합니다.

아래는 기억해두면 좋은 편리한 표입니다:

파이프라인 단계 명령 예시(개념적으로) 무엇을 확인하는가 전형적인 실패
configure
cmake -S . -B build
프로젝트를 설정할 수 있는지(컴파일러, 의존성 발견, CMakeLists의 문법 등) "CMake가 찾지 못함", "CMake 문법 오류", "표준 버전 불일치"
build
cmake --build build
코드가 컴파일되고 링킹되는지 "컴파일 오류", "undefined reference", "잘못된 include"
test
ctest --test-dir build
동작이 깨지지 않았는지, 테스트가 통과하는지 "테스트 실패", "리그레션", "잘못된 결과"

시각적으로 정리하면 다음과 같습니다:

flowchart LR
    A[configure] -->|ok| B[build]
    A -->|fail| X[PIPELINE FAILED]

    B -->|ok| C[test]
    B -->|fail| X

    C -->|ok| Y[PIPELINE PASSED]
    C -->|fail| X

중요한 점: 각 단계는 별도의 명령이며, 각 명령은 0/0이 아님으로 종료됩니다. 따라서 파이프라인은 복잡한 시스템이 아니라 단순한 체인입니다: "이전 단계가 성공이면 다음을 실행".

Configure: 왜 이 단계가 컴파일 이전에 프로젝트를 '떨꿀' 수 있는가

초보자는 configure 단계를 과소평가하는 경우가 많습니다. "CMake는 뭔가를 생성하기만 하지 않나"라고 생각할 수 있습니다. 실제로 configure는 프로젝트가 해당 환경에서 '정상적으로 기술되어 있는지'와 빌드 가능한지를 확인하는 검사입니다.

예를 들어 CMakeLists.txt에 오타가 있으면 C++ 컴파일 전에 오류가 날 수 있습니다. 이는 오히려 좋은 일로, 문제를 더 일찍 잡아냅니다.

아이디어를 위한 최소한의 CMakeLists.txt는 다음과 같습니다:

cmake_minimum_required(VERSION 3.20)
project(TaskBook LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
add_executable(taskbook src/main.cpp)

Configure가 통과하지 못하면, 해당 컴퓨터나 설정에서는 아직 프로젝트를 빌드할 수 없다는 의미입니다. CI에서는 이 점이 특히 중요합니다. CI 머신은 보통 '깨끗한' 환경이기 때문에 로컬의 임시 처리(예: IDE 히든 설정)에 의존하면 안 됩니다.

Build: 빌드는 링킹까지 포함한다는 점

많은 사람이 build 단계를 '컴파일이 알려줄 것이다'라고 생각합니다. 맞지만 오류는 문법뿐 아니라 링킹에서도 발생합니다.

링크 오류 예: 함수를 선언만 하고 파일을 빌드에 추가하는 것을 잊음; .cpp 파일 이름을 바꿨지만 CMake를 수정하지 않음; 동일한 함수의 구현을 두 곳에 둠. 초보자에게는 이런 오류들이 '마법 같고 이해 불가'로 보일 수 있습니다. CI는 이런 문제를 안정적으로 재현해서 드러내 줍니다.

흔한 상황 예: 새 파일 src/parse.cpp를 추가하고 그 안에 parse_priority를 구현했는데, IDE는 로컬에서 알아서 빌드에 포함시켜줬습니다(캐시나 IDE 설정 덕분). 하지만 CMake에 파일 추가를 잊으면 CI에서는 빌드가 즉시 실패합니다.

Test: 테스트는 세 번째 필수 단계인 이유

테스트 단계는 '컴파일된다'를 '약속한 대로 작동한다'로 바꾸는 역할을 합니다. 핵심 단어는 '약속'입니다. 테스트는 동작 계약을 고정합니다.

CTest를 사용하면 일반적으로 별도의 테스트 실행 파일이 있고 CTest가 그것을 실행합니다.

개념적으로는 다음과 같습니다:

enable_testing()

add_executable(taskbook_tests tests/test_main.cpp)
add_test(NAME taskbook_core COMMAND taskbook_tests)

그 후 ctest는 등록된 테스트들을 실행하고 결과를 요약합니다. 하나라도 실패하면 ctest는 0이 아닌 코드로 종료하며, 그 결과 파이프라인은 '빨간색'이 됩니다.

이 지점에서 CI는 초보자에게도 매우 명확해집니다: 테스트가 실패하면 동작을 망가뜨린 것입니다. '아마'나 '같아 보인다'가 아니라 특정 규칙을 위반한 것이며 테스트 러너가 어디가 문제인지 보통 알려줍니다.

3. 초보자에게 CI가 필요한 이유

초보자가 흔히 겪는 두 가지 큰 문제는: 프로젝트를 실수로 망가뜨리고 어디서 망가뜨렸는지 모른다는 점, 그리고 '눈으로만' 확인해서 모든 것이 작동한다고 확신할 수 없다는 점입니다. CI는 철학 없이 순수 기술적으로 이 두 문제를 해결합니다.

첫째, CI는 프로젝트를 '내 컴퓨터에서만 빌드는 된다'가 아니라 반복 가능하게 빌드되어야 하는 대상으로 생각하게 만듭니다. 이는 '서버와 회사' 문제만이 아니라 간단한 현실입니다: 일주일 후의 당신은 지금의 당신과 달라져 있을 수 있고, IDE에서 어떤 버튼을 눌러야 했는지 기억하지 못할 수 있습니다.

둘째, CI는 '코드가 컴파일된다'와 '코드가 올바르게 동작한다'를 분리하도록 강제합니다. 초보자는 종종 이 둘을 혼동합니다. '빌드가 됐으니 괜찮다'는 것은 단지 컴파일러가 텍스트를 이해했다는 뜻일 뿐, 논리가 맞다는 보장은 아닙니다.

셋째, CI는 리그레션을 잡아줍니다. 리그레션은 코드를 '개선'했더니 이전 기능이 조용히 죽는 경우입니다. 문제는 변경한 곳이 아니라 전혀 다른 파일에서 증상이 발생할 수 있다는 점입니다. 자동화된 테스트와 자동 실행은 이런 놀라움에 대한 경보 시스템입니다.

'CI 친화적인 테스트'란 무엇인가

테스트는 종종 '가끔 통과, 가끔 실패'하는 방식으로 작성되기 쉽습니다. 초보자는 이미 '가끔 작동'하는 세계에 살고 있으므로 테스트까지 그런 식이면 안 됩니다.

CI 친화적 테스트는 동일한 입력에 대해 항상 같은 결과를 주는 테스트입니다. 즉 결정적이고 외부 세계에 의존하지 않아야 합니다. 초보자가 실수로 만드는 흔한 사례는 std::cin을 읽는다든지, 현재 시간을 본다든지, 고정된 seed 없이 난수를 사용한다든지, 테스트 실행 순서에 의존하는 것입니다.

실용적인 규칙: 논리 검증을 위해 콘솔 입력을 요구하는 테스트는 유닛 테스트가 아니라 '사용자 역할극'입니다. CI는 입력을 제공하지 않으며, 그런 역할극은 자동화에 적합하지 않습니다.

덜 명백한 예로, 출력 문자열 전체를 공백과 줄바꿈까지 포함해 비교하는 테스트는 너무 취약할 수 있습니다. 때때로 필요하지만 보통은 데이터 구조나 오류 코드 같은 '의미'를 테스트하는 것이 더 낫습니다.

혼자 개발해도 CI가 유용한 이유

'CI는 팀용이고 나 혼자라면 필요 없다'는 유혹이 있습니다. 그러나 개인 프로젝트도 CI의 이점을 누립니다. CI는 외부의 '객관적인 심판'을 제공합니다. 피곤하지 않고, 서두르지 않으며, 테스트 실행을 잊지 않고, Debug/Release를 혼동하지 않습니다.

또한 CI는 작은 안전한 변경을 습관화하게 합니다. 한 번에 하나의 수정만 하고 바로 파이프라인을 돌리면 무엇을 망가뜨렸는지 정확히 알 수 있습니다. 반면 15개의 수정을 하고 나서야 테스트를 돌리면 어떤 수정이 원인인지 알기 어렵습니다. 이건 재능의 문제가 아니라 단순한 조합의 문제입니다.

마지막으로, 파이프라인이 녹색일 때는 리팩터링을 더 마음 편히 할 수 있습니다. 코드를 유리한 그릇처럼 다루지 않게 됩니다.

4. 실습 예: TaskBook과 테스트하기 좋은 함수

추상적이지 않게 하기 위해 작은 콘솔 애플리케이션 TaskBook을 계속 예로 듭니다. 이 프로그램은 메모리에서 작업 목록을 관리하고 작업을 추가, 검증, 출력하는 기능이 있습니다. 테스트하기 좋은 부분은 '로직'을 함수나 소규모 모듈로 분리하여 std::cin 없이 호출 가능하게 만드는 것입니다.

예를 들어 라이브러리 부분에 테스트하기 쉬운 함수가 있다고 합시다:

#include <string>
#include <optional>

std::optional<int> parse_priority(const std::string& s) {
    if (s.empty()) return std::nullopt;
    int x = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return std::nullopt;
        x = x * 10 + (c - '0');
    }
    if (x < 1 || x > 5) return std::nullopt;
    return x;
}

CI 관점에서:

  • 단계 build는 해당 함수가 프로젝트와 함께 컴파일되는지 확인합니다;
  • 단계 test는 정상 및 비정상 입력에 대해 동작이 올바른지 확인합니다.

5. 로컬 미니 파이프라인: 'CI 없는 CI'

초보자에게 아주 유용한 생각: CI는 반드시 '클라우드 서비스'일 필요는 없습니다. CI는 여러분이 한 번에 반복해서 실행할 수 있는 명령 또는 스크립트을 갖고 있고, 그것이 configure/build/test를 실행해 적절한 종료 코드를 반환할 때 시작됩니다.

CMake 프로젝트에서 가장 전형적인 로컬 순서는(out-of-source 빌드, 폴더 build 사용) 다음과 같습니다:

cmake -S . -B build
cmake --build build
ctest --test-dir build

이미 이것만으로도 '미니 CI'입니다. 이유는:

  • 명령이 매번 동일하게 실행됩니다;
  • 결과는 각 명령의 종료 코드로 결정됩니다;
  • IDE의 숨은 설정이나 캐시에 의존하지 않습니다.

이걸 단추 하나로 만들고 싶다면 예를 들어 ci_local.sh 스크립트를 만들 수 있습니다:

#!/usr/bin/env bash
set -e

cmake -S . -B build
cmake --build build
ctest --test-dir build

여기서 set -e는 "어떤 명령이라도 실패하면 즉시 스크립트를 종료하라"는 의미입니다. 이 동작은 실제 CI 파이프라인의 동작과 매우 유사합니다: 어떤 실패라도 전체가 빨간색입니다.

6. 전형적인 실수들

실수 #1: build = 모든 것이 작동한다라고 생각하기.
빌드는 단지 프로젝트가 컴파일되고 링킹된다는 것만 확인합니다. 수식 오류, 빈 문자열 처리 실수, 조건문 혼동 등은 모두 컴파일될 수 있습니다. 테스트를 자동으로 실행하지 않으면 곧바로 '눈으로 버그 찾기' 취미에 빠지게 됩니다.

실수 #2: 테스트가 입력, 시간 또는 난수에 의존한다.
테스트가 std::cin을 읽으면 인터랙티브해져 자동 실행에 부적합합니다. 현재 날짜/시간에 의존하면 특정 시각에만 실패할 수 있고, 고정된 seed 없는 난수 사용은 '가끔 빨간색'이라는 가장 골치 아픈 문제를 만듭니다.

실수 #3: '로컬에서 통과했으니 괜찮다'며 클린 빌드를 무시한다.
로컬 환경에는 캐시나 남은 아티팩트가 있어 문제를 가릴 수 있습니다. 그래서 out-of-source 빌드와 반복 가능한 파이프라인이 중요합니다: 그것들이 CI의 깨끗한 머신에서 보일 결과에 더 가깝습니다.

실수 #4: 하나의 거대한 테스트 시나리오를 만들고 여러 개의 명확한 테스트 대신 사용하는 것.
초보자는 종종 '모두를 위한 하나의 거대한 테스트'를 씁니다: 작업을 추가하고, 또 추가하고, 출력 전체를 비교합니다. 이런 테스트가 실패하면 무엇이 망가졌는지 모릅니다: 파싱인지 정렬인지 포맷인지. 한 규칙만 검증하는 작은 테스트 여러 개가 훨씬 견고합니다.

실수 #5: 테스트는 실패하는데 종료 코드가 잘못되어 파이프라인이 녹색이다.
테스트 실행 바이너리가 문제가 있어도 항상 0을 반환하면 CI는 모든 것이 괜찮다고 판단합니다. 테스트 프레임워크와 CTest는 이런 부분을 규율해 줍니다: 실패는 자동으로 0이 아닌 종료 코드로 전환됩니다.

1
과제
C++ SELF, 레벨 37, 레슨 4
잠금
빌드 종료 코드
빌드 종료 코드
1
과제
C++ SELF, 레벨 37, 레슨 4
잠금
작업 우선순위
작업 우선순위
1
과제
C++ SELF, 레벨 37, 레슨 4
잠금
테스트 프로젝트
테스트 프로젝트
1
과제
C++ SELF, 레벨 37, 레슨 4
잠금
로컬 파이프라인
로컬 파이프라인
1
설문조사/퀴즈
단위 테스트, 레벨 37, 레슨 4
사용 불가능
단위 테스트

단위 테스트

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION