기능 개발을 마치고 로컬 서버를 띄운 뒤, Postman으로 API를 호출해 DB에 정상적으로 저장되는지 확인한다. 콘솔에 출력된 쿼리와 로그를 눈으로 훑고 나서야 PR(Pull Request)을 올린다. 실무를 경험하며 누구나 거쳐왔을 익숙한 흐름이다.

 

하지만 서비스 규모가 커지고 비즈니스 로직이 복잡해지면, 이러한 수동 테스트는 명확한 한계를 드러낸다. 첫 번째로 짚고 넘어가야 할 부분은, 왜 비용을 들여가며 테스트 코드를 작성해야 하는가에 대한 근본적인 이유다.

 

눈으로 확인하는 수동 테스트의 함정

수동 테스트는 실행 비용이 높고 반복하기 엉렵다. 특정 비즈니스 로직의 분기문이 10개라면, 모든 엣지 케이스를 검증하기 위해 서버를 재시작하고 파라미터를 바꿔가며 10번의 API 호출을 수행해야 한다.

 

 - 휴먼 에러의 발생 : 사람이 직접 눈으로 결과값을 검증하므로, 피로도나 부주의에 따라 놓치는 엣지 케이스가 반드시 발생한다.

 

 - 낮은 커버리지와 회귀 버그 : 새로운 기능을 추가했을 때, 기존에 잘 동작하던 기능이 망가지지 않았는지  확인하는 회귀 테스트(Regression Test)를 전체 시스템 대상으로 매번 수동 수행하는 것은 불가능하다.

 

[Bad Code] System.out.println을 활용한 수동 검증

public class CalculatorTest {
    public static void main(String[] args) {
        Calculator calc = new Calculator();
        int result = calc.add(10, 20);
        
        // 개발자가 콘솔을 눈으로 직접 확인해야 하며, CI/CD 파이프라인에서 자동화될 수 없다.
        if (result == 30) {
            System.out.println("덧셈 테스트 성공");
        } else {
            System.out.println("덧셈 테스트 실패");
        }
    }
}

 

위와 같은 main 메서드 기반의 검증 방식은 프로젝트의 크기가 커질수록 방치되어 결국 죽은 코드가 된다.

[Good Code] JUnit 5와 AssertJ를 활용한 자동화된 검증

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class CalculatorTest {

    @Test
    @DisplayName("두 정수를 더하면 정확한 합이 반환되어야 한다")
    void add_ShouldReturnSum_WhenGivenTwoIntegers() {
        // given
        Calculator calc = new Calculator();

        // when
        int result = calc.add(10, 20);

        // then
        // 단언(Assert)을 통해 기계가 성공 여부를 즉시 판별한다.
        assertThat(result).isEqualTo(30);
    }
}

 

단위 테스트 프레임워크를 사용하면 검증 로직이 시스템적으로 자동화되어 수천 개의 테스트도 단 몇 초 만에 끝낼 수 있다.

 

개발자의 책임과 심리적 안정감

테스트 코드를 작성하는 것은 단순히 버그를 찾는 행위를 넘어, 자신이 작성한 코드가 의도한 대로 동작함을 증명하는 개발자의 초소한의 책임이다.

 

실무에서 레거시 시스템을 개편하거나 라이브러리 버전을 올릴 때 가장 두려운 것은 예상치 못한 사이드 이펙트다. 촘촘하게 짜여진 테스트 코드는 이러한 변경 작업 시 든든한 안전망 역할을 한다. 코드를 리팩토링한 후 테스트 셋을 모두 돌려 통과(Green) 하는 것을 확인했을 때 얻는 심리적 안정감은 개발 속도를 비약적으로 끌어올리고 코드 품질에 대한 확신을 준다.

 

좋은 테스트 코드는 좋은 설계를 유도한다

단위 테스트를 작성하다 보면 유독 테스트하기 어려운 코드를 만나게 된다. 외부 API나 DB에 강하게 결합되어 있거나, 하나의 메서드가 너무 많은 책임(SRP 위배)을 가지고 있는 경우가 대표적이다.

 

