Recent Posts

All Posts →

VO vs DTO: 정의와 용어 혼용의 역사

getter/setter만 있는 데이터 운반용 객체를 VO(Value Object)라고 부르는 관행은 혼란을 부릅니다. VO는 값의 의미와 동등성에 관한 분류이고, DTO는 데이터를 전송하는 역할에 관한 분류입니다. Java/J2EE 진영에서 용어 혼용을 확산시킨 Core J2EE Patterns의 사례와 두 패턴의 정의를 정리합니다.

getter/setter만 있는, 값을 실어나르는 객체를 VO(Value Object)라고 부르는 사례를 실무에서 종종 접합니다. 원격 호출 프로세스 사이에서 데이터를 운반하는 역할이라면 그 객체는 DTO(Data Transfer Object)로 칭하는 편이 혼란의 여지가 적습니다. Fowler와 DDD에서 널리 쓰이는 정의에 따르면 Value Object는 값에 의해 동등성이 판단되는 객체이기 때문입니다. 이 글에서는 두 패턴의 정의와 용어가 뒤섞인 역사를 정리합니다.

Value Object의 정의

Value Object는 식별성 없이 값이 같으면 같다고 간주되는 객체입니다. 돈, 색상, 날짜 같은 개념이 대표적인 예입니다. 10,000원이라는 금액은 어떤 지폐나 계좌 잔액으로 표현되더라도 같은 금액으로 취급할 수 있습니다. 아래 3곳의 링크에서 이 정의를 확인할 수 있습니다.

Eric Evans의 'Domain-Driven Design’에서도 'VALUE OBJECT’를 식별성 없이 속성만으로 동일성을 판단하는 객체로 정의합니다.

Java 코드에서는 동등성을 판단할 속성 값을 기준으로 equals()hashCode()를 구현하면 Value Object가 됩니다. 이때 지켜야 할 두 메서드의 규약은 Joshua Bloch의 'Effective Java' 3판 Item 10과 Item 11에 정리되어 있습니다. Java 16부터 정식 기능이 된 record를 사용하면 간결하게 정의할 수 있습니다. record를 도입한 JEP 395도 record를 불변 데이터의 투명한 운반자 역할을 하는 클래스("classes that act as transparent carriers for immutable data")로 소개합니다.

public record Money(BigDecimal amount, Currency currency) {
}

이 클래스의 두 인스턴스는 담고 있는 값이 같으면 동등합니다.

Currency krw = Currency.getInstance("KRW");
Money price1 = new Money(BigDecimal.valueOf(10000), krw);
Money price2 = new Money(BigDecimal.valueOf(10000), krw);

assertThat(price1).isEqualTo(price2); // 참조가 달라도 값이 같으면 동등

Java 언어와 JVM에도 DDD 및 Fowler의 Value Object와 핵심 성질을 공유하는, 식별성 없는 value object 개념이 도입되고 있습니다. Project Valhalla의 JEP 401: Value Objects (Preview)는 불변이고 객체 식별성이 없으며 필드 값으로만 구분되는 value object를 제안합니다. 2026년 8월 현재 이 JEP는 JDK 28에 미리보기 기능으로 통합되어 있습니다. JEP 169: Larval State for Value Objects는 이러한 불변 value object의 임시 가변 상태를 다루는 별도의 Draft 제안입니다. DDD의 설계 패턴과 Valhalla의 언어·JVM 개념은 동일한 추상화는 아니지만, 어느 쪽에서도 value object를 getter/setter만 가진 객체라는 의미로 사용하지 않습니다.

한편 실무에서는 앞의 정의와 무관하게, 값 동등성과 상관없이 데이터를 담는 객체(data holder)라는 의미로 폭넓게 해석해서 getter/setter만 가진 운반용 객체를 VO라고 부르는 관례도 퍼져 있습니다. 이 관례를 확산시킨 대표적인 출처는 뒤의 Core J2EE Patterns 절에서 살펴봅니다.

불변성의 위상: 정의 요건 vs 바람직한 설계

Value Object는 완전한 불변 객체로 만드는 것이 권장됩니다. 여러 책과 글에서 이를 권고합니다.

  • Fowler는 'Patterns of Enterprise Application Architecture' 486쪽에서 Value Object를 불변으로 만드는 것이 매우 좋은 방법이라고("it’s a very good idea to make them immutable") 권고합니다.

  • Fowler의 Value Object 글도 마찬가지입니다. 2016년 개정 전 버전에서는 "A general heuristic is that value objects should be entirely immutable."라고 썼고, 개정된 현재 버전에서도 "value objects should be immutable"를 중요한 규칙으로 제시합니다.

  • Joshua Bloch는 'Effective Java' 3판의 Item 17 "Minimize mutability"에서 클래스의 가변성을 최소화하고 가능하면 불변으로 만들라고 권고합니다.

  • Eric Evans는 'Domain-Driven Design' 99~103쪽에서 불변 Value Object를 자유롭게 공유할 수 있다는 점을 설명합니다.

VO가 불변이면 여러 객체가 같은 인스턴스를 공유해도 별칭 문제(aliasing bug) 같은 부작용에서 자유롭습니다. Java의 java.util.DateCalendar는 값의 성격을 가진 객체인데도 가변으로 설계되어서 공유된 인스턴스가 한쪽에서 수정되는 버그의 원인이 되었습니다. Java 8의 java.time 패키지가 모든 날짜 클래스를 불변으로 만든 것도 이 교훈의 반영입니다.

그런데 Fowler와 Evans의 문장을 자세히 보면 불변성을 VO의 정의에 포함하지는 않았습니다. Fowler가 Value Object의 정의로 제시하는 성질은 값에 의한 동등성이고, 앞에서 인용한 "value objects should be immutable"도 전체 문장을 보면 별칭 문제를 피하기 위해 따르는 규칙("To avoid aliasing bugs I follow a simple but important rule: value objects should be immutable.")으로 등장합니다. 같은 글에서 불변으로 만드는 대신 대입할 때마다 복사해서 별칭 문제를 피할 수도 있다고("it’s also possible to avoid them by ensuring assignments always make a copy") 대안까지 언급합니다. Evans의 문장도 명령형의 지침입니다. 'Domain-Driven Design' 99쪽의 "Treat the VALUE OBJECT as immutable."는 VALUE OBJECT가 무엇인지에 대한 정의가 아니라 그것을 다루는 방법에 대한 권고이고, 이어지는 내용은 그렇게 했을 때 얻는 공유와 참조 전달의 안전성입니다.

Ward Cunningham의 wiki에 있는 ValueObjectsShouldBeImmutable 페이지에 Fowler가 남긴 문장은 이 뉘앙스를 더 분명하게 보여줍니다.

If you are using a ValueObject that is mutable, treat it like it is immutable. You may not realize why, but you will save a lot of time and money.

"가변인 ValueObject를 쓰고 있다면"이라는 가정 자체가 불변이 아닌 Value Object의 존재를 전제합니다. 페이지 이름도 MustBeImmutable이 아니라 ShouldBeImmutable입니다. 기술 명세의 요구 수준 표기 표준인 IETF의 RFC 2119도 MUST는 절대적인 요구 사항("an absolute requirement")으로, SHOULD는 특정 상황에서 타당한 이유가 있으면 충분히 검토한 뒤 따르지 않을 수도 있는 권고로 정의합니다.

