CodeGym /행동 /C++ SELF /값에 의한 전달: 복사와 복사 비용

값에 의한 전달: 복사와 복사 비용

C++ SELF
레벨 15, 레슨 0
사용 가능

1. 소개

함수를 작성하기 시작하면 이렇게 생각하기가 아주 쉽습니다. “변수를 함수에 넘겼으니, 함수는 내 변수 자체와 작업하겠지.” 특히 아직 언어 규칙에 익숙하지 않다면 아주 자연스러운 기대입니다. 문제는 C++가(많은 언어들처럼) 인수를 여러 방식으로 전달할 수 있다는 점이며, 가장 단순한 방식인 값에 의한 전달은 실제로 복사본을 만든다는 것입니다. 그리고 그 복사본은 거의 공짜일 수도 있고(int), 꽤 비용이 클 수도 있습니다(std::string, std::vector).

오늘의 목표는 단순합니다. 함수 시그니처를 계약처럼 읽는 습관을 만드는 것입니다. T x를 봤다면 머릿속으로 “좋아, 복사본이 만들어지겠군”이라고 말해야 합니다. 그리고 나서 “이게 나에게 맞는가, 아니면 내가 실수로 복사 공장을 차리고 있는가?”를 판단하면 됩니다.

값에 의한 전달: 함수에는 복사본이 들어간다

값에 의한 전달은 이렇게 생깁니다. 매개변수가 일반 변수처럼 선언됩니다 — T x. 여기서 중요한 점은 함수 매개변수는 함수 내부에서 살아가는 변수라는 것입니다. 이 변수가 생기려면 초기값이 필요합니다. 값에 의한 전달에서는 그 초기값을 인수의 복사로 얻습니다.

감각적으로는 이런 상황과 비슷합니다. 친구에게 자신의 여권 원본이 아니라 사본을 준 것입니다. 친구는 그 사본에 얼마든지 수염을 그려 넣을 수 있지만, 원본은 멀쩡하게 남아 있습니다. 데이터도 정확히 같습니다. 함수 내부에서 매개변수를 바꿔도 main()의 원래 변수는 바뀌지 않습니다.

도식으로 그려 봅시다(메모리의 깊은 세부 사항은 제외한 직관적인 수준에서):

flowchart LR
    A["main(): 변수 a"] -->|복사| B["func(x): 매개변수 x"]
    B --> C[x를 변경]
    C --> D[func를 나가면 x는 사라짐]
    A --> E[main에는 원래 값이 남음]

예제 1: 값으로 전달된 int — 내부에서 바꿔도 외부에서는 바뀌지 않는다

#include <iostream>

void set_zero(int x) {
    x = 0;
}

int main() {
    int a = 5;
    set_zero(a);
    std::cout << a << '\n'; // 5
}

여기서 x는 a의 복사본입니다. 따라서 a는 5로 그대로 남습니다.

왜 int는 괜찮고, string/vector는 부담스러운가

여기서 사고방식의 중요한 갈림길이 나옵니다. 값에 의한 전달은 “나쁘다”도 아니고 “좋다”도 아닙니다. 이것은 도구입니다. 복사가 “조약돌 하나를 집는 것”처럼 느껴지는 작은 타입에는 이상적입니다. 하지만 내부에 많은 데이터를 담고 있는 객체에서는 복사가 “감자 자루를 옮기는 것”처럼 무거워질 수 있으며, 심지어 함수 호출 때마다 그 일이 반복되기도 합니다.

대략적으로는 이런 표를 머릿속에 두면 됩니다:

매개변수 타입 T x일 때 대략 무엇이 복사되는가 보통 체감상
int, double, bool, char 몇 바이트 거의 공짜
std::string 문자열의 문자들(일반적인 경우) 눈에 띌 수 있음
std::vector<int> 벡터의 모든 요소 자주 눈에 띔
std::vector<std::string> 많은 문자열, 각 문자열이 복사됨 시간과 메모리 양쪽에서 눈에 띔

여기서 “일반적인 경우”라는 표현이 매우 중요합니다. 표준 라이브러리에는 최적화가 있지만, 우리는 엔지니어처럼 생각할 것입니다. 데이터 크기가 커질수록 복사 비용도 커진다. 그리고 실수로 두 함수에 한 번꼴로 데이터를 복사하고 있다면, 프로그램은 눈에 보이는 이유 없이 느려지기 시작합니다.