즉, 테스트 코드는 객체지향 설계의 지표가 된다. 단위 테스트 작성이 고통스럽다면 테스트 프레임워크의 문제가 아니라, 프로덕션 코드의 결합도가 지나치게 높거나 의존성 주입(DI)이 제대로 이루어지지 않았다는 설계쌍의 경고음으로 받아들여야 한다.

 

결론

수동 테스트에 의존하는 개발 프로세스는 결국 기술 부채를 누적시킨다. 시간 부족을 이유로 테스트 코드 작성을 미루는 것은, 향후 디버깅과 장애 처리에 훨씬 더 많은 시간을 지불하겠다는 의미와 같다.

 

단위 테스트는 초기의 학습 비용과 작성 시간이 수반되지만, 장기적으로는 코드의 품질을 보장하고 유지보수 비용을 획기적으로 낮추는 가장 확실한 투자다. 다음 2편에서는 기존 레거시 시스템에서 많이 사용되던 JUnit 4와 현재 실무 표준인 JUnit 5의 아키텍처 차이, 그리고 마이그레이션 포인트를 살펴본다.

이전 포스팅에서 테스트 코드가 없는 레거시 환경에서 GitLab Runner를 구축하고 스모크 빌드(Smoke Build)를 통해 컴파일과 패키징 무결성을 확보하는 과정에 대해 알아보았다.

 

스모크 빌드로 기본 파이프라인의 뼈대를 갖추었다면, 다음 단계는 코드 레벨의 결함을 사전에 차단하는 품질 게이트(Quality Gate)를 설계하는 것이다. 테스트 검증 없이 빌드 산출물만 뽑아내는 파이프라인은 버그가 포함된 산출물 또한 자동으로 배포하는 위험을 내포한다.

 

레거시 Java/Spring 프로젝트에 JUnit/Mockito 기반 단위 테스트를 작성하는 기준과, JaCoCo를 활용해 최소 코드 커버리지를 시스템적으로 강제(Build Break) 하는 방법에 대해 알아본다.

 

1. 레거시 환경에서의 단위 테스트 작성 전략

테스트 코드가 전무한 환경에서 전체 코드베이스를 한 번에 테스트하려는 접근은 비효율적이다. 외부 의존성(DB, 외부 API, 파일 I/O)과 결합도가 높은 서비스 계층의 핵심 비즈니스 로직부터 우선적으로 격리하여 테스트를 작성해야 한다.

 

 - @SpringBootTest 지양 : 전체 스프링 컨텍스트를 로드하는 통합 테스트는 기동 시간이 오래 걸려 CI 파이프라인의 병목을 유발한다.

 

 - 순수 단위 테스트 우선 : Mockito를 활용하여 외부 의존성을 Mock 객체로 대체하고, POJO 기반의 빠른 단위 테스트를 구축한다.

 

2. [Bad vs Good] Test 예제 비교

외부 I/O에 의존하여 실행 속도가 느리고 환경에 종속적인 테스트와, Mocking을 통해 비즈니스 로직만 빠르게 검증하는 테스트의 차이다.

 

[Bad Test] 스프링 컨텍스트와 실제 DB에 의존하는 테스트

@SpringBootTest
@Transactional
class OrderServiceLegacyTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void createOrder_Success() {
        // 실제 DB 상태에 의존하므로 테스트 데이터 오염 및 실행 지연 발생
        OrderRequest request = new OrderRequest("ITEM-001", 2);
        
        Long orderId = orderService.createOrder(request);
        Order result = orderRepository.findById(orderId).orElseThrow();

        assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
    }
}

 

문제점

매 테스트마다 스프링 컨테이너가 기동되어 CI 빌드 시간이 급증한다. DB 접속 실패나 타 테스트의 데이터 오염으로 인해 테스트가 간헐적으로 실패(Flaky Test)할 수 있다.