반면 불변성을 정의의 일부로 서술하는 자료도 있습니다.

  • 앞에서 인용한 Microsoft의 .NET 아키텍처 문서는 value object의 두 가지 주요 특성으로 식별성 없음과 불변성을 나란히 들고("There are two main characteristics for value objects: They have no identity. They are immutable."), 불변성을 중요한 요건("Immutability is an important requirement.")이라고 씁니다.

  • Wikipedia도 같게 생성된 두 value object가 계속 같아야 한다는 암묵적 계약을 위해 불변성이 요구된다고 서술합니다.

  • Vaughn Vernon의 'Implementing Domain-Driven Design'(2013) 6장도 Value Object의 특성을 열거하면서 불변성을 그중 하나로 포함합니다.

  • 김우근의 '자바/스프링 개발자를 위한 실용주의 프로그래밍'(2024)도 "VO는 이러한 불변성이라는 특징을 갖고 있는 객체를 말합니다"(43쪽)라고 설명하고, 불변성·동등성·자가 검증이라는 세 특성을 만족하는 객체를 VO로 정의합니다.

  • JEP 401의 value object처럼 언어 차원에서 불변성이 강제되는 개념도 있습니다.

저는 불변성을 VO의 정의 요건이라기보다는 바람직한 설계 규범으로 인식합니다. 어차피 새로 만드는 Value Object는 불변으로 설계할 것이므로, 실무에서 should와 must의 구분이 드러나는 경우는 많지 않습니다. 그래도 이 구분이 무의미하지는 않습니다. java.util.Date처럼 가변으로 만들어진 값 성격의 객체를 만났을 때, 불변성을 정의에 포함하면 그 객체는 VO가 아닌 무언가가 되고, 권고로 보면 불변 권고를 지키지 못한 VO가 됩니다. c2 wiki의 Fowler 문장은 후자의 관점에서 나온 실용적인 조언입니다. 불변 권고를 지키지 못한 VO를 이미 마주쳤을 때, 그것을 VO가 아니라고 배제하는 대신 최소한 불변인 것처럼 다루라는 지침입니다.

'술을 마시고 운전하지 않는다’를 운전자의 정의에 포함할 것인지, 운전자가 지켜야 할 규범으로 볼 것인지의 차이와 비슷합니다. 어느 쪽이든 술을 마시고 운전하면 안 된다는 결론은 같지만, 규범까지 정의에 포함하면 현실에 존재하는 위반 사례를 부를 이름이 사라져서 그런 사례를 논의하기가 어려워집니다.

DTO(Data Transfer Object)의 정의

DTO의 원래 정의는 원격 호출을 효율화하기 위한 객체입니다. Martin Fowler의 'Patterns of Enterprise Application Architecture' 카탈로그와 책의 401쪽에서는 원격 호출의 횟수를 줄이기 위해 여러 데이터를 한 번에 담아서 나르는 객체로 정의합니다. 원격 호출은 비용이 크므로 한 번의 호출로 필요한 데이터를 모두 전달하려는 의도에서 나온 패턴입니다.

HTTP API의 요청과 응답처럼 네트워크를 건너는 데이터를 담는 객체는 프로세스 경계를 넘고 직렬화된다는 점에서 이 정의와 통합니다. 다만 모든 HTTP 메시지가 원격 호출 횟수를 줄이기 위해 도입되는 것은 아니므로, 이를 DTO라고 부르는 용법은 원래 정의를 현대적인 API 경계에 확장한 것입니다.

실무에서는 여기에서 더 나아가 이 정의를 폭넓게 해석해서, 원격 호출과 무관하게 같은 프로세스 안에서 계층의 경계를 넘어 데이터를 운반하는 객체까지 DTO라고 부르는 관례가 생겼습니다. DB 조회 결과를 담는 객체, 서비스 계층에서 뷰 렌더링 계층으로 전달하는 객체, JPA 엔티티를 서비스 레이어 바깥에 직접 노출하지 않도록 변환한 응답 전용 객체가 그런 예입니다. 이런 용법은 원래 정의를 한층 더 적극적으로 확장한 것이어서, 원격 호출 횟수를 줄인다는 엄밀한 정의와는 거리가 있으므로 DTO라고 부르기 어렵다는 주장도 충분히 나올 수 있습니다. Fowler 자신도 LocalDTO에서 로컬 맥락에서는 DTO가 필요하지 않을 뿐 아니라 오히려 해롭다고("Not just do you not need them in a local context, they are actually harmful") 말합니다.

결국 VO와 DTO를 구분하는 기준은 객체의 특성과 역할입니다. 값 개념을 표현하며 동등성을 값으로 판단하는 것은 VO의 성질이고, 경계를 넘어 데이터를 운반하는 것은 DTO의 역할입니다. 이 둘은 서로 배타적인 분류가 아니므로, 값 동등성을 가진 불변 DTO는 동시에 VO의 성질을 가질 수도 있습니다. 반대로 DTO가 반드시 값 동등성을 가져야 하는 것은 아닙니다.

Core J2EE Patterns 초판의 VO와 개정판의 TO

Java/J2EE(현 Jakarta EE) 진영에서 두 용어의 혼용을 확산시킨 대표적인 초기 출처는 Deepak Alur 등이 쓴 'Core J2EE Patterns: Best Practices and Design Strategies’입니다. 2001년에 나온 초판에서는 티어(tier) 사이의 데이터 전송 객체를 Value Object라는 이름의 패턴으로 정의했습니다. 이 이름이 값 동등성 객체로서의 Value Object와 혼동을 유발했기 때문에, 2003년의 2판에서는 같은 패턴을 TO(Transfer Object)로 개칭했습니다. Martin Fowler는 같은 역할의 패턴을 DTO라고 부릅니다('Patterns of Enterprise Application Architecture' 401쪽). 정리하면 다음 세 가지는 같은 전송 패턴을 가리킵니다.

Core J2EE Patterns 초판의 VO = 2판의 TO = Martin Fowler의 DTO

Oracle의 Transfer Object 문서에서 개칭된 이름으로 정리된 패턴 설명을 볼 수 있습니다.

같은 용법은 다른 책에서도 확인됩니다. 여러 서적이 VO와 DTO를 같은 의미로 쓴다고 명시했습니다.

  • Rod Johnson의 'Expert One-on-One J2EE Design and Development'(2002, 265쪽): "Value objects are sometimes referred to as Data Transfer Objects (DTOs)."

  • Rod Johnson과 Juergen Hoeller의 'Expert One-on-One J2EE Development without EJB'(2004, 27쪽): "Transfer objects, often referred to as Data Transfer Objects (DTOs) or Value Objects."

  • 'Professional Java EE Design Patterns'(2014, 12장): "The DTO is also referred to as the Value Object"

  • 'The Java EE Architect’s Handbook, Second Edition'(2014, 5장): "My definition of "value object" is very close to a Data Transfer Object (DTO)"

