C++ replace·replace_if·replace_copy: 값 치환과 transform 중 무엇을 쓸까
이 글의 핵심
치환 알고리즘은 원본을 직접 수정하는지 복사본을 만드는지에 따라 용도가 갈리는데, 이름이 비슷해 헷갈리기 쉽습니다. 여러 값을 한 번에 치환하는 방법, 반복자 무효화 주의점, 부분 문자열 치환은 string::replace로 해야 하는 이유를 짚고 텍스트·데이터 전처리에 적용하는 예제로 마무리합니다.
들어가며
C++ STL의 replace 알고리즘은 컨테이너의 요소를 효율적으로 치환할 수 있게 해줍니다. 값 기반 치환과 조건 기반 치환을 모두 지원합니다.
반복문 대신 replace를 쓰는 이유
for 문으로 돌면서 if (x == 2) x = 9;를 쓰는 것과 std::replace는 성능상 차이가 거의 없습니다. 둘 다 원소를 한 번씩 훑는 O(n)이고, 최적화된 빌드에서는 비슷한 기계어가 나옵니다. 그럼에도 알고리즘 함수를 쓰는 이유는 의도가 이름에 드러나기 때문입니다. std::replace(v.begin(), v.end(), 2, 9) 한 줄은 “2를 9로 바꾼다” 외의 일을 하지 않는다는 것이 보장되지만, 직접 쓴 반복문은 안을 읽어 봐야 인덱스를 잘못 다루지 않았는지, 중간에 다른 일을 하지 않는지 알 수 있습니다. 저는 코드 리뷰에서 “이 루프가 정확히 무엇을 하나”를 추적하는 시간이 이런 이름 있는 알고리즘으로 바꾸면 눈에 띄게 줄어드는 것을 여러 번 경험했습니다.
치환 알고리즘 네 개는 두 가지 축의 조합입니다. 무엇을 바꿀지를 값으로 정하느냐(replace) 조건 함수로 정하느냐(_if), 그리고 결과를 어디에 둘지를 원본으로 하느냐 다른 곳에 복사하느냐(_copy)입니다. 이 축을 알면 이름만 보고 동작을 알 수 있습니다. 네 개 모두 원소의 개수는 바꾸지 않고 값만 바꾼다는 점이 앞 글의 remove와 다릅니다.
std::replace로 특정 값 바꾸기
정수 벡터에서 값 치환
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 2, 4, 2, 5};
std::cout << "원본: ";
for (int x : v) {
std::cout << x << " "; // 1 2 3 2 4 2 5
}
std::cout << std::endl;
// 2를 9로 치환
std::replace(v.begin(), v.end(), 2, 9);
std::cout << "치환 후: ";
for (int x : v) {
std::cout << x << " "; // 1 9 3 9 4 9 5
}
std::cout << std::endl;
}
문자열의 문자 단위 치환
#include <iostream>
#include <algorithm>
#include <string>
int main() {
std::string text = "Hello World";
// 공백을 밑줄로 치환
std::replace(text.begin(), text.end(), ' ', '_');
std::cout << text << std::endl; // Hello_World
// 특정 문자 치환
std::string code = "int x = 10;";
std::replace(code.begin(), code.end(), ' ', '\t');
std::cout << code << std::endl; // int x = 10;
}
함수 시그니처:
template<class ForwardIt, class T>
void replace(ForwardIt first, ForwardIt last,
const T& old_value, const T& new_value);
핵심 개념:
- 원본 수정: 컨테이너를 직접 수정
- 모든 일치: 일치하는 모든 요소 치환
- 시간 복잡도: O(n)
시그니처에서 old_value와 new_value가 같은 템플릿 매개변수 T를 쓴다는 점이 컴파일 에러의 흔한 원인입니다. std::vector<double>에서 std::replace(v.begin(), v.end(), 2, 9.5)처럼 정수와 실수를 섞어 넘기면 T가 int인지 double인지 정할 수 없어 “deduced conflicting types for parameter ‘T’ (‘int’ and ‘double’)” 에러가 납니다. 2.0처럼 타입을 맞추면 해결됩니다. 반대로 문자열에서는 'l'과 "L"(문자와 문자열 리터럴)을 섞으면 비슷한 에러가 나는데, std::replace는 원소 하나를 원소 하나로 바꾸는 알고리즘이라 문자 대 문자만 가능합니다(아래 “std::string::replace와 혼동” 참고).
또 하나 알려진 함정은 old_value와 new_value가 참조로 전달된다는 것입니다. std::replace(v.begin(), v.end(), v[0], 9)처럼 범위 안의 원소를 그대로 넘기면, 알고리즘이 v[0]을 9로 바꾸는 순간 비교 기준인 old_value도 9가 되어 이후에는 9를 9로 바꾸는 엉뚱한 동작을 합니다. 기준 값은 먼저 지역 변수에 복사해 두고 넘기세요.
std::replace_if로 조건에 맞는 값 바꾸기
람다 조건으로 치환
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수를 0으로 치환
std::replace_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }, 0);
for (int x : v) {
std::cout << x << " "; // 1 0 3 0 5 0 7 0 9 0
}
std::cout << std::endl;
}
여러 조건을 묶은 predicate
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> scores = {45, 67, 89, 34, 92, 78, 56};
// 60점 미만을 0으로
std::replace_if(scores.begin(), scores.end(),
[](int score) { return score < 60; }, 0);
std::cout << "점수: ";
for (int score : scores) {
std::cout << score << " "; // 0 67 89 0 92 78 0
}
std::cout << std::endl;
}
replace_if의 조건 함수(predicate)는 원소 하나를 받아 bool을 반환하는 호출 가능한 객체면 무엇이든 됩니다. 람다가 가장 흔하고, 기준값이 바뀌는 경우에는 [threshold](int s) { return s < threshold; }처럼 캡처로 넘깁니다. 조건 함수는 원소를 읽기만 해야 합니다. 매개변수를 int&로 받아 안에서 값을 바꾸거나 호출 횟수를 세는 등의 부수 효과를 넣으면, 표준이 조건 함수의 복사나 호출 방식을 자유롭게 허용하므로 결과를 예측하기 어려워집니다. 매개변수는 int나 const T&로 받는 것이 원칙입니다.
이 예제처럼 점수를 0으로 바꾸는 작업은 정보를 잃는 연산이라는 점도 생각해 둘 만합니다. 한 번 0이 된 점수는 원래 45점이었는지 34점이었는지 알 수 없으므로, 원래 값이 나중에 필요할 수 있다면 다음 절의 replace_copy_if로 사본에 적용하는 편이 안전합니다.
replace_copy로 원본을 두고 결과만 만들기
replace_copy
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> src = {1, 2, 3, 2, 4, 2, 5};
std::vector<int> dst;
// 복사하며 치환 (원본 유지)
std::replace_copy(src.begin(), src.end(),
std::back_inserter(dst), 2, 9);
std::cout << "원본: ";
for (int x : src) {
std::cout << x << " "; // 1 2 3 2 4 2 5 (변경 없음)
}
std::cout << std::endl;
std::cout << "복사본: ";
for (int x : dst) {
std::cout << x << " "; // 1 9 3 9 4 9 5
}
std::cout << std::endl;
}
std::back_inserter(dst)가 이 예제의 핵심입니다. _copy 계열 알고리즘은 결과를 쓸 출력 반복자만 받을 뿐 목적지 컨테이너의 크기를 늘리지 못합니다. 빈 dst에 dst.begin()을 넘기면 존재하지 않는 원소에 쓰게 되어 미정의 동작이고, 디버그 빌드의 MSVC는 “vector iterator not dereferencable” 같은 assertion으로, 릴리스 빌드에서는 조용한 메모리 오염으로 나타납니다. back_inserter는 쓸 때마다 push_back을 호출해 주므로 크기를 몰라도 안전합니다. 크기를 알고 있다면 std::vector<int> dst(src.size());로 미리 만든 뒤 dst.begin()을 넘기는 방식이 재할당이 없어 조금 더 빠릅니다.
반환값도 유용합니다. _copy 알고리즘은 마지막으로 쓴 위치의 다음을 가리키는 출력 반복자를 돌려주므로, 미리 할당한 버퍼에 쓸 때 실제로 어디까지 채워졌는지 알 수 있습니다. 또 원본과 목적지 범위가 겹치면 안 됩니다. 제자리에서 바꾸고 싶다면 _copy가 아닌 replace를 쓰세요.
replace_copy_if
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> src = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> dst;
// 짝수를 0으로 치환하며 복사
std::replace_copy_if(src.begin(), src.end(),
std::back_inserter(dst),
[](int x) { return x % 2 == 0; }, 0);
std::cout << "원본: ";
for (int x : src) {
std::cout << x << " "; // 1 2 3 4 5 6 7 8 9 10
}
std::cout << std::endl;
std::cout << "복사본: ";
for (int x : dst) {
std::cout << x << " "; // 1 0 3 0 5 0 7 0 9 0
}
std::cout << std::endl;
}
네 가지 치환 알고리즘 비교
원본 수정 여부와 조건 지원
| 알고리즘 | 원본 수정 | 조건 | 시간 복잡도 |
|---|---|---|---|
replace | ✅ | 값 비교 | O(n) |
replace_if | ✅ | Predicate | O(n) |
replace_copy | ❌ | 값 비교 | O(n) |
replace_copy_if | ❌ | Predicate | O(n) |
함수 시그니처
// 값 치환 (원본 수정)
template<class ForwardIt, class T>
void replace(ForwardIt first, ForwardIt last,
const T& old_value, const T& new_value);
// 조건 치환 (원본 수정)
template<class ForwardIt, class UnaryPredicate, class T>
void replace_if(ForwardIt first, ForwardIt last,
UnaryPredicate pred, const T& new_value);
// 복사하며 값 치환
template<class InputIt, class OutputIt, class T>
OutputIt replace_copy(InputIt first, InputIt last, OutputIt d_first,
const T& old_value, const T& new_value);
// 복사하며 조건 치환
template<class InputIt, class OutputIt, class UnaryPredicate, class T>
OutputIt replace_copy_if(InputIt first, InputIt last, OutputIt d_first,
UnaryPredicate pred, const T& new_value);
치환할 때 자주 틀리는 네 가지
원본을 바꾸는지 복사하는지 헷갈림
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 2, 4};
// replace: 원본 수정
std::replace(v.begin(), v.end(), 2, 9);
std::cout << "원본 수정: ";
for (int x : v) {
std::cout << x << " "; // 1 9 3 9 4
}
std::cout << std::endl;
// ✅ 원본 유지하려면 replace_copy
std::vector<int> v2 = {1, 2, 3, 2, 4};
std::vector<int> dst;
std::replace_copy(v2.begin(), v2.end(),
std::back_inserter(dst), 2, 9);
std::cout << "원본: ";
for (int x : v2) {
std::cout << x << " "; // 1 2 3 2 4 (변경 없음)
}
std::cout << std::endl;
}
여러 값을 한 번에 바꾸려고 replace를 반복 호출함
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
// ❌ 비효율적: 여러 번 순회
std::replace(v.begin(), v.end(), 2, 0);
std::replace(v.begin(), v.end(), 4, 0);
// ✅ 효율적: 한 번 순회
std::vector<int> v2 = {1, 2, 3, 4, 5};
std::replace_if(v2.begin(), v2.end(),
[](int x) { return x == 2 || x == 4; }, 0);
for (int x : v2) {
std::cout << x << " "; // 1 0 3 0 5
}
std::cout << std::endl;
}
std::string::replace와 혼동
#include <iostream>
#include <algorithm>
#include <string>
int main() {
std::string text = "hello world";
// std::replace (알고리즘): 문자 하나씩 치환
std::replace(text.begin(), text.end(), 'l', 'L');
std::cout << text << std::endl; // heLLo worLd
// std::string::replace (멤버 함수): 부분 문자열 치환
text.replace(0, 5, "HELLO");
std::cout << text << std::endl; // HELLO worLd
// 다른 기능!
}
이름이 같아서 가장 자주 혼동하는 부분입니다. std::string::replace(pos, len, str)는 위치와 길이로 지정한 구간을 다른 문자열로 바꾸며, 바꿀 문자열의 길이가 달라도 됩니다. 그래서 “world”를 “C++“로 바꾸는 식의 단어 치환은 이쪽으로 합니다. 다만 한 번에 한 구간만 바꾸므로, 모든 등장을 바꾸려면 find로 위치를 찾아 반복해야 합니다. 이때 흔한 버그가 바꾼 뒤 검색 위치를 새 문자열 길이만큼 옮기지 않는 것입니다. “a”를 “aa”로 바꾸면서 같은 위치부터 다시 찾으면 방금 넣은 “a”를 또 찾아 무한 루프에 빠집니다. pos += replacement.size();로 넘어가야 합니다. 정규식 패턴 치환이 필요하면 regex 글의 std::regex_replace가 편하지만, 성능이 중요한 경로에서는 std::regex가 느리다는 점도 알려져 있습니다.
치환 중 반복자는 무효화되지 않는다
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
// ✅ replace는 반복자를 무효화하지 않음
auto it = v.begin();
std::replace(v.begin(), v.end(), 3, 99);
std::cout << *it << std::endl; // 1 (여전히 유효)
// 주의: 크기 변경 연산(insert, erase)은 반복자 무효화
}
replace 계열이 반복자를 무효화하지 않는 이유는 원소에 대입만 하고 컨테이너의 구조(크기, 메모리 위치)는 건드리지 않기 때문입니다. 그래서 std::list, std::deque, 일반 배열에도 똑같이 쓸 수 있습니다. 반대로 원소가 const인 컨테이너, 즉 std::set과 std::map의 키에는 쓸 수 없습니다. 정렬 순서를 유지해야 하는 키를 제자리에서 바꾸면 트리 구조가 깨지기 때문에, 반복자가 const 원소를 가리키도록 되어 있어 “assignment of read-only location” 에러가 납니다. map의 값(second)을 바꾸고 싶다면 for (auto& [k, v] : m) 루프를 쓰는 편이 간단합니다.
transform과 replace 중 무엇을 쓸까
동작 차이
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2 = {1, 2, 3, 4, 5};
// replace: 특정 값만 치환
std::replace(v1.begin(), v1.end(), 2, 9);
std::cout << "replace: ";
for (int x : v1) {
std::cout << x << " "; // 1 9 3 4 5
}
std::cout << std::endl;
// transform: 모든 요소 변환
std::transform(v2.begin(), v2.end(), v2.begin(),
[](int x) { return x * 2; });
std::cout << "transform: ";
for (int x : v2) {
std::cout << x << " "; // 2 4 6 8 10
}
std::cout << std::endl;
}
상황별 선택
| 상황 | 사용할 알고리즘 |
|---|---|
| 특정 값을 다른 값으로 | replace |
| 조건에 맞는 값만 치환 | replace_if |
| 모든 요소를 변환 | transform |
| 원본 유지하며 치환 | replace_copy |
사실 replace_if(first, last, pred, v)는 transform(first, last, first, [&](auto x) { return pred(x) ? v : x; })와 결과가 같습니다. 그래서 둘 중 무엇을 써도 되는 상황이 많은데, 기준은 읽는 사람이 무엇을 알아야 하느냐입니다. “조건에 맞는 것만 한 값으로 바꾼다”면 replace_if가 의도를 더 정확히 전달하고, 조건에 맞지 않는 원소는 건드리지 않는다는 것이 이름으로 보장됩니다. 원소마다 계산이 다르거나 타입이 바뀐다면(vector<int>를 vector<string>으로) transform만 가능합니다. C++20 Ranges에서는 std::ranges::replace(v, 2, 9)처럼 범위를 통째로 넘길 수 있어 begin()/end()를 빠뜨리는 실수도 사라집니다.
센서 값 정제·구두점 치환·최소 점수 보장 예제
오류 값을 평균으로 치환하기
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
// 센서 데이터 (오류 값 -1 포함)
std::vector<int> sensorData = {23, -1, 25, 24, -1, 26, 23, -1};
// 오류 값을 평균값으로 치환
int validSum = 0;
int validCount = 0;
for (int value : sensorData) {
if (value != -1) {
validSum += value;
validCount++;
}
}
int average = validSum / validCount;
std::replace(sensorData.begin(), sensorData.end(), -1, average);
std::cout << "정제된 데이터: ";
for (int value : sensorData) {
std::cout << value << " "; // 23 24 25 24 24 26 23 24
}
std::cout << std::endl;
}
유효한 값의 합은 121, 개수는 5라서 평균은 24.2인데, int 나눗셈이라 소수점이 버려져 24가 됩니다. 정밀도가 중요하다면 double로 계산해야 합니다. 이 코드에는 경계 조건이 하나 빠져 있습니다. 모든 값이 -1이면 validCount가 0이 되어 validSum / validCount에서 0으로 나누기가 일어나고, 정수 나눗셈이라 대부분의 플랫폼에서 “Floating point exception”(SIGFPE)으로 프로그램이 죽습니다. 센서가 통째로 고장 난 경우처럼 실제로 일어날 수 있는 입력이므로 if (validCount == 0) 처리가 필요합니다.
결측치를 평균으로 채우는 방식 자체의 트레이드오프도 알아 둘 만합니다. 평균 대체는 간단하지만 데이터의 분산을 인위적으로 줄이고, 시계열 데이터라면 앞뒤 값과 동떨어진 값이 끼어들 수 있습니다. 시계열에서는 직전 값으로 채우거나(forward fill) 앞뒤 값의 보간을 쓰는 경우가 많은데, 이런 처리는 “이웃 원소”를 봐야 하므로 원소 하나만 보는 replace로는 표현할 수 없고 인덱스 루프가 필요합니다.
구두점을 공백으로 바꾸기
#include <iostream>
#include <algorithm>
#include <string>
int main() {
std::string text = "Hello, World! How are you?";
// 구두점을 공백으로
std::replace_if(text.begin(), text.end(),
[](char c) { return c == ',' || c == '!' || c == '?'; }, ' ');
std::cout << text << std::endl;
// Hello World How are you
// 연속된 공백 제거는 unique 사용
}
구두점을 지우지 않고 공백으로 바꾸는 이유는 replace가 문자열 길이를 바꿀 수 없기 때문입니다. 출력에 공백이 두 칸씩 생기는 것도 그래서입니다. 구두점을 아예 없애려면 remove 글의 erase-remove 관용구를, 공백으로 바꾼 뒤 연속 공백을 하나로 줄이려면 std::unique를 이어서 쓰면 됩니다. 구두점 목록을 ||로 나열하는 대신 std::ispunct(static_cast<unsigned char>(c))를 쓰면 모든 ASCII 구두점을 한 번에 처리할 수 있는데, unsigned char로 변환하지 않으면 한글 같은 멀티바이트 문자의 바이트에서 미정의 동작이 되니 주의하세요.
60점 미만을 60점으로 올리기
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> grades = {95, 45, 78, 34, 89, 67, 23, 91};
// 60점 미만을 60점으로 상향 (최소 점수 보장)
std::replace_if(grades.begin(), grades.end(),
[](int grade) { return grade < 60; }, 60);
std::cout << "조정된 점수: ";
for (int grade : grades) {
std::cout << grade << " "; // 95 60 78 60 89 67 60 91
}
std::cout << std::endl;
}
“최소값 보장”은 사실 치환보다 범위 제한에 가까운 연산이라, C++17의 std::clamp와 std::transform을 조합해 std::transform(g.begin(), g.end(), g.begin(), [](int x) { return std::clamp(x, 60, 100); });처럼 쓰면 하한과 상한을 한 번에 처리할 수 있습니다. replace_if는 조건에 맞는 원소를 같은 하나의 값으로 바꿀 때 가장 잘 맞고, 원소마다 다른 값이 필요하면 transform이 맞습니다. 이 구분은 아래 “transform과 replace 중 무엇을 쓸까”에서 다시 정리합니다.
여러 단계로 데이터 전처리하기
#include <iostream>
#include <algorithm>
#include <cmath>
#include <vector>
#include <numeric>
class DataPreprocessor {
public:
// 이상치 제거 (평균으로 치환)
static void replaceOutliers(std::vector<double>& data, double threshold) {
double mean = std::accumulate(data.begin(), data.end(), 0.0) / data.size();
std::replace_if(data.begin(), data.end(),
[mean, threshold](double x) {
return std::abs(x - mean) > threshold;
}, mean);
}
// 음수를 0으로
static void replaceNegatives(std::vector<int>& data) {
std::replace_if(data.begin(), data.end(),
[](int x) { return x < 0; }, 0);
}
// 결측치 처리
static void replaceMissing(std::vector<int>& data, int missingValue, int replacement) {
std::replace(data.begin(), data.end(), missingValue, replacement);
}
};
int main() {
// 센서 데이터
std::vector<double> temperatures = {23.5, 24.0, 100.0, 23.8, 24.2, -50.0, 24.5};
std::cout << "원본: ";
for (double t : temperatures) {
std::cout << t << " ";
}
std::cout << std::endl;
// 이상치 제거 (평균에서 50 이상 차이)
DataPreprocessor::replaceOutliers(temperatures, 50.0);
std::cout << "전처리 후: ";
for (double t : temperatures) {
std::cout << t << " ";
}
std::cout << std::endl;
}
이 예제를 실행하면 100.0과 -50.0이 모두 약 24.29로 바뀝니다. 그런데 결과가 그럴듯해 보이는 것은 운이 좋아서입니다. 평균을 구할 때 이상치 두 개가 포함되는데, 마침 +100과 -50이 서로 상쇄되어 평균이 정상 범위에 가깝게 나왔을 뿐입니다. 이상치가 100 하나뿐이었다면 평균이 약 35까지 끌려 올라가고, 그 값으로 이상치를 채우게 됩니다. 이상치를 찾는 기준 자체가 이상치에 오염되는 문제로, 실무에서는 이상치에 둔감한 중앙값(std::nth_element로 O(n)에 구할 수 있습니다)과 중앙값 절대 편차를 기준으로 쓰는 경우가 많습니다.
코드에도 두 가지 주의점이 있습니다. std::abs를 double에 쓰려면 <cmath>가 필요하며, 빠뜨리면 일부 환경에서 정수 버전 abs(int)가 선택되어 값이 잘리는 조용한 버그가 됩니다. 또 data가 비어 있으면 data.size()가 0이라 평균이 NaN(0.0/0)이 되고, NaN과의 비교는 항상 거짓이라 아무것도 바뀌지 않은 채 조용히 넘어갑니다. 빈 입력은 함수 앞에서 걸러 두는 편이 좋습니다.
치환 알고리즘 요약
- replace: 특정 값을 다른 값으로 치환 (원본 수정)
- replace_if: 조건에 맞는 값 치환
- replace_copy: 복사하며 치환 (원본 유지)
- replace_copy_if: 복사하며 조건 치환
- 시간 복잡도: 모두 O(n)
이어서 볼 STL 알고리즘
같이 보면 좋은 글
- STL 알고리즘 기본기
- C++ reverse·rotate·reverse_copy
- C++ remove·remove_if가 원소를 지우지 않는 이유: erase-remove와 C++20 erase_if
- C++ count·count_if와 all_of·any_of·none_of로 조건 집계하기
자주 묻는 질문 (FAQ)
Q. std::replace와 std::string::replace는 무엇이 다른가요?
A.