[Good Test] 단위 격리와 Mockito르 활용한 빠른 단위 테스트

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @InjectMocks
    private OrderService orderService;

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentClient paymentClient;

    @Test
    void createOrder_ShouldCalculateDiscountAndSave() {
        // Given: 의존성 행위 정의
        OrderRequest request = new OrderRequest("ITEM-001", 2);
        given(paymentClient.processPayment(any())).willReturn(true);
        given(orderRepository.save(any(Order.class)))
            .willAnswer(invocation -> invocation.getArgument(0));

        // When: 순수 비즈니스 로직 실행
        Order result = orderService.createOrder(request);

        // Then: 상태 검증 및 상호작용 검증
        assertThat(result).isNotNull();
        assertThat(result.getItemCode()).isEqualTo("ITEM-001");
        then(orderRepository).should(times(1)).save(any(Order.class));
    }
}

 

개선점

JVM 메모리 내에서만 실행되므로 수 밀리초(ms) 단위로 검증이 완료된다. 외부 인프라 장애 여부와 무관하게 일관된 결과를 보장한다.

 

3. JaCoCo (Java Code Coverage) 연동 및 설정

JaCoCo는 실행된 테스트 코드가 프로덕션 코드의 몇 %를 통과했는지 측정하는 도구다. 단순 측정에 그치지 않고 설정한 커버리지 기준에 미달할 경우 빌드를 실패(Build Break) 처리하도록 설정한다.

 

build.gradle 설정

plugins {
    id 'java'
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.11"
}

test {
    useJUnitPlatform()
    finalizedBy jacocoTestReport // test 실행 완료 후 커버리지 리포트 생성
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
        csv.required = false
    }
    
    // 검증 제외 대상 설정 (Q클래스, DTO, Config 등)
    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                '**/dto/**',
                '**/config/**',
                '**/*Application*',
                '**/Q*'
            ])
        }))
    }
    finalizedBy jacocoTestCoverageVerification // 리포트 생성 후 검증 수행
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            element = 'CLASS'
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.70 // 라인 커버리지 최소 70% 강제
            }
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.60 // 분기 커버리지 최소 60% 강제
            }
            excludes = [
                '*.dto.*',
                '*.config.*',
                '*Application*',
                '*.Q*'
            ]
        }
    }
}

 

counter = 'LINE' : 실행된 소스 코드 라인의 비율을 검증한다.

counter = 'BRANCH' : if, switch 등의 조건 분기문이 모든 경로를 거쳤는지 검증한다.

minimun : 설정한 임계값 이하로 커버리지가 떨어지면 Gradle 빌드가 에러(FAILURE)를 반환하며 중단된다.

 

4. GitLab CI 파이프라인 연동 (.gitlab-ci.yml)

스모크 빌드 단계 앞에 test 스테이지를 추가하여, 테스트 실패 및 커버리지 미달 시 즉시 파이프라인을 중단하고 병합을 차단한다.

 

stages:
  - test
  - build

variables:
  GRADLE_OPTS: "-Dorg.gradle.daemon=false"

cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .gradle/wrapper/
    - .gradle/caches/

# 1단계: 테스트 및 커버리지 검증
unit_test_and_coverage:
  stage: test
  tags:
    - backend
  script:
    - chmod +x ./gradlew
    - ./gradlew test jacocoTestCoverageVerification
  artifacts:
    name: "coverage-report-$CI_COMMIT_SHORT_SHA"
    when: always
    paths:
      - build/reports/jacoco/test/html/
    expire_in: 7 days
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"'