이 책들이 모두 Core J2EE Patterns 초판의 직접적인 영향을 받았다고 단정할 수는 없습니다. 다만 등장 시기와 여러 문헌에서 반복되는 표현을 고려하면, 초판의 용법이 이후 Java/J2EE 문헌의 표현에도 어느 정도 영향을 미치지 않았을까 추정합니다. 2판에서 이름을 개칭한 지 20년이 넘은 지금까지도 그 관행은 남아 있습니다.

데이터 운반 객체를 VO라고 부를 때의 비용

이미 팀 안에서 통용되는 이름을 굳이 바꿔야 하는지 의문이 들 수도 있습니다. 그러나 데이터 운반 객체를 VO라고 부르는 관행에는 실질적인 비용이 있습니다. 값 동등성 객체로서의 Value Object가 여러 중요한 맥락에서 계속 등장하기 때문입니다.

  • DDD(Domain-Driven Design): VALUE OBJECT는 ENTITY와 함께 도메인 모델을 구성하는 핵심 요소입니다. AGGREGATE는 하나의 ENTITY를 루트로 삼는 데이터 변경의 일관성 경계이며, 내부에 다른 ENTITY와 VALUE OBJECT를 포함할 수 있습니다.

  • ORM: Hibernate와 JPA의 @Embeddable은 자체 영속 식별성 없이 소유한 엔티티에 종속되는 객체를 매핑합니다. DDD의 VALUE OBJECT를 매핑할 때 흔히 사용하는 구조이지만, JPA가 값 동등성이나 불변성을 강제하지는 않으므로 모든 @Embeddable이 곧 DDD VALUE OBJECT인 것은 아닙니다.

  • Java 언어·JVM 기능: JEP 401을 비롯한 Project Valhalla의 문서들은 value object라는 용어를 불변이고 객체 식별성이 없으며 필드 값으로 구분되는 객체라는 의미로 씁니다.

getter/setter만 가진 데이터 홀더를 VO라고 이해하고 있다면 이런 자료를 읽을 때 용어가 어긋나서 혼란이 생깁니다. 원격 프로세스 경계를 넘는 데이터 운반 객체를 DTO라고 부르면 Fowler의 정의와도, Core J2EE Patterns 2판 이후의 개칭된 이름과도 자연스럽게 연결됩니다.

앞의 DTO 정의 절에서 본 것처럼 근래의 관례로는 계층 경계를 넘는 데이터 홀더라는 넓은 의미로 DTO를 쓰기도 합니다. 그러나 클래스 이름에 DTO를 남용하면 단점이 있습니다. HTTP 요청 파라미터를 담는 객체, 통계 쿼리의 결과를 담는 객체까지 모두 DTO라고 부르면 이름만으로는 역할이 드러나지 않습니다.

더 좋은 방법은 객체의 역할에 따라서 접미어를 분리하는 것입니다. 이렇게 하면 이름만으로 역할이 드러나고, 원격 호출과 관련되지 않은 계층에서 쓰이는 객체를 DTO라고 부를 때 엄밀한 정의에서 벗어난다는 논쟁도 피할 수 있습니다. 예를 들면 다음과 같은 이름입니다.

역할 이름 예

이슈 조회 JSON 응답

IssueResponse, IssueDetailDto

이슈 생성 JSON 요청

IssueCreationRequest, IssueCreationCommand

이슈 조회 조건

IssueQuery, IssueCriteria

이슈 DB 통계 조회 결과

IssueStatsRow

GitHub Actions 워크플로 튜닝: 저장소 다섯 곳의 적용 사례

앞 글에서 정리한 GitHub Actions 튜닝 기법을 제 공개 저장소 다섯 곳에 적용하며 측정한 기록입니다. 단계별 시간 측정으로 찾은 병목, 트리거 필터와 concurrency 설정, 캐시가 이득이 아니었던 실험, 매트릭스 병렬화의 손익을 실제 실행 기록으로 살펴봅니다.

앞 글에서 GitHub Actions의 실행 파이프라인 구조와, 측정 → 실행 횟수 줄이기 → 캐시 → 병렬화 → 러너 선택 순서의 튜닝 기법을 정리했습니다. 이 글은 그 기법을 제 개인 저장소 다섯 곳에 적용하며 측정한 기록입니다. 기법의 원리와 주의점은 앞 글에 있으므로, 여기서는 각 저장소에서 무엇을 적용했고 결과가 어땠는지에 집중합니다.

대상은 benelog/flashcard, benelog/spider-silk, benelog/pdf-refinery, benelog/spring-jdbc-book, benelog/til입니다. 모두 공개 저장소라 4 vCPU / 16GB 사양의 표준 Linux 러너에서 실행됐습니다. 측정값은 2026년 8월 28일에 확인한 실제 실행 기록에서 가져왔습니다.

1. spider-silk: 측정으로 찾은 병목

spider-silk의 Publish 워크플로에 앞 글의 gh run view 명령을 돌린 결과입니다.

JOB publish: 354s
  Set up job: 3s
  Run actions/checkout@v7: 1s
  Run actions/setup-java@v4: 0s
  Run gradle/actions/setup-gradle@v6: 9s
  Run ./gradlew build: 58s
  Run ./gradlew publishAllPublicationsToGitHubPackagesRepository: 276s
  Post Run gradle/actions/setup-gradle@v6: 3s

전체 354초 중 276초를 GitHub Packages에 아티팩트를 올리는 데 씁니다. 빌드는 58초입니다. Gradle 빌드 캐시를 아무리 잘 맞춰도 이 워크플로는 6분에서 5분으로밖에 줄지 않습니다. 측정하지 않고 "Gradle 빌드가 느리다"고 짐작해서 캐시 설정부터 손댔다면, 효과가 거의 없는 곳에 시간을 쓰게 됐을 것입니다.

2. flashcard: 트리거 필터와 중복 실행 취소

flashcard의 CI는 Go 코드를 검사하는 워크플로라, 원고 디렉터리만 고쳤을 때는 실행하지 않도록 paths-ignore를 걸었습니다.

flashcard/.github/workflows/ci.yml
on:
  push:
    branches: [main, release]
    paths-ignore:
      - 'book/**'
      - 'book-template/**'
  pull_request:
    paths-ignore:
      - 'book/**'
      - 'book-template/**'
  workflow_dispatch:

같은 브랜치에 커밋을 연달아 밀 때의 중복 실행은 concurrency로 취소합니다.

flashcard/.github/workflows/ci.yml
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

대기 시간도 확인했습니다. 실행 생성이 14:04:15Z, 잡 시작이 14:04:18Z로 대기가 3초였습니다. 개인 저장소라 러너 배정 대기는 문제가 아니었고, 튜닝 대상은 실행 시간 쪽임을 확인했습니다.

잡 분리의 준비 비용도 이 저장소에서 확인할 수 있습니다. Set up job 1초, 체크아웃 2초, actions/setup-go 7초로 실제 작업 전에 10초를 씁니다. 잡을 나누면 이 10초를 잡마다 다시 내므로, 이 정도 규모의 워크플로에서는 잡 분리가 이득이 되기 어렵습니다.

3. spring-jdbc-book: 경로 필터와 캐시 손익 실험

spring-jdbc-book은 예제 코드가 바뀔 때만 테스트를 돌리도록 paths로 대상을 지정했습니다. 워크플로 파일 자신을 목록에 넣어 둔 점이 중요합니다. 이렇게 해야 워크플로를 고쳤을 때 그 변경이 검증됩니다.