2. std::string 값 전달: 텍스트의 복사와 “보이지 않는 비용”

std::string은 하나의 객체처럼 보이지만, 논리적으로는 “문자들의 컨테이너”입니다. 문자열을 복사할 때는 마치 그것을 통째로 새 공책에 옮겨 적는 것과 같습니다. 문자열이 짧으면 눈치채지 못할 수도 있습니다. 하지만 문자열이 천 자이고 이 작업을 여러 번 한다면, 복사는 계속 돌아가는 백그라운드 작업이 됩니다.

예제 2: 함수가 문자열을 바꾸지만 원래 문자열은 바뀌지 않는다

#include <iostream>
#include <string>

void add_exclamation(std::string s) {
    s += "!";
    std::cout << s << '\n'; // Hello!
}

int main() {
    std::string msg = "Hello";
    add_exclamation(msg);
    std::cout << msg << '\n'; // Hello
}

여기서 s는 msg의 복사본입니다. 복사본으로 작업하고 싶을 때는 편리합니다. 하지만 실수로 원본을 바꾸고 있다고 생각했다면 불편해집니다.

예제 3: “변환 함수” — 값 전달이 적절하다

아주 흔하고 깔끔한 스타일이 있습니다. 함수가 값을 받아 수정된 버전을 만들고 그것을 반환하는 것입니다. 이때 값에 의한 전달은 논리적으로 보입니다. “내 인스턴스를 하나 받아서, 바꾸고, 결과를 돌려준다.”

#include <iostream>
#include <string>

std::string make_tag(std::string s) {
    s = "[" + s + "]";
    return s;
}

int main() {
    std::string x = "TODO";
    std::cout << make_tag(x) << '\n'; // [TODO]
    std::cout << x << '\n';           // TODO
}

이것은 순수한 변환처럼 읽힙니다. 물론 복사본은 있지만, 그 의미와 잘 맞습니다. x가 바뀌는 “부작용”은 원하지 않기 때문입니다.

2. std::vector 값 전달: 요소 복사와 O(N)

std::vector에서는 상황이 더 선명해집니다. 벡터는 많은 요소를 담을 수 있는 컨테이너입니다. 벡터를 값으로 전달한다는 것은 새 벡터를 만들고 그 안에 요소들을 복사한다는 뜻입니다. 요소가 N개라면, 복사는 대략 N에 비례하는 작업입니다. 직관적으로 말하면 O(N)입니다. 벡터가 클수록 더 오래 걸립니다.

바로 그래서 초보자들은 종종 이런 수수께끼를 만납니다. “왜 내 코드는 작은 데이터에서는 빠른데, 큰 데이터에서는 갑자기 느려질까?” 매우 흔한 답은 이것입니다. “모든 함수에서 벡터를 복사하고 있기 때문입니다.”

예제 4: 벡터 복사본의 변경은 원래 벡터에 영향을 주지 않는다

#include <iostream>
#include <vector>

void push_demo(std::vector<int> v) {
    v.push_back(99);
    std::cout << v.size() << '\n'; // 4
}

int main() {
    std::vector<int> data{1, 2, 3};
    push_demo(data);
    std::cout << data.size() << '\n'; // 3
}

바깥의 data는 그대로 남았습니다. push_demo() 내부에서는 복사본 v만 변경한 것입니다.

예제 5: 벡터 복사는 요소 복사다

이것을 시간으로 보여주기보다는(아직 시간을 제대로 측정하는 방법은 배우지 않았으므로), 의미로 보여 보겠습니다:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> a{1, 2, 3};
    std::vector<int> b = a; // 모든 요소의 복사본

    b[0] = 100;
    std::cout << a[0] << '\n'; // 1
    std::cout << b[0] << '\n'; // 100
}

만약 b가 단순히 a의 “다른 이름”이었다면, a[0]도 100이 되었을 것입니다. 하지만 그렇지 않았습니다. 즉, b는 자기 자신의 요소를 가진 별도의 컨테이너입니다.

3. 유용한 뉘앙스: const, 변환 함수, 그리고 “숨은 복사”

const T x — 여전히 복사본이다

