Skip to Content
독학사독학사 3단계프로그래밍언어론14. 하위프로그램 개념과 매개변수 전달 방식

이번 문서의 목표: 이 문서를 다 읽으면 하위프로그램의 선언·정의·호출을 구분하고, 값 전달·참조 전달·값-결과 전달·이름 호출 방식이 같은 코드에서 왜 서로 다른 결과를 내는지 변수 값을 직접 추적해 설명할 수 있다.

왜 매개변수 전달 방식을 언어론에서 따로 다루는가

C나 Java만 배웠다면 “함수에 값을 넘기는 방법”은 하나뿐이라고 느꼈을 수 있다. 하지만 프로그래밍언어론(programming language concepts)에서는 하위프로그램(subprogram, 함수·프로시저를 통칭하는 용어)에 인자를 넘기는 방식 자체가 언어 설계자가 선택하는 요소라는 점이 핵심이다. 값 전달, 참조 전달, 값-결과 전달, 이름 호출은 저마다 “호출자의 변수가 하위프로그램 안에서 바뀔 수 있는가”에 대해 다른 답을 준다.

쉽게 말하면: 매개변수 전달 방식은 “함수를 부를 때 원본 데이터를 넘길지, 데이터의 사본만 넘길지, 아니면 데이터가 있는 위치(주소)를 넘길지”를 정하는 규칙이다.

04편(제어 구조와 하위프로그램 개념 지도)에서 하위프로그램·매개변수·인자라는 용어를 처음 소개했다. 이 문서는 그 용어를 발판 삼아 전달 방식의 구현 원리와 결과 차이를 깊게 파고든다. 하위프로그램이 실제로 스택에서 어떻게 구현되는지(활성화 레코드)는 15편에서 다룬다.

하위프로그램의 선언·정의·호출 구조

용어 정리 — 선언과 정의는 다르다

용어정의
선언(declaration)하위프로그램의 이름·매개변수 형·반환형만 컴파일러에 알리는 것(본문 없음 가능)
정의(definition)하위프로그램의 실제 실행 코드(본문)를 작성하는 것
호출(call/invocation)정의된 하위프로그램을 실제로 실행시키는 문장
형식 매개변수(formal parameter, 가인자)하위프로그램을 정의할 때 괄호 안에 적는, 값을 받을 자리를 나타내는 변수 이름
실제 인자(actual argument, 실인자)하위프로그램을 호출할 때 실제로 넘기는 값·변수·식
int add(int a, int b); /* 선언: 이름, 매개변수 형, 반환형만 알림 */ int add(int a, int b) { /* 정의: 실행 코드(본문) 포함 */ return a + b; } int result = add(3, 5); /* 호출: a에는 3, b에는 5가 대입되어 실행 */

여기서 a, b는 형식 매개변수이고, 호출 시 넘긴 3, 5는 실제 인자다. C처럼 선언(프로토타입, prototype)과 정의를 분리하는 언어도 있고, Python처럼 정의가 곧 선언을 겸하는 언어도 있다.

자주 틀리는 점: “선언과 정의가 항상 별개의 문장”이라고 착각하지 않는 것이 중요하다. C에서도 int add(int a, int b) { return a + b; }처럼 선언 없이 바로 정의만 쓰면, 그 정의가 선언의 역할까지 겸한다. 다만 다른 함수보다 아래에 정의된 함수를 미리 호출하려면 별도의 선언(프로토타입)이 필요하다.

매개변수 전달 방식 — 값이 오가는 방향으로 분류

매개변수 전달 방식을 이해하는 가장 좋은 틀은 “데이터가 호출자에서 하위프로그램으로만 흐르는가, 하위프로그램에서 호출자로도 흐르는가”다.

분류전달 방식데이터 흐름 방향
입력 모드(in mode)값 전달(pass-by-value)호출자 → 하위프로그램 (한쪽 방향만)
출력 모드(out mode)결과 전달(pass-by-result)하위프로그램 → 호출자 (한쪽 방향만)
입출력 모드(inout mode)참조 전달(pass-by-reference), 값-결과 전달(pass-by-value-result)양방향
특수 방식이름 호출(pass-by-name)호출될 때마다 다시 계산(지연 평가)

값 전달(pass-by-value) — 사본만 넘긴다

값 전달은 실제 인자의 값을 복사해서 형식 매개변수에 대입한다. 하위프로그램 안에서 형식 매개변수를 바꿔도 호출자의 원본 변수는 전혀 영향을 받지 않는다.