spring-jdbc-book/.github/workflows/test-examples.yml
on:
  push:
    branches: [main]
    paths:
      - 'examples/**'
      - '.github/workflows/test-examples.yml'

3.1. 캐시가 잡 시간을 줄이지 못한 실험

이 저장소의 테스트 워크플로를 4분 간격으로 두 번 실행해 캐시의 손익을 쟀습니다. 첫 실행에는 복원할 캐시가 없었고, 두 번째 실행은 첫 실행이 저장해 둔 캐시를 복원했습니다.

단계 1회차 2회차

Set up Gradle (캐시 복원)

3초

13초

Run tests

113초

99초

Post Set up Gradle (캐시 저장)

39초

40초

잡 전체

161초

159초

캐시 덕분에 테스트 실행은 113초에서 99초로 14초 줄었습니다. 그런데 복원에 10초가 더 들고 저장 시간은 그대로라, 잡 전체로는 161초에서 159초가 되어 차이가 없었습니다. 이 저장소의 예제는 의존성이 적고 Testcontainers로 PostgreSQL을 띄우는 시간이 테스트 시간의 대부분이라, 캐시로 줄일 수 있는 몫이 애초에 작았습니다. 두 번만 비교한 값이라 테스트 시간 14초 차이에는 러너 성능 편차도 섞여 있을 수 있습니다. 그래도 캐시 저장에 매번 40초 가까이 든다는 점은 두 실행에서 같았습니다.

3.2. 실패했을 때만 올리는 테스트 리포트

아티팩트는 필요할 때만 올립니다. 이 워크플로는 테스트 리포트를 실패했을 때만 업로드합니다. 성공한 실행에서는 이 단계가 0초로 끝납니다.

