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. 성능을 좌우하는 조건 평가 시점

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

  • on의 paths·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. 트리거 조건으로 불필요한 실행 제거

on에 paths나 paths-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: max와 cancel-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/checkout의 fetch-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번부터 시작하면, 병목이 다른 곳에 있는 워크플로에서 헛수고를 하게 됩니다. 이 순서대로 제 저장소 다섯 곳에 적용하며 측정한 기록을 다음 글에 정리했습니다.

Call by value vs Call by reference

Call by value와 call by reference의 정의를 정리하고, Java, Kotlin, JavaScript, C, C++, C#, Python, Go, Rust, Ada가 인자를 어떻게 전달하는지 비교합니다.

"Java의 메서드 호출에서 primitive type은 call by value로, 객체는 call by reference로 인자가 전달된다"는 설명을 종종 접합니다. 그러나 Java에는 call by value만 있습니다. 메서드에 객체를 전달할 때 복사되는 값이 '객체를 가리키는 참조값’일 뿐입니다. 이 글에서는 두 용어의 정의를 정리하고, Java, Kotlin, JavaScript, C, C++, C#, Python, Go, Rust, Ada가 인자를 어떻게 전달하는지 비교합니다.

Call by value와 call by reference

Call by value는 인자로 넘긴 값으로 파라미터라는 별도의 변수를 초기화하는 방식입니다. 대개 값을 복사하며, Rust처럼 소유권을 옮기는 언어도 있습니다. 함수 안에서 파라미터에 다른 값을 대입해도 호출한 쪽의 변수는 영향을 받지 않습니다.

Call by reference는 변수 자체를 전달하는 방식입니다. 파라미터는 호출한 쪽 변수의 별칭(alias)이 되므로, 함수 안에서 파라미터에 새 값을 대입하면 호출한 쪽의 변수도 함께 바뀝니다.

이 정의는 프로그래밍 언어론의 표준적인 설명을 따릅니다.

Compilers: Principles, Techniques, and Tools 2판은 1.6.6절에서 두 방식을 다음과 같이 설명합니다.

In call-by-value, the actual parameter is evaluated (if it is an expression) or copied (if it is a variable). The value is placed in the location belonging to the corresponding formal parameter of the called procedure. This method is used in C and Java, and is a common option in C++, as well as in most other languages. …​

In call-by-reference, the address of the actual parameter is passed to the callee as the value of the corresponding formal parameter. Uses of the formal parameter in the code of the callee are implemented by following this pointer to the location indicated by the caller. Changes to the formal parameter thus appear as changes to the actual parameter.

Call by value에서는 실제 파라미터가 표현식이면 평가하고, 변수이면 복사한다. 그 값은 호출된 프로시저에서 대응하는 형식 파라미터의 저장 위치에 놓인다. 이 방식은 C와 Java에서 쓰이며, C++와 대부분의 다른 언어에서도 흔히 쓰는 선택지다. …​

Call by reference에서는 실제 파라미터의 주소가 대응하는 형식 파라미터의 값으로 호출된 쪽에 전달된다. 호출된 쪽의 코드에서 형식 파라미터를 쓰는 부분은 이 포인터를 따라 호출한 쪽이 가리킨 위치에 접근하도록 구현된다. 따라서 형식 파라미터의 변경은 실제 파라미터의 변경으로 나타난다.

— Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman
Compilers: Principles, Techniques, and Tools 2판 1.6.6절

인용문의 실제 파라미터(actual parameter)는 이 글의 인자에, 형식 파라미터(formal parameter)는 이 글의 파라미터에 해당합니다. 이 책은 1977년의 전신 Principles of Compiler Design부터 대학 컴파일러 강의의 기본 교재로 쓰여 온 표준 교과서입니다. 표지 그림 때문에 Dragon Book으로도 불립니다.

Compilers: Principles, Techniques, and Tools 2판 표지
Figure 1. Compilers: Principles, Techniques, and Tools 2판 표지 (이미지 출처: Open Library)

Christopher Strachey는 1967년 강의를 정리한 Fundamental Concepts in Programming Languages 3.4.2절에서 이 구분을 L-value와 R-value로 설명했습니다. 파라미터에 표현식의 R-value를 넘기는 방식을 call by value, L-value를 넘기는 방식을 call by reference라고 불렀습니다.

When the function is used (or called or applied) we write f[ε] where ε can be an expression. If we are using a referentially transparent language all we require to know about the expression ε in order to evaluate f[ε] is its value. There are, however, two sorts of value, so we have to decide whether to supply the R-value or the L-value of ε to the function f. Either is possible, so that it becomes a part of the definition of the function to specify for each of its bound variables (also called its formal parameters) whether it requires an R-value or an L-value. These alternatives will also be known as calling a parameter by value (R-value) or reference (L-value).

함수를 사용할(호출하거나 적용할) 때는 f[ε]라고 쓰며, ε는 표현식일 수 있다. 참조 투명한 언어라면 f[ε]를 평가하는 데 표현식 ε에 대해 알아야 할 것은 그 값뿐이다. 그러나 값에는 두 종류가 있으므로 함수 f에 ε의 R-value를 넘길지 L-value를 넘길지 정해야 한다. 어느 쪽도 가능하므로, 함수의 각 속박 변수(형식 파라미터라고도 한다)가 R-value를 요구하는지 L-value를 요구하는지 정하는 일이 함수 정의의 일부가 된다. 이 두 가지를 파라미터를 값(R-value)으로 호출하는 방식과 참조(L-value)로 호출하는 방식이라고도 부른다.

— Christopher Strachey
Fundamental Concepts in Programming Languages 3.4.2절

L-value는 대입문의 왼쪽(Left)에 놓인 표현식이 뜻하는 저장 위치이고, R-value는 오른쪽(Right)에 놓인 표현식이 뜻하는 그 위치에 담긴 값입니다. 이 강의록은 L-value와 R-value, first-class 객체 같은 용어의 출처로 지금도 인용되는 프로그래밍 언어론의 고전입니다.

두 방식을 구분하는 실용적인 기준은 한 문장으로 요약할 수 있습니다. 함수 안에서 파라미터에 새 값을 대입했을 때 호출한 쪽의 변수가 바뀌는가? 바뀌면 call by reference이고, 바뀌지 않으면 call by value입니다. 이 기준은 이 글처럼 두 방식만 비교하고, 다시 대입할 수 있는 변수를 인자로 넘기며, 파라미터에도 대입할 수 있다고 가정할 때 성립합니다. Call by copy-restore나 call by name 등 다른 평가 전략까지 포함하면 이 질문만으로 모든 인자 전달 방식을 분류할 수는 없습니다.

또한 이 기준에서 말하는 대입은 targetTv = new Tv()처럼 파라미터 변수 자체에 다른 값을 넣는 것입니다. 파라미터가 객체를 가리킨다면 다른 객체를 가리키게 바꾸는 셈입니다. targetTv.setChannel("Rock")처럼 파라미터가 가리키는 객체의 내부 상태를 바꾸는 것은 별개의 문제입니다. 뒤에서 볼 오해는 이 둘을 혼동하는 것에서 생깁니다.

Pass by ~와 call by ~ 용어

Call by value와 call by reference는 pass by value, pass by reference라고도 부릅니다. call by ~는 1960년에 발표된 ALGOL 60 보고서부터 쓰인 형태입니다. 1963년 개정 보고서의 4.7.3절이 파라미터 전달 방식을 규정하며 4.7.3.1에 'Value assignment (call by value)', 4.7.3.2에 'Name replacement (call by name)'이라는 제목을 붙였습니다. 실무와 교재에서는 pass by ~도 널리 쓰입니다. 이 글에서는 call by ~로 통일했습니다.

Call by sharing

Java, JavaScript, Python처럼 호출자 변수 자체가 아니라 객체를 공유하는 인자 전달 동작을 call by sharing이라고 부르기도 합니다. Barbara Liskov를 비롯한 설계팀이 1970년대 중반에 만든 언어인 CLU의 설계 문서에서 쓴 용어입니다. 뒤에서 살펴볼 Java, Kotlin, JavaScript, Python의 객체 인자 동작과 C#의 class 인스턴스 전달이 모두 여기에 해당합니다. Python 튜토리얼이 쓴 call by object reference도 같은 뜻입니다. Go의 맵이나 포인터, C의 포인터를 전달할 때도 같은 구조가 나타납니다.

Call by value라고 하면 '객체가 통째로 복사된다’는 오해를, call by reference라고 하면 '재할당도 호출한 쪽 변수에 반영된다’는 오해를 낳을 수 있습니다. Call by sharing은 이런 오해를 피하는 데 좋은 용어입니다. 뒤에서 인용할 MDN의 JavaScript 문서처럼 이 용어를 쓰는 자료도 있지만, 교재와 언어 사양에서 표준 용어로 자리 잡지는 않았습니다. 그래서 이 글에서는 call by value와 call by reference 두 가지로만 구분해서 설명을 이어 가겠습니다.

Java

swap 메서드의 실행 결과

아래 코드의 출력으로 Java의 전달 방식을 확인할 수 있습니다.

swap 메서드 테스트
public class ReferenceTest {
    public static void main(String[] args) {
        String x1 = "Hello";
        String y1 = "World";
        swap(x1, y1); // (1)
        System.out.println(x1); // Hello

        int x2 = 1;
        int y2 = 2;
        swap(x2, y2); // (2)
        System.out.println(x2); // 1
    }

    static void swap(Object x, Object y) {
        Object temp = x;
        x = y; // (3)
        y = temp;
    }

    static void swap(int x, int y) {
        int temp = x;
        x = y;
        y = temp;
    }
}
  1. x1과 y1에 담긴 참조값의 복사본이 파라미터 x와 y에 대입됩니다.

  2. int 값이 swap(int, int) 오버로드의 파라미터에 복사됩니다. 이 오버로드가 없으면 Object 파라미터에 오토박싱된 Integer 객체의 참조값이 전달되는데, 결과는 같습니다.

  3. 메서드 안에서 파라미터 x와 y에 서로의 값을 대입해도, 호출한 쪽의 x1, y1, x2, y2는 바뀌지 않습니다.

즉, 앞에서 정리한 기준대로라면 Java는 primitive type이든 객체든 call by value로 동작합니다.

파라미터 재할당이 호출자 변수에 반영되지 않는 이유

메서드 파라미터로 객체를 전달하는 경우를 더 자세히 살펴보겠습니다.

메서드 안에서 파라미터 재할당
class Tv {
    private String channel;

    void setChannel(String channel) { this.channel = channel; }
    String getChannel() { return channel; }
}

public class ReassignTest {
    public static void main(String[] args) {
        var myTv = new Tv();
        myTv.setChannel("Classic");

        reassign(myTv); // (1)
        System.out.println(myTv.getChannel()); // Classic
    }