# 2단계: 테스트 통과 시에만 패키징 실행
package_artifact:
  stage: build
  tags:
    - backend
  script:
    - ./gradlew bootJar -x test --no-daemon
  artifacts:
    name: "build-artifact-$CI_COMMIT_SHORT_SHA"
    paths:
      - build/libs/*.jar
    expire_in: 3 days
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"'

 

 - artifacts (when: always) : 테스트나 커버리지 검증이 실패하더라도 생성된 HTML 커버리지 리포트를 보관하여 어떤 코드에서 누락이 발생했는지 즉각 확인할 수 있도록 한다.

 - 의존 관계 제어 : unit_test_and_coverage 작업이 실패하면 후속 package_artifact 작업은 실행되지 않는다.

 

5. 결론

테스트 코드와 JaCoCo 커버리지 게이트를 CI 파이프라인에 통합함으로써 다음과 같은 엔지니어링 이점을 확보한다.

 

 - 회귀 결함 차단 : 코드 수정 시 기존 비즈니스 로직이 파괴되는 문제를 중앙 저장소 단계에서 즉시 검출한다.

 - 테스트 작성 문화의 시스템적 강제 : 커버리지 임계값을 통과하지 못하면 MR 병합과 빌드가 차단되므로, 테스트 코드 누락을 사전에 방지한다.

 - 빠른 피드백 루프 : 가벼운 단위 테스트 중심으로 구성하여 커밋 후 수 분 이내에 코드 무결성을 검증받을 수 있다.

이전 포스팅에서 수동 배포가 초래하는 환경 파편화와 휴먼 에러, 그리고 릴리즈 리스크를 제어하기 위한 CI/CD의 기본 개념에 대해 알아보았다.

 

https://hsunnystory.tistory.com/309

 

[Software Enginnering] 수동 배포의 한계와 CI/CD 파이프라인의 엔지니어링 가치

운영 서버에 변경 사항을 반영할 때 로컬 PC에서 빌드한 산출물을 올리거나, 수정된 .class 파일만 서버에 직접 덮어쓰는 방식을 사용하는 환경을 종종 마주한다. 개발자가 터미널을 열고 직접 프

hsunnystory.tistory.com

 

실무에서 CI 파이프라인을 도입하려 할 때 가장 먼저 마주치는 현실적인 문제는 프로젝트에 작성된 단위 테스트가 전무한 상황이다. 테스트 코드가 없다는 이유로 파이프라인 구축을 미루면 수동 빌드와 배포의 비효율을 지속하게 된다.

 

테스트 코드가 없는 레거시 환경에서도 즉각적으로 적용할 수 있는 스모크 빌드(Smoke Build)의 개념을 정의하고, 사내 GitLab Runner 구축 및 파이프라인 연동 과정을 정리한다.

 

1. 테스트 부재 환경에서의 CI 목표 : 스모크 빌드

CI(지속적 통합)의 기본 목적은 코드 병합 시점에 결함을 조기에 검출하는 것이다. 작성된 테스트 코드가 없다면 최소한 다음 세 가지 검증을 자동화하는 것으로 첫 단계를 시작해야 한다.

 

 - 컴파일 무결성 검증 : 여러 개발자가의 코드가 병합되었을 때 구문 오류나 의존성 충돌 없이 컴파일 되는지 확인한다.

 

 - 패키징 검증 : 배포 가능한 산출물( jar / war ) 이 정상적으로 생성되는지 검증한다.

 

 - 정적 분석 및 확장 기반 마련 : 빌드 라이프사이클에 정적 분석이나 린트 도구를 결합할 수 있는 실행 환경을 확보한다.

 

애플리케이션의 핵심 빌드 라이프사이클이 정상 작동하는지 확인하는 최소한의 검증 단계를 스모크 빌드라고 정의한다.

 

2. GitLab CI와 GitLab Runner의 동작 구조

GitLab CI는 중앙 제어 역할을 하는 GitLab Server와 실제 작업을 실행하는 GitLab Runner로 역할이 분리되어 있다.

 

 [GitLab Server]                                                                                             [GitLab Runner Host]
  - 소스 코드 형상 관리                                                                                    - 데몬(Daemon) 상시 대기
  - .gitlab-ci.yml 파싱                                                                                    - 주기적 Job 폴링 (Long Polling)
  - 파이프라인 상태 시각화        <──(Job 요청 / 결과 전송)──        - 격리된 환경에서 빌드 스크립트 실행

 

 - GitLab Server : 개발자가 코드를 push 하거나 Merge Request를 생성하면 .gitlab-ci.yml을 파싱하여 파이프라인 작업을 생성한다.

 

 - GitLab Runner : 별도의 서버나 가상 머신에 설치되어 GitLab Server로부터 할당받은 작업(Job)을 실행한다.

 

 - 통신 방식 : GitLab Server가 Runner에 직접 접속(Inbound)하는 방식이 아니라, Runner가 Server로 주기적인 요청(Outbound HTTP/HTTPS)을 보내 작업을 가져온다. 포트포워딩 환경이나 방화벽 내부에서도 별도의 인바운드 포트 개방 없이 동작한다.

 

3. GitLab Runner 설치 및 등록

Linux 호스트에 GitLab Runner를 설치하고 사내 GitLab 인스턴스에 등록하는 절차다.

 

(1) GitLab Runner 설치 (RHEL / CentOS 기준)

 

# 1. GitLab 공식 레포지토리 추가
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash

# 2. 패키지 설치
sudo yum install gitlab-runner -y

# 3. 서비스 기동 및 등록 확인
sudo systemctl enable --now gitlab-runner

 

(2) 러너 등록 (Registration)

GitLab 웹 UI의 Settings > CI/CD > Runners 에서 발급된 Registration TokenURL을 확인하여 서버에 등록한다.

 

sudo gitlab-runner register \
  --non-interactive \
  --url "http://gitlab.yourcompany.com/" \
  --registration-token "PROJECT_OR_GROUP_TOKEN" \
  --executor "shell" \
  --description "backend-build-runner" \
  --tag-list "java,backend,build" \
  --run-untagged="true"

 

--executor "shell" : 빌드 머신의 호스트 환경(설치된 JDK, Gradle 등)을 직접 사용하여 작업을 수행하는 실행자 방식이다.

--tag-list : 파이프라인 정의 파일(.gitlab-ci.yml)에서 특정 러너를 지정하여 실행하기 위한 식별 태그다.

 

4. [Bad vs Good] 파이프라인 정의

테스트가 없는 프로젝트에서 작성하기 쉬운 비효율적인 설정과, 캐싱 및 아티팩트 보관을 고려한 최적화 설정을 비교한다.

 

[Bad] 캐시 부재 및 아티팩트 관리 누락

# 매 작업마다 의존성을 처음부터 다시 내려받아 빌드 시간이 급증한다.
stages:
  - build

build_job:
  stage: build
  script:
    - chmod +x ./gradlew
    - ./gradlew build -x test # 테스트 제외 빌드

 

 - 문제점 : 빌드 수행 시마다 모든 외부 라이브러리를 원격 저장소에서 새로 다운로드하므로 네트워크 대역폭과 빌드 시간을 낭비한다. 빌드 후 생성된 산출물(jar)이 보존되지 않아 후속 배포 단계로 넘길 수 없다.

 

[Good] 의존성 캐싱 및 빌드 산출물 격리 정의

stages:
  - compile
  - package

# 의존성 캐시 설정: 브랜치별로 의존성 라이브러리를 보관하여 빌드 시간 단축
cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .gradle/wrapper/
    - .gradle/caches/

# 1단계: 컴파일 검증
compile_check:
  stage: compile
  tags:
    - backend
  script:
    - chmod +x ./gradlew
    - ./gradlew compileJava --no-daemon --console=plain
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"'

# 2단계: 패키징 및 산출물 보관
package_artifact:
  stage: package
  tags:
    - backend
  script:
    - ./gradlew bootJar -x test --no-daemon --console=plain
  artifacts:
    name: "build-artifact-$CI_COMMIT_SHORT_SHA"
    paths:
      - build/libs/*.jar
    expire_in: 3 days # 디스크 점유 방지를 위한 보관 기간 설정
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"'

 

 - cache : .gradle/ 경로를 캐싱하여 의존성 재다운로드를 방지한다.

 - artifacts :  생성된 jar 파일을 GitLab 서버에 임시 보관하여 추후 배포(CD) 단계에서 재사용할 수 있도록 격리한다.

 - rules : 불필요한 커밋 실행을 제어하고, Merge Request나 주요 브랜치(develop, main)에만 파이프라인이 동작하도록 제어한다.

 

5. 결론

테스트 코드가 없는 상태에서 구축한 스모크 빌드 파이프라인은 다음과 같은 엔지니어링 이점을 제공한다.

 

 - 브랜치 병합 안정성 확보 : 개발자가 로컬에서 컴파일 오류가 나는 코드를 실수로 올리거나 의존성 성정을 누락했을 때, 중앙 저장소 단계에서 즉시 감지하고 병합을 차단한다.

 

 - 수동 패키징 제거 : 로컬 PC에서 IDE나 터미널로 jar를 만들던 수동 작업이 제거되고, 단일한 환경에서 표준화된 빌드 산출물이 생성된다.

 

 - 품질 게이트 확장의 토대 : 파이프라인 뼈대가 완성되었으므로, 다음 단계인 단위 테스트와 정적 분석 도구를 단계(Stage)로 손쉽게 추가할 수 있다.

운영 서버에 변경 사항을 반영할 때 로컬 PC에서 빌드한 산출물을 올리거나, 수정된 .class 파일만 서버에 직접 덮어쓰는 방식을 사용하는 환경을 종종 마주한다.

 

개발자가 터미널을 열고 직접 프로세스를 제어하는 방식은 초기에는 직관적으로 보이지만, 기능이 추가되고 팀 규모가 커질수록 소프트웨어 릴리즈 라이프사이클 전체의 리스크를 가중시키는 주원인이 된다.

 

수동 배포 환경이 초래하는 기술적 결함을 분석하고, 지속적 통합(CI)과 지속적 배포(CD)가 시스템 안정성과 소프트웨어 품질에 미치는 아키텍처적 가치를 정리해 본다.

 

1. 수동 배포 프로세서의 구조적 한계

수동 배포는 일반적으로 개발자 로컬 환경에서 컴파일한 바이너리를 운영 서버로 직접 전송하고 프로세스를 재기동하는 방식으로 진행된다.

 

   [개발자 로컬 환경]                                                                           [운영 Linux 서버]

   코드 수정 & 빌드     ──>      (SCP / SFTP 전송)         ──>      기존 프로세스 PID 조회 (ps -ef)
                                                                                                                                              ↓
                                                                                                                      강제 종료 (kill -9 PID)
                                                                                                                                              ↓
                                                                                                                      신규 실행 (nohup java -jar ...)
                                                                                                                                              ↓
                                                                                                                      로그 수동 확인 (tail -f app.log)

 

이 구조는 다음과 같은 명확한 엔지니어링 리스크를 내포한다.

 

(1) 환경 파편화

 - 개발자의 로컬 OS(MacOS, Windows), JDK 마이너 버전, 기본 타임존, 시스템 인코딩과 운영 서버(Linux)의 런타임 환경이 일치하지 않는다.

 - 로컬에서 정상 동작하던 코드가 운영 환경에서 예기치 않은 런타임 예외를 유발하며, 이는 환경의 불변성이 깨졌기 때문에 발생한다.

 

(2) 클래스 패치로 인한 애플리케이션 무결성 훼손

 - 전체 애플리케이션을 다시 패키징하지 않고 변경된 특정 .class 파일만 운영 서버의 클래스패스에 덮어쓰는 방식은 클래스 로더 캐싱 문제, 내부 익명 클래스 누락, 컴파일 타임 상수풀(Constant Pool) 불일치를 야기한다.

 - 운영 서버에 올라간 바이너리가 Git의 어떤 커밋 시점인지 추적할 수 없게 되어, 장애 발생 시 원인 규명과 정상 버전으로의 롤백이 불가능해진다.

 

(3) 휴먼 에러와 배포 공포 (Deployment Anxiety)

 - 프로세스 종료 실패, 설정 파일 오탈자, 포트 충돌 등 사람이 직접 터미널 명령을 수행할 때 발생하는 실수는 시스템적으로 방어할 수 없다.

 - 배포 과정의 실패 확률이 높을수록 릴리즈 주기가 길어지고, 한 번에 대규모 변경 사항을 배포하게 되어 장애 발생 시 영향 범위가 커진다.

 

 

2. CI (Continuous Integration) 지속적 통합

CI의 본질은 단순 빌드 스크립트 실행이 아니라, 모든 개발자의 작업 코드를 메인 브랜치에 수시로 병합하고 격리된 환경에서 자동 검증하여 결함을 조기에 검출하는 것이다.

 

[개발자 작업] ──(Push / MR)──> [Git 저장소] ──(Webhook)──> [격리된 CI 러너]
                                                                                                                                                        ├─ 1. 소스 체크아웃
                                                                                                                                                        ├─ 2. 의존성 다운로드
                                                                                                                                                        ├─ 3. 컴파일 및 정적 분석
                                                                                                                                                        └─ 4. 단위/통합 테스트 자동 실행
                                                                                                                                                                               ↓ (검증 실패 시)
                                                                                                                                                                        [병합 차단 & 알림]

 

CI 파이프라인의 필수 구성 요소

 - 격리된 빌드 환경 : 개발자 로컬 PC가 아닌 독립된 CI 빌드 에이전트(GitLab Runner, Jenkins Agent 등)에서 코드를 클린 빌드하여 환경 종속성을 제거한다.

 

 - 자동화된 테스트 검증 : 코드가 병합되기 전 JUnit 단위/통합 테스트를 실행하여 기존 기능이 깨지지 않았는지(회귀 버그 방지) 검증한다.

 

 - 품질 게이트(Quality Gate) : JaCoCo를 통한 테스트 커버리지 기준치 검증 및 SonarQube 정적 분석을 연동하여 기술 부채와 보안 취약점을 사전에 차단한다.

 

3. CD (Continuous Delivery vs Continuous Deployment)의 차이

CI 검증을 통과한 빌드 산출물을 실제 환경에 배포하는 단계는 비즈니스 승인 절차에 따라 두 가지로 구분된다.

 

[CI 통과 산출물] ──> [Staging 환경 배포 & 검증] ──> [수동 승인 버튼] ──> [운영 배포]
└── Continuous Delivery (지속적 제공) ────────────────────┘

[CI 통과 산출물] ──> [Staging 환경 배포 & 검증] ─────────────────────> [운영 배포]
└── Continuous Deployment (지속적 배포) ───────────────────────────┘

 

Continuous Delivery (지속적 제공)

 - CI 파이프라인을 통과한 아티팩트를 언제든 프로덕션에 배포할 수 있는 상태로 유지한다.

 - 스테이징 검증까지 자동화되어 있으며, 최종 운영 배포는 책임자의 수동 승인(Manual Approval)을 거친다. 금융, 공공 등 컴플라이언스와 배포 승인 절차가 필수적인 엔터프라이즈 환경에서 표준으로 사용된다.

 

Continuous Deployment (지속적 배포)

 - 메인 브랜치 병합부터 테스트, 스테이지 검증, 프로덕션 배포까지 사람의 개입 없이 100% 자동화되어 반영된다.

 - 높은 테스트 커버리지와 자동 롤백 아키텍처가 전제되어야 한다.

 

4. 수동 배포 vs CI/CD 파이프라인 비교

비교 항목 수동 배포 방식 CI/CD 파이프라인 구축 방식
빌드 주체 개발자 로컬 PC 격리된 CI 빌드 에이전트 (Runner)
품질 검증 개발자의 주관적 수동 확인 자동화된 테스트 슈트 (JUnit, Mockito)
산출물 형태 파편화된 .class 파일 또는 로컬 jar 버전/태그 기반의 불변 산출물 (Docker 이미지)
배포 실행 SSH 접속 후 수동 명령어 실행 선언적 파이프라인 코드 기반 자동 실행
장애 복구 수동 원복 또는 재빌드 업로드 직전 안정 버전 이미지 태그로 즉각 롤백
릴리즈 리스크 긴 배포 주기, 대규모 장애 위험 잦은 소규모 배포로 리스크 분산

 

5. 결론

CI/CD는 단순한 빌드 및 배포 자동화 도구가 아니라 소프트웨어 릴리즈 라이프사이클의 리스크를 제어하는 엔지니어링 체계다.

 

격리된 환경에서 빌드와 테스트를 자동화하면 개발자는 배포에 대한 심리적 부담 없이 비즈니스 로직 구현에 집중할 수 있으며, 결함이 발생하더라도 배포 시점이 아닌 코드 커밋 시점에 즉각 수정할 수 있따.

 

다음 포스틍에서는 사내 GitLab 환경을 기준으로, 테스트 코드가 없는 레거시 환경에서 시작하는 GitLab Runner 구축 및 Smoke Build 파이프라인 설계에 대해 알아본다.

+ Recent posts