void increment(int x) { x = x + 1; printf("함수 안 x = %d\n", x); } int main() { int num = 10; increment(num); printf("함수 밖 num = %d\n", num); return 0; }
함수 안 x = 11 함수 밖 num = 10

한 줄씩 실행 추적

단계위치num(호출자)x(형식 매개변수)
1int num = 10;10(아직 없음)
2increment(num) 호출 — num의 값 10을 복사해 x에 대입1010
3x = x + 1; 실행1011
4함수 종료, num은 원래 그대로10(소멸)

x는 num과 완전히 별개의 저장 공간이므로 x를 아무리 바꿔도 num은 10 그대로다. C의 기본 매개변수 전달 방식이 바로 이 값 전달이며, Java의 원시형(int, double 등) 매개변수도 마찬가지다.

자주 틀리는 점: Java에서 “객체(object)를 매개변수로 넘기면 참조 전달이다”라고 잘못 아는 경우가 매우 많다. Java는 항상 값 전달이며, 다만 객체 변수에 들어 있는 값이 “객체를 가리키는 참조값(reference)“이기 때문에 그 참조값의 사본이 넘어가 같은 객체를 함께 가리키게 되는 것이다. 참조값 자체(어떤 객체를 가리킬지)를 함수 안에서 다른 객체로 바꿔치기해도 호출자의 변수는 원래 객체를 계속 가리킨다.