때로는 이렇게 “상황을 고치고” 싶어집니다. “좋아, 문제가 변경이라면 매개변수를 const로 만들면 복사가 없겠지.” 이해할 만한 시도지만, 그렇게 되지 않습니다.

const는 함수 내부에서 지역 변수를 바꾸지 못하게 할 뿐이며, 그 지역 변수가 생성된다는 사실 자체를 없애지는 못합니다. 즉, 복사본은 여전히 만들어집니다.

예제 6: const std::string s는 복사를 막아 주지 않는다

#include <iostream>
#include <string>

std::size_t len(const std::string s) {
    return s.size();
}

int main() {
    std::string text = "abcdef";
    std::cout << len(text) << '\n'; // 6
}

여기서도 문자열은 여전히 복사됩니다. 다만 len() 내부에서는 s += "!"를 쓸 수 없을 뿐입니다.

짧게 말하면, const는 변경 금지에 관한 것이지, 속도에 관한 것이 아닙니다.

언제 값에 의한 전달이 좋은 선택인가

여기서 반대 극단으로 가면 안 됩니다. “값 전달은 나쁘다, 절대 쓰지 않겠다.” 이것은 마치 “불은 뜨거우니 다시는 요리하지 않겠다”라고 결론 내리는 것과 비슷합니다. 불은, 여기가 가스레인지이고 저기가 커튼이라는 것을 이해하면, 아주 유용합니다.

값에 의한 전달은 의미상 함수가 독립적인 값으로 작업해야 할 때 좋습니다. 보통은 두 경우 중 하나입니다.

첫 번째 경우는 작고 단순한 타입입니다. int가 있다면, 값으로 전달하는 방식은 자연스럽고 더 읽기 쉽습니다. 필요하지도 않은 곳에서 “계약을 복잡하게” 만들지 않게 해 줍니다.

두 번째 경우는 변환 함수입니다. 의도적으로 복사본을 받아서 바꾸고 새 버전을 반환하는 함수들입니다. 이런 방식은 코드를 예측 가능하게 만듭니다. “어딘가 깊은 곳에서” 예상치 못한 변경이 일어나지 않기 때문입니다.

예제 7: 할 일 목록을 변환해서 새 목록을 반환한다

우리는 콘솔 애플리케이션 “할 일 목록”을 계속 발전시키고 있습니다. 현재 이 프로그램은 할 일을 문자열들의 std::vector<std::string>로 저장합니다. 이제 모든 작업에 "[TODO] " 접두사를 붙이고 새 목록을 반환하는 함수를 만들어 보겠습니다. 물론 복사는 일어납니다. 하지만 이것은 아이디어의 일부입니다. 우리는 바로 새 목록을 원하기 때문입니다.

#include <string>
#include <vector>

std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
    for (std::string& t : tasks) {
        t = "[TODO] " + t;
    }
    return tasks;
}

주의해서 보세요. 우리는 “바깥의” 원래 목록을 바꾸지 않고 새 결과를 만듭니다. 이것이 “받았다 → 변환했다 → 반환했다” 스타일입니다.

예제 8: main()에서 변환을 사용한다

#include <iostream>
#include <string>
#include <vector>

std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
    for (std::string& t : tasks) t = "[TODO] " + t;
    return tasks;
}

int main() {
    std::vector<std::string> tasks{"buy milk", "learn C++"};
    auto tagged = add_todo_prefix(tasks);

    std::cout << tasks[0] << '\n';  // buy milk
    std::cout << tagged[0] << '\n'; // [TODO] buy milk
}

이것은 매우 읽기 쉬운 계약입니다. 입력은 바꾸지 않고, 새로운 값을 가지고 나옵니다.

“숨은 복사”: 어떻게 실수로 프로그램을 더 무겁게 만드는가

이제 아주 현실적인 코드 조각을 만들어 봅시다. 함수가 할 일 목록을 출력합니다. 직관적으로는 이렇게 쓰고 싶을 수 있습니다. “벡터를 넘겨서 출력하면 되겠지.” 그리고 값으로 전달합니다. 코드는 동작하겠지만, 그때마다 목록 전체가 복사됩니다. 작업이 두 개뿐이라면 상관없습니다. 하지만 작업이 만 개라면 이야기가 달라지고, 컴퓨터가 개인적인 원한이라도 품은 것처럼 느껴지기 시작할 수 있습니다.

