1. 소개
이미 필드마다 getName()/setName() 스타일의 코드를 본 적이 있다면, 쉽게 생각하기 쉽습니다: “음, 괜찮아, 관습이려니”. 그런데 C++ 코드에서는 getX() 대신 단순히 x() 를 쓰고, setX() 대신 moveTo(...) 같은 메서드를 쓰는 경우를 만나면, 왜 그런지 이해하기 어려울 수 있습니다.
핵심 아이디어는 다음과 같습니다: 게터와 세터는 마법이나 “성숙한 개발자의 의식”이 아니라 평범한 메서드입니다. 그들의 가치는 불변식을 유지하고 연산의 의미를 표현할 때 생깁니다. 단순히 필드에 대해 1:1 접근을 복제한다면, 인터페이스가 시끄러워지고 “캡슐화”의 환상을 만들 뿐 실제로는 아무 것도 보호하지 못하며, 클래스를 고통 없이 발전시키기 어렵게 만듭니다.
추상적이지 않게 하기 위해 학습용 콘솔 애플리케이션 TaskTracker (미니 작업 트래커)를 계속 사용하겠습니다. 이전(예: struct 단계)에는 작업이 id, title, done처럼 보였을 수 있습니다. 이제 우리는 외부에서 객체를 망가뜨리지 못하도록 하는 “스마트 객체”로 한 단계 나아가고자 합니다.
2. 게터: "읽어주기"도 책임이다
“게터”라고 들으면 보통은 “필드 값을 얻기 위한 것”을 떠올립니다. 하지만 클래스 디자인에서 게터는 단순한 읽기뿐만 아니라 외부 코드가 데이터를 어떤 형태로 보게 되는지도 책임집니다.
이 점은 생각보다 중요합니다: 동일한 사실을 안전하게 또는 위험하게, 저렴하게 또는 비싸게, 편리하게 또는 불편하게 “내어줄” 수 있습니다. C++에는 몇 가지 선택지가 있습니다: 값을 반환하거나, const T&를 반환하거나, 때때로(매우 주의해서) T&를 반환합니다. 게터에서 흔히 캡슐화에 균열이 시작되는 이유는 “좀 더 편하려고” 했던 선택이 종종 내부 제어권을 외부에 넘기는 결과로 이어지기 때문입니다.
작은 타입에 대한 간단하고 안전한 게터
int, bool 같은 작은 타입은 값으로 반환하는 것이 거의 항상 편하고 안전합니다:
class Task {
public:
int id() const { return id_; }
bool is_done() const { return done_; }
private:
int id_ = 0;
bool done_ = false;
};
여기에는 함정이 없습니다: int와 bool는 빠르게 복사되고, 내부 필드에 대한 직접 접근을 외부에 "제공"하지 않으므로 불변식이 훼손되지 않습니다.
std::string용 게터: 값 반환인가, const&인가
문자열에서는 상황이 더 흥미로워집니다. 작업에 제목이 있다면 다음과 같이 할 수 있습니다:
#include <string>
class Task {
public:
std::string title() const { return title_; } // 복사
private:
std::string title_ = "untitled";
};
이것은 안전하지만 비용이 들 수 있습니다: 문자열이 복사됩니다. 경우에 따라 괜찮습니다(짧은 문자열, 호출이 적음). 때로는 복사 없이 문자열을 읽고 싶어서 const-레퍼런스를 반환합니다:
#include <string>
class Task {
public:
const std::string& title() const { return title_; } // 복사 없음
private:
std::string title_ = "untitled";
};
이는 여전히 캡슐화에 안전합니다. const std::string&는 외부 코드가 문자열을 직접 변경하지 못하게 합니다. 그러나 중요한 뉘앙스가 생깁니다: 레퍼런스는 객체에 "묶여" 있습니다. 객체가 파괴되면 레퍼런스는 댕글링이 됩니다.
규칙은 간단히 말할 수 있습니다: const& 게터의 결과를 해당 객체보다 오래 저장하면 안 됩니다. 그 자리에 즉시 사용하는 동안에는 괜찮습니다.
T& 게터가 클래스를 자주 깨뜨리는 이유
다음은 “매우 편리해 보이는” 게터입니다:
#include <string>
class Task {
public:
std::string& title() { return title_; } // 위험함
private:
std::string title_ = "untitled";
};
이제 외부 코드는 무엇이든 할 수 있습니다:
Task t;
// 외부 코드는 변경 가능한 레퍼런스를 얻어 어떤 검사도 우회할 수 있다.
t.title().clear();
만약 불변식이 “작업의 제목은 비어 있으면 안 된다”였다면, 그 불변식은 지금 깨졌습니다. 형식적으로 클래스는 private 필드로 "캡슐화"되어 있지만, 실제로는 내부에 대한 직접적인 제어권을 외부에 내주었습니다. 이것을 흔히 “캡슐화의 누수”라 부릅니다.
작은 표: 게터에서 반환 타입을 선택하는 방법
클래스를 작성할 때 간단한 지침을 갖고 있으면 유용합니다. 이것은 “법칙”이 아니라, 매번 메서드를 고민할 때 도움이 되는 상식입니다.
| 무엇을 반환할까 | 언제 적절한가 | 얻는 이점 | 무엇이 잘못될 수 있나 |
|---|---|---|---|
| T (값으로) | 작은 타입(int, bool)이거나 복사가 문제되지 않을 때 | 단순함과 안전성 | 큰 객체에는 비용이 들 수 있음 |
| const T& | 큰 타입(std::string, std::vector)이고 자주 읽을 때 | 복사 없음, 여전히 안전함 | 레퍼런스를 객체 수명보다 오래 보관하면 안 됨 |
| T& | 드물게: 외부 코드가 내부를 변경하도록 의도적으로 허용할 때 | 최대한의 "편의성" | 거의 항상 불변식과 디자인을 깨뜨림 |
주의하세요: C++에서 게터는 API의 일부입니다. 한 번 T&를 노출하면, 사용자에게 내부를 직접 변경해도 된다고 거의 약속한 셈이 됩니다. 나중에 검사를 추가하고 싶어도, 이미 T&를 통해 우회되고 있을 수 있습니다.
3. 세터와 연산: 불변식을 잃지 않고 상태를 변경하는 법
세터는 간단해 보입니다: “필드에 값을 할당”하면 됩니다. 하지만 정상적인 클래스에서 세터는 통제 지점입니다: 여기서 변경이 허용되는지, 데이터를 정규화할지, 거부할지, 불변식을 어떻게 유지할지를 결정합니다.
주요 기준은 다음과 같습니다: 세터는 규칙과 의미를 표현할 때 정당화됩니다. 만약 단지 field_ = value; 만 한다면, 그것은 종종 “공개 필드가 코드 몇 줄의 오버헤드로 감싸져 있는 것”에 불과합니다.
규칙 없는 세터: 형식적으로는 있지만 실질적으로는 아무것도 보호하지 못함
#include <string>
class TaskBad {
public:
void set_title(const std::string& title) {
title_ = title; // 검사가 없음
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
여기서 문제는 무엇일까요? "접근을 통제한다"고 선언했지만 실제로는 통제하지 않습니다. 외부 코드는 빈 문자열이나 공백만 있는 문자열, 너무 긴 문자열 등 아무 값이나 넣을 수 있습니다. 클래스는 스스로를 보호하지 못합니다.
검사가 있는 세터: "성격 있는" 세터
불변식으로: 작업의 제목은 비어 있으면 안 된다.
#include <string>
class Task {
public:
bool set_title(const std::string& title) {
if (title.empty()) return false;
title_ = title;
return true;
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
이제 세터는 실제로 유용합니다: 단순히 할당하지 않고 객체가 부적절해지지 않도록 합니다.
스타일에 주목하세요: 우리는 외부 코드가 성공했는지 볼 수 있도록 bool을 반환합니다. 예외를 사용하지 않았습니다(이것은 별도의 큰 주제입니다). 따라서 "정중한 실패"를 반환하는 것은 정상적인 관행입니다.
왜 "모든 것에 get/set"이 클래스를 빈약하게 만드는가
초보자가 가장 자주 하는 실수는 이렇게 말합니다: “클래스가 있고 필드가 private 이니까, 필드마다 get/set을 만들자”. 그럴듯해 보이지만, 만약 set_* 안에 로직과 불변식 보호가 없다면, 클래스는 단지 소음이 많은 struct의 변형이 됩니다.
의미는 추가하지 않으면서 책임은 늘어납니다: 외부 코드는 수십 개의 작은 메서드에 의존하게 되고 내부 표현을 바꾸기가 어려워집니다.
예를 들어 작업에 우선순위가 1에서 5까지 있다고 합시다. 검사 없이 set_priority(int p)를 만들면, -100이나 999를 설정할 수 있게 됩니다. 프로그램 전반에 걸쳐 if (task.priority() < 1) ... 같은 임시방편이 생기고, 클래스는 더 이상 자신의 올바름을 책임지지 못하게 됩니다.
세터 대신 연산-메서드
습관적으로 세터를 쓰려 할 때, 잠깐 멈춰서 스스로에게 물어보세요: “이건 정말로 필드를 설정하는 것인가, 아니면 하나의 연산인가?” 많은 경우 연산-메서드를 제공하는 쪽이 더 가독성 좋고 안전합니다.
예를 들어 set_done(true) 대신 보통은 다음이 더 낫습니다:
- mark_done()
- reopen()
이것은 명령으로 읽히며, 어떤 값이 무엇을 의미하는지 기억할 필요가 없고 내부에서 불변식을 유지하기 쉽습니다.
4. 실전 예: 불필요한 get/set 없이 TaskTracker 개선하기
이제 우리 TaskTracker를 위해 좀 더 현실적인 조각을 모아봅시다. 작업은 다음을 가집니다:
- id (생성 이후 변경되지 않음),
- title (비어 있지 않음),
- done (연산-메서드를 통해 변경됨),
- priority (1..5).
최소한의 "올바른" Task 클래스
#include <string>
class Task {
public:
Task(int id, const std::string& title, int priority)
: id_(id), title_(title), priority_(priority) {
// 이 강의에서는 생성자를 깊게 다루지 않지만, 아이디어는 이미 보셨습니다.
// 검사는 여기서도 할 수 있지만, 현재는 API에 초점을 맞춥니다.
if (title_.empty()) title_ = "untitled";
if (priority_ < 1) priority_ = 1;
if (priority_ > 5) priority_ = 5;
}
int id() const { return id_; }
const std::string& title() const { return title_; }
int priority() const { return priority_; }
bool is_done() const { return done_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
bool set_priority(int p) {
if (p < 1 || p > 5) return false;
priority_ = p;
return true;
}
void mark_done() { done_ = true; }
void reopen() { done_ = false; }
private:
int id_ = 0;
std::string title_ = "untitled";
int priority_ = 1; // 불변식: 1..5
bool done_ = false;
};
여기서 주목할 점: 우리는 set_id를 만들지 않았다 — 왜냐하면 id는 작업의 정체성(identity)이기 때문입니다. 외부 코드가 id를 변경할 수 있다면 곧 혼란이 옵니다: "어제 #3이던 작업이 오늘은 #17" 같은 상황이 발생합니다. 때로는 변경 가능해야 할 때도 있지만, 그건 더 이상 'id'가 아니라 '목록에서의 번호' 같은 별개의 개념입니다.
작게 main()에서 클래스 사용하기
#include <iostream>
#include <string>
int main() {
Task t(1, "Buy milk", 3);
std::cout << t.id() << ": " << t.title() << "\n"; // 1: Buy milk
t.mark_done();
std::cout << t.is_done() << "\n"; // 1
if (!t.rename("")) {
std::cout << "Bad title!\n"; // Bad title!
}
}
예, 이는 필드에 직접 접근하는 것보다 약간 길게 보일 수 있습니다. 하지만 이제 "올바름"은 프로그램 전체에 흩어져 있지 않고 Task 내부에 집중되어 있습니다.
5. 캡슐화 누수: 무해해 보이는 예
가끔은 "빠르게" 또는 "편하게" 하려는 유혹이 있습니다: 예를 들어 작업의 제목에 대한 변경 가능한 레퍼런스를 노출하여 직접 편집하게 하는 것. 불변식 관점에서 이것이 왜 위험한지 보여드리겠습니다.
나쁜 예:
#include <string>
class TaskLeaky {
public:
std::string& title() { return title_; } // 제어권을 외부에 넘김
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled"; // 불변식: 비어 있지 않아야 함
};
사용 예:
#include <iostream>
int main() {
TaskLeaky t;
t.title().clear(); // 불변식이 깨짐
std::cout << "[" << t.title() << "]\n"; // []
}
이제 클래스는 "비어 있지 않음"이라는 규칙을 보장할 수 없습니다. 외부에 뚫린 우회 경로가 그 원인입니다.
올바른 예 — 읽기만 허용하고 변경은 메서드로:
#include <string>
class TaskSafe {
public:
const std::string& title() const { return title_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
private:
std::string title_ = "untitled";
};
이제 변경은 오직 제어 지점인 rename을 통해서만 가능하므로 검사를 포함할 수 있습니다.
6. 다이어그램: 클래스 인터페이스가 상태를 어떻게 보호하는가
개념을 "눈으로" 보는 것이 도움이 될 때가 있습니다:
flowchart TD
A[외부 코드] -->|공개 메서드 호출| B[클래스의 공개 인터페이스]
B -->|검사와 규칙| C[프라이빗 필드]
A -->|만약 T& 를 외부에 줬다면| C
의미는 간단합니다: 상태 변경의 정상 경로는 공개 인터페이스를 통해 이루어지며, 그곳에서 검사가 존재합니다. 그러나 내부에 대한 변경 가능한 레퍼런스(T&)를 외부에 제공하면 외부 코드는 "직접" 접근하게 되고, 여러분의 규칙은 무의미해집니다.
7. 게터와 세터의 이름: getTitle() vs title()
코드베이스마다 스타일이 다릅니다. 교육용 예제에서는 종종 title() / priority() / is_done() 스타일을 사용합니다 — 짧고 "속성"처럼 읽힙니다. getTitle() 스타일도 잘못된 것은 아니고, 팀 합의가 있다면 괜찮습니다.
더 중요한 것은 메서드 이름이 의미를 반영해야 한다는 점입니다. 만약 메서드가 내부에서 문자열을 정규화(공백 제거, 빈 문자열을 "untitled"로 바꿈 등)한다면, 이를 set_title이라고 부르는 것보다 rename이나 try_set_title 같은 이름이 더 의미에 맞을 수 있습니다.
"set"이라고 이름 붙여놓고 내부에서 "없으면 추가" 같은 동작을 하면 오해의 소지가 큽니다.
게터와 세터가 정말 적절한 경우
게터와 세터가 훌륭한 도구인 상황도 있으며, 이를 악마화할 필요는 없습니다.
필드가 private이고 안전한 읽기가 필요하면 게터는 거의 항상 정당화됩니다. 세터는 필드에 대해 분명한 "설정 모드"와 올바름 규칙이 있을 때 정당화됩니다. 우리의 Task에서는 set_priority가 정당화됩니다. 왜냐하면 "우선순위"는 명확한 범위가 있는 조정 가능한 값이기 때문입니다. 반면 done에 대해서는 mark_done/reopen 같은 연산을 선택했는데, 이는 읽기 쉽고 의미를 혼동할 여지가 적기 때문입니다.
8. 전형적인 실수
실수 #1: "필드마다 get과 set을 만든다, 그게 관습이니까".
이 접근 방식은 클래스가 시끄럽고 무의미해지기 쉽습니다: 불변식은 보호되지 않고 로직도 없으며, 외부 코드는 많은 작은 메서드에 의존하게 됩니다. 규칙과 의미를 추가하지 않는다면, 차라리 struct로 두거나, 진짜 의미 있는 연산과 검사를 갖춘 "진짜" 클래스를 만드는 편이 낫습니다.
실수 #2: 검사가 없는 세터로 부적절한 값을 통과시키는 것.
메서드 이름이 set_*라면, 호출자는 그 이후 객체가 여전히 올바를 것이라고 기대합니다. 만약 아무 값이나 기록할 수 있게 허용했다면, 이후 검사들이 프로젝트 곳곳에 흩어지게 되고 클래스는 더 이상 불변식의 수호자가 아닙니다. 무엇보다 "조용히 거부하지 않고 쓰레기를 받아들이는" 것보다는 거부(예: false 반환)하는 것이 낫습니다.
실수 #3: "편해서" T&를 반환하는 게터.
이것은 전형적인 캡슐화 누수입니다: 외부 코드는 여러분의 규칙 없이 내부를 변경할 수 있습니다. 특히 std::string&나 std::vector<T>&를 내주는 것은 위험합니다. 외부 쪽에서 컨테이너를 비우거나 이상한 요소를 추가하거나 필드 간의 관계를 깨뜨릴 수 있습니다. 복사 없이 읽기만 필요하면 const T&를 반환하세요. 변경이 필요하면, 검사를 포함한 의미 있는 연산 메서드를 만드세요.
실수 #4: 객체의 "정체성"을 변경하는 세터.
set_id(...) 같은 메서드는 처음에는 괜찮아 보일 수 있지만, 객체를 컨테이너에 저장하고 id로 찾거나 보고서를 출력하거나 데이터를 연결하는 상황에서는 일관성을 깨뜨립니다. 대체로 정체성은 생성 시 결정되고 이후 변경하지 않는 것이 일반적입니다.
실수 #5: 게터가 레퍼런스를 반환하고 호출자가 그것을 너무 오래 보관하는 경우.
const std::string& 반환은 편리하고 빠르지만, 레퍼런스는 객체가 살아 있는 동안에만 유효합니다. 어디선가 그 레퍼런스를 저장했다가 객체가 파괴되거나 교체되면, 이미 존재하지 않는 데이터에 접근하는 상황이 됩니다. 학습 예제에서는 규칙을 지키는 편이 쉬운데: 게터에서 받은 레퍼런스는 바로 사용하고 "나중에 저장"할 필요가 있으면 복사본을 만드세요.
GO TO FULL VERSION