spring-jdbc-book/.github/workflows/test-examples.yml
      - name: Upload test reports on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: examples/*/build/reports/tests/test

4. pdf-refinery: 매트릭스 병렬화와 캐시 후보

pdf-refinery는 Python 세 버전을 매트릭스로 돌립니다.

pdf-refinery/.github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

실행 기록을 보면 세 잡이 각각 305초, 240초, 308초 걸렸습니다. 러너 사용 시간의 합은 853초지만, 세 잡이 동시에 시작해서 실제로 기다린 시간은 가장 느린 308초입니다. 버전별 검증이라는 목적을 러너 준비 비용(잡당 10초 안팎)만 더 내고 체감 시간 증가 없이 달성한 셈입니다.

캐시는 아직 붙이지 않았습니다. 매 실행마다 pip install -e '.[dev]'에 41~50초를 쓰는데, 이 시간은 거의 전부 다운로드라서 캐시를 붙이면 줄어들 가능성이 높습니다. spring-jdbc-book과 달리 이쪽은 캐시가 이득일 후보입니다.

5. til: 배포 워크플로의 concurrency 설정

til의 Cloud Run 배포 워크플로는 중간에 취소되면 어중간한 상태로 남을 수 있습니다. 그래서 cancel-in-progress를 켜지 않고, 그룹만 묶어 한 번에 하나만 돌게 했습니다.

til/.github/workflows/deploy.yml
# 연달아 푸시해도 배포가 서로 앞지르지 않게 한 번에 하나만 돌린다.
concurrency:
  group: deploy-cloud-run
  cancel-in-progress: false

이 설정에서 진행 중인 실행은 끝까지 돌고 새 실행은 대기합니다. 다만 기본값으로는 한 그룹에서 대기할 수 있는 실행이 하나뿐이라, 커밋 세 개를 연달아 밀면 첫 번째는 배포되고 두 번째는 취소되며 세 번째만 배포됩니다. 중간 실행까지 모두 돌려야 하는 워크플로라면 앞 글에서 다룬 queue: max를 고려해야 합니다.

6. 정리

다섯 저장소에 적용한 내용과 결과입니다.

저장소 적용·측정한 것 결과

spider-silk

단계별 소요 시간 측정

병목은 빌드(58초)가 아니라 GitHub Packages 업로드(276초). 캐시 튜닝으로는 줄일 수 없는 구조

flashcard

paths-ignore, concurrency, 대기 시간 측정

원고만 고친 커밋은 실행 생략. 대기는 3초라 문제가 아님

spring-jdbc-book

paths 필터, 캐시 실험, 조건부 아티팩트

캐시를 붙여도 잡 전체는 161초 → 159초로 효과 없음

pdf-refinery

매트릭스 병렬화

러너 시간 합 853초를 체감 308초로. pip install 41~50초는 캐시 후보

til

배포 워크플로의 concurrency

cancel-in-progress를 꺼서 배포 중단 방지

다섯 저장소에서 공통으로 확인한 것은 측정이 먼저라는 점입니다. spider-silk처럼 병목이 짐작과 다른 곳에 있기도 하고, spring-jdbc-book처럼 정석으로 알려진 캐시가 측정해 보면 이득이 없기도 합니다. 기법을 적용하기 전과 후의 잡 전체 시간을 비교하는 습관이 어떤 설정보다 효과가 컸습니다.

7. 참고 자료

이 블로그

이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.

GitHub Actions의 실행 구조와 워크플로 튜닝 기법

GitHub Actions에서 이벤트가 워크플로 실행이 되어 러너에 배정되기까지의 파이프라인 구조와 최근 장애 이력을 살펴보고, 그 구조에서 나오는 튜닝 기법을 측정, 트리거 조건, concurrency, 캐시, 잡 분리, 러너 선택 순서로 정리했습니다.

GitHub Actions 워크플로가 느려지면 푸시한 뒤 결과를 확인하기까지 기다리는 시간이 길어지고, 리뷰와 배포도 함께 밀립니다. 그런데 워크플로 파일만 보고 어느 설정을 고쳐야 할지 정하기는 어렵습니다. 느린 원인이 빌드일 수도, 의존성 내려받기일 수도, 러너를 배정받기까지의 대기일 수도 있기 때문입니다.

이 글에서는 먼저 워크플로가 실행되기까지 GitHub 내부에서 거치는 파이프라인 구조와 최근 장애 이력을 살펴봅니다. 그 다음에 실행 시간을 줄이는 기법을 측정 → 실행 횟수 줄이기 → 캐시 → 병렬화 → 러너 선택 순서로 정리했습니다.

GitHub의 사양·요금·한도는 2026년 8월 28일에 확인한 값입니다. 바뀔 수 있으므로 글 끝의 공식 문서를 함께 확인하시기를 권합니다.

1. 이벤트에서 러너까지의 실행 파이프라인과 장애

구체적인 튜닝 기법을 보기 전에, 푸시 한 번이 러너 위에서 도는 잡이 되기까지 GitHub 내부에서 거치는 경로를 정리합니다. 튜닝하려면 시간이 어디에서 소모되는지 알아야 하는데, GitHub이 Actions의 내부 구조를 직접 공개하지는 않습니다. 대신 공식 문서와 장애 보고서(availability report)에 흩어져 있는 내부 서비스에 대한 언급으로 구조를 재구성할 수 있고, 장애 사례는 그 구조의 어느 단계가 실제로 막히는지도 보여줍니다.

1.1. 워크플로를 트리거하는 이벤트의 종류

워크플로는 on에 선언한 이벤트가 발생할 때 실행됩니다. 이벤트는 성격에 따라 네 갈래로 나뉩니다.

분류 이벤트 특성

webhook 이벤트

push, pull_request, issues

저장소 활동이 만드는 이벤트입니다. issues처럼 특정 브랜치에 묶이지 않는 이벤트는 기본 브랜치의 워크플로 파일만 트리거합니다.

스케줄

schedule

cron 문법으로 지정하고 기본 브랜치에서만 실행됩니다. 부하가 높은 시간대에는 실행이 지연될 수 있습니다.

수동·외부 트리거

workflow_dispatch, repository_dispatch

workflow_dispatch는 사용자가 UI·CLI·API로 직접 시작합니다. repository_dispatch는 외부 시스템이 REST API를 호출해 트리거합니다.

워크플로 간 트리거

workflow_run, workflow_call

workflow_run은 다른 워크플로의 시작·완료에 반응해 별도 실행을 만듭니다. workflow_call은 재사용 워크플로를 호출하는 메커니즘이라, 호출한 쪽 실행 안에서 돌고 별도 실행을 만들지 않습니다.

성능 관점에서는 schedule의 공식 문서 문구를 알아둘 만합니다. GitHub은 schedule 이벤트가 Actions 전체의 부하가 높은 시간대에 지연될 수 있고, 매시 정각이 바로 그런 시간대이며, 부하가 충분히 높으면 큐잉된 잡이 아예 드랍될 수도 있다고 안내합니다. cron의 분을 0이 아닌 값으로 두면 정각에 몰리는 혼잡을 피할 수 있습니다.

이벤트 자체가 생성되지 않는 경우도 있습니다. 한 번에 5,000개가 넘는 브랜치를 푸시하면 push 이벤트가 만들어지지 않고, 태그를 한 번에 3개 넘게 푸시하면 태그에 대한 이벤트가 만들어지지 않습니다. 워크플로가 실행되지 않았는데 로그도 없다면, 워크플로 설정보다 앞 단계인 이벤트 생성이 생략되지 않았는지 의심해 볼 수 있습니다.

1.2. 내부 큐 구조와 GraphQL 계층

서버 쪽 내부 구조를 설명하는 문서는 없지만, 공식 문서와 장애 보고서 곳곳에 언급된 내부 서비스와 의존 시스템의 정보를 이으면 큰 흐름을 재구성할 수 있습니다.

  1. 이벤트 접수: push 같은 저장소 활동은 webhook 이벤트가 되어 Kafka 기반의 백그라운드 잡 파이프라인으로 들어갑니다. 2023년 8월 장애 보고서는 이 파이프라인이 막히자 webhook 전달과 Actions 트리거가 함께 지연됐다고 기록합니다. 즉 Actions는 이벤트를 직접 받는 것이 아니라 GitHub의 공용 비동기 처리 계층을 거쳐 받습니다.

  2. 실행 생성: "이벤트를 처리하고 잡을 생성하는 내부 Actions 서비스"가 저장소의 워크플로 파일에서 트리거 조건을 평가해 워크플로 실행(run)과 잡을 만듭니다. 이 표현은 2026년 8월 6일 장애의 공식 원인 분석에 나옵니다. 이 경로는 커밋에 검사 상태를 기록하는 check suite 생성과 GraphQL 계층도 지납니다. 2021년 3월에는 check suite ID가 Int32 범위를 넘어서자 의존하던 GraphQL 라이브러리가 JSON을 읽지 못했고, 그 결과 잡이 큐에 들어가지 못하는 장애가 있었습니다.

  3. 잡 큐잉과 배정: 만들어진 잡은 큐에 들어가고, 잡 배정을 담당하는 서비스가 runs-on 레이블·그룹이 맞는 유휴 러너를 찾아 잡을 배정합니다. 잡 오케스트레이션은 Redis 클러스터에 의존합니다.

  4. 러너 수령: 러너는 GitHub으로 아웃바운드 HTTPS 연결만 열고, 50초 단위의 long poll로 잡을 기다립니다. 러너 애플리케이션은 Azure Pipelines Agent의 포크이고, 로그와 아티팩트는 Azure Blob Storage로 올라갑니다.

이벤트 접수부터 러너 수령까지 GitHub Actions 실행 파이프라인 다이어그램

이와 같이 워크플로 실행은 여러 큐와 서비스를 지나는 비동기 파이프라인입니다. webhook 처리 → 실행 생성 → 잡 큐 → 러너 배정 중 어느 구간이 막혀도 사용자에게는 똑같이 "워크플로가 시작되지 않는" 증상으로 보입니다.

참고로 러너가 GraphQL API를 직접 호출하지는 않습니다. 액션 내려받기와 API 호출은 api.github.com 등 REST 엔드포인트와 *.actions.githubusercontent.com 계열 도메인을 씁니다. 워크플로 파일을 읽거나 액션을 resolve하는 단계에서 내부적으로 GraphQL을 쓰는지는 공개된 자료가 없습니다.

1.3. 성능을 좌우하는 조건 평가 시점

이 구조에서 성능에 크게 영향을 주는 요소는 워크플로 파일이나 조건식의 개수가 아니라, 조건이 파이프라인의 어느 단계에서 평가되는가입니다. 조건은 앞쪽에서 평가될수록 이득입니다. 같은 "건너뛰기"라도 평가 시점이 다릅니다.

  • onpaths·branches 필터는 실행 생성 전에 평가됩니다. 필터에 걸리면 실행 자체가 만들어지지 않아 파이프라인에 아무것도 들어가지 않습니다.

  • jobs.<job_id>.if는 실행이 만들어진 뒤 잡 수준에서 평가됩니다. 조건이 거짓이면 잡은 skipped가 되어 러너를 쓰지 않지만, 실행은 생성되어 실행 목록에 남습니다.

그래서 튜닝의 핵심은 실행과 잡을 만들지 않도록 조건을 가능한 한 앞 단(on 필터)으로 옮기는 일입니다.

반대로 개수는 걱정할 요인이 아닙니다. 트리거 평가는 파일 단위로 일어나므로 파일이 많으면 평가할 대상도 늘어나는 셈이지만, 파일 수가 실행 시작을 늦춘다는 공식 자료나 측정은 찾지 못했습니다. 공식 한도도 파일 수가 아니라 파일 하나의 크기(500KB), 트리거 이벤트 빈도(저장소당 10초에 1,500개), 실행 생성 빈도(10초에 500개)에 걸려 있습니다. 조건식도 마찬가지입니다. 표현식 평가는 서버 쪽에서 일어나고, 그 비용이 실행 시간에 잡힐 만큼 크다는 근거는 없습니다. 워크플로 파일을 합치거나 조건식을 줄이는 정리는 관리 편의로는 의미가 있어도, 실행 시간을 줄이는 수단은 아닙니다.

두 평가 시점의 차이는 필수 상태 검사 문제와도 이어집니다. on 필터로 워크플로를 건너뛰면 검사가 Pending으로 남아 병합이 막히지만, 잡 수준 if로 건너뛴 잡은 상태가 Success로 보고되어 병합을 막지 않습니다. 그래서 필수 검사로 지정한 워크플로에서는 경로 필터 대신 잡 수준에서 변경 파일을 판별해 건너뛰는 방식이 우회책이 됩니다.

1.4. 잦은 Actions 장애와 파이프라인 구조의 관계

앞 절에서 장애 보고서는 구조를 재구성하는 근거였는데, 이 절에서는 장애 사례 자체를 봅니다. 장애가 파이프라인의 어느 단계를 막는지, 그리고 그때 생기는 지연이 왜 워크플로 튜닝의 범위 밖인지가 여기서 드러납니다.

2025년 말부터 GitHub의 장애는 잦아지고 있습니다. 월별 availability report에 집계된 인시던트 수가 2025년 하반기에는 월 3~5건이었는데 2026년에는 월 6~10건으로 늘었습니다. 이 글을 쓰는 2026년 8월에는 1일부터 27일까지 githubstatus.com에 올라온 인시던트 25건 중 7건이 Actions 관련이었습니다. 증상도 "실행 시작 지연", "잡이 queued 상태로 고착"처럼 파이프라인 앞 단이 막히는 유형이 반복됩니다.

원인을 나눠 보면, Actions 파이프라인 자체의 장애와 파이프라인이 의존하는 공용 서비스·인프라의 장애가 섞여 있습니다.

  • 파이프라인 자체의 장애: 2026년 8월 6일에는 이벤트를 처리해 잡을 생성하는 내부 서비스에 정기 배포가 나가면서 잠재해 있던 용량 문제가 드러났고, 피크에는 워크플로 실행의 71%가 실패했습니다. 잡 배정 서비스의 버그로 러너가 이미 무효가 된 잡을 배정받아 재시도를 반복했고, GitHub은 복구를 위해 webhook 유입을 평소의 15% 수준으로 줄였습니다. 그 밖에도 잡 오케스트레이션용 Redis 클러스터 문제로 실행의 95%가 5분 안에 시작하지 못한 장애(2026년 3월 5일), 러너 관련 서비스의 SSL 인증서 만료(2026년 7월과 8월), 데이터베이스 primary 포화(2026년 8월 26일)가 이어졌습니다. 2026년 5월 26일에는 자동 계정 심사 시스템이 Actions의 내부 서비스 계정을 오인해 정지시키는 바람에 새로 큐잉된 모든 실행이 실패하기도 했습니다.

  • 공용 서비스·인프라 장애의 전파: Actions는 webhook, API, 데이터베이스·캐시 계층, Azure 인프라에 바탕을 두고 있습니다. 2025년 10월 29일 Azure Front Door 장애 때는 호스티드 러너 잡이 실패했고, 2026년 6월 8일에는 github.com의 릴리스 다운로드 엔드포인트가 504를 반환하자 그 엔드포인트에 의존하는 워크플로들이 실패했습니다. Actions 자체는 정상이어도 의존 대상이 무너지면 함께 무너지는 구조라서, github.com 전반에 걸친 장애에서는 거의 항상 Actions가 영향 목록에 들어갑니다.

GitHub도 이 구조를 문제로 인정하고, 2026년 3월에 "Actions와 Git 같은 핵심 시스템이 공유 인프라 문제에 영향받지 않도록 의존성을 격리하겠다"고 밝혔습니다. 커뮤니티에서는 진행 중인 데이터센터 이전 작업을 장애 증가와 연결하는 추측도 있지만, 공식 장애 보고서가 이전 작업을 원인으로 지목한 적은 없습니다.

장애나 혼잡으로 생기는 대기 시간은 워크플로 안쪽을 아무리 튜닝해도 줄지 않습니다. 그래서 이 글에서는 먼저 튜닝할 대상을 측정할 수 있는 기법을 설명합니다.

2. 단계별 소요 시간 측정

워크플로를 고치기 전에 어느 단계가 시간을 쓰는지부터 확인해야 합니다. GitHub UI의 실행 결과 화면에서 각 단계를 펼치면 소요 시간이 나오지만, 여러 실행을 비교하려면 gh CLI로 뽑는 편이 빠릅니다.

gh run view -R <owner>/<repo> <run-id> --json jobs --jq '
  .jobs[] | "JOB \(.name): \(((.completedAt|fromdateiso8601) - (.startedAt|fromdateiso8601)))s",
  (.steps[] | "  \(.name): \(((.completedAt|fromdateiso8601) - (.startedAt|fromdateiso8601)))s")'

측정하지 않고 "빌드가 느리다"고 짐작해서 캐시 설정부터 손대면, 효과가 거의 없는 곳에 시간을 쓰게 됩니다. 전체 시간의 대부분을 빌드가 아닌 단계(예: 아티팩트나 패키지 업로드)가 차지하는 워크플로도 흔합니다.

2.1. 대기 시간 확인

잡이 실제로 실행된 시간과 별개로, 러너를 배정받기까지 기다리는 시간이 따로 있습니다. 워크플로 실행이 생성된 시각(createdAt)과 잡이 시작된 시각(startedAt)의 차이가 대기 시간입니다. 앞에서 본 파이프라인으로 말하면 잡 큐잉부터 러너 배정까지의 구간입니다.

gh run view -R <owner>/<repo> <run-id> --json createdAt,jobs \
  --jq '"created: \(.createdAt)", (.jobs[] | "started: \(.startedAt)")'

개인 저장소에서는 대기가 거의 없지만, 동시 실행 한도에 걸리는 조직 저장소에서는 대기가 실행 시간보다 길어지기도 합니다. 그런 상황이면 워크플로 내부를 아무리 다듬어도 체감 시간이 줄지 않습니다. 저장소나 조직의 Insights 탭에 있는 Actions Performance Metrics에서 잡별 평균 실행 시간과 평균 대기 시간을 최대 1년 치까지 볼 수 있으니, 여기서 대기가 문제인지 실행이 문제인지를 먼저 구분하는 편이 좋습니다.

3. 실행 횟수 줄이기

가장 확실하게 시간을 줄이는 방법은 실행 횟수 자체를 줄이는 것입니다. 실행할 필요가 없는 워크플로는 트리거 조건으로 거르고, 결과를 볼 필요가 없어진 실행은 concurrency로 취소합니다.

3.1. 트리거 조건으로 불필요한 실행 제거

onpathspaths-ignore를 걸면 바뀐 파일 경로에 따라 실행을 건너뜁니다. 앞에서 본 대로 이 필터는 실행 생성 전에 평가되므로, 필터에 걸리면 파이프라인에 아무것도 들어가지 않습니다.

on:
  push:
    branches: [main]
    paths:
      - 'examples/**'
      - '.github/workflows/test-examples.yml'

paths를 쓸 때는 워크플로 파일 자신을 목록에 넣어 두는 점이 중요합니다. 이렇게 해야 워크플로를 고쳤을 때 그 변경이 검증됩니다.

Caution

브랜치 보호 규칙에서 필수 상태 검사(required status check)로 지정한 워크플로에는 경로 필터를 쓰면 안 됩니다. 필터 때문에 워크플로를 건너뛰면 검사 상태가 Pending으로 남고, 풀 리퀘스트는 "Waiting for status to be reported" 상태로 병합이 막힙니다. GitHub 문서는 필수 검사에 경로·브랜치 필터를 쓰지 말라고 안내하고, 굳이 써야 한다면 같은 이름으로 항상 성공하는 워크플로를 paths-ignore와 함께 하나 더 두는 우회책을 제시합니다.

3.2. concurrency로 중복 실행 취소

같은 브랜치에 커밋을 연달아 밀어 넣으면 이전 커밋의 워크플로가 계속 돌고 있는데 새 실행이 또 시작됩니다. 결과를 볼 필요가 없는 실행이 러너를 차지합니다. concurrency로 그룹을 묶고 cancel-in-progress를 켜면 진행 중이던 실행이 취소됩니다.

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

그런데 이 설정을 모든 워크플로에 넣으면 안 됩니다. 배포 워크플로는 중간에 취소되면 어중간한 상태로 남을 수 있습니다. 그런 워크플로는 그룹만 묶고 취소는 끕니다.

concurrency:
  group: deploy
  cancel-in-progress: false

cancel-in-progress: false로 두면 진행 중인 실행은 끝까지 돌고 새 실행은 대기합니다. 다만 기본값으로는 한 그룹에서 대기할 수 있는 실행이 하나뿐이고, 뒤에 들어온 실행이 먼저 대기하던 실행을 취소합니다. 커밋 세 개를 연달아 밀면 첫 번째는 배포되고 두 번째는 취소되며 세 번째만 배포됩니다. 중간 실행까지 모두 돌려야 한다면 queue: max를 지정해 최대 100개까지 줄을 세울 수 있습니다. queue: maxcancel-in-progress: true는 동작이 서로 어긋나므로 함께 쓸 수 없습니다.

4. 의존성 캐시와 그 비용

의존성을 내려받는 시간은 대개 캐시로 줄입니다. actions/cache를 직접 쓰기 전에, 언어별 setup 액션에 내장된 캐시 옵션을 먼저 확인하는 편이 간단합니다.

액션 설정 캐시 대상

actions/setup-go

cache: true (기본값)

모듈 캐시와 빌드 캐시

actions/setup-node

cache: npm 또는 pnpm, yarn

패키지 매니저의 전역 캐시 디렉터리

actions/setup-python

cache: pip

pip 캐시 디렉터리

actions/setup-java

cache: gradle 또는 maven

~/.gradle, ~/.m2/repository

gradle/actions/setup-gradle

기본 동작

Gradle 사용자 홈과 설정 캐시

4.1. 캐시의 손익 계산

캐시는 복원과 저장에도 시간을 씁니다. 복원은 setup 단계에서, 저장은 잡 마지막의 Post 단계에서 일어나므로, 실행 결과 화면에서 이 두 단계의 시간을 보면 캐시가 얼마를 쓰는지 알 수 있습니다.

캐시가 이득인지는 "다시 내려받는 시간"과 "복원 + 저장 시간"의 차이로 정해집니다. 의존성 다운로드에 시간을 많이 쓰는 프로젝트는 캐시로 줄어들 가능성이 높지만, 압축한 결과가 수백 MB인 캐시를 만들면 올리고 내리는 시간이 다시 받는 시간을 넘길 수 있습니다. 어느 쪽인지는 캐시를 붙인 전후의 잡 전체 시간을 비교해야 알 수 있습니다. 다음 글에 캐시를 붙여도 잡 전체 시간이 줄지 않은 실제 측정 사례를 실었습니다.

4.2. 캐시 한도와 스코프

캐시를 설계할 때 알아야 할 GitHub의 제약이 세 가지 있습니다.

  • 저장소당 기본 한도는 10GB입니다. 한도를 넘으면 기존 캐시가 삭제됩니다.

  • 7일 넘게 접근되지 않은 캐시는 삭제됩니다. 한 달에 한 번 도는 워크플로는 매번 캐시가 없는 상태로 시작합니다.

  • 워크플로 실행은 현재 브랜치와 기본 브랜치의 캐시를 복원할 수 있습니다. 풀 리퀘스트에서 실행됐다면 base 브랜치의 캐시도 복원합니다. 자식 브랜치나 형제 브랜치의 캐시는 쓰지 못합니다.

풀 리퀘스트에는 규칙이 하나 더 붙습니다. 풀 리퀘스트에서 만든 캐시는 병합 참조(refs/pull/…​/merge)에 묶이므로, 그 풀 리퀘스트를 다시 실행할 때만 복원됩니다. 기본 브랜치나 다른 풀 리퀘스트는 그 캐시를 쓰지 못합니다. 따라서 캐시를 처음 채우는 일은 기본 브랜치의 워크플로가 맡아야 하고, 풀 리퀘스트는 거기서 복원해 쓰는 구조가 됩니다.

5. 잡 분리와 매트릭스의 손익

한 잡 안에서 순서대로 실행하던 작업을 여러 잡으로 나누면 병렬로 돌아갑니다. 매트릭스를 쓰면 같은 작업을 여러 입력으로 반복하는 잡을 한 번에 선언할 수 있습니다.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

나눠진 잡들은 동시에 시작하므로, 러너 사용 시간의 합은 잡들의 합이지만 실제로 기다리는 시간은 가장 느린 잡의 시간으로 줄어듭니다.

여기에 두 가지 대가가 따릅니다.

첫째, 잡마다 러너 준비 비용을 다시 냅니다. Set up job, 체크아웃, setup 액션까지 실제 작업 전에 대개 10~30초를 씁니다. 잡을 셋으로 나누면 이 비용을 세 번 냅니다. 전체가 30초짜리인 워크플로를 나누면 오히려 느려집니다.

둘째, 잡 사이에 데이터를 넘기려면 아티팩트 업로드와 다운로드가 필요합니다. 한 잡 안에서는 파일 시스템에 그대로 두면 되던 빌드 결과물을, 잡을 나누면 올렸다가 다시 받아야 합니다.

그래서 잡을 나눌 만한 경우는 다음과 같습니다.

  • 서로 결과를 주고받지 않는 작업이다. 예: 린트, 테스트, 문서 빌드

  • 각 작업이 러너 준비 비용(10~30초)보다 충분히 오래 걸린다

  • 매트릭스처럼 같은 작업을 다른 입력으로 반복한다

needs로 잡 사이 의존을 걸면 병렬성이 사라지므로, 정말 앞 잡의 결과가 필요할 때만 씁니다. 매트릭스에서는 fail-fast가 기본값 true라, 한 조합이 실패하면 나머지 조합이 취소됩니다. 실패를 빨리 알고 러너 시간을 아끼려면 그대로 두고, 어느 버전에서 깨지는지 한 번에 다 보려면 false로 바꿉니다.

6. 러너 사양과 종류 선택

같은 워크플로도 저장소 공개 여부에 따라 다른 사양에서 돕니다.

구분 공개 저장소 비공개 저장소

Linux 표준 러너

4 vCPU, 16GB RAM

2 vCPU, 8GB RAM

디스크

14GB SSD

14GB SSD

비공개 저장소의 표준 러너는 2코어입니다. Gradle의 병렬 빌드나 go test -p처럼 코어 수에 비례하는 작업은 여기서 기대만큼 빨라지지 않습니다. 공개 저장소에서 측정한 시간을 비공개 저장소에 그대로 대입하면 안 되는 이유이기도 합니다.

선택할 수 있는 러너는 세 가지입니다.

  • arm64 표준 러너: ubuntu-24.04-arm 같은 레이블로 씁니다. 2025년 8월에 공개 저장소에서 정식 출시됐고, 2026년 1월부터 비공개 저장소에서도 쓸 수 있습니다. x64 대비 분당 단가가 낮고, 워크로드에 따라 더 빠릅니다. 다만 네이티브 확장을 빌드하는 프로젝트는 arm64용 휠이나 바이너리가 있는지 확인해야 합니다.

  • Larger runner: 코어를 8개 이상으로 늘린 유료 러너입니다. 공개 저장소에서도 항상 과금되므로, 늘어난 코어를 실제로 쓰는 작업인지 먼저 확인해야 합니다.

  • Self-hosted runner: 캐시와 도구를 디스크에 남겨 둘 수 있어 준비 시간이 짧습니다. 대신 러너를 직접 관리해야 하고, 공개 저장소에서는 포크의 풀 리퀘스트가 임의의 코드를 실행할 수 있어 보안 위험이 큽니다.

운영체제 선택도 시간과 비용에 영향을 줍니다. 표준 러너의 분당 단가는 Linux 2코어가 $0.006, Windows 2코어가 $0.010, macOS 3~4코어가 $0.062입니다. 공개 저장소에서는 표준 러너가 무료지만, 비공개 저장소에서 macOS 러너를 쓰면 같은 시간에 Linux의 10배가 넘게 청구됩니다. macOS 러너는 애플 플랫폼용 빌드에만 쓰고, 나머지는 Linux에 두는 편이 낫습니다.

7. 체크아웃과 아티팩트 다듬기

남은 두 가지는 개별 효과가 크지는 않지만 설정이 간단합니다.

actions/checkoutfetch-depth 기본값은 1이라 최신 커밋 하나만 받습니다. 0으로 바꾸면 전체 이력을 받는데, 커밋이 수만 개인 저장소에서는 이 단계만 수십 초가 걸립니다. 릴리스 노트 생성처럼 이력이 정말 필요한 잡에만 0을 지정하고, 나머지는 기본값을 두면 됩니다. 모노레포에서 일부 디렉터리만 필요하다면 sparse-checkout으로 받는 범위를 줄일 수 있습니다.

- uses: actions/checkout@v4
  with:
    sparse-checkout: |
      services/api
      libs/common

아티팩트는 필요할 때만 올립니다. 예를 들어 테스트 리포트를 실패했을 때만 업로드하면, 성공한 실행에서는 이 단계가 0초로 끝납니다.

- name: Upload test reports on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: test-reports
    path: build/reports/tests/test

actions/upload-artifact는 v4에서 업로드·다운로드가 최대 10배 빨라졌으므로, v3 이하를 쓰고 있다면 올리는 편이 좋습니다. 다만 v4부터 아티팩트가 워크플로가 아닌 잡 단위로 묶이므로, 매트릭스 잡에서 같은 이름으로 올리면 충돌합니다. 이름에 매트릭스 값을 붙여 구분해야 합니다.

8. GitHub Enterprise Server에서 달라지는 손익 계산

지금까지의 계산은 github.com을 전제로 했습니다. 앞의 조건 평가 시점 절에서 파일 수와 조건식의 개수는 걱정할 요인이 아니라고 했는데, 그 전제는 트리거 평가와 실행 생성이 GitHub의 공용 인프라에서 일어난다는 것이었습니다. 설치형인 GitHub Enterprise Server(GHES)에서는 이 전제가 달라집니다. webhook 접수부터 트리거 평가, 실행 생성, 잡 큐잉까지 파이프라인 전체가 사내 인스턴스의 자원으로 돌아가기 때문입니다. GitHub은 GHES에서 Actions를 켜는 데에 최소 8 vCPU와 64GB 메모리를 요구하고, 연결하는 러너 수에 따라 권장 사양을 올려 잡습니다(예: 32 vCPU·160GB 메모리의 최대 연결 러너 수는 2,700대).

그래서 github.com에서는 유지보수성 관점에서 추천했던 파일 정리가 GHES에서는 자기 인스턴스의 용량 절감이 됩니다. 안 쓰는 워크플로를 삭제·비활성화하고, 미러링이나 봇의 대량 푸시처럼 워크플로가 필요 없는 이벤트를 줄이면, 그만큼 인스턴스가 처리할 평가와 실행 생성이 줄어듭니다. schedule의 정각 분산도 의미가 달라집니다. github.com에서는 GitHub의 혼잡을 피하는 요령이었지만, GHES에서는 조직 전체의 cron이 정각에 몰리면 내 인스턴스에 부하 스파이크가 생깁니다. 다만 GHES에서도 트리거 평가 자체가 병목이라는 근거가 따로 있지는 않으므로, 이런 정리는 지연을 고치는 처방이라기보다 인스턴스 용량을 아끼는 예방에 가깝습니다.

러너와 스토리지의 전제도 다릅니다. GHES에는 GitHub 호스티드 러너가 없어 셀프 호스티드 러너만 쓸 수 있습니다. 잡이 큐에서 기다리는 시간이 전적으로 사내 러너 풀의 용량에 달려 있으므로, 러너 사양 절에서 본 표준 러너의 사양·단가 비교는 GHES에 적용되지 않습니다. 로그·아티팩트·캐시는 별도로 준비한 외부 blob storage(S3, Azure Blob 등)에 저장되므로, 캐시의 손익도 그 스토리지의 성능에 따라 달라집니다.

측정 수단은 오히려 늘어납니다. github.com에서는 실행 생성 앞 구간이 블랙박스지만, GHES 관리자는 관리 콘솔의 모니터 대시보드에서 CPU·메모리 포화, 백그라운드 잡 큐 적체, Actions 관련 지표를 직접 볼 수 있습니다. 실행 시작이 느릴 때 워크플로를 고치기 전에 인스턴스 쪽 병목부터 확인할 수 있으므로, 측정을 먼저 하라는 이 글의 순서는 GHES에서도 그대로 적용됩니다.

9. 정리

지금까지 살펴본 기법을 적용 순서대로 정리하면 다음과 같습니다.

순서 확인할 것 주의할 점

1

gh run view로 단계별 시간과 대기 시간 측정

대기가 길면 워크플로 내부 최적화는 효과가 없다

2

paths·paths-ignore로 실행 자체를 제거

필수 상태 검사에는 쓰지 않는다

3

concurrency로 중복 실행 취소

배포 워크플로에는 cancel-in-progress를 켜지 않는다

4

setup 액션의 내장 캐시 적용

복원 + 저장 시간이 다시 받는 시간보다 클 수 있다

5

잡 분리와 매트릭스로 병렬화

잡마다 준비 비용 10~30초를 다시 낸다

6

러너 사양과 운영체제 선택

비공개 저장소는 2코어, macOS는 Linux의 10배 단가

1번을 건너뛰고 2번부터 시작하면, 병목이 다른 곳에 있는 워크플로에서 헛수고를 하게 됩니다. 이 순서대로 제 저장소 다섯 곳에 적용하며 측정한 기록을 다음 글에 정리했습니다.