    static void reassign(Tv targetTv) {
        targetTv = new Tv(); // (2)
        targetTv.setChannel("Rock");
    }
}
  1. main 메서드의 myTv에 담긴 참조값이 복사되어 reassign 메서드의 파라미터 targetTv에 대입됩니다. 두 변수는 같은 객체를 가리키지만 서로 다른 변수입니다.

  2. 파라미터 targetTv에 새 객체를 대입합니다. 파라미터 targetTv만 새 객체를 가리키게 되고, main 메서드의 myTv는 원래 객체를 그대로 가리킵니다. 그래서 출력은 Classic입니다.

참조값이 복사된 뒤 파라미터만 새 객체를 가리키게 되는 과정

오해의 근원인 객체 상태 변경

그런데 파라미터를 재할당하지 않고 파라미터가 가리키는 객체의 내부 상태를 바꾸면, 호출한 쪽의 변수로 접근해도 바뀐 상태가 그대로 확인됩니다.

메서드 안에서 파라미터 재할당 없이 객체 상태 변경
public class ChangeStateTest {
    public static void main(String[] args) {
        var myTv = new Tv();
        myTv.setChannel("Classic");

        changeState(myTv); // (1)
        System.out.println(myTv.getChannel()); // (3)
    }

    static void changeState(Tv targetTv) {
        targetTv.setChannel("Rock"); // (2)
    }
}
  1. 앞의 예제와 같이 myTv에 담긴 참조값이 복사되어 changeState 메서드의 파라미터 targetTv에 대입됩니다. 두 변수는 같은 객체를 가리킵니다.

  2. 파라미터 targetTv를 재할당하지 않고, 파라미터가 가리키는 객체의 채널을 Rock으로 바꿉니다. main 메서드의 myTv가 가리키는 바로 그 객체입니다.

  3. main 메서드의 myTv로 채널을 읽으면 Rock이 출력됩니다. 변수는 바뀌지 않았지만 변수가 가리키는 객체의 상태가 바뀌었기 때문입니다.

두 변수가 같은 객체를 가리키므로 내부 상태 변경이 호출자 변수로 접근해도 확인되는 상황

호출자 변수로 읽어도 바뀐 상태가 보이니 "객체는 call by reference로 전달된다"는 설명이 그럴듯하게 들립니다. 그러나 앞에서 설명했듯이, 두 방식을 구분하는 실용적인 기준은 파라미터에 대입한 값이 호출자 변수에 반영되는가입니다. 호출자 변수로 바뀐 상태가 보이는 것은 두 변수가 같은 객체를 가리키기 때문일 뿐이고, 전달되는 것은 여전히 참조값의 복사본입니다. 즉 call by value입니다.

Head First Java에서는 객체 참조를 TV 리모컨에 비유해서 설명합니다. 객체를 전달하면 TV(객체)가 복사되는 것이 아니라 그 TV를 조작할 수 있는 리모컨(참조값)이 복사됩니다. 복사된 리모컨으로 채널을 바꾸면(객체 상태 변경) 원래 리모컨을 가진 사람에게도 바뀐 채널이 보입니다. 복사된 리모컨을 새 TV에 연결하면(재할당, targetTv = new Tv()) 그 리모컨으로는 원래 TV를 조작할 수 없고, 원래 리모컨은 여전히 원래 TV에 연결되어 있습니다.

Java의 reference와 용어의 혼란

Java의 창시자 James Gosling은 공저한 The Java Programming Language에서 이 오해를 직접 지적했습니다.

Some people will say incorrectly that objects are passed "by reference." …​ The Java programming language does not pass objects by reference; it passes object references by value.

어떤 사람들은 객체가 '참조로(by reference)' 전달된다고 잘못 말한다. …​ Java 프로그래밍 언어는 객체를 참조로 전달하지 않는다. 객체의 참조를 값으로 전달한다.

— Ken Arnold, James Gosling, David Holmes
The Java Programming Language 4판
The Java Programming Language 4판 표지
Figure 2. The Java Programming Language 4판 표지 (이미지 출처: Open Library)

최신 공식 Java 학습 자료인 Dev.java에서도 같은 내용을 설명합니다.

Reference data type parameters, such as objects, are also passed into methods by value. …​ when the method returns, the passed-in reference still references the same object as before.

객체 같은 참조 데이터 타입 파라미터도 메서드에 값으로 전달된다. …​ 메서드가 반환될 때 전달된 참조는 여전히 이전과 같은 객체를 참조한다.

— Dev.java
Calling Methods and Constructors

앞에서 인용한 Dragon Book도 같은 절에서 Java는 call by value만 쓴다고 밝힙니다. 그런데 이어지는 call by reference 항목에서는 Java가 기본 타입이 아닌 값에는 call by reference를 쓰는 것처럼 동작한다고 적었습니다.

Even though Java uses call-by-value exclusively, whenever we pass the name of an object to a called procedure, the value received by that procedure is in effect a pointer to the object. Thus, the called procedure is able to affect the value of the object itself. …​

As we noted when discussing call-by-value, languages such as Java solve the problem of passing arrays, strings, or other objects by copying only a reference to those objects. The effect is that Java behaves as if it used call-by-reference for anything other than a basic type such as an integer or real.

Java는 call by value만 쓰지만, 호출되는 프로시저에 객체의 이름을 넘기면 그 프로시저가 받는 값은 사실상 그 객체를 가리키는 포인터다. 따라서 호출된 프로시저는 객체 자체의 값에 영향을 줄 수 있다. …​

Call by value를 다루면서 언급했듯이, Java 같은 언어는 배열, 문자열, 그 밖의 객체를 넘기는 문제를 그 객체에 대한 참조만 복사하는 방식으로 해결한다. 그 결과 Java는 정수나 실수 같은 기본 타입이 아닌 모든 것에 call by reference를 쓰는 것처럼 동작한다.

— Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman
Compilers: Principles, Techniques, and Tools 2판 1.6.6절

'call by reference를 쓰는 것처럼 동작한다(behaves as if)'는 객체를 통째로 복사하지 않고, 호출된 메서드에서 바꾼 객체 상태가 호출한 쪽에서도 보인다는 효과를 가리킵니다. 이 책이 앞에서 밝혔듯이 실제 전달 방식은 call by reference가 아니라는 전제를 깐 표현입니다.

'reference’라는 용어도 Java가 객체를 call by reference로 전달한다는 오해를 키운 것으로 보입니다. Java에서는 객체를 가리키는 값을 'reference’라고 부르는데, 이 단어가 'call by reference’의 reference와 겹치기 때문입니다. 더 정확하게 표현하면 Java는 객체를 가리키는 참조값을 call by value로 전달합니다.

Java의 reference는 객체를 식별하고 접근하는 값입니다. 포인터처럼 이해할 수 있습니다. 다음의 Java 객체 접근 동작은

Java 객체 접근
var myTv = new Tv();
myTv.setChannel("Rock");

C++ 포인터를 쓴 다음 코드와 비슷합니다.

C++ 포인터
Tv *myTv = new Tv();
myTv->setChannel("Rock");

Java의 myTv 변수는 객체 자체가 아니라 객체를 가리키는 값을 담고 있습니다. myTv를 다른 메서드의 인자로 넘기면 이 참조값이 복사되어 전달됩니다. 다만 위 C++ 코드는 객체 접근과 포인터 재할당을 설명하기 위한 비유이며, 객체의 수명 관리나 참조의 실제 표현까지 같다는 뜻은 아닙니다.

이와 관련된 JLS(Java Language Specification)의 서술을 보겠습니다. JLS도 '포인터’라는 단어를 씁니다. 4.3.1. Objects 절은 참조값을 객체를 가리키는 포인터로 정의합니다.

The reference values (often just references) are pointers to these objects, and a special null reference, which refers to no object.

참조값(흔히 그냥 참조라고 부른다)은 이 객체들을 가리키는 포인터이고, 여기에 어떤 객체도 가리키지 않는 특별한 null 참조가 있다.

— Java Language Specification
4.3.1. Objects

다만 이는 언어 수준의 의미를 설명하는 표현이지, 참조값이 원시 메모리 주소와 같은 형식이라고 보장하는 문장은 아닙니다. JVMS(Java Virtual Machine Specification) 2.7은 객체의 내부 구조를 특정하지 않으며, 일부 구현에서는 참조가 객체 데이터가 아니라 핸들을 가리킬 수도 있다고 설명합니다. 여기서 핸들은 참조와 객체 데이터 사이에 놓인 작은 중간 구조입니다. 참조는 핸들을 가리키고, 핸들 안의 포인터가 다시 객체 데이터와 메서드 테이블을 가리킵니다. 참조값이 객체가 놓인 메모리 주소 그 자체가 아닐 수도 있다는 뜻입니다.

핸들에 관한 이 설명은 JVMS 초판(1997)에서 "Sun의 현재 구현"을 설명하던 것입니다. 2판에서 "Sun의 일부 구현"으로, Oracle 인수 뒤의 판에서 "Oracle의 일부 구현"으로 바뀌었을 뿐 내용은 같습니다. 초판 당시 Sun의 JVM은 JDK 1.0·1.1의 Classic VM이었고, The Java HotSpot Performance Engine Architecture의 'Handleless Objects' 절은 이 Classic VM이 간접 핸들을 썼다고 설명합니다. 같은 절에 따르면 현재 Oracle JDK와 OpenJDK가 공통으로 쓰는 HotSpot은 핸들을 쓰지 않고 객체 참조를 객체를 직접 가리키는 포인터로 구현합니다.

메서드 호출 시의 전달 방식은 8.4.1. Formal Parameters 절에 있습니다. 인자 표현식의 '값’이 새로 만들어진 파라미터 변수를 초기화한다고 설명할 뿐입니다. 앞에서 정의한 call by reference처럼 파라미터가 호출한 쪽 변수의 별칭이 된다는 서술은 없습니다.

When the method or constructor is invoked (§15.12), the values of the actual argument expressions initialize newly created parameter variables, each of the declared type, before execution of the body of the method or constructor.

메서드나 생성자를 호출하면(§15.12), 그 본문을 실행하기 전에 실제 인자 표현식의 값이 새로 만들어진 파라미터 변수를 초기화한다. 새로 만들어지는 파라미터 변수는 각각 선언된 타입을 따른다.

— Java Language Specification
8.4.1. Formal Parameters

메서드 호출마다 새로 만들어지는 프레임

JLS는 메서드 호출을 실행하는 과정도 단계별로 서술합니다. 15.12.4.5. Create Frame, Synchronize, Transfer Control 절은 호출할 메서드가 정해지면 activation frame을 새로 만들고 그 안에 인자 값을 담는다고 설명합니다. Activation frame은 메서드 호출의 파라미터, 지역 변수, 반환 주소 같은 실행 상태를 담고 호출이 끝나면 사라지는 메모리 영역입니다.

