C++ Proxy 패턴: 가상·보호·캐싱 프록시로 지연 로딩과 접근 제어 구현하기
이 글의 핵심
Proxy 패턴은 실제 객체와 같은 인터페이스를 가진 대리 객체를 앞에 세워 생성 시점, 접근 권한, 호출 결과를 가로채는 방법입니다. 구조가 Decorator와 비슷해 헷갈리기 쉬운데, 기능을 덧붙이는 것이 아니라 접근을 통제한다는 목적이 다릅니다. 프록시가 여러 겹 쌓일 때의 문제와 소유권을 잘못 설계해 생기는 메모리 누수, 호출 간접화에 따른 오버헤드도 함께 짚습니다.
실제 객체 앞에 대리 객체를 두는 이유
쓰지도 않을 이미지를 생성 시점에 읽을 때
문제: 이미지를 로드하는 객체가 생성 시점에 디스크에서 읽어 느립니다. 실제로 사용하지 않으면 낭비입니다.
// 나쁜 예: 즉시 로딩
class RealImage {
public:
RealImage(const std::string& filename) {
loadFromDisk(filename); // 생성 시점에 로딩 (느림)
}
};
RealImage image("huge.jpg"); // 사용 안 해도 로딩
해결: Proxy Pattern은 대리 객체를 제공합니다. Proxy는 Real Subject와 같은 인터페이스를 가지며, 지연 로딩, 접근 제어, 캐싱 등을 추가합니다. JavaScript의 Proxy·데코레이터와 비교해 보면 역할이 잡힙니다. 구조 패턴 시리즈에서도 같은 카테고리로 묶여 있습니다.
// 좋은 예: 지연 로딩
// 타입 정의
class ImageProxy : public Image {
std::unique_ptr<RealImage> realImage;
public:
void display() override {
if (!realImage) {
realImage = std::make_unique<RealImage>("huge.jpg"); // 사용 시점에 로딩
}
realImage->display();
}
};
flowchart LR
client[Client]
subject["Subject (Image)"]
proxy["Proxy (ImageProxy)"]
real["RealSubject (RealImage)"]
client --> subject
proxy -.implements.-> subject
real -.implements.-> subject
proxy --> real
이 구조의 핵심은 클라이언트가 Image 인터페이스만 안다는 점입니다. 클라이언트 코드는 자기가 받은 것이 RealImage인지 ImageProxy인지 모르고 display()만 호출하므로, 나중에 프록시를 끼워 넣거나 빼도 클라이언트를 고칠 필요가 없습니다. 반대로 말하면, 프록시가 인터페이스를 공유하지 않고 ImageLoader::getImage() 같은 별도 API를 제공한다면 그것은 프록시라기보다 팩토리나 서비스 객체에 가깝습니다. “같은 인터페이스로 끼워 넣을 수 있는가”가 Proxy 패턴의 판별 기준입니다.
가상 프록시로 처음 쓸 때까지 로딩 미루기
#include <iostream>
#include <memory>
#include <string>
class Image {
public:
virtual void display() = 0;
virtual ~Image() = default;
};
class RealImage : public Image {
public:
RealImage(const std::string& filename) : filename_(filename) {
loadFromDisk();
}
void display() override {
std::cout << "Displaying " << filename_ << '\n';
}
private:
std::string filename_;
void loadFromDisk() {
std::cout << "Loading " << filename_ << " from disk (expensive)\n";
}
};
class ImageProxy : public Image {
public:
ImageProxy(const std::string& filename) : filename_(filename) {}
void display() override {
if (!realImage_) {
std::cout << "[Proxy] Creating real image\n";
realImage_ = std::make_unique<RealImage>(filename_);
}
realImage_->display();
}
private:
std::string filename_;
std::unique_ptr<RealImage> realImage_;
};
int main() {
std::unique_ptr<Image> image = std::make_unique<ImageProxy>("photo.jpg");
std::cout << "Image proxy created (not loaded yet)\n\n";
std::cout << "First display:\n";
image->display();
std::cout << "\nSecond display:\n";
image->display(); // 이미 로딩됨
}
출력:
Image proxy created (not loaded yet)
First display:
[Proxy] Creating real image
Loading photo.jpg from disk (expensive)
Displaying photo.jpg
Second display:
Displaying photo.jpg
가상 프록시는 “만들 수도 있지만 안 쓸 수도 있는” 무거운 객체가 많을 때 효과가 큽니다. 문서 편집기가 수백 쪽짜리 문서의 이미지를 모두 열자마자 디코딩하지 않고, 화면에 보이는 쪽의 이미지만 실제로 로드하는 것이 대표적인 예입니다. 프록시 객체 자체는 파일 이름 문자열 하나만 가지므로 수백 개를 만들어도 부담이 없습니다.
대가는 비용이 발생하는 시점이 예측하기 어려워진다는 것입니다. 로딩 비용이 생성자에서 첫 display() 호출로 옮겨 가므로, 사용자가 처음 스크롤하는 순간 화면이 멈칫하는 식으로 지연이 드러납니다. 또 파일이 없거나 손상된 경우의 에러도 생성 시점이 아니라 첫 사용 시점에 나서, 에러를 처리할 코드가 엉뚱한 곳에 있게 됩니다. 이 예제의 display()는 여러 스레드에서 동시에 호출되면 두 스레드가 모두 !realImage_를 보고 이미지를 두 번 로드하는 경쟁이 생기므로, 멀티스레드 환경이라면 std::call_once나 mutex로 초기화를 보호해야 합니다.
보호 프록시로 권한 검사하기
#include <iostream>
#include <memory>
#include <string>
class Document {
public:
virtual void read() = 0;
virtual void write(const std::string& content) = 0;
virtual ~Document() = default;
};
class RealDocument : public Document {
public:
void read() override {
std::cout << "Reading document: " << content_ << '\n';
}
void write(const std::string& content) override {
content_ = content;
std::cout << "Writing document: " << content << '\n';
}
private:
std::string content_;
};
class ProtectedDocumentProxy : public Document {
public:
ProtectedDocumentProxy(const std::string& user)
: user_(user), doc_(std::make_unique<RealDocument>()) {}
void read() override {
std::cout << "[Proxy] Checking read permission for " << user_ << '\n';
doc_->read();
}
void write(const std::string& content) override {
std::cout << "[Proxy] Checking write permission for " << user_ << '\n';
if (user_ == "admin") {
doc_->write(content);
} else {
std::cout << "[Proxy] Access denied: " << user_ << " cannot write\n";
}
}
private:
std::string user_;
std::unique_ptr<RealDocument> doc_;
};
int main() {
std::unique_ptr<Document> adminDoc = std::make_unique<ProtectedDocumentProxy>("admin");
adminDoc->write("Secret data");
adminDoc->read();
std::cout << '\n';
std::unique_ptr<Document> userDoc = std::make_unique<ProtectedDocumentProxy>("guest");
userDoc->write("Attempt to write");
userDoc->read();
}
보호 프록시는 권한 검사를 RealDocument 밖으로 빼내어, 문서 클래스는 읽기·쓰기라는 본래 책임만 갖게 합니다. 권한 정책이 바뀌어도 문서 코드를 건드릴 필요가 없고, 테스트에서는 프록시 없이 RealDocument를 직접 검증할 수 있습니다.
이 예제에는 실무에서 조심해야 할 점이 몇 가지 보입니다. read()는 “권한을 확인한다”는 로그만 찍고 실제로는 아무것도 확인하지 않는데, 이런 코드가 리뷰를 통과해 “읽기는 누구나 가능”이 의도치 않은 정책이 되는 경우가 있습니다. 또 거부를 로그 출력으로만 알리면 호출자는 쓰기가 실패했는지 알 수 없습니다. 인터페이스가 void를 반환하므로 예외(PermissionDenied)를 던지거나 결과 타입을 바꿔야 호출자가 실패에 대응할 수 있습니다. 가장 중요한 것은 프록시를 우회할 수 없어야 보호가 의미 있다는 점입니다. 클라이언트가 RealDocument를 직접 만들 수 있다면 보호 프록시는 권장 사항에 불과하므로, 실제 객체의 생성자를 감추고 팩토리에서만 프록시로 감싸 내주는 구조가 필요합니다.
캐싱 프록시로 느린 조회 결과 재사용하기
#include <iostream>
#include <memory>
#include <string>
#include <map>
#include <chrono>
#include <thread>
class DataService {
public:
virtual std::string getData(const std::string& key) = 0;
virtual ~DataService() = default;
};
class RealDataService : public DataService {
public:
std::string getData(const std::string& key) override {
std::cout << "[Real] Fetching data for " << key << " (slow)\n";
std::this_thread::sleep_for(std::chrono::seconds(2)); // 느린 작업
return "Data for " + key;
}
};
class CachingProxy : public DataService {
public:
CachingProxy() : service_(std::make_unique<RealDataService>()) {}
std::string getData(const std::string& key) override {
auto it = cache_.find(key);
if (it != cache_.end()) {
std::cout << "[Proxy] Cache hit for " << key << '\n';
return it->second;
}
std::cout << "[Proxy] Cache miss for " << key << '\n';
std::string data = service_->getData(key);
cache_[key] = data;
return data;
}
private:
std::unique_ptr<RealDataService> service_;
std::map<std::string, std::string> cache_;
};
int main() {
std::unique_ptr<DataService> service = std::make_unique<CachingProxy>();
std::cout << service->getData("user123") << "\n\n";
std::cout << service->getData("user123") << "\n\n"; // 캐시됨
std::cout << service->getData("user456") << '\n';
}
캐싱 프록시는 느린 작업의 결과를 저장해 같은 요청을 빠르게 돌려줍니다. 이 구현은 동작 원리를 보여 주기 위한 최소 형태라서, 실제로 쓰려면 세 가지를 더 결정해야 합니다.
- 만료: 원본 데이터가 바뀌어도 캐시는 영원히 예전 값을 돌려줍니다. 항목별 저장 시각을 두고 TTL이 지나면 다시 가져오거나, 데이터가 바뀔 때 캐시를 무효화하는 경로가 필요합니다.
- 크기 제한:
std::map에 키가 계속 쌓이므로 키 종류가 많으면 메모리가 끝없이 늘어납니다. LRU처럼 오래 쓰지 않은 항목을 버리는 정책이 필요합니다. - 동시성: 여러 스레드가
getData를 호출하면cache_에 대한 데이터 경쟁이 생깁니다. 또 같은 키에 대한 캐시 미스가 동시에 여러 번 일어나면 느린 작업이 중복 실행되는데(cache stampede), 첫 요청만 실제로 가져오고 나머지는 그 결과를 기다리게 하려면std::shared_future를 캐시에 넣는 방식을 씁니다.
또 CachingProxy가 생성자에서 RealDataService를 직접 만드는 것도 개선할 부분입니다. std::unique_ptr<DataService>를 생성자 인자로 받으면 테스트용 가짜 서비스나 다른 프록시를 주입할 수 있어 조합이 유연해집니다.
스마트 포인터도 프록시다
#include <iostream>
#include <memory>
class Resource {
public:
Resource() { std::cout << "Resource created\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
void use() { std::cout << "Using resource\n"; }
};
// 참조 카운팅 프록시
template<typename T>
class RefCountProxy {
public:
RefCountProxy(T* ptr) : ptr_(ptr), refCount_(new int(1)) {}
RefCountProxy(const RefCountProxy& other)
: ptr_(other.ptr_), refCount_(other.refCount_) {
++(*refCount_);
std::cout << "RefCount: " << *refCount_ << '\n';
}
~RefCountProxy() {
--(*refCount_);
std::cout << "RefCount: " << *refCount_ << '\n';
if (*refCount_ == 0) {
delete ptr_;
delete refCount_;
}
}
T* operator->() { return ptr_; }
// 대입은 이 예제에서 지원하지 않음 (규칙 3: 기본 대입이면 이중 해제 발생)
RefCountProxy& operator=(const RefCountProxy&) = delete;
private:
T* ptr_;
int* refCount_;
};
int main() {
RefCountProxy<Resource> proxy1(new Resource());
proxy1->use();
{
RefCountProxy<Resource> proxy2 = proxy1;
proxy2->use();
}
proxy1->use();
}
스마트 포인터는 operator->를 오버로딩해 원래 포인터와 같은 방식으로 쓸 수 있게 하면서, 그 뒤에서 수명 관리를 가로채는 프록시입니다. proxy1->use()는 proxy1.operator->()->use()로 해석되어, 프록시가 돌려준 원시 포인터로 호출이 이어집니다.
이 예제의 RefCountProxy는 원리를 보여 주는 용도이며, 직접 이런 클래스를 만드는 것은 권하지 않습니다. 복사 생성자와 소멸자만 정의하고 복사 대입을 정의하지 않으면 컴파일러가 만든 기본 대입이 포인터를 그대로 복사해, 두 객체가 같은 카운터를 줄이며 이중 해제가 일어납니다. 그래서 위 코드에는 대입을 명시적으로 삭제했습니다. 이 밖에도 카운터가 int라 여러 스레드에서 복사하면 경쟁이 생기고, 이동 연산이 없어 불필요한 증감이 일어나며, 생성자에서 new int(1)이 실패하면 ptr이 새어 나갑니다. std::shared_ptr은 이 모든 문제를 원자적 카운터와 제어 블록으로 해결해 두었으므로 실무에서는 표준 스마트 포인터를 씁니다.
프록시 체인과 Real Subject 누수
프록시를 여러 겹 감싸 성능이 떨어짐
증상: 성능 저하. 원인: Proxy가 Proxy를 감싸면 간접 참조가 증가.
// ❌ 잘못된 사용: Proxy 체인
ImageProxy proxy1("image.jpg");
ImageProxy proxy2(proxy1); // Proxy of Proxy
// ✅ 해결: Proxy는 Real Subject만 감싸기
위 코드는 개념을 보여 주기 위한 의사 코드입니다. 실제로 앞의 ImageProxy는 std::unique_ptr 멤버 때문에 복사 생성자가 삭제되어 있어 ImageProxy proxy2(proxy1);은 컴파일되지 않고, 설령 된다 해도 프록시를 감싼 것이 아니라 복사한 것입니다. 프록시가 겹치는 현실적인 형태는 std::make_unique<CachingProxy>(std::make_unique<AuthProxy>(std::make_unique<LoggingProxy>(real)))처럼 각 프록시가 Subject 인터페이스를 감싸는 경우입니다.
체인에서 진짜 문제는 간접 호출 몇 번의 비용이 아니라 순서와 가시성입니다. 캐싱 프록시가 인증 프록시보다 바깥에 있으면, 관리자가 조회해 캐시에 들어간 결과를 권한 없는 사용자가 인증 검사 없이 캐시에서 받아 가게 됩니다. 이런 버그는 각 프록시를 따로 테스트하면 모두 정상이라 찾기 어렵습니다. 프록시를 여러 겹 쓴다면 조립 순서를 한 곳(팩토리)에 고정하고, 순서가 보안에 영향을 주는지 명시해 두는 것이 좋습니다.
Real Subject가 해제되지 않음
증상: Real Subject가 해제되지 않음. 원인: Proxy가 raw pointer로 관리.
// ❌ 잘못된 사용
class Proxy {
RealSubject* real; // 누가 delete?
};
// ✅ 올바른 사용
class Proxy {
std::unique_ptr<RealSubject> real;
};
소유권은 프록시가 실제 객체를 만들었는지 받았는지로 정합니다. 가상 프록시처럼 프록시가 직접 생성하는 경우는 unique_ptr로 소유하는 것이 자연스럽습니다. 반면 이미 다른 곳에서 관리하는 객체 앞에 프록시를 세우는 경우라면 참조나 원시 포인터로 빌려 쓰고 소유하지 않는 것이 맞고, 이때는 실제 객체가 프록시보다 먼저 파괴되지 않는다는 보장이 필요합니다. 여러 프록시가 같은 실제 객체를 공유한다면 shared_ptr을 쓰되, 실제 객체가 프록시를 다시 참조하는 순환이 생기지 않도록 주의해야 합니다.
Copy-on-Write 프록시
#include <memory>
#include <string>
#include <iostream>
class String {
public:
String(const std::string& s) : data_(std::make_shared<std::string>(s)) {}
// 읽기: 공유
char operator[](size_t i) const {
return (*data_)[i];
}
// 쓰기: 복사
void set(size_t i, char c) {
if (data_.use_count() > 1) {
std::cout << "Copy-on-write triggered\n";
data_ = std::make_shared<std::string>(*data_);
}
(*data_)[i] = c;
}
void print() const {
std::cout << *data_ << '\n';
}
private:
std::shared_ptr<std::string> data_;
};
int main() {
String s1("Hello");
String s2 = s1; // 공유
s1.print(); // Hello
s2.print(); // Hello
s2.set(0, 'J'); // Copy-on-write
s1.print(); // Hello
s2.print(); // Jello
}
Copy-on-Write는 “복사는 싸게, 실제 복사는 쓰기 직전에”를 구현하는 프록시입니다. String s2 = s1은 shared_ptr만 복사하므로 문자열 길이와 상관없이 비용이 일정하고, set()이 호출되어 공유 중인 데이터를 바꿔야 할 때만 자기 사본을 만듭니다.
흥미로운 점은 C++11 표준이 std::string의 COW 구현을 사실상 금지했다는 것입니다. GCC의 libstdc++는 예전에 COW 문자열을 썼지만, operator[]가 쓰기 가능한 참조를 반환하면 그 참조가 살아 있는 동안 공유 여부를 추적할 수 없고, 멀티스레드 환경에서는 모든 복사와 해제에 원자적 카운터 연산이 필요해 오히려 느려지는 문제가 있었습니다. 이 예제가 쓰기를 operator[] 대신 set()으로 분리한 것도 그 때문입니다. 또 use_count()는 다른 스레드가 동시에 복사하거나 파괴하는 중에는 정확한 값을 보장하지 않으므로, 이 방식의 COW는 단일 스레드에서만 안전합니다. 현대 C++에서 COW는 문자열보다는 불변 데이터 구조나 큰 설정 객체를 공유할 때 주로 쓰입니다.
데이터베이스 연결 프록시 만들기
#include <iostream>
#include <memory>
#include <string>
#include <map>
#include <chrono>
class DatabaseConnection {
public:
virtual std::string query(const std::string& sql) = 0;
virtual ~DatabaseConnection() = default;
};
class RealDatabaseConnection : public DatabaseConnection {
public:
RealDatabaseConnection(const std::string& host) : host_(host) {
connect();
}
std::string query(const std::string& sql) override {
std::cout << "[DB] Executing: " << sql << '\n';
return "Result of " + sql;
}
~RealDatabaseConnection() {
std::cout << "[DB] Disconnecting from " << host_ << '\n';
}
private:
std::string host_;
void connect() {
std::cout << "[DB] Connecting to " << host_ << " (expensive)\n";
}
};
class DatabaseProxy : public DatabaseConnection {
public:
DatabaseProxy(const std::string& host, const std::string& user)
: host_(host), user_(user) {}
std::string query(const std::string& sql) override {
// 접근 제어
if (!checkPermission(sql)) {
return "Access denied";
}
// 지연 로딩
if (!conn_) {
std::cout << "[Proxy] Creating connection\n";
conn_ = std::make_unique<RealDatabaseConnection>(host_);
}
// 캐싱
auto it = cache_.find(sql);
if (it != cache_.end()) {
std::cout << "[Proxy] Cache hit\n";
return it->second;
}
// 로깅
std::cout << "[Proxy] User " << user_ << " executing query\n";
std::string result = conn_->query(sql);
cache_[sql] = result;
return result;
}
private:
std::string host_;
std::string user_;
std::unique_ptr<RealDatabaseConnection> conn_;
std::map<std::string, std::string> cache_;
bool checkPermission(const std::string& sql) {
if (sql.find("DELETE") != std::string::npos && user_ != "admin") {
std::cout << "[Proxy] Permission denied for " << user_ << '\n';
return false;
}
return true;
}
};
int main() {
std::unique_ptr<DatabaseConnection> db =
std::make_unique<DatabaseProxy>("localhost:5432", "guest");
std::cout << "=== Query 1 ===\n";
std::cout << db->query("SELECT * FROM users") << "\n\n";
std::cout << "=== Query 2 (cached) ===\n";
std::cout << db->query("SELECT * FROM users") << "\n\n";
std::cout << "=== Query 3 (denied) ===\n";
std::cout << db->query("DELETE FROM users") << "\n\n";
}
이 예제는 한 프록시에 접근 제어, 지연 연결, 캐싱, 로깅을 모두 넣었습니다. 흐름을 보여 주기에는 좋지만, 코드 순서가 곧 정책이라는 점을 눈여겨봐야 합니다. 권한 검사가 연결보다 먼저 있으므로 거부될 요청 때문에 불필요하게 DB에 연결하지 않고, 캐시 확인이 권한 검사보다 뒤에 있으므로 권한 없는 사용자가 캐시로 우회하지 못합니다. 순서를 바꾸면 성능이나 보안 특성이 바뀝니다.
실제 코드로 옮길 때는 문제가 되는 부분도 분명히 짚어 두겠습니다. 첫째, sql.find("DELETE")로 권한을 판단하는 것은 보안 검사로 쓸 수 없습니다. delete from users처럼 소문자로 쓰거나, DROP TABLE, UPDATE처럼 다른 파괴적 명령을 쓰면 그대로 통과합니다. 권한은 SQL 문자열이 아니라 DB 계정 권한이나 “어떤 작업을 하는가”라는 의미 단위(예: deleteUser(id) 메서드)로 검사해야 합니다. 둘째, 모든 쿼리를 SQL 문자열을 키로 캐시하므로 INSERT나 UPDATE 결과까지 캐시되고, 데이터가 바뀐 뒤에도 SELECT 결과가 예전 값으로 남습니다. 캐시는 읽기 쿼리에만, 만료 시간과 함께 적용해야 합니다. 셋째, 이 프록시는 연결을 하나만 만들어 계속 쓰므로 연결이 끊겼을 때 재연결하는 로직이 없습니다. 실무에서 이 역할은 보통 커넥션 풀이 맡습니다. 제가 이런 “만능 프록시”를 코드 리뷰에서 만나면 가장 먼저 권하는 것도 책임별로 프록시를 나누고 조립 순서를 한 곳에서 명시하는 것입니다.
Proxy 패턴 요약
| 개념 | 설명 |
|---|---|
| Proxy Pattern | 대리 객체로 접근 제어 |
| 목적 | 지연 로딩, 접근 제어, 캐싱, 로깅 |
| 구조 | Subject, RealSubject, Proxy |
| 장점 | 성능 최적화, 보안, 투명성 |
| 단점 | 간접 참조, 복잡도 증가 |
| 사용 사례 | 스마트 포인터, 원격 객체, 캐싱, 보안 |
Proxy Pattern은 객체 접근을 제어하는 강력한 패턴입니다.
FAQ
Q1: Proxy Pattern은 언제 쓰나요?
A: 지연 로딩, 접근 제어, 캐싱, 로깅이 필요할 때 사용합니다.
Q2: Decorator와 차이는?
A: Proxy는 접근 제어, Decorator는 기능 추가에 집중합니다.
Q3: 가상 프록시 vs 보호 프록시?
A: 가상 프록시는 지연 로딩, 보호 프록시는 접근 제어에 집중합니다.
Q4: 스마트 포인터는 Proxy인가요?
A: 네, shared_ptr은 참조 카운팅 프록시입니다.
Q5: 성능 오버헤드는?
A: 호출마다 가상 함수 호출과 간접 참조가 한 번 더 붙습니다. 대부분의 경우 무시할 수 있는 수준이며, 캐싱 프록시처럼 느린 작업을 건너뛰게 해 주면 오히려 성능이 좋아집니다. 초당 수백만 번 호출되는 작은 함수라면 인라인이 막히는 비용이 보일 수 있으므로 템플릿 기반 정적 프록시를 검토합니다.
Q6: Proxy Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Proxy Pattern Proxy Pattern으로 객체 접근을 제어하고 최적화할 수 있습니다. 다음으로 Bridge Pattern을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++ Decorator 패턴
- C++ Adapter 패턴
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
- C++ Command 패턴
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this