static void reassign(int[] arr) { arr = new int[]{99, 99, 99}; // 참조값 자체를 새 배열로 바꿔치기 } static void modify(int[] arr) { arr[0] = 99; // 참조가 가리키는 객체의 내용을 변경 } public static void main(String[] args) { int[] data = {1, 2, 3}; reassign(data); System.out.println(data[0]); // 1 modify(data); System.out.println(data[0]); // 99 }
1 99

reassign은 형식 매개변수 arr이 가리키는 대상 자체를 새 배열로 바꿨을 뿐이므로, 이는 arr이라는 참조값 사본에만 영향을 주고 data가 가리키는 원본 배열에는 아무 영향이 없다(그래서 첫 출력이 여전히 1). 반면 modify는 참조값은 그대로 두고 그 참조가 가리키는 배열 내용물을 바꿨으므로, dataarr이 같은 배열을 가리키고 있어 원본에도 변화가 반영된다(그래서 두 번째 출력이 99).

참조 전달(pass-by-reference) — 원본 저장 위치를 공유한다

참조 전달은 실제 인자의 값을 복사하는 대신, 그 값이 저장된 메모리 위치(주소) 자체를 형식 매개변수와 공유한다. 따라서 형식 매개변수를 바꾸는 것이 곧 호출자의 원본 변수를 바꾸는 것과 같다.

void increment(int &x) { // C++의 참조 매개변수 문법: & 기호 x = x + 1; } int main() { int num = 10; increment(num); std::cout << num << std::endl; return 0; }
11

한 줄씩 실행 추적: increment(num)을 호출하면 C++은 num의 값을 복사하지 않고, num이라는 저장 공간 자체를 x라는 별명(alias)으로 하나 더 만든다. xnum완전히 같은 메모리 위치를 가리키므로 x = x + 1;은 곧 num = num + 1;과 동일한 효과를 낸다. 그래서 함수가 끝난 뒤 num은 11이 된다.

언어별 참조 전달 지원: Pascal은 var 키워드(procedure increment(var x: integer))로, C++은 & 기호로 참조 전달을 지원한다. C와 Java는 참조 전달을 언어 차원에서 직접 지원하지 않으며, C는 포인터(pointer, 메모리 주소를 담는 변수)를 매개변수로 넘겨 비슷한 효과를 흉내 낸다.

void increment(int *x) { /* 포인터를 값으로 전달(값 전달) */ *x = *x + 1; /* 역참조(dereference)로 원본 위치의 값을 변경 */ } int main() { int num = 10; increment(&num); /* num의 주소를 값으로 넘김 */ printf("%d\n", num); return 0; }
11

자주 틀리는 점: “C의 포인터 매개변수는 참조 전달이다”라고 단정하면 개념적으로 부정확하다. 정확히는 “주소값을 값 전달로 넘긴 것”이다. 포인터 변수 x 자체는 num의 주소를 담은 값의 사본이며, x를 다른 주소로 재대입해도 num이 가리키는 원본 변수의 주소는 바뀌지 않는다(단지 그 주소를 통해 값을 바꾸는 *x = ...는 원본 값을 바꾼다). 진짜 참조 전달(C++의 &)에서는 매개변수 자체가 원본과 완전히 같은 저장 공간의 별명이 되어 이런 구분이 아예 필요 없다.

값-결과 전달(pass-by-value-result) — 복사해서 넣고, 복사해서 되가져온다

값-결과 전달(copy-in copy-out이라고도 부른다)은 값 전달과 참조 전달을 절충한 방식이다. 호출 시점에는 실제 인자의 값을 형식 매개변수로 복사해 넣고(copy-in), 하위프로그램이 끝나는 시점에는 형식 매개변수의 최종값을 다시 실제 인자 위치로 복사해 되돌린다(copy-out). 실행 도중에는 형식 매개변수와 실제 인자가 서로 다른 별개의 저장 공간이라는 점이 참조 전달과 다르다.

값-결과 전달 의사코드 예시: procedure swap_add(inout a, inout b) a = a + 10 b = a end procedure x = 1 y = 2 swap_add(x, y)

이 예시를 한 줄씩 추적해 보자.

단계실행 위치x(실제 인자)y(실제 인자)a(형식 매개변수)b(형식 매개변수)
1호출 직전12(없음)(없음)
2copy-in: x→a, y→b1212
3a = a + 10 실행1(아직 미반영)2(아직 미반영)112
4b = a 실행1(아직 미반영)2(아직 미반영)1111
5copy-out: a→x, b→y1111(소멸)(소멸)

최종적으로 x = 11, y = 11이 된다. 만약 이 코드가 참조 전달이었다면 ax, by가 실행 도중에도 계속 같은 저장 공간을 공유하므로 결과가 달라질 수 있다(예: 별칭 문제가 있는 경우). 값-결과 전달은 실행 도중에는 독립적인 사본으로 작동하다가 끝날 때만 결과를 반영한다는 점이 핵심 차이다.

자주 틀리는 점: 값-결과 전달과 참조 전달은 별칭(aliasing, 서로 다른 이름이 같은 저장 공간을 가리키는 상황)이 없는 대부분의 경우 같은 최종 결과를 낸다. 두 방식의 차이가 드러나는 것은 같은 변수를 두 개의 형식 매개변수 자리에 동시에 넘기거나, 전역 변수를 매개변수와 함께 건드리는 등 별칭이 생기는 상황에서다. “항상 결과가 다르다”고 단정하면 틀린다.

이름 호출(pass-by-name) — 인자를 코드 조각처럼 넘긴다

이름 호출은 앞의 세 방식과 근본적으로 다르다. 실제 인자를 값이나 주소로 미리 계산해 넘기는 것이 아니라, 하위프로그램 안에서 형식 매개변수가 실제로 쓰일 때마다 실제 인자의 식(expression) 자체를 그 자리에 다시 대입해 계산한다. 이는 Algol 60이 채택했던 방식으로, 현대 언어에서는 거의 쓰이지 않지만 시험에서 개념 문제로 자주 등장한다.

이름 호출 의사코드 예시(Algol 60 스타일): procedure p(name x) x = x + 1 end procedure integer array a[1..10] integer i i = 1 p(a[i])

p(a[i])를 호출하면 형식 매개변수 xa[i]라는 식 자체로 치환된다. 즉 x = x + 1은 실행될 때마다 a[i] = a[i] + 1로 텍스트가 다시 치환되어 계산된다. 만약 p의 본문 안에서 i의 값 자체가 바뀐다면, x가 가리키는 배열 원소도 매번 달라진다 — 이것이 이름 호출의 악명 높은 부작용이다.

procedure p(name x) i = i + 1 x = 0 end procedure i = 1 a[1] = 10 a[2] = 20 p(a[i])

이 코드를 한 줄씩 추적하면, p 안에서 먼저 i = i + 1이 실행되어 i가 2로 바뀐다. 그 다음 x = 0이 실행될 때 x호출 시점의 a[1]이 아니라, 지금 이 순간 다시 계산한 a[i], 즉 a[2]로 치환된다. 결과적으로 a[1]은 그대로 10이고 a[2]가 0으로 바뀐다. 값 전달이나 참조 전달이었다면 x는 호출 시점에 a[1]로 고정되어 a[1]이 0이 되었을 것이다.

쉽게 말하면: 이름 호출은 매개변수 자리에 “값”이 아니라 “그 값을 구하는 방법(식)“을 통째로 넘겨서, 필요할 때마다 그 방법을 다시 실행하는 방식이다.

자주 틀리는 점: 이름 호출을 “지연 평가(lazy evaluation)와 완전히 같다”고 오해하기 쉽지만, 지연 평가는 값이 필요한 시점까지 계산을 미루되 한 번 계산하면 그 값을 재사용(캐싱)하는 경우가 많은 반면(Haskell 등), 순수한 이름 호출은 형식 매개변수를 참조할 때마다 매번 다시 식을 계산한다는 점에서 차이가 있다.

매개변수 전달 방식 종합 비교

방식데이터 흐름실행 중 별개 저장공간?대표 언어별칭 문제성능 특성
값 전달(by-value)호출자→하위프로그램만예(사본)C, Java(모든 형), Pascal 기본값없음(원본과 무관)큰 자료구조 복사 시 비용 증가
참조 전달(by-reference)양방향(실시간 공유)아니오(같은 위치 공유)C++(&), Pascal(var), Fortran있음(별칭으로 예기치 못한 값 변경 가능)복사 비용 없음(주소만 전달)
값-결과 전달(by-value-result)양방향(끝에만 반영)예(호출 중엔 사본)Ada(in out 모드의 구현 방식 중 하나)별칭 상황에서만 참조 전달과 결과가 달라짐복사 비용 있으나 지역성 확보 가능
이름 호출(by-name)호출마다 재계산해당 없음(식 자체를 치환)Algol 60매우 심각(i가 바뀌면 대상 자체가 바뀜)매번 재계산이라 비용 큼, 현대 언어는 거의 미사용

설계 관점의 정리: 값 전달은 “안전하지만(원본 보호) 큰 데이터는 비효율적”이고, 참조 전달은 “효율적이지만(복사 없음) 별칭 문제로 코드를 읽기 어렵게 만들 위험”이 있다. 값-결과 전달은 이 둘을 절충하려 했지만 별칭 상황에서 참조 전달과 다른 결과를 낼 수 있어 오히려 예측을 어렵게 만든다는 비판을 받는다. 이런 trade-off(상충 관계) 판단이 07편에서 다룬 “언어 평가 기준(신뢰성 vs 효율성)“과 직접 연결된다.

핵심 정리

  • 하위프로그램은 선언(이름·형만 알림)·정의(실행 코드 작성)·호출(실행)로 구분되며, 형식 매개변수는 정의에, 실제 인자는 호출에 등장한다.
  • 값 전달은 사본만 넘기므로 원본이 보호되고, Java의 객체 매개변수도 “참조값 자체”가 값 전달되는 것이지 참조 전달이 아니다.
  • 참조 전달(C++ &, Pascal var)은 실행 중 형식 매개변수와 실제 인자가 완전히 같은 저장 공간을 공유해 하위프로그램 안의 변경이 즉시 원본에 반영된다.
  • 값-결과 전달은 호출 시점과 종료 시점에만 값을 복사하며, 별칭이 있을 때만 참조 전달과 다른 결과를 낼 수 있다.
  • 이름 호출은 값이 아니라 식 자체를 넘겨 참조될 때마다 다시 계산하며, 참조하는 인덱스 변수가 하위프로그램 안에서 바뀌면 가리키는 대상 자체가 바뀌는 심각한 부작용이 있다.

마무리 복습

문제 14지선다
하위프로그램의 '선언(declaration)'과 '정의(definition)'의 차이를 가장 정확히 설명한 것은?
문제 24지선다
다음 C 함수를 값 전달로 호출했을 때, 함수 실행 후 호출자의 원본 변수 num의 값은?
void f(int x) {
  x = x * 2;
}

int num = 5;
f(num);
문제 34지선다
Java에서 배열(객체)을 매개변수로 넘겼을 때의 동작에 대한 설명으로 옳은 것은?
문제 44지선다
C++의 참조 매개변수(&)를 사용한 다음 함수의 실행 결과는? 코드: inc(참조 매개변수 x)는 x에 5를 더한다. num을 3으로 초기화한 뒤 inc(num)을 호출하고 num을 출력한다.
문제 54지선다
값-결과 전달(pass-by-value-result)이 참조 전달(pass-by-reference)과 다른 결과를 낼 수 있는 상황으로 가장 적절한 것은?
문제 64지선다
이름 호출(pass-by-name)에 대한 설명으로 옳은 것은?

참고 자료

Last updated on