Now a new activation frame is created, containing the target reference (if any) and the argument values (if any), as well as enough space for the local variables and stack for the method to be invoked …​ The effect of this is to assign the argument values to corresponding freshly created parameter variables of the method, and to make the target reference available as this, if there is a target reference.

이제 새 activation frame을 만든다. 여기에는 대상 참조(있다면)와 인자 값(있다면), 그리고 호출할 메서드의 지역 변수와 스택에 필요한 공간이 들어간다. …​ 그 결과 인자 값은 메서드에 새로 만들어진 각각의 파라미터 변수에 대입되고, 대상 참조가 있다면 this로 쓸 수 있게 된다.

— Java Language Specification
15.12.4.5. Create Frame, Synchronize, Transfer Control

호출한 쪽의 변수가 아니라 새 프레임에 새로 만들어진 파라미터 변수에 인자의 '값’을 대입한다는 서술입니다. 8.4.1절과 같은 결론입니다. 인스턴스 메서드를 호출한 대상 객체의 참조값(target reference)도 같은 방식으로 새 프레임에 담겨 this가 됩니다.

이 프레임을 가상 머신 수준에서 정의한 문서는 JVMS입니다. 2.6. Frames 절은 메서드를 호출할 때마다 새 프레임이 스레드의 JVM stack에 만들어지고, 프레임마다 자기만의 지역 변수 배열이 있다고 설명합니다.

A new frame is created each time a method is invoked. …​ Frames are allocated from the Java Virtual Machine stack (§2.5.2) of the thread creating the frame. Each frame has its own array of local variables (§2.6.1), …​

메서드를 호출할 때마다 새 프레임이 만들어진다. …​ 프레임은 그 프레임을 만드는 스레드의 Java Virtual Machine stack(§2.5.2)에 할당된다. 각 프레임은 자기만의 지역 변수 배열(§2.6.1)을 가진다.

— Java Virtual Machine Specification
2.6. Frames

파라미터는 이 지역 변수 배열로 전달됩니다. 2.6.1. Local Variables 절의 설명입니다.

The Java Virtual Machine uses local variables to pass parameters on method invocation. On class method invocation, any parameters are passed in consecutive local variables starting from local variable 0. On instance method invocation, local variable 0 is always used to pass a reference to the object on which the instance method is being invoked (this in the Java programming language). Any parameters are subsequently passed in consecutive local variables starting from local variable 1.

Java Virtual Machine은 메서드를 호출할 때 지역 변수로 파라미터를 전달한다. 클래스 메서드를 호출하면 파라미터는 0번 지역 변수부터 차례로 전달된다. 인스턴스 메서드를 호출하면 0번 지역 변수는 항상 그 인스턴스 메서드가 호출된 객체의 참조(Java 프로그래밍 언어의 this)를 전달하는 데 쓰인다. 파라미터는 그다음 1번 지역 변수부터 차례로 전달된다.

— Java Virtual Machine Specification
2.6.1. Local Variables

앞의 reassign 예제에서 myTv의 참조값이 파라미터 targetTv로 복사되는 과정을 이 구조에 맞춰 따라가 보겠습니다. 그러려면 먼저 다음 개념을 알아야 합니다.

  • Operand stack: 프레임마다 하나씩 있는 후입선출 스택으로, JVM 명령이 피연산자를 꺼내고 결과를 올리는 작업 공간입니다. 메서드에 넘길 인자를 준비하는 데도 쓰입니다.

  • 바이트코드: javac가 Java 소스를 컴파일해 class 파일에 담는 JVM 명령어입니다. 이 예제의 호출 과정에는 두 명령이 등장합니다.

    • aload_1: 현재 프레임의 1번 지역 변수에 담긴 참조값을 operand stack에 올립니다.

    • invokestatic: static 메서드를 호출합니다. 인자 값을 operand stack에서 꺼내고, 새 프레임을 만들어 그 값을 새 프레임의 0번 지역 변수부터 차례로 넣습니다.

이 개념으로 reassign 호출을 그리면 다음 그림과 같습니다. reassign은 static 메서드이므로 파라미터 targetTv는 reassign 프레임의 0번 지역 변수입니다. javac가 main 프레임의 1번 지역 변수에 배정한 myTv에 담긴 참조값이 이 칸으로 복사됩니다. 파라미터가 0번부터 들어가는 것은 JVMS가 정하지만, 파라미터가 아닌 지역 변수를 몇 번 칸에 둘지는 컴파일러가 정합니다. 바이트코드 수준에서는 aload_1 명령이 이 참조값을 main 프레임의 operand stack에 올리고, invokestatic 명령이 그 값을 꺼내 새 프레임의 0번 지역 변수에 넣습니다. 두 칸은 서로 다른 프레임에 속한 별개의 저장 공간이므로 targetTv에 새 객체를 대입해도 main 프레임의 myTv는 여전히 처음의 객체를 가리킵니다.

reassign 메서드의 프레임과 main 메서드의 프레임이 각자의 지역 변수 배열을 가지고 참조값만 복사되는 구조

다만 JVMS는 추상 기계를 정의할 뿐입니다. 특정 구현의 메모리 구조가 아니라 명령의 동작 규칙을 정한 모델이라는 뜻입니다. JVMS 2장의 서두는 런타임 데이터 영역의 메모리 배치를 구현자의 재량에 맡긴다고 밝힙니다. 예를 들어 JIT 컴파일러가 만든 기계어 코드는 파라미터를 CPU 레지스터에 둘 수도 있습니다. 그렇더라도 호출한 쪽의 변수와 파라미터가 별개의 변수라는 언어의 의미는 달라지지 않습니다.

Kotlin

Kotlin도 call by reference를 지원하지 않습니다. 그런데 이 글의 판별 기준인 '파라미터에 새 값을 대입했을 때 호출한 쪽의 변수가 바뀌는가’를 Kotlin에서는 실험할 수 없습니다. Kotlin Language Specification의 Function declaration 절은 각 파라미터가 함수 본문 안에서 값의 이름으로 도입되므로 파라미터는 final이고 함수 안에서 바꿀 수 없다고 규정합니다.

Each parameter pi: Pi = vi introduces pi as a name of value with type Pi available inside function body b; therefore, parameters are final and cannot be changed inside the function.

각 파라미터 pi: Pi = vi는 함수 본문 b 안에서 쓸 수 있는 타입 Pi의 값 이름 pi를 도입한다. 따라서 파라미터는 final이고 함수 안에서 바꿀 수 없다.

— Kotlin Language Specification
Function declaration

파라미터에 새 값을 대입하는 코드는 컴파일 오류입니다.

Kotlin 파라미터 재할당과 객체 상태 변경
class Tv(var channel: String)

fun main() {
    val myTv = Tv("Classic")
    changeState(myTv)
    println(myTv.channel) // Rock
}

fun reassign(targetTv: Tv) {
    targetTv = Tv("Rock") // (1)
}

fun changeState(targetTv: Tv) {
    targetTv.channel = "Rock" // (2)
}
  1. 컴파일 오류가 납니다. 오류 메시지는 "'val' cannot be reassigned"입니다.

  2. 파라미터가 가리키는 객체의 속성을 바꾸므로 호출한 쪽에서도 바뀐 값이 보입니다. 컴파일 오류가 나는 reassign 함수를 지우고 실행하면 Rock이 출력됩니다.

재할당이 막혀 있을 뿐 나머지는 Java와 같습니다. JVM에서 실행할 때 전달되는 것은 Java와 같은 참조값의 복사본입니다. 파라미터가 final이라는 규칙은 언어 사양이므로 Kotlin/JS나 Kotlin/Native 등 JVM이 아닌 플랫폼으로 컴파일해도 달라지지 않습니다.

JavaScript

JavaScript도 Java와 같은 방식으로 동작합니다. 먼저 swap 예제입니다.

JavaScript swap 함수
function swap(x, y) {
  const temp = x;
  x = y;
  y = temp;
}

let x1 = "Hello";
let y1 = "World";
swap(x1, y1);
console.log(x1); // Hello

객체를 전달할 때 함수 안에서 재할당하거나 상태를 바꾼 결과도 Java와 같습니다.

JavaScript 객체 전달 시 재할당과 상태 변경
const myTv = { channel: "Classic" };

reassign(myTv);
console.log(myTv.channel); // Classic

changeState(myTv);
console.log(myTv.channel); // Rock

function reassign(targetTv) {
  targetTv = { channel: "Rock" }; // (1)
}

function changeState(targetTv) {
  targetTv.channel = "Rock"; // (2)
}
  1. 파라미터 targetTv에 새 객체를 대입해도 호출한 쪽의 myTv는 바뀌지 않습니다.

  2. 파라미터가 가리키는 객체의 프로퍼티를 바꾸면 호출한 쪽의 myTv에서도 바뀐 값이 보입니다.

앞의 두 그림에서 Tv 객체를 객체 리터럴로 바꾸면 JavaScript의 동작을 설명하는 그림이 됩니다.

MDN의 Functions 문서도 이 동작을 같은 구조로 정리합니다. 인자는 항상 값으로 전달되며, 객체 인자는 더 정확히 말하면 공유로 전달된다는 설명입니다.

Arguments are always passed by value and never passed by reference. This means that if a function reassigns a parameter, the value won’t change outside the function. More precisely, object arguments are passed by sharing, which means if the object’s properties are mutated, the change will impact the outside of the function.

인자는 항상 값으로 전달되며 참조로 전달되는 일은 없다. 즉 함수가 파라미터를 재할당해도 함수 밖의 값은 바뀌지 않는다. 더 정확히 말하면 객체 인자는 공유로 전달되므로, 객체의 프로퍼티를 바꾸면 그 변경이 함수 밖에 영향을 준다.

— MDN Web Docs
Functions - Passing arguments

ECMAScript 사양도 이 동작을 호출자 변수의 별칭이 아니라, 인자 값으로 새 파라미터 바인딩을 초기화하는 과정으로 기술합니다. ArgumentListEvaluation은 인자 표현식에서 GetValue로 값을 얻어 ECMAScript 언어 값 목록을 만들고, FunctionDeclarationInstantiation은 그 목록으로 새 함수 실행 환경의 파라미터 바인딩을 초기화합니다.

ECMAScript 사양에서는 Java와 달리 객체에 접근하는 값을 'reference value’라고 부르지 않습니다. 사양이 프로그램에서 직접 다루는 값의 종류로 정한 여덟 가지 언어 타입(ECMAScript language type) 중 하나가 Object이고, 객체 자체가 그 타입의 값입니다. 따라서 이 글의 '참조값 복사’는 관찰되는 동작을 설명하기 위한 모델로 이해하는 편이 정확합니다.