예제 9: 목록 출력, 하지만 불필요한 복사가 있다

#include <iostream>
#include <string>
#include <vector>

void print_tasks(std::vector<std::string> tasks) {
    std::cout << "Tasks: " << tasks.size() << '\n';
}

int main() {
    std::vector<std::string> tasks{"buy milk", "learn C++"};
    print_tasks(tasks); // 출력: Tasks: 2
}

함수의 의미는 “그냥 보기”입니다. 하지만 시그니처가 뜻하는 것은 “먼저 목록 전체를 복사하고, 그다음 본다”입니다. 데이터가 작으면 눈치채지 못하고, 크면 눈에 띄게 됩니다.

바로 여기에서 오늘 강의의 핵심 규율이 등장합니다. T x를 쓴다면, 왜 복사본이 필요한지에 대한 머릿속 설명이 반드시 있어야 합니다. 그 설명이 “원래 늘 이렇게 쓰니까요”라면, 그것은 경고 신호입니다.

4. 값에 의한 전달에서 자주 하는 실수

실수 №1: T x가 바깥 변수를 바꿀 것이라고 기대하기.
이것은 고전적인 실수입니다. 사람이 void clear(std::string s) { s = ""; }를 작성하고 clear(name)를 호출하지만, 문자열은 비워지지 않습니다. 이유는 간단합니다. s는 복사본이기 때문입니다. 이런 상황에서는 “매개변수는 지역 변수다”라고 직접 말해 보는 것이 도움이 됩니다. 원래 객체를 바꾸고 싶다면, 그것이 함수 계약에 표현되어 있어야 합니다(하지만 그 계약의 세부 사항은 다음 강의에서 다루겠습니다).

실수 №2: 읽기만 하는 함수에서 std::vector와 std::string을 “실수로” 복사하기.
이런 함수들은 무해해 보입니다. print, count, sum, has_word 같은 것들입니다. 하지만 큰 객체를 값으로 받으면, 각 검사마다 “먼저 데이터를 복사하고, 그다음 본다”가 됩니다. 이것은 특히 반복문에서 더 불쾌합니다. 자신도 모르게 “N번 복사”를 만들어 버릴 수 있고, 프로그램은 뚜렷한 이유 없이 느려집니다.

실수 №3: const로 복사를 해결하려고 하기.
const는 실제로 유용하지만, 다른 문제를 해결합니다. 실수로 변경하는 것을 막아 줄 뿐입니다. 복사본 자체를 없애지는 않습니다. 따라서 const std::vector<int> v는 “바꿀 수 없는 복사본”이지, “복사가 없는 상태”가 아닙니다.

실수 №4: 한 함수 안에서 두 가지 다른 스타일을 섞기: “변경한다”와 “반환한다”.
예를 들어 함수가 std::string s(복사본)을 받아서 그것을 바꾸고, 동시에 호출하는 쪽의 “원래 문자열도 바꾸려는” 시도를 한다고 합시다(값 전달에서는 불가능합니다). 이런 코드는 혼란을 만듭니다. 결과는 실제로 어디에 존재할까요? 한 가지 스타일을 선택하는 습관을 들이세요. 함수가 새 값을 순수하게 반환하든지, 아니면 전달된 객체를 명시적으로 바꾸든지 해야 합니다(하지만 그것은 이미 다음 학습 단계의 주제입니다).

실수 №5: “검사 한 번”을 위해 비싼 복사를 하기.
때로는 이런 식으로 작성합니다. “벡터의 크기만 알고 싶어서 값을 기준으로 함수에 넘기고 size()를 호출했다.” 이것은 얼음 한 조각 때문에 냉장고를 사는 것과 같습니다. 읽기 작업은 작고 복사는 큰데, 실제로 필요한 것이 아닌 것에 비용을 지불하고 있는 셈입니다.

1
과제
C++ SELF, 레벨 15, 레슨 0
잠금
무전기에서의 0
무전기에서의 0
1
과제
C++ SELF, 레벨 15, 레슨 0
잠금
임시본에 느낌표 추가
임시본에 느낌표 추가
1
과제
C++ SELF, 레벨 15, 레슨 0
잠금
목록 초안
목록 초안
1
과제
C++ SELF, 레벨 15, 레슨 0
잠금
보고서와 태그
보고서와 태그
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION