C++ emplace_back vs push_back: 실제 성능 차이와 언제 다른지
이 글의 핵심
emplace가 항상 빠르다고 모든 곳에 쓰면 explicit 생성자를 우회해 의도하지 않은 변환이 컴파일되거나, 중괄호 초기화 리스트를 그대로 넘길 수 없는 문제에 부딪힙니다. 예외 안전성 주의점과 예상보다 느린 성능·컴파일 에러 트러블슈팅을 거쳐 두 함수 중 무엇을 고를지 기준을 세웁니다.
emplace_back이 정말 더 빠른가
C++11에서 도입된 emplace 계열 함수는 객체를 컨테이너 안에서 바로 만드는 제자리 생성(in-place construction)으로, push 계열에서 생길 수 있는 임시 객체와 이동 한 번을 없앱니다.
std::vector<std::string> vec;
vec.push_back(std::string("Hello")); // 임시 string 생성 → 벡터 안으로 이동
vec.emplace_back("Hello"); // 벡터 안에서 string("Hello")를 바로 생성
emplace_back의 원리는 단순합니다. template<class... Args> emplace_back(Args&&... args)로 인자를 완벽 전달받아, 벡터가 확보한 메모리 위에서 T(std::forward<Args>(args)...)를 직접 호출합니다(정확히는 할당자의 construct를 통한 placement new). 반면 push_back(const T&)/push_back(T&&)는 이미 완성된 T를 받으므로, "Hello"처럼 T가 아닌 인자를 넘기면 먼저 임시 T가 만들어지고 그것이 벡터 안으로 이동됩니다. 둘의 차이는 결국 “임시 객체 하나 + 이동 한 번”이며, 이 비용이 얼마나 큰지는 타입에 달려 있습니다. 이 점을 기억하면 아래의 벤치마크와 주의사항이 모두 같은 원리로 설명됩니다.
복사·이동 후 삽입 vs 제자리 생성
push_back: 만든 객체를 복사하거나 이동
#include <iostream>
#include <vector>
class Data {
public:
Data(int x, int y) : x_(x), y_(y) {
std::cout << "Data(" << x_ << ", " << y_ << ") 생성\n";
}
Data(const Data& other) : x_(other.x_), y_(other.y_) {
std::cout << "Data 복사\n";
}
Data(Data&& other) noexcept : x_(other.x_), y_(other.y_) {
std::cout << "Data 이동\n";
}
private:
int x_, y_;
};
int main() {
std::vector<Data> vec;
vec.reserve(10); // 재할당으로 인한 이동 출력을 막기 위해
vec.push_back(Data(1, 2));
// 출력:
// Data(1, 2) 생성
// Data 이동
}
emplace_back: 생성자 인자를 받아 제자리 생성
int main() {
std::vector<Data> vec;
vec.reserve(10);
vec.emplace_back(1, 2);
// 출력:
// Data(1, 2) 생성
}
reserve를 빼면 두 번째 원소부터는 재할당 때문에 기존 원소의 “Data 이동” 출력이 섞여 나오므로, 비교할 때는 용량을 미리 확보해 두어야 합니다.
| 항목 | push_back | emplace_back |
|---|---|---|
| 받는 것 | 완성된 T | T의 생성자 인자 |
T가 아닌 인자를 줄 때 | 임시 T 생성 후 이동 | 제자리 생성 |
이미 만든 T를 줄 때 | 복사 또는 이동 | 복사 또는 이동 (같음) |
| explicit 생성자 | 인자만으로는 호출 불가 | 호출 가능 |
| 도입 | C++98 (T&& 오버로드는 C++11) | C++11 |
벤치마크로 본 실제 차이
int 같은 단순 타입
#include <benchmark/benchmark.h>
#include <vector>
static void BM_PushBack_Int(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
vec.reserve(1000);
for (int i = 0; i < 1000; ++i) {
vec.push_back(i);
}
benchmark::DoNotOptimize(vec.data());
}
}
BENCHMARK(BM_PushBack_Int);
static void BM_EmplaceBack_Int(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
vec.reserve(1000);
for (int i = 0; i < 1000; ++i) {
vec.emplace_back(i);
}
benchmark::DoNotOptimize(vec.data());
}
}
BENCHMARK(BM_EmplaceBack_Int);
int처럼 사소한 타입은 “임시 객체를 만들고 이동”하는 것이 값을 한 번 쓰는 것과 같아서, 최적화 빌드에서 두 함수는 사실상 같은 기계어로 컴파일됩니다. 이 벤치마크에서 의미 있는 차이가 나온다면 측정 잡음이거나, 결과 벡터를 쓰지 않아 컴파일러가 루프 일부를 없앤 경우일 가능성이 높습니다. 위 코드에 benchmark::DoNotOptimize(vec.data())를 넣은 이유가 그것입니다.
string 멤버를 가진 객체
#include <string>
struct ComplexData {
std::string name;
std::vector<int> values;
ComplexData(const std::string& n, std::size_t count)
: name(n), values(count, 0) {}
};
static void BM_PushBack_Complex(benchmark::State& state) {
for (auto _ : state) {
std::vector<ComplexData> vec;
vec.reserve(100);
for (int i = 0; i < 100; ++i) {
vec.push_back(ComplexData("data", 1000));
}
benchmark::DoNotOptimize(vec.data());
}
}
BENCHMARK(BM_PushBack_Complex);
static void BM_EmplaceBack_Complex(benchmark::State& state) {
for (auto _ : state) {
std::vector<ComplexData> vec;
vec.reserve(100);
for (int i = 0; i < 100; ++i) {
vec.emplace_back("data", 1000);
}
benchmark::DoNotOptimize(vec.data());
}
}
BENCHMARK(BM_EmplaceBack_Complex);
이 벤치마크의 결과는 생각보다 차이가 작게 나오는 경우가 많습니다. 두 버전 모두 values(count, 0)로 1000개짜리 벡터를 할당하고 0으로 채우는 비용이 똑같이 들고, push_back 쪽에서만 추가되는 것은 임시 ComplexData의 이동입니다. 이동은 std::string(짧은 문자열이라 SSO 버퍼 복사)과 std::vector(포인터 세 개 복사)를 옮기는 정도라, 할당·초기화 비용에 비하면 작습니다. 즉 “복잡한 객체라서 emplace가 훨씬 빠르다”기보다 “이동 한 번을 아낀다”가 정확한 설명이고, 실제 차이는 컴파일러·표준 라이브러리·CPU에 따라 달라지므로 자기 환경에서 측정해야 합니다. 차이가 크게 벌어지는 경우는 이동 생성자가 없어 복사로 대체되는 타입이나, std::array처럼 이동이 곧 전체 복사인 큰 값 타입입니다.
타입별로 차이가 생기는 곳
| 타입 | push_back(임시)에서 추가되는 비용 | emplace_back 이점 |
|---|---|---|
| int, double | 없음 (최적화로 동일) | 없음 |
| std::string, std::vector | 이동 한 번 (포인터·크기 몇 개 복사) | 작음 |
| std::array, 큰 trivially copyable 구조체 | 이동 = 전체 복사 | 큼 |
| 이동 생성자가 없고 복사가 비싼 타입 | 복사 한 번 | 큼 |
절대 시간은 환경마다 크게 다르므로 표에는 넣지 않았습니다. 판단 기준은 “그 타입의 이동이 얼마나 비싼가”입니다. 예를 들어 다음 타입은 이동해도 1KB를 그대로 복사합니다.
#include <array>
struct LargeObject {
std::array<int, 256> data; // int가 4바이트면 1KB
explicit LargeObject(int value) { data.fill(value); }
};
// vec.push_back(LargeObject(7)); // 생성 + 1KB 복사(이동)
// vec.emplace_back(7); // 제자리 생성만
생성자 호출 횟수 세어 보기
#include <iostream>
struct Tracker {
static inline int constructorCalls = 0;
static inline int copyCalls = 0;
static inline int moveCalls = 0;
Tracker(int) { ++constructorCalls; }
Tracker(const Tracker&) { ++copyCalls; }
Tracker(Tracker&&) noexcept { ++moveCalls; }
};
// vec.reserve(...) 후
// vec.push_back(Tracker(42)); → 생성자 1, 이동 1
// vec.emplace_back(42); → 생성자 1
이런 카운터 타입을 두고 실제 코드 경로에서 호출 횟수를 세어 보면, emplace로 바꿔서 정말 이동이 줄어드는지 추측 없이 확인할 수 있습니다.
컴파일러 최적화가 차이를 줄이는 방식
함수 반환값을 넣을 때
std::string createString() {
return std::string("Hello"); // C++17부터 복사 생략이 보장됨
}
std::vector<std::string> vec;
vec.push_back(createString()); // 반환값(임시 객체) → 벡터 안으로 이동 한 번
vec.emplace_back(createString()); // 똑같이 이동 한 번
vec.emplace_back("Hello"); // 제자리 생성
복사 생략은 createString() 안에서 만든 객체를 호출자 쪽 임시 객체 자리에 바로 만들어 주지만, 그 임시 객체가 push_back(T&&)의 매개변수에 묶인 뒤 벡터의 버퍼로 옮겨지는 이동은 생략되지 않습니다. 벡터 원소는 벡터가 관리하는 메모리에 있어야 하므로, 함수 반환값을 그 자리에 직접 만들 방법이 없기 때문입니다. 반환값을 넣는 경우라면 emplace_back(createString())도 같은 이동을 하므로 둘은 동일합니다.
최적화 수준에 따른 차이
-O0 빌드에서는 인라인이 일어나지 않아 임시 객체와 이동 생성자 호출이 모두 그대로 남습니다. -O2/-O3에서는 인라인과 상수 전파 덕분에 int나 작은 trivially copyable 타입에서는 차이가 사라지는 경우가 많습니다. 반면 이동 생성자에 부수 효과가 있거나 메모리를 많이 복사하는 타입은 최적화 빌드에서도 이동이 남습니다. 확인하고 싶다면 g++ -S -O2로 두 버전의 어셈블리를 비교하거나 Compiler Explorer에서 나란히 보면 됩니다.
emplace가 맞는 곳과 push가 맞는 곳
생성자 인자를 여러 개 받는 객체
#include <initializer_list>
#include <string>
#include <vector>
struct Person {
std::string name;
int age;
std::vector<std::string> hobbies;
Person(const std::string& n, int a, std::initializer_list<std::string> h)
: name(n), age(a), hobbies(h) {}
};
int main() {
std::vector<Person> people;
// 중괄호 목록은 템플릿 인자로 추론되지 않으므로 initializer_list 타입을 명시해야 함
people.emplace_back("Alice", 30, std::initializer_list<std::string>{"독서", "음악"});
// people.emplace_back("Bob", 25, {"운동", "게임"}); // 컴파일 에러
// Person 생성자를 직접 호출하면 {}를 그대로 쓸 수 있음
people.push_back(Person("Charlie", 35, {"요리"}));
}
주석으로 막은 줄이 emplace_back의 가장 흔한 컴파일 에러입니다. {"운동", "게임"} 같은 중괄호 초기화 목록은 그 자체로 타입이 없어서, Args&&...로 받는 템플릿 매개변수가 무엇인지 추론할 수 없습니다. GCC는 no matching function for call to 'emplace_back(const char [4], int, <brace-enclosed initializer list>)'와 함께 “couldn’t deduce template parameter”라는 설명을 냅니다. 반면 Person(...)을 직접 호출하면 생성자 매개변수 타입이 정해져 있으므로 중괄호 목록이 std::initializer_list<std::string>으로 변환됩니다. 해결책은 위처럼 타입을 명시하거나, 이런 경우에는 push_back(Person{...})을 쓰는 것입니다.
explicit 생성자를 가진 타입
#include <vector>
#include <string>
class File {
std::string path_;
public:
explicit File(const std::string& path) : path_(path) {}
};
int main() {
std::vector<File> files;
files.emplace_back("data.txt"); // explicit 생성자 호출 가능
// files.push_back("data.txt"); // 컴파일 에러: 암묵적 변환 불가
files.push_back(File("data.txt")); // OK: 명시적으로 생성
}
File처럼 생성자 인자가 곧 객체의 의미인 타입이라면 emplace_back("data.txt")는 자연스럽습니다. 다만 같은 성질이 의도하지 않은 변환도 통과시킨다는 점은 아래 주의사항에서 다룹니다.
이미 만들어 둔 객체와 단순 타입
std::vector<std::string> vec;
std::string str = "Hello";
vec.push_back(str); // 복사
vec.push_back(std::move(str)); // 이동
vec.emplace_back(str); // 복사: push_back(str)과 같은 일을 함
std::vector<int> nums;
nums.push_back(42); // emplace_back(42)와 같은 코드가 됨
이미 만든 객체를 넣을 때는 emplace도 결국 복사 생성자나 이동 생성자를 호출하므로 이점이 없습니다. 이때는 “완성된 값을 넣는다”는 의도가 드러나는 push_back이 읽기 쉽고, 아래에서 볼 explicit 우회 문제도 생기지 않습니다.
map·set과 실제 코드에 적용하기
map/unordered_map
std::map<std::string, Person> people;
// Person 임시 객체를 먼저 만들어 넘기므로 insert와 거의 같음
people.emplace("alice", Person("Alice", 30, {}));
// 키와 값을 각각 제자리 생성
people.emplace(std::piecewise_construct,
std::forward_as_tuple("bob"),
std::forward_as_tuple("Bob", 25, std::initializer_list<std::string>{}));
// C++17: 키는 그대로, 나머지 인자로 값을 제자리 생성
people.try_emplace("dave", "Dave", 40, std::initializer_list<std::string>{});
people.insert({"charlie", Person("Charlie", 35, {})});
map의 원소는 std::pair<const Key, Value>라 emplace의 이점을 제대로 쓰려면 모양이 조금 복잡해집니다. 첫 번째 people.emplace("alice", Person(...))는 Person 임시 객체를 먼저 만들어 넘기므로 insert와 거의 다르지 않습니다. 키와 값을 각각 제자리에서 생성하려면 두 번째처럼 std::piecewise_construct와 forward_as_tuple로 “키 생성자 인자 묶음”과 “값 생성자 인자 묶음”을 따로 넘겨야 합니다. 이 문법이 번거로워 C++17에서 추가된 것이 try_emplace(key, value_args...)입니다.
emplace와 try_emplace의 차이
std::map<std::string, std::string> cache;
auto [it, inserted] = cache.try_emplace("key", "value");
if (!inserted) {
// 키가 이미 있음: "value"로 아무것도 만들지 않았음
}
std::string big = makeLargeValue();
cache.emplace("key", std::move(big)); // 키가 이미 있으면 big이 이동된 채 버려질 수 있음
emplace는 키가 이미 있는지 확인하기 전에 노드(pair)를 먼저 만들 수 있어서, 키가 중복이면 방금 만든 값을 버리고, 인자로 이동해 넘긴 값도 이미 빈 상태가 될 수 있습니다. try_emplace는 키가 없을 때만 값을 생성하고, 키가 있으면 인자를 건드리지 않은 채 기존 원소의 반복자와 false를 돌려줍니다. 캐시에 없을 때만 넣는 코드라면 try_emplace가 의도도 분명하고 불필요한 생성도 피합니다.
set/unordered_set
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) {}
bool operator<(const Point& other) const {
return x < other.x || (x == other.x && y < other.y);
}
};
std::set<Point> points;
points.emplace(10, 20); // 노드 안에서 Point(10, 20) 생성
points.insert(Point(30, 40)); // 임시 Point 생성 후 이동
set의 emplace도 중복 여부를 판단하려면 먼저 원소를 만들어 비교해야 하므로, 이미 있는 값을 넣으면 만든 원소를 버립니다. 중복이 잦은 데이터라면 이 점에서 insert보다 오히려 비용이 클 수 있습니다.
생성자 인자를 받아 넣는 함수
struct LogEntry {
std::chrono::system_clock::time_point timestamp;
std::string level;
std::string message;
std::string file;
int line;
LogEntry(std::string level, std::string msg, std::string file, int line)
: timestamp(std::chrono::system_clock::now()),
level(std::move(level)),
message(std::move(msg)),
file(std::move(file)),
line(line) {}
};
class Logger {
std::vector<LogEntry> buffer_;
std::mutex mutex_;
public:
void log(std::string level, std::string msg, const char* file, int line) {
std::lock_guard<std::mutex> lock(mutex_);
buffer_.emplace_back(std::move(level), std::move(msg), file, line);
}
};
// logger.log("ERROR", "File not found", __FILE__, __LINE__);
emplace_back으로 임시 LogEntry는 없앴지만, 문자열이 몇 번 복사되는지는 따로 따져야 합니다. log가 const std::string&로 받은 뒤 그대로 넘기면 LogEntry의 값 매개변수로 한 번씩 복사됩니다. 위처럼 log도 값으로 받아 std::move로 넘기면 호출자가 넘긴 임시 문자열이 끝까지 이동만으로 전달됩니다. 원소 생성 방식보다 이런 인자 전달 경로가 실제 비용을 더 크게 좌우하는 경우가 많습니다.
패킷 큐
struct Packet {
uint32_t id;
std::vector<uint8_t> data;
std::chrono::steady_clock::time_point timestamp;
Packet(uint32_t id, std::vector<uint8_t> data)
: id(id), data(std::move(data)), timestamp(std::chrono::steady_clock::now()) {}
};
class PacketQueue {
std::deque<Packet> queue_;
std::mutex mutex_;
public:
void enqueue(uint32_t id, std::vector<uint8_t> data) {
std::lock_guard<std::mutex> lock(mutex_);
queue_.emplace_back(id, std::move(data));
}
std::optional<Packet> dequeue() {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) return std::nullopt;
Packet packet = std::move(queue_.front());
queue_.pop_front();
return packet;
}
};
std::deque는 끝에 원소를 추가해도 기존 원소를 옮기지 않으므로, 큐처럼 앞에서 빼고 뒤에 넣는 용도에는 재할당 문제가 없습니다.
explicit 우회·중괄호 목록·예외 안전성 함정
emplace는 explicit 생성자도 호출한다
class Size {
std::size_t value_;
public:
explicit Size(std::size_t value) : value_(value) {}
};
std::vector<Size> vec;
// vec.push_back(10); // 컴파일 에러: 암묵적 변환 불가
vec.emplace_back(10); // 컴파일됨
emplace_back은 내부에서 직접 초기화(T(args...))를 하므로 explicit 생성자도 호출합니다. explicit은 “암묵적 변환으로 이 생성자를 쓰지 말라”는 표시인데, emplace는 그 의도를 우회합니다. 이것이 실제 버그가 되는 대표적인 예는 스마트 포인터입니다. std::vector<std::unique_ptr<Widget>> v; v.push_back(ptr);는 raw 포인터에서 unique_ptr로의 변환이 explicit이라 컴파일되지 않지만, v.emplace_back(ptr);는 컴파일됩니다. 이미 다른 곳이 소유하고 있는 포인터라면 두 곳에서 해제하는 이중 해제가 됩니다. std::vector<std::regex> v; v.emplace_back(nullptr);처럼 말이 안 되는 코드가 컴파일되어(regex(const char*) 생성자에 널 포인터가 전달됨) 실행 중에 정의되지 않은 동작을 일으키는 사례도 잘 알려져 있습니다. 타입 검사를 원한다면 push_back이 더 안전한 기본값입니다.
중괄호 초기화 목록과 괄호의 의미 차이
std::vector<std::vector<int>> vec;
vec.push_back({1, 2, 3}); // 원소 1, 2, 3
// vec.emplace_back({1, 2, 3}); // 컴파일 에러 (타입 추론 불가)
vec.emplace_back(std::initializer_list<int>{1, 2, 3}); // 원소 1, 2, 3
vec.emplace_back(3, 100); // 원소 100, 100, 100
emplace_back(3, 100)은 std::vector<int>(3, 100), 즉 “100이 세 개”인 벡터를 만듭니다. 반면 push_back({3, 100})은 원소가 3과 100인 벡터를 넣습니다. 괄호와 중괄호의 차이가 생성자 선택을 바꾸는 C++의 오래된 함정이 emplace에서 더 잘 드러나는 셈입니다. 같은 이유로 struct Point { int x, y; };처럼 생성자가 없는 집합체는 C++20 이전에는 emplace_back(1, 2)로 만들 수 없었습니다. C++20부터는 괄호로도 집합체 초기화가 가능해져 컴파일됩니다.
예외 안전성: 생성자가 던질 때와 raw 포인터를 넘길 때
class Resource {
public:
explicit Resource(int* ptr) {
if (ptr == nullptr) throw std::runtime_error("nullptr");
}
};
std::vector<Resource> vec;
try {
vec.emplace_back(nullptr);
} catch (...) {
// vec.size()는 변하지 않음
}
vector::emplace_back과 push_back은 끝에 추가할 때 강한 예외 보장을 제공합니다. 생성자나 재할당 중 예외가 나면 벡터는 호출 전 상태로 남습니다(단, 이동 생성자가 noexcept가 아니고 복사할 수도 없는 타입이면 보장이 약해집니다). 그래서 위 코드 자체는 안전합니다.
emplace에서 실제로 문제가 되는 예외 안전성 함정은 자원을 raw 포인터로 넘길 때입니다.
std::vector<std::unique_ptr<Widget>> widgets;
widgets.emplace_back(new Widget); // 재할당 중 bad_alloc이 나면 Widget 누수
widgets.push_back(std::make_unique<Widget>()); // unique_ptr이 먼저 소유권을 가짐
emplace_back(new Widget)은 new로 객체를 만든 뒤 벡터가 공간을 확보하는데, 이때 재할당이 실패하면 unique_ptr이 아직 만들어지지 않아 Widget을 해제할 주체가 없습니다. make_unique로 먼저 소유권을 잡아 두면 어느 시점에 예외가 나도 누수가 없습니다. Effective Modern C++ 항목 42가 이 사례를 들어 “emplace가 항상 낫지는 않다”고 설명합니다.
emplace로 바꿨는데 여전히 느릴 때
std::vector<std::string> vec;
for (int i = 0; i < 1000000; ++i) {
vec.emplace_back("test");
}
이런 루프가 느리다면 원인은 emplace와 push의 차이보다 재할당일 가능성이 큽니다. 용량이 차면 벡터는 더 큰 버퍼(구현에 따라 보통 1.52배)를 새로 할당하고 기존 원소를 모두 옮긴 뒤 이전 버퍼를 해제합니다. 원소 100만 개를 넣는 동안 이런 재할당이 2030여 번 일어나고, 원소 타입의 이동 생성자가 noexcept가 아니면 강한 예외 보장을 지키기 위해 이동 대신 복사를 합니다. 직접 만든 클래스에 이동 생성자를 정의했다면 noexcept를 붙였는지 확인하는 것이 emplace로 바꾸는 것보다 더 큰 효과를 내는 경우가 많습니다. 최종 크기를 안다면 reserve로 재할당 자체를 없앱니다.
std::vector<std::string> vec;
vec.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
vec.emplace_back("test");
}
코드 리뷰에서 쓰는 선택 기준
객체를 컨테이너에 추가
↓
이미 생성된 객체인가?
├─ Yes → push_back (복사/이동은 어차피 같음)
└─ No → 계속
↓
int·double 같은 단순 타입인가?
├─ Yes → push_back (생성 코드가 같음)
└─ No → 계속
↓
explicit 생성자를 우회하면 위험한 타입인가? (스마트 포인터 등)
├─ Yes → push_back(T(...)) 또는 make_unique
└─ No → 이동이 비싸거나 불가능하면 emplace_back
리뷰에서는 이미 만든 객체를 emplace_back(str)으로 넣는 코드, raw 포인터를 emplace_back(new T)로 넘기는 코드, 그리고 반복 삽입 전에 reserve가 빠진 코드를 주로 살펴봅니다.
직접 측정할 때
#include <chrono>
#include <iostream>
#include <string>
template <typename Container, typename... Args>
void benchmarkInsertion(const std::string& name, const Args&... args) {
auto start = std::chrono::steady_clock::now();
Container container;
container.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
container.emplace_back(args...); // 반복마다 같은 인자를 전달
}
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << name << ": " << ms.count() << "ms (size " << container.size() << ")\n";
}
// benchmarkInsertion<std::vector<std::string>>("emplace", "test");
반복문 안에서 인자를 std::forward로 넘기면 rvalue 인자가 첫 반복에서 이동되어 이후 반복은 빈 값을 넣게 되므로, 여기서는 인자를 lvalue로 전달합니다. 경과 시간은 시스템 시계 조정의 영향을 받지 않는 steady_clock으로 잽니다. 정밀한 비교가 필요하면 반복 실행과 통계를 처리해 주는 Google Benchmark 같은 도구가 더 적합합니다.