TypeScript도 인자 전달 방식은 JavaScript와 같습니다. TypeScript 핸드북은 원칙적으로 TypeScript는 JavaScript 코드의 런타임 동작을 바꾸지 않는다고 밝힙니다.

As a principle, TypeScript never changes the runtime behavior of JavaScript code.

원칙적으로 TypeScript는 JavaScript 코드의 런타임 동작을 절대 바꾸지 않는다.

— TypeScript Handbook
TypeScript for the New Programmer - Runtime Behavior

TypeScript 컴파일러는 타입 검사가 끝나면 타입 정보를 지우고 평범한 JavaScript를 내놓으므로 인자 전달 방식도 위에서 설명한 JavaScript와 동일합니다. 뒤에서 살펴볼 C#의 ref, `out`에 해당하는 참조 파라미터 문법도 없습니다.

JavaScript: The Definitive Guide의 call by reference 서술

일부만 읽으면 JavaScript의 함수 호출을 call by reference로 오해하기 쉬운 자료도 있습니다. David Flanagan이 쓰고 오랫동안 JavaScript의 표준적인 책으로 꼽혀온 JavaScript: The Definitive Guide 4판은 11.2절 'By Value Versus by Reference’의 요약 표에서 객체가 by reference로 복사·전달·비교된다고 정리합니다. 이 요약 표만 보면 JavaScript는 call by reference라고 설명해도 될 것처럼 보입니다.

그런데 같은 절의 동작 설명을 읽어 보면, 이 책이 말하는 by reference는 이 글의 앞부분에서 정의한 call by reference와 다른 의미입니다. '객체 값 전체가 복사되는 대신 객체를 가리키는 참조가 전달된다’는 의미로 쓴 표현이고, 파라미터를 재할당해도 호출한 쪽 변수에 반영되지 않는다는 점은 이 책도 똑같이 설명합니다.

A function can use the reference to modify properties of the object or elements of the array. But if the function overwrites the reference with a reference to a new object or array, that modification is not visible outside of the function. Readers familiar with the other meaning of this term may prefer to say that objects and arrays are passed by value, but the value that is passed is actually a reference rather than the object itself.

함수는 그 참조로 객체의 프로퍼티나 배열의 원소를 수정할 수 있다. 그러나 함수가 그 참조를 새 객체나 새 배열의 참조로 덮어쓰면, 그 변경은 함수 밖에서 보이지 않는다. 이 용어의 다른 뜻에 익숙한 독자라면 객체와 배열이 값으로 전달되며 다만 전달되는 값이 객체 자체가 아니라 참조라고 말하고 싶을 것이다.

— David Flanagan
JavaScript: The Definitive Guide 4판, 11.2 By Value Versus by Reference

이 설명에 이어지는 예제의 제목도 'References themselves are passed by value', 즉 참조 자체는 값으로 전달된다는 것입니다. 결국 관찰되는 동작을 설명하는 내용은 이 글과 같고, 전달되는 것을 무엇이라고 부르는지만 다릅니다.

C

C에는 C++의 참조 파라미터 같은 문법이 없으므로 call by value만 있습니다. C11 표준 초안 N1570의 6.5.2.2 Function calls 4항은 함수 호출을 준비할 때 인자를 평가하고 각 파라미터에 대응하는 인자의 값을 대입한다고 규정합니다.

In preparing for the call to a function, the arguments are evaluated, and each parameter is assigned the value of the corresponding argument.

함수 호출을 준비할 때 인자를 평가하고, 각 파라미터에 대응하는 인자의 값을 대입한다.

— C11 Working Draft N1570
6.5.2.2 Function calls 4항

같은 항에 달린 각주 93은 함수가 파라미터의 값을 바꿀 수는 있지만 그 변경이 인자의 값에 영향을 줄 수는 없고, 대신 객체를 가리키는 포인터를 넘기면 함수가 그 객체의 값을 바꿀 수 있다고 설명합니다.

C 포인터 파라미터로 구현한 swap
void swap(int* x, int* y) {
    int temp = *x;
    *x = *y; // (1)
    *y = temp;
}

int a = 1;
int b = 2;
swap(&a, &b); // (2)
  1. 포인터가 가리키는 변수에 값을 쓰므로 호출한 쪽의 a가 바뀝니다. 그러나 x에 다른 주소를 대입해도 호출한 쪽의 a는 그대로입니다.

  2. &는 주소를 구하는 연산자일 뿐이고, 포인터라는 값의 복사본이 파라미터 x와 y에 대입됩니다. 호출 뒤 a는 2, b는 1입니다.

앞에서 정리한 구분 기준을 적용하면 포인터 파라미터에 새 값을 대입해도 호출한 쪽 변수는 바뀌지 않으므로 call by value입니다. Java가 객체의 참조값을 전달하는 방식과 같은 구조입니다.

C++

C++는 이 글의 정의에 맞는 call by reference인 참조 파라미터를 제공합니다.

C++ 참조 파라미터로 구현한 swap
void swap(int& x, int& y) { // (1)
    int temp = x;
    x = y; // (2)
    y = temp;
}

int a = 1;
int b = 2;
swap(a, b); // (3)
  1. 파라미터를 int& 타입으로 선언하면 x는 호출한 쪽 변수 a의 별칭이 됩니다.

  2. 함수 안의 대입이 호출한 쪽 변수를 실제로 바꿉니다.

  3. C의 swap(&a, &b)와 달리 호출 지점에 주소를 넘긴다는 표시가 없습니다. 호출 뒤 a는 2, b는 1입니다.

포인터 파라미터와 참조 파라미터의 차이

C++에는 C에서 물려받은 포인터 파라미터(예: int*)도 있어서 둘을 구분해야 합니다. 포인터 파라미터는 C++에서도 call by value입니다. void swap(int* x, int* y)처럼 포인터를 받으면 앞의 C 예제와 똑같이 포인터 값의 복사본이 파라미터에 대입됩니다. 함수 안에서 x에 다른 주소를 대입해도 호출한 쪽 변수에는 반영되지 않습니다.

반면 int& 참조 파라미터에는 재할당할 문법 자체가 없습니다. x = y;는 참조를 다른 변수에 다시 묶는 문장이 아니라, 참조가 가리키는 객체에 값을 대입하는 문장입니다. 앞에서 정리한 구분 기준을 두 경우에 적용하면 결과가 다릅니다. 포인터 파라미터(int*)에 대입하면 호출한 쪽 변수는 그대로지만, 참조 파라미터(int&)에 대입하면 호출한 쪽 변수가 바뀝니다.

C++ 표준이 기술하는 참조

C++ 표준도 참조를 전달되는 값이 아니라 별칭으로 기술합니다. [dcl.ref]의 주석은 참조를 "a name of an object", 즉 객체의 이름으로 생각할 수 있다고 설명합니다. 같은 절 4항은 참조가 저장 공간을 필요로 하는지는 규정하지 않는다고 명시합니다.

It is unspecified whether or not a reference requires storage ([basic.stc]).

참조가 저장 공간을 필요로 하는지 여부는 규정하지 않는다([basic.stc]).

— C++ Working Draft N4950
[dcl.ref] 4항

컴파일러가 대개 참조를 주소로 구현하더라도 표준은 참조에 저장 공간이 있는지조차 정하지 않습니다.

기계 수준에서 무엇이 복사되는지를 기준으로 삼으면 call by reference인 언어는 하나도 없게 됩니다. 두 용어는 컴파일된 결과가 아니라 언어가 정의한 의미론을 구분하는 말입니다.

다만 C++ 표준이 참조 파라미터를 call by reference라고 부르지는 않습니다. 표준 문서 전체를 찾아보면 'call by value', 'call by reference', 'pass by value’라는 용어는 한 번도 나오지 않고 'passed by reference’만 네 번 나오는데, 모두 규범적인 정의가 아니라 주석이나 라이브러리 절의 설명입니다. 표준은 평가 전략에 이름을 붙이는 대신 동작으로 규정합니다. [expr.call] 6항은 각 파라미터가 대응하는 인자로 초기화된다고만 적습니다.

When a function is called, each parameter ([dcl.fct]) is initialized ([dcl.init], [class.copy.ctor]) with its corresponding argument.

함수가 호출되면 각 파라미터([dcl.fct])는 대응하는 인자로 초기화된다([dcl.init], [class.copy.ctor]).

— C++ Working Draft N4950
[expr.call] 6항

참조 타입 파라미터가 어떻게 초기화되는지는 참조 바인딩 규칙이 따로 정합니다. Call by value와 call by reference는 특정 언어의 표준 용어가 아니라 여러 언어의 인자 전달 방식을 비교하기 위한 프로그래밍 언어론의 용어입니다.

C#

C#도 C++처럼 call by reference를 문법으로 지원합니다. C# 레퍼런스의 Method parameters 문서는 C#이 기본적으로 인자를 값으로 전달한다고 밝히고, struct 같은 값 타입은 값의 복사본을, class 같은 참조 타입은 참조의 복사본을 메서드가 받는다고 설명합니다.

By default, C# passes arguments to functions by value. This approach passes a copy of the variable to the method. For value (struct) types, the method gets a copy of the value. For reference (class) types, the method gets a copy of the reference.

기본적으로 C#은 인자를 함수에 값으로 전달한다. 이 방식은 변수의 복사본을 메서드에 넘긴다. 값 타입(struct)이면 메서드는 값의 복사본을 받고, 참조 타입(class)이면 참조의 복사본을 받는다.

— C# reference
Method parameters and modifiers

참조 타입을 값으로 넘기면 메서드 안에서 파라미터를 재할당해도 호출한 쪽의 변수는 바뀌지 않고, 인스턴스 멤버를 바꾸면 두 변수가 같은 인스턴스를 가리키므로 호출한 쪽 변수로 접근해도 바뀌어 있다는 설명도 이어집니다. 여기까지는 Java와 같습니다.

파라미터를 참조로 전달하려면 ref, out, in, ref readonly 수정자를 붙입니다. 이 가운데 호출한 쪽 변수에 대입까지 할 수 있는 것은 `ref`와 `out`입니다. 같은 문서는 참조로 전달된 파라미터가 자기 값을 갖지 않고 다른 변수(referent)를 가리키는 참조 변수(reference variable)라고 설명합니다. 이 글의 정의대로 파라미터가 호출자 변수의 별칭이 되는 call by reference입니다.

C# ref 파라미터로 구현한 Swap
void Swap(ref int x, ref int y) // (1)
{
    int temp = x;
    x = y; // (2)
    y = temp;
}

int a = 1;
int b = 2;
Swap(ref a, ref b); // (3)
  1. ref 수정자를 붙인 파라미터 x는 호출한 쪽 변수 a의 별칭이 됩니다.

  2. 파라미터에 대입하면 호출한 쪽 변수가 바뀝니다.

  3. C++와 달리 호출 지점에도 `ref`를 써야 합니다. 호출 뒤 a는 2, b는 1입니다.

ref 파라미터는 메서드 선언과 호출 지점 양쪽에 `ref`를 명시해야 하므로, C++의 참조 파라미터와 달리 호출식만 보고도 호출한 쪽 변수가 바뀔 수 있음을 알 수 있습니다. `out`은 호출한 쪽에서 초기화하지 않은 변수를 넘기고 메서드가 반드시 값을 대입해야 하는 변형이고, `in`과 `ref readonly`는 메서드가 값을 바꿀 수 없는 읽기 전용 참조입니다.

`ref`는 참조 타입 변수에도 쓸 수 있습니다. class 인스턴스를 그냥 넘기면 Java처럼 참조의 복사본이 전달되어 파라미터 재할당이 호출자 변수에 반영되지 않지만, `ref`로 넘기면 파라미터가 호출자 변수의 별칭이 되므로 재할당으로 호출자 변수가 가리키는 객체 자체를 바꿀 수 있습니다.

C# ref 파라미터로 호출자의 참조 타입 변수 교체
var myTv = new Tv();
myTv.Channel = "Classic";

Replace(ref myTv); // (1)
Console.WriteLine(myTv.Channel); // Rock

void Replace(ref Tv targetTv)
{
    targetTv = new Tv(); // (2)
    targetTv.Channel = "Rock";
}

class Tv
{
    public string Channel { get; set; } = "";
}
  1. Replace(Tv targetTv)처럼 ref 없이 선언하고 Replace(myTv)로 호출하면 Java의 reassign(myTv)와 같아서 Classic이 출력됩니다. 선언에 `ref`가 있는 상태에서 호출 지점의 `ref`만 빼면 컴파일 오류입니다.

  2. ref 파라미터에 새 객체를 대입하면 호출한 쪽의 myTv가 새 객체를 가리키게 됩니다. .NET 8로 실행하면 Rock이 출력됩니다.

'참조 타입을 값으로 전달’하는 것과 '변수를 ref로 참조 전달’하는 것이 서로 다른 동작이라는 점을 C#은 문법으로 구분합니다.

Python

Python에도 Java처럼 call by value만 있습니다. 공식 튜토리얼 4.8 Defining Functions는 인자가 호출된 함수의 지역 심벌 테이블에 도입되므로 값이 항상 객체 참조인 call by value로 전달된다고 설명합니다.

The actual parameters (arguments) to a function call are introduced in the local symbol table of the called function when it is called; thus, arguments are passed using call by value (where the value is always an object reference, not the value of the object).

함수 호출의 실제 파라미터(인자)는 호출 시점에 호출된 함수의 지역 심벌 테이블에 도입된다. 따라서 인자는 call by value로 전달된다(이때 값은 항상 객체 참조이지, 객체의 값이 아니다).

— The Python Tutorial
4.8 Defining Functions

이 문장에 달린 각주는 변경 가능한 객체를 넘기면 호출한 쪽에서도 변경이 보이므로 call by object reference가 더 나은 표현이라고 덧붙입니다. Python FAQ는 더 직접적으로 답합니다. 인자는 대입으로 전달되고(passed by assignment), 대입은 객체에 대한 참조를 만들 뿐이므로 호출한 쪽과 호출된 쪽의 인자 이름 사이에 별칭이 없고, 따라서 call by reference도 없다는 설명입니다.

Python swap 함수와 객체 전달
def swap(x, y):
    x, y = y, x  # (1)

a, b = 1, 2
swap(a, b)
print(a)  # 1


class Tv:
    def __init__(self, channel):
        self.channel = channel


def main():
    my_tv = Tv("Classic")
    reassign(my_tv)
    print(my_tv.channel)  # Classic
    changeState(my_tv)
    print(my_tv.channel)  # Rock


def reassign(targetTv):
    targetTv = Tv("Rock")  # (2)


def changeState(targetTv):
    targetTv.channel = "Rock"  # (3)


main()
  1. 지역 이름 x와 y를 서로 다른 객체에 다시 묶을 뿐이라 호출한 쪽의 a는 1로 남습니다.

  2. 파라미터 targetTv를 새 객체에 다시 묶으므로 호출한 쪽의 my_tv는 그대로이고, Classic이 출력됩니다.

  3. targetTv가 가리키는 객체의 속성을 바꾸므로 호출한 쪽의 my_tv로 접근해도 바뀌어 있고, Rock이 출력됩니다.

Java의 Tv 예제, JavaScript의 reassign과 changeState 예제와 동일한 결과입니다.

정수나 문자열을 넘기면 호출한 쪽 변수가 그대로인데 리스트나 딕셔너리를 넘기면 바뀌어 있어서, 타입에 따라 전달 규칙이 다르다고 오해하기 쉽습니다. 전달 규칙은 같습니다. 정수와 문자열은 불변 객체라 내부 상태를 바꿀 방법이 없을 뿐입니다. 리스트를 넘겨도 함수 안에서 파라미터를 x = [1]처럼 다시 묶으면 호출한 쪽은 그대로입니다.

Go

Go도 함수 호출에서 호출자 변수 자체를 파라미터의 암묵적인 별칭으로 만들지 않습니다. Go 사양의 Calls 절은 함수 값과 인자를 평가한 뒤 파라미터와 결과를 포함한 함수의 변수를 위해 새 저장 공간을 할당하고, 인자를 대응하는 파라미터에 대입한다고 설명합니다. Go FAQ는 더 직접적으로 답합니다.

As in all languages in the C family, everything in Go is passed by value. That is, a function always gets a copy of the thing being passed, as if there were an assignment statement assigning the value to the parameter.

C 계열의 모든 언어처럼 Go에서도 모든 것이 값으로 전달된다. 즉 함수는 넘긴 것의 복사본을 항상 받으며, 대입문으로 파라미터에 값을 대입한 것과 같다.

— Go FAQ
When are function parameters passed by value?

'C 계열의 모든 언어’를 글자 그대로 읽으면 맞지 않습니다. 이 글에서 살펴본 C++과 C#은 C 계열이지만 참조 파라미터를 문법으로 지원합니다. 다만 두 언어도 기본 전달 방식은 값 전달이고 참조 전달은 수정자를 붙여야 하는 예외이므로, 이 문장은 'C 계열 언어의 기본은 값 전달이고 Go에는 그 기본만 있다’는 뜻으로 읽는 것이 알맞습니다. Go는 C처럼 호출자 변수를 바꾸려면 포인터를 명시적으로 넘겨야 합니다.

Go 포인터 파라미터로 구현한 swap
func swap(x, y *int) {
    *x, *y = *y, *x // (1)
}

a, b := 1, 2
swap(&a, &b) // (2)
  1. 포인터가 가리키는 변수에 값을 쓰므로 호출한 쪽의 a와 b가 바뀝니다. 함수 안에서 x에 다른 포인터를 대입해도 호출한 쪽의 a는 바뀌지 않습니다.

  2. 일반 함수로 호출한 쪽의 값을 바꾸려면 C처럼 포인터를 넘깁니다. 포인터 값의 복사본이 파라미터 x와 y에 대입됩니다. 호출 뒤 a는 2, b는 1입니다.

다만 메서드 호출에서는 호출 지점에 &가 없어도 됩니다. Go 사양의 Calls 절은 x가 주소를 구할 수 있는 값이고 &x의 메서드 집합에 m이 있으면 x.m()이 (&x).m()의 축약 표기라고 규정합니다. 포인터 수신자 메서드는 이 규칙에 따라 컴파일러가 주소를 대신 구해 넘기므로, 호출식의 모양만으로 호출한 쪽 변수가 바뀌는지 판단할 수는 없습니다. 이때도 전달되는 것은 포인터 값입니다.

Rust

Rust는 이 글에서 정의한 의미의 call by reference, 즉 파라미터가 호출자 변수의 별칭이 되는 전달 방식을 지원하지 않습니다. Rust by Example은 &T를 넘기는 것을 'passed by reference’라고 부릅니다. 그러나 함수 호출이 호출자 변수의 별칭을 만들지는 않으며, &mut T 같은 참조도 하나의 값으로서 call by value로 전달됩니다. Rust Book은 변수를 함수에 넘기면 대입과 마찬가지로 값이 이동하거나 복사된다고 설명합니다.

Passing a variable to a function will move or copy, just as assignment does.

변수를 함수에 넘기면 대입과 마찬가지로 이동하거나 복사된다.

— The Rust Programming Language
4.1 What is Ownership? - Ownership and Functions

이 설명에서 '이동’을 이해하려면 Rust의 소유권(ownership)을 알아야 합니다. Rust Book의 Ownership Rules 절은 소유권을 세 규칙으로 정리합니다. 모든 값에는 소유자(owner)인 변수가 있고, 소유자는 한 번에 하나뿐이며, 소유자가 스코프를 벗어나면 값이 해제됩니다. let s2 = s1;처럼 대입하면 소유권이 s2로 넘어가는데 이를 이동(move)이라 부르고, 이동한 뒤에 s1을 쓰면 컴파일 오류가 납니다.

'복사’가 일어나는 타입은 Copy 트레이트가 정합니다. 트레이트는 여러 타입이 공유하는 동작을 선언하는 문법으로, 다른 언어의 인터페이스에 가깝습니다. 메서드 없이 컴파일러에게 타입의 성질만 알려 주는 트레이트도 있는데, Copy가 그런 트레이트입니다.

Copy는 값을 다른 변수에 대입해도 원래 변수를 계속 쓸 수 있는 타입에 붙는 트레이트입니다. Rust Book의 Stack-Only Data: Copy 절은 Copy를 구현한 타입의 변수는 이동하지 않고 그대로 복사되어 다른 변수에 대입한 뒤에도 유효하다고 설명합니다. 정수, 실수, bool, char, 그리고 Copy인 타입만 담은 튜플이 여기에 속합니다. 반면 String이나 Vec처럼 힙 메모리를 할당하거나 자원을 소유하는 타입은 Copy가 될 수 없고, 값이 스코프를 벗어날 때 정리 작업을 하는 Drop을 구현한 타입에 Copy를 붙이면 컴파일 오류가 납니다. 메모리를 그대로 복제해도 안전한 타입만 Copy가 되는 셈입니다. 그래서 let t = s;에서 s가 i32이면 이후에도 s를 쓸 수 있지만, String이면 앞에서 본 것처럼 이동합니다.

String처럼 Copy가 아닌 타입의 값은 소유권이 파라미터로 이동하고, i32처럼 Copy인 타입의 값은 복사됩니다. 다만 &mut T 타입의 변수를 &mut T 파라미터에 넘기면 컴파일러가 암묵적으로 재대여(reborrow)하므로, &mut T가 Copy가 아니어도 호출 뒤에 그 변수를 다시 쓸 수 있습니다.

이 글의 관점에서 이동과 복사는 모두 파라미터가 인자 값으로 초기화되는 같은 방식이고, 호출자 변수의 별칭이 생기지 않는다는 점도 같습니다. 다른 점은 호출 뒤에 호출자 변수를 계속 쓸 수 있느냐뿐입니다.

Rust 가변 참조로 구현한 swap
fn swap(x: &mut i32, y: &mut i32) { // (1)
    let temp = *x;
    *x = *y; // (2)
    *y = temp;
}

let mut a = 1;
let mut b = 2;
swap(&mut a, &mut b); // (3)
  1. 파라미터 x에 전달되는 것은 &mut i32 타입의 값입니다.

  2. 가변 참조가 가리키는 변수에 값을 쓰므로 호출한 쪽의 a가 바뀝니다.

  3. 일반 함수로 호출한 쪽의 값을 바꾸려면 이렇게 가변 참조를 넘깁니다. 호출 뒤 a는 2, b는 1입니다.

표준 라이브러리의 std::mem::swap 함수도 fn swap<T>(x: &mut T, y: &mut T) 시그니처로 같은 방식을 씁니다. 가변 참조를 넘겨도 파라미터 자체를 재할당해서 호출자의 변수 바인딩을 바꾸는, 이 글에서 정의한 call by reference는 아닙니다. x를 재할당하려면 파라미터를 mut x: &mut i32로 선언해야 하고, 그렇게 재할당해도 호출한 쪽의 a에는 아무 영향이 없습니다.

메서드 호출에서는 Go의 포인터 수신자 메서드처럼 호출 지점에 &mut가 없어도 됩니다. Rust Reference의 메서드 호출 규칙에 따라 v.push(1)처럼 쓰면 컴파일러가 수신자를 자동으로 대여해 &mut v를 넘기며, 이때도 전달되는 것은 가변 참조 값입니다.

Ada: call by reference와 copy-restore의 차이

정의 절에서 '파라미터에 새 값을 대입했을 때 호출한 쪽의 변수가 바뀌는가’라는 기준만으로는 모든 전달 방식을 분류할 수 없다고 했습니다. Ada가 그 예입니다. Ada는 파라미터에 in, in out, out 모드를 선언하고, in out이나 out 파라미터에 대입한 값은 호출한 쪽 변수에 반영됩니다. 호출한 쪽에서 관찰되는 결과만 보면 C++의 참조 파라미터와 같습니다.

그런데 Ada Reference Manual 6.2는 복사로 전달할지 참조로 전달할지를 모드가 아니라 주로 타입으로 정합니다. Integer 같은 elementary type은 by-copy type이라 복사로 전달하고, tagged type 등 by-reference type은 참조로 전달하며, aliased로 선언한 형식 파라미터는 타입과 관계없이 참조로 전달합니다. 어느 쪽에도 해당하지 않는 파라미터는 복사와 참조 중 어느 방식으로 전달할지 규정하지 않고 구현에 맡깁니다. 복사로 전달된 in out, out 파라미터는 6.4.1에 따라 서브프로그램이 정상 종료할 때 그 값을 실제 인자에 되돌려 씁니다. 이것이 정의 절에서 언급한 call by copy-restore입니다. 호출한 쪽의 변수가 바뀐다는 결과는 call by reference와 같지만 실제 전달은 복사입니다.

복사 전달과 참조 전달의 차이는 프로시저 안에서 실제 인자로 넘긴 변수를 직접 읽어 보면 드러납니다. 아래 예제는 같은 변수 Original을 by-copy로 받는 프로시저와 aliased로 받는 프로시저에 각각 넘기고, 파라미터에 대입한 직후 Original을 읽습니다.

Ada by-copy 전달과 aliased 참조 전달의 차이
with Ada.Text_IO; use Ada.Text_IO;

procedure Demo is
   Original : aliased Integer := 1;

   procedure By_Copy (Param : in out Integer) is
   begin
      Param := 2;
      Put_Line ("inside By_Copy: Original =" & Integer'Image (Original)); -- (1)
   end By_Copy;

   procedure By_Reference (Param : aliased in out Integer) is
   begin
      Param := 2;
      Put_Line ("inside By_Reference: Original =" & Integer'Image (Original)); -- (3)
   end By_Reference;
begin
   By_Copy (Original);
   Put_Line ("after By_Copy: Original =" & Integer'Image (Original)); -- (2)

   Original := 1;
   By_Reference (Original);
   Put_Line ("after By_Reference: Original =" & Integer'Image (Original));
end Demo;
  1. Integer는 by-copy 타입이므로 Param은 Original의 복사본입니다. Param에 2를 대입한 직후에도 Original은 아직 1입니다.

  2. 프로시저가 정상 종료하면서 Param의 값을 Original에 되돌려 써서 Original이 2가 됩니다.

  3. aliased 파라미터는 참조로 전달되므로 Param은 Original의 별칭입니다. Param에 대입하는 순간 Original도 2입니다.

GCC에 포함된 Ada 컴파일러인 GNAT 13.3으로 실행한 출력입니다.

실행 결과
inside By_Copy: Original = 1
after By_Copy: Original = 2
inside By_Reference: Original = 2
after By_Reference: Original = 2

호출이 끝난 뒤의 값은 둘 다 2로 같지만, 프로시저 안에서 Original을 읽은 결과는 다릅니다. 파라미터 모드를 문법으로 지원한다는 사실과 그 모드가 call by reference로 구현된다는 사실은 별개입니다.

정리

이 글에서 살펴본 언어의 인자 전달 방식을 표로 정리하면 다음과 같습니다.

언어 Call by reference 지원 전달 방식 파라미터를 통해 호출한 쪽 변수를 바꾸는 방법

Java

아니오

call by value. 객체는 참조값의 복사본 전달

없음. 객체 상태 변경만 공유

Kotlin

아니오

call by value. 파라미터가 final이라 재할당 자체가 컴파일 오류

없음. 객체 상태 변경만 공유

JavaScript

아니오

call by value. Object 값을 별도의 파라미터 바인딩에 대입

없음. 객체 상태 변경만 공유

C

아니오

call by value

포인터를 넘김(swap(&a, &b))

C++

예. 참조 파라미터(int&)에 한함

기본은 call by value. 참조 파라미터만 call by reference

참조 파라미터 선언. C처럼 포인터를 넘길 수도 있음

C#

예. ref, out, in, ref readonly 파라미터. 이 중 파라미터에 대입할 수 있는 것은 ref, out

기본은 call by value. class 인스턴스는 참조의 복사본 전달

ref, out 파라미터 선언. 호출 지점에도 ref, out 표기

Python

아니오

call by value. 값은 항상 객체 참조

없음. 객체 상태 변경만 공유

Go

아니오

call by value. 맵·슬라이스 값은 포인터처럼 동작

포인터를 넘김(swap(&a, &b)). 포인터 수신자 메서드는 &를 자동으로 붙임

Rust

아니오

call by value. 대입과 같이 타입에 따라 이동 또는 복사하며, 가변 참조 &mut T도 값으로 전달

가변 참조를 넘김(swap(&mut a, &mut b)). 메서드 호출은 수신자를 자동으로 대여

Ada

부분적으로. by-reference 타입이나 aliased로 선언한 파라미터는 모드와 관계없이 참조로 전달. 그 밖의 타입은 명세가 정하지 않음. by-copy 타입의 in out, `out`은 정상 종료 시 값을 되돌려 씀(copy-restore)

타입에 따라 by-copy 또는 by-reference

in out, out 모드 선언

  • Java, Kotlin, JavaScript, Python은 인자 값으로 별도의 파라미터 변수 또는 바인딩을 초기화합니다. 호출자 변수 자체가 파라미터의 별칭이 되는 call by reference를 지원하지 않습니다.

    • 객체를 전달하면 객체가 복제되는 것도, 호출자의 변수 자체가 전달되는 것도 아닙니다. Java에서는 객체를 가리키는 참조값이, JavaScript에서는 Object 값이 별도의 파라미터에 전달되며 호출자와 피호출자가 같은 객체를 관찰할 수 있습니다.

    • 함수 안에서 파라미터를 재할당해도 호출한 쪽의 변수는 바뀌지 않습니다. 반면 파라미터가 가리키는 객체의 내부 상태를 바꾸면 호출한 쪽 변수로 접근해도 바뀌어 있습니다. 이 차이가 "객체는 call by reference"라는 오해의 근원입니다.

  • C와 Go는 포인터를, Rust는 가변 참조를 넘겨서 호출한 쪽의 값을 바꿉니다. 변수의 주소나 참조를 그 자리에서 구해 넘기는 호출에서는 &나 &mut가 드러나지만, 이미 포인터나 참조를 담은 변수를 넘기거나 Go와 Rust의 메서드 호출처럼 컴파일러가 주소 취득과 대여를 대신하는 경우에는 호출식의 모양만으로 판단할 수 없습니다. 파라미터가 호출자 변수의 별칭이 되는 언어는 C++, C#, Ada뿐이고, 그중 Ada는 타입에 따라 복사로 전달했다가 되돌려 쓰기도 합니다.

  • 객체를 공유하는 이 동작을 call by sharing이라고 부르기도 합니다. JavaScript: The Definitive Guide 4판처럼 일부 자료는 객체를 공유하는 동작을 by reference라고 표현하지만, 호출자 변수의 별칭을 전달한다는 의미의 call by reference와는 구분해야 합니다.

참고 자료

Java 문자열 더하기 연산의 최적화

String을 +로 더하는 코드가 javac과 JDK 런타임에서 버전에 따라 어떻게 최적화되는지 정리합니다. JDK 1.4의 StringBuffer 치환, JDK 5의 StringBuilder 치환, JDK 9의 invokedynamic 도입, JDK 15의 대안 전략 제거, JDK 24의 hidden class 기반 구현까지의 변화를 JDK 25에서 확인한 바이트코드와 함께 설명합니다.

String을 + 연산자로 더하는 코드는 컴파일러와 JVM이 최적화합니다. JDK의 javac은 JDK 1.4까지 StringBuffer를 쓰는 코드로, JDK 5부터 JDK 8까지는 StringBuilder를 쓰는 코드로 컴파일 시점에 변환했습니다. JDK 9부터는 기본적으로 invokedynamic 명령으로 치환되고, 실제 결합 코드는 실행 시점에 링크합니다. 어떤 경우에 자동으로 최적화되는지 알고 있으면, 반복문 밖에서는 + 연산을 그대로 쓰고 반복문 안에서 누적할 때만 StringBuilder를 직접 쓰는 식으로 기준을 정할 수 있습니다.

Java 언어 명세는 문자열 결합의 결과와 평가 순서를 규정하지만, 구체적인 구현 방식은 컴파일러의 재량에 맡깁니다. 따라서 이 글의 버전 구분은 JDK javac의 기본 바이트코드와 OpenJDK 런타임 구현을 기준으로 합니다. [1]

컴파일 타임 상수식의 결합

문자열 타입의 컴파일 타임 상수식은 컴파일 시점에 평가되어 하나의 문자열이 되고, 이 문자열은 String.intern()을 거친 것처럼 문자열 풀에 등록되어 공유됩니다. String.intern()은 같은 내용에 대한 문자열 풀(string pool)의 canonical reference(대표 참조)를 반환합니다. 내용이 같은 String 인스턴스가 힙에 여러 개 있어도 문자열 풀에는 내용별로 대표 인스턴스가 하나만 등록되는데, canonical reference는 그 대표 인스턴스를 가리키는 참조입니다. 그래서 내용이 같은 문자열을 intern하면 동일한 인스턴스를 참조하게 됩니다. 이는 힙에 존재하는 내용이 같은 모든 String 객체가 자동으로 문자열 풀에 합쳐진다는 뜻은 아닙니다. 문자열 리터럴뿐 아니라 primitive 리터럴과 상수 변수(예: `static final int SIZE = 10;`으로 선언한 SIZE)도 이런 상수식에 포함될 수 있습니다. [2] 예를 들어 아래의 두 선언은 같은 코드로 컴파일됩니다.

String str1 = "It's" + " a string" + "....";
String str2 = "It's a string....";

이 규칙은 이 글에서 다루는 모든 JDK 버전에서 같습니다. 뒤에서 소개할 JDK 25의 바이트코드에서도 두 변수가 상수 풀의 같은 항목(ldc #7)을 읽는 것을 확인할 수 있습니다. 두 변수는 문자열 풀의 같은 인스턴스를 참조하므로 equals() 메서드뿐 아니라 str1 == str2 비교도 true가 됩니다. 반면 비상수 결합식의 결과는 새 String으로 만들어지고 자동으로 intern되지 않으므로, 내용이 같은 리터럴이나 별도로 만들어진 문자열과 ==로 비교하면 false가 됩니다.

변수가 섞인 결합의 StringBuffer·StringBuilder 치환

상수와 String 변수를 섞어서 더하는 연산을 JDK 8까지의 javac으로 컴파일하면 StringBuffer나 StringBuilder의 append(), toString() 메서드를 호출하는 코드로 변환됩니다. JDK 1.4의 javac도 StringBuffer를 사용하는 방식이었습니다. 같은 시기의 Java ME 문서인 CLDC 1.1의 StringBuffer javadoc에는 당시 컴파일러의 변환 방식이 아래와 같이 설명되어 있습니다.

String buffers are used by the compiler to implement the binary string concatenation operator. For example, the code: x = "a" + 4 + "c" is compiled to the equivalent of: x = new StringBuffer().append("a").append(4).append("c").toString() which creates a new string buffer (initially empty), appends the string representation of each operand to the string buffer in turn, and then converts the contents of the string buffer to a string. Overall, this avoids creating many temporary strings.

JDK 5부터 JDK 8까지의 javac은 StringBuffer 대신 StringBuilder로 변환합니다. StringBuilder의 메서드에는 `synchronized`가 없어서 `append()`를 호출할 때마다 락을 잡지 않습니다. 이를 바이트코드나 역컴파일 결과로 확인할 수 있습니다. 원래의 소스가 아래와 같을 때

String str0 = "It's a string....";
String str1 = "It's" + " a string" + "....";
String str2 = "It's a string...." + str0 + "000";
str2 = str0 + str1 + "1111" ;
str2 = str2 + "1111";
str2 += "1111";
for (int i=0;i<10;i++){
    str2 = str2 + "1111";
    str2 += "1111";
}

JDK 5 이상 JDK 8 이하의 javac으로 컴파일한 후 역컴파일하면 다음과 같은 결과가 나옵니다.

String str0 = "It's a string....";
String str1 = "It's a string....";
String str2 = (new StringBuilder("It's a string....")).append(str0).append("000").toString();
str2 = (new StringBuilder(String.valueOf(str0))).append(str1).append("1111").toString();
str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
for(int i = 0; i < 10; i++) {
    str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
    str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
}
Note

역컴파일 도구의 옵션에 따라서는 원래의 소스와 비슷하게 복원해서 + 연산이 그대로 보일 수도 있습니다. cfr이라는 디컴파일러가 그런 경우입니다. javap -c 명령으로 바이트코드를 확인하는 것이 정확합니다.

반복문 안에서 누적하는 문자열 결합

위의 역컴파일 결과에서 주목할 부분은 반복문입니다. 바이트코드에는 반복 주기마다 새로운 StringBuilder를 만들고, toString()을 호출해서 다음 반복에 넘길 String을 만드는 코드가 들어 있습니다. HotSpot의 C2 JIT 컴파일러는 기본으로 켜져 있는 OptimizeStringConcat 최적화로 append()`와 `toString() 호출이 이어진 패턴을 인식해 이 임시 StringBuilder의 힙 할당을 없앨 수 있으므로, 바이트코드의 new 횟수가 항상 실제 객체 할당 횟수와 같지는 않습니다. 그러나 매번 더 길어진 이전 결과를 새 결과에 복사해야 한다는 문제는 남습니다.

따라서 성능이 중요한 반복문에서 문자열을 많이 누적할 때는 + 대신 하나의 StringBuilder를 직접 쓰는 편이 좋습니다. 고정된 길이의 문자열을 반복해서 붙인다면 + 방식의 누적 복사량은 반복 횟수에 대해 제곱으로 증가하지만, StringBuilder는 내부 버퍼를 재사용합니다. 반복 횟수가 100회 정도로 작다면 두 방식의 차이가 작으므로(아래 측정 결과 참고) 가독성이 좋은 쪽을 골라도 됩니다. [3]

StringBuilder sb = new StringBuilder(str2);
for (int i = 0; i < 10; i++) {
    sb.append("1111");
}
str2 = sb.toString();

두 방식의 차이를 JDK 25(Temurin 25+36)에서 간단히 측정해 보았습니다. 4글자 문자열을 n회 누적하는 코드를 JIT 워밍업으로 200번 실행한 뒤, 조건마다 20번(100,000회는 5번) 실행해서 가장 짧은 시간을 기록한 결과입니다. 측정에 쓴 코드는 examples/string-concat에 있습니다. [4]

반복 횟수 + 결합 StringBuilder 직접 사용 배율

100

0.008ms

0.001ms

약 10배

1,000

0.53ms

0.009ms

약 56배

10,000

10.6ms

0.023ms

약 471배

100,000

1,485ms

0.22ms

약 6,667배

반복 횟수가 10배씩 늘 때 + 결합 시간은 약 66배, 20배, 140배로 늘었고, StringBuilder는 10배 이하로 늘었습니다. 누적 복사량이 반복 횟수의 제곱에 비례한다면 10배마다 100배씩 늘어야 합니다. 측정값은 JIT와 GC의 영향으로 들쭉날쭉하지만 앞의 설명과 같은 경향을 보입니다. 반복 횟수가 100회라면 두 방식 모두 0.01ms 미만이라 차이를 체감하기 어렵습니다.

JDK 9부터 적용된 invokedynamic 방식

JDK 9부터는 JEP 280: Indify String Concatenation으로 javac의 기본 변환 방식이 완전히 달라졌습니다. 컴파일러가 StringBuilder 호출 코드를 만들어 넣는 대신 invokedynamic 호출 지점을 남기고, 실제 결합 메서드는 실행 시점에 연결합니다.

invokedynamic의 동작 구조

invokedynamic은 호출할 메서드를 실행 시점에 결정하는 바이트코드 명령입니다. invokevirtual이나 invokestatic 같은 다른 호출 명령은 호출 대상이 클래스 파일에 고정되어 있지만, invokedynamic은 호출 지점(call site)을 어떤 메서드에 연결할지를 실행 시점의 Java 코드에 위임합니다. JVM에서 동적 언어를 지원하기 위해 JSR 292로 JDK 7에 추가되었고, JDK 8의 람다 표현식 구현에도 쓰입니다.

이 연결을 담당하는 코드가 bootstrap 메서드입니다. 클래스 파일에는 invokedynamic 호출 지점마다 사용할 bootstrap 메서드가 기록됩니다. JVM은 호출 지점을 처음 실행하기 전에 bootstrap 메서드를 호출해 링크하고, bootstrap 메서드는 실제 실행할 메서드를 가리키는 CallSite를 만들어 반환합니다. 여러 스레드가 같은 호출 지점을 동시에 최초 실행하면 bootstrap 메서드가 병행 호출될 수 있지만, JVM은 그중 하나의 결과만 호출 지점에 설치합니다. 문자열 결합에서는 StringConcatFactory.makeConcatWithConstants()가 bootstrap 메서드이고, 이 메서드가 결합 코드를 만들어 CallSite에 담아 돌려줍니다.

MethodHandle은 java.lang.invoke 패키지에 있는, 실행할 대상을 가리키는 참조입니다. 메서드뿐 아니라 생성자 호출이나 필드 접근도 가리킬 수 있고, 인수와 반환 타입 정보는 MethodType 객체에 담겨 있습니다. 리플렉션의 Method와 쓰임새는 비슷하지만, 접근 권한을 MethodHandle을 만드는 시점에 한 번만 확인하고 invokeExact() 같은 메서드로 바로 호출한다는 점이 다릅니다. 또한 MethodHandles.filterArguments()`나 `MethodHandles.foldArguments() 같은 메서드로 여러 MethodHandle을 엮어 새로운 MethodHandle을 만들 수 있습니다. 뒤에서 볼 문자열 결합 전략 중 이름이 MH로 시작하는 3가지가 이 조합 기능으로 결합 코드를 구성합니다.

CallSite는 같은 패키지의 객체로, 실행할 메서드를 가리키는 MethodHandle 하나를 target이라는 속성으로 참조합니다. CallSite.getTarget() 메서드가 현재 target인 MethodHandle을 돌려줍니다. JVM은 bootstrap 메서드가 반환한 CallSite를 호출 지점에 연결해 두는데, 이를 링크라고 합니다. 링크된 뒤의 invokedynamic 실행은 그 시점의 target MethodHandle을 호출하는 것과 같습니다. MutableCallSite처럼 실행 중에 target을 다른 MethodHandle로 바꿀 수 있는 구현도 있지만, 문자열 결합에는 target이 바뀌지 않는 ConstantCallSite가 쓰입니다. 그래서 한 번 링크된 뒤에는 일반 메서드 호출처럼 동작하고 JIT 컴파일러의 인라인 최적화 대상도 됩니다.

JDK 25에서 확인한 invokedynamic 바이트코드

JDK 25에서 아래의 소스를 컴파일해서 확인해 보았습니다.

StringTest.java의 인스턴스 메서드 본문
String str0 = "It's a string....";
String str1 = "It's" + " a string" + "....";
String str2 = str0 + str1 + "1111";
for (int i = 0; i < 10; i++) {
    str2 = str2 + "1111";
}

javap -c StringTest 명령으로 바이트코드를 보면 StringBuilder 호출 대신 invokedynamic 명령이 나옵니다.

javap -c StringTest 출력 중 해당 메서드 (JDK 25로 컴파일)
 0: ldc           #7    // String It's a string....
 2: astore_1
 3: ldc           #7    // String It's a string....
 5: astore_2
 6: aload_1
 7: aload_2
 8: invokedynamic #9,  0  // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
13: astore_3
14: iconst_0
15: istore        4
17: iload         4
19: bipush        10
21: if_icmpge     37
24: aload_3
25: invokedynamic #13,  0 // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
30: astore_3
31: iinc          4, 1
34: goto          17
37: return

같은 소스를 javac --release 8 옵션으로 컴파일하면 앞 절에서 역컴파일로 보았던 것과 같은 StringBuilder 호출 코드가 나옵니다.

이 방식의 장점은 결합 전략이 컴파일된 클래스 파일이 아니라 JDK 런타임에 있다는 점입니다. 클래스 파일에는 결합할 인수 자리와 상수를 적은 recipe 문자열만 남습니다. 예를 들어 위의 str0 + str1 + "1111"은 인수 자리를 뜻하는 \u0001 두 개 뒤에 1111이 붙은 recipe가 되고, javap -v 출력의 BootstrapMethods 항목에서 확인할 수 있습니다. 그래서 한 번 컴파일한 클래스도 실행하는 JDK를 올리면 새 런타임의 결합 전략을 재컴파일 없이 적용받습니다. JDK 11로 컴파일한 클래스를 JDK 25에서 실행하면, JDK 24에서 도입된 hidden class 기반의 일반 결합 경로가 적용됩니다. 자세한 배경은 JDK 9/JEP 280: String Concatenations Will Never Be the Same에 설명되어 있습니다.

StringConcatFactory 결합 전략의 변화

JDK 9의 StringConcatFactory에는 6가지 결합 전략이 있었고, java.lang.invoke.stringConcat 시스템 프로퍼티로 선택할 수 있었습니다. 전략 이름에서 BC는 실행 시점의 바이트코드 생성을, MH는 MethodHandle 조합을, SB는 StringBuilder 사용을, SIZED는 버퍼 초기 용량 추정을, SIZED_EXACT는 정확한 용량 계산을 뜻합니다.

전략 설명

BC_SB

StringBuilder를 호출하는 클래스를 실행 시점에 바이트코드로 생성합니다. JDK 8까지의 javac이 컴파일 시점에 만들던 코드와 같은 형태입니다.

BC_SB_SIZED

BC_SB와 같지만 StringBuilder의 초기 용량을 추정값으로 지정해서 내부 버퍼의 확장을 줄입니다.

BC_SB_SIZED_EXACT

인수들의 길이를 미리 계산해서 StringBuilder의 초기 용량을 정확한 값으로 지정합니다.

MH_SB_SIZED

바이트코드 생성 대신 MethodHandle 조합으로 StringBuilder 호출을 구성합니다. 초기 용량은 추정값입니다.

MH_SB_SIZED_EXACT

MH_SB_SIZED와 같은 방식이지만 초기 용량을 정확하게 계산합니다.

MH_INLINE_SIZED_EXACT

기본 전략입니다. StringBuilder를 거치지 않고 최종 길이의 byte 배열을 직접 만들어 인수들을 그 배열에 바로 씁니다.

기본 전략인 MH_INLINE_SIZED_EXACT는 다 채운 byte 배열로 추가 복사 없이 String을 생성하므로, 임시 객체와 복사가 가장 적습니다. StringConcatFactory.java의 generateMHInlineCopy() 메서드가 그 구현입니다. 두 참조 값을 더하는 단순한 형태에는 StringConcatHelper.java의 simpleConcat() 같은 특수 경로도 사용됩니다.

기본 전략 외의 나머지 5가지 선택 전략과 java.lang.invoke.stringConcat 프로퍼티는 JDK-8245455로 JDK 15에서 제거되었습니다. 이 글에서는 simpleConcat() 같은 특수한 형태 전용의 빠른 경로를 제외하고, 대부분의 결합이 기본으로 타는 경로를 일반 결합 경로라고 부르겠습니다. JDK 15부터 JDK 22까지의 일반 결합 경로는 MH_INLINE_SIZED_EXACT에서 이어진 inline-copy MethodHandle 방식입니다. 여러 MethodHandle을 조합해서, 정확한 최종 길이의 byte 배열을 만들고 각 인수를 중간 버퍼 없이 그 배열에 바로 복사해 넣는(inline copy) 결합 메서드를 구성합니다.

JDK 23에서는 JDK-8327247로 인수 개수가 기본 임계값 20을 넘는 결합에 StringBuilder 호출 바이트코드를 생성하는 경로가 추가되었습니다. 인수가 많은 결합에서는 MethodHandle 표현식 트리가 커져서, 극단적인 경우 C2 컴파일러가 이를 컴파일하는 데 최대 2GB의 메모리를 쓰는 문제가 있었습니다. 이 문제를 피하기 위한 변경입니다.

JDK 24에서는 JDK-8336856으로 주된 일반 결합 경로가 인수 형태(shape)별 hidden class를 생성하는 방식으로 다시 바뀌었습니다.

hidden class는 JEP 371: Hidden Classes로 JDK 15에 추가된 특수한 클래스입니다. Lookup.defineHiddenClass() 메서드로 정의하며, 클래스 이름으로 찾을 수 없고 참조가 사라지면 개별적으로 언로드될 수 있어서, 프레임워크나 JDK가 실행 시점에 만들어 내는 코드에 적합합니다. JDK 15부터는 람다 표현식의 구현 클래스도 hidden class로 만듭니다. JDK 25 구현에서 hidden class 캐시의 키가 되는 인수 형태는 동적 인수의 개수와 정규화된 타입으로 이루어진 MethodType입니다. 참조 타입은 Object로, byte와 short는 int로 정규화됩니다. 상수의 내용과 배치는 캐시 키에 포함되지 않고 호출 지점마다 별도의 String[]에 저장됩니다. 따라서 앞의 바이트코드에서 str0 + str1 + "1111"의 호출 지점은 시그니처가 (String, String) → String이지만, 캐시에서는 (Object, Object) → String 형태를 사용합니다. 반복문 안 str2 + "1111"의 호출 지점은 (Object) → String 형태를 사용하므로 두 호출 지점은 서로 다른 형태입니다. 인수 형태별 hidden class 방식은 생성한 결합 클래스를 캐시에 두고 같은 형태의 다른 호출 지점에서 재사용할 수 있습니다. 캐시는 soft reference를 사용하므로 캐시된 클래스가 회수되면 같은 형태의 클래스를 다시 만들 수도 있습니다. 깊게 중첩된 MethodHandle 트리 대신 JIT 컴파일러가 다루기 쉬운 평범한 바이트코드가 만들어지므로, 링크와 초기 구동(warmup) 비용이 줄어듭니다.

JDK 25의 StringConcatFactory는 먼저 단순 결합용 빠른 경로를 확인하고, 그 밖의 결합은 기본적으로 InlineHiddenClassStrategy로 처리합니다. 이 전략은 동적 인수가 하나인 경우 미리 구현된 전용 핸들러를 사용하고, 그 밖의 일반적인 결합에는 인수 형태별 hidden class를 사용합니다. 결합 코드는 최종 크기의 byte 배열을 채우고 그 배열로 String을 만듭니다. JDK 25의 StringConcatFactory.java에서 이를 확인할 수 있습니다.

한편 위의 바이트코드에서 보듯이 반복문 안의 invokedynamic 명령은 반복 주기마다 이미 링크된 결합 메서드를 호출하여 새로운 결과 String을 만듭니다. StringConcatFactory의 bootstrap 메서드가 매번 다시 실행되는 것은 아닙니다. 컴파일러나 런타임이 누적용 StringBuilder 하나를 반복문 밖에 만들어 주지는 않으므로, 많은 문자열을 누적하는 반복문에서 직접 StringBuilder를 쓰라는 결론은 JDK 9 이후에도 같습니다.

정리

JDK 버전 + 결합 연산의 처리 방식

JDK 1.4까지

javac이 컴파일 시점에 StringBuffer의 append(), toString() 호출로 변환

JDK 5 ~ 8

javac이 컴파일 시점에 동기화를 하지 않는 StringBuilder 호출로 변환

JDK 9 ~ 14

javac이 invokedynamic 호출 지점을 생성. 첫 링크 시 StringConcatFactory가 결합 메서드를 연결하며 6가지 전략 중 선택 가능

JDK 15 ~ 22

대안 전략을 제거하고 inline-copy MethodHandle 방식을 일반 결합 경로로 사용

JDK 23

인수가 많은 결합에는 StringBuilder 호출 바이트코드를 생성하는 경로 사용

JDK 24 ~ 25

단순 결합용 빠른 경로, 단일 인수 전용 구현과 정규화된 인수 형태별 hidden class 기반의 일반 결합 경로 사용

문자열 타입의 컴파일 타임 상수식이 하나의 상수로 합쳐지는 동작은 모든 버전에서 같습니다. 반복문 밖에서 문자열 한두 개를 더하는 코드는 javac과 JDK 런타임이 최적화할 수 있으므로 가독성이 좋은 + 연산을 그대로 쓰면 됩니다. 반면 컴파일러나 런타임이 반복문을 누적용 StringBuilder 하나로 자동 변환하지는 않습니다. 반복 횟수가 1,000회 이상인 반복문에서 문자열을 누적한다면 StringBuilder를 직접 쓰는 편이 좋습니다. 앞의 측정에서는 1,000회에서 약 56배, 10,000회에서 약 471배 차이가 났습니다.


1. JLS 15.18.1은 SE 25판에서도 컴파일러가 StringBuffer나 그와 비슷한 기법을 쓸 수 있다고만 설명합니다. StringBuilder와 invokedynamic도 여기서 말하는 비슷한 기법(similar technique)에 해당합니다.
2. 컴파일 타임 상수식의 정확한 범위는 JLS 15.29를 참고합니다.
3. 성능 테스트 사례는 String concatenation with Java 8을 참고합니다.
4. JMH를 쓰지 않은 System.nanoTime() 기반의 단순 측정이라 미세한 수치는 부정확할 수 있지만, 10,000회 이상에서는 수백 배 이상 차이가 나므로 결론에는 영향이 없습니다.