C/C++ 디자인 패턴 (12) — 프록시(Proxy)

수백 MB짜리 PDF를 목록에 띄울 때마다 전부 파싱하면 화면이 멈춘다. 일부 페이지는 관리자만 봐야 하고, 같은 페이지를 반복해서 읽는다면 결과를 재사용하고 싶다. 이런 요구를 문서 클래스 자체에 넣으면 본래 역할과 무관한 코드가 뒤섞인다. 프록시(Proxy)는 실제 객체와 같은 인터페이스를 가진 대리 객체를 앞에 두고, 실제 객체로 가는 접근을 통제한다(지연 생성, 권한 검사, 캐싱, 동기화, 원격 호출 등). 이 글에서는 세 가지 고전 프록시, operator-> 연쇄로 만든 동기화 프록시, 표준 라이브러리의 프록시 객체 vector<bool>::reference, 그리고 C의 LD_PRELOAD 함수 가로채기를 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
SubjectRealSubject와 Proxy의 공통 인터페이스Document
RealSubject실제 작업을 하는 객체PdfDocument
ProxyRealSubject 참조를 갖고 접근을 통제한 뒤 전달LazyDocument, GuardedDocument, CachedDocument

가상·보호·캐싱 프록시

// proxies.cpp — 같은 인터페이스 뒤에서 접근을 통제하는 세 가지 프록시
#include <iostream>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>

struct Document {                                     // Subject
	virtual ~Document() = default;
	virtual std::string read(int page) = 0;
};

struct PdfDocument : Document {                       // RealSubject: 열기가 비싸다
	explicit PdfDocument(std::string p) : path(std::move(p)) { std::cout << "  [open " << path << ": 비싼 작업]\n"; ++opens; }
	std::string read(int page) override { ++reads; return path + "#p" + std::to_string(page); }
	std::string path;
	static inline int opens = 0, reads = 0;
};

class LazyDocument : public Document {                // 가상 프록시: 처음 필요할 때 생성
public:
	explicit LazyDocument(std::string p) : path_(std::move(p)) {}
	std::string read(int page) override
	{
		if (!real_)
			real_ = std::make_unique<PdfDocument>(path_);
		return real_->read(page);
	}
private:
	std::string path_;
	std::unique_ptr<PdfDocument> real_;
};

class GuardedDocument : public Document {             // 보호 프록시: 권한 검사
public:
	GuardedDocument(Document &d, std::string role) : d_(d), role_(std::move(role)) {}
	std::string read(int page) override
	{
		if (page > 10 && role_ != "admin")
			throw std::runtime_error("page " + std::to_string(page) + " requires admin");
		return d_.read(page);
	}
private:
	Document &d_;
	std::string role_;
};

class CachedDocument : public Document {              // 캐싱 프록시: 결과 재사용
public:
	explicit CachedDocument(Document &d) : d_(d) {}
	std::string read(int page) override
	{
		auto [it, miss] = cache_.try_emplace(page);
		if (miss)
			it->second = d_.read(page);
		return it->second;
	}
private:
	Document &d_;
	std::map<int, std::string> cache_;
};

int main()
{
	LazyDocument lazy("manual.pdf");
	std::cout << "proxy created, opens=" << PdfDocument::opens << '\n';
	std::cout << lazy.read(1) << ", opens=" << PdfDocument::opens << '\n';

	GuardedDocument guest(lazy, "guest");
	try {
		std::cout << guest.read(3) << '\n';
		std::cout << guest.read(42) << '\n';
	} catch (const std::exception &e) {
		std::cout << "denied: " << e.what() << '\n';
	}

	CachedDocument cached(lazy);
	PdfDocument::reads = 0;
	for (int i = 0; i < 5; ++i)
		cached.read(7);
	std::cout << "5 cached reads -> real reads=" << PdfDocument::reads << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o proxies proxies.cpp && ./proxies
proxy created, opens=0
  [open manual.pdf: 비싼 작업]
manual.pdf#p1, opens=1
manual.pdf#p3
denied: page 42 requires admin
5 cached reads -> real reads=1

프록시를 만든 시점에는 열기 비용이 없고, 첫 read()에서 한 번만 연다. 보호 프록시와 캐싱 프록시는 가상 프록시를 다시 감싸 쌓였다.

종류통제하는 것실제 예
가상(virtual)생성 시점이미지·문서 지연 로딩
보호(protection)접근 권한역할 기반 API 게이트
캐싱반복 호출조회 결과 메모이제이션
원격(remote)위치RPC 스텁(gRPC 클라이언트)
스마트 참조참조 시 부가 작업shared_ptr, 아래의 동기화 프록시

동기화 프록시: operator-> 연쇄

C++에서 operator->가 포인터가 아닌 객체를 반환하면 그 객체의 operator->가 다시 적용된다. 중간 객체가 생성자에서 잠그고 소멸자에서 풀면, safe->push_back(i) 한 호출 전체가 잠금 아래에서 실행된다.

// locked.cpp — 호출마다 락을 거는 동기화 프록시: operator->가 임시 잠금 객체를 돌려준다
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

template <class T>
class Synchronized {
	class Locked {                                    // 이 임시 객체가 살아 있는 동안 잠김
	public:
		Locked(T &obj, std::mutex &m) : obj_(obj), lock_(m) {}
		T *operator->() { return &obj_; }
	private:
		T &obj_;
		std::unique_lock<std::mutex> lock_;
	};
public:
	Locked operator->() { return Locked(obj_, m_); }  // operator-> 연쇄 적용
	T &unsafe() { return obj_; }
private:
	T obj_;
	std::mutex m_;
};

int main()
{
	Synchronized<std::vector<int>> safe;

	auto run = [](auto push) {
		std::vector<std::thread> ts;
		for (int t = 0; t < 4; ++t)
			ts.emplace_back([&] { for (int i = 0; i < 50000; ++i) push(i); });
		for (auto &t : ts)
			t.join();
	};

	run([&](int i) { safe->push_back(i); });               // 매 호출이 잠금 하에 실행
	std::cout << "synchronized size=" << safe.unsafe().size() << " (expected 200000)\n";
}
$ g++ -std=c++20 -Wall -Wextra -O2 -pthread -o locked locked.cpp && ./locked
synchronized size=200000 (expected 200000)
$ g++ -std=c++20 -O1 -g -pthread -fsanitize=thread -o locked_tsan locked.cpp && setarch -R ./locked_tsan; echo "exit=$?"
synchronized size=200000 (expected 200000)
exit=0

스레드 4개가 동시에 20만 번 넣어도 크기가 정확하고 ThreadSanitizer도 경고하지 않았다. 이 환경의 TSan은 ASLR과 충돌해(unexpected memory mapping) setarch -R로 실행했다.

표준 라이브러리 속 프록시: vector<bool>

std::vector<bool>은 비트 단위로 압축 저장하므로 operator[]가 bool&을 돌려줄 수 없다. 대신 비트 하나를 대리하는 프록시 객체를 돌려준다.

// vecbool.cpp — 표준 라이브러리 속 프록시: vector<bool>::reference
#include <iostream>
#include <type_traits>
#include <vector>

int main()
{
	std::vector<bool> flags = {false, false};
	auto f = flags[0];                         // bool이 아니라 프록시 객체
	f = true;                                  // 원본 비트를 바꾼다
	std::cout << std::boolalpha << "flags[0]=" << flags[0]
		  << ", decltype is bool? " << std::is_same_v<decltype(f), bool> << '\n';

	bool copy = flags[1];                      // 명시적으로 bool로 받으면 값 복사
	copy = true;
	std::cout << "copy=" << copy << ", flags[1]=" << flags[1] << " (unchanged)\n";
	std::cout << "sizeof(vector<bool>::reference)=" << sizeof(flags[0]) << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o vecbool vecbool.cpp && ./vecbool
flags[0]=true, decltype is bool? false
copy=true, flags[1]=false (unchanged)
sizeof(vector<bool>::reference)=16

auto로 받은 f는 bool이 아니라 원본을 가리키는 프록시라서, 대입하면 원본이 바뀐다. 프록시가 원본보다 오래 살면 문제가 된다.

// dangling_read.cpp — 임시 vector<bool>의 프록시를 붙잡아 읽으면
#include <iostream>
#include <vector>
std::vector<bool> make_flags() { return {true, false, true}; }
int main()
{
	auto f = make_flags()[0];
	std::cout << "read: " << bool(f) << '\n';
}
$ g++ -std=c++20 -O1 -g -fsanitize=address -ffile-prefix-map=$PWD=. -o dangling_read dangling_read.cpp && ./dangling_read 2>&1 | grep -E 'ERROR|read:|#[0-1] ' | head -3
==35614==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x62c99797222e bp 0x7ffe97445a20 sp 0x7ffe97445a10
    #0 0x62c99797222d in std::_Bit_reference::operator bool() const /usr/include/c++/13/bits/stl_bvector.h:100
    #1 0x62c99797222d in main dangling_read.cpp:8

C 구현: LD_PRELOAD로 함수 가로채기

동적 링크된 프로그램은 같은 이름의 심볼을 먼저 로드된 라이브러리에서 찾는다. LD_PRELOAD로 open()을 정의한 라이브러리를 끼우면, 프로그램을 다시 빌드하지 않고도 모든 open() 호출 앞에 프록시를 둘 수 있다. 진짜 함수는 dlsym(RTLD_NEXT, "open")으로 찾는다.

/* openlog.c — LD_PRELOAD 프록시: 프로그램을 고치지 않고 open() 호출을 가로채 기록·검사한 뒤 진짜 함수로 넘긴다 */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>

typedef int (*open_fn)(const char *, int, ...);

static int allowed(const char *path)
{
	if (strstr(path, "data/")) {                        /* 관심 있는 경로만 기록 */
		fprintf(stderr, "[proxy] open(%s)\n", path);
		if (strstr(path, "secret")) {                   /* 보호 프록시 역할 */
			errno = EACCES;
			return 0;
		}
	}
	return 1;
}

int open(const char *path, int flags, ...)
{
	static open_fn real;
	mode_t mode = 0;

	if (!real)
		real = (open_fn)dlsym(RTLD_NEXT, "open");     /* 다음 라이브러리(libc)의 진짜 open */
	if (flags & (O_CREAT | O_TMPFILE)) {
		va_list ap;
		va_start(ap, flags);
		mode = va_arg(ap, mode_t);
		va_end(ap);
	}
	if (!allowed(path))
		return -1;
	return real(path, flags, mode);
}
$ mkdir -p data && echo "hello" > data/public.txt && echo "token=xyz" > data/secret.txt && gcc -std=c11 -Wall -Wextra -O2 -fPIC -shared -o libopenlog.so openlog.c -ldl && LD_PRELOAD=./libopenlog.so cat data/public.txt data/secret.txt; echo "exit=$?"
[proxy] open(data/public.txt)
hello
[proxy] open(data/secret.txt)
cat: data/secret.txt: Permission denied
exit=1

시스템의 cat을 그대로 썼는데 열기 요청이 기록되고, 비밀 파일 접근은 EACCES로 거부됐다. 가로챌 이름은 프로그램이 실제로 가져다 쓰는(import) 심볼이어야 한다.

$ nm -D /usr/bin/cat | grep -wE 'open|open64|openat|openat64'
                 U open@GLIBC_2.2.5

cat은 openat이 아니라 open을 가져다 쓴다. 그래서 같은 프록시를 openat 이름으로 만들면 아무것도 잡히지 않는다.

데코레이터·어댑터와 비교

패턴인터페이스목적RealSubject 생성
프록시같음접근 통제프록시가 직접 관리하는 경우가 많음
데코레이터같음기능 추가, 여러 겹외부에서 만들어 넘김
어댑터다름인터페이스 변환외부에서 넘김

주의사항

  • 지연 생성과 스레드: LazyDocument의 if (!real_)은 여러 스레드가 동시에 부르면 두 번 생성될 수 있다. 공유한다면 std::call_once나 뮤텍스로 감싼다.
  • 동기화 프록시의 범위: 잠금은 호출 하나 동안만 유지된다. if (safe->empty()) safe->push_back(1);처럼 두 호출에 걸친 조건은 원자적이지 않으므로, 이런 경우는 명시적으로 락을 잡는 API가 필요하다.
  • 프록시 객체와 auto: vector<bool>, std::bitset::reference, 표현식 템플릿 라이브러리(Eigen 등)는 프록시를 돌려준다. 값이 필요하면 bool x = v[i];처럼 타입을 명시한다.
  • 캐싱 프록시와 무효화: 원본이 바뀔 수 있다면 캐시 만료나 버전 검사가 필요하다. 그렇지 않으면 오래된 값을 계속 돌려준다.
  • LD_PRELOAD의 한계: 정적 링크 프로그램, 시스템 콜을 직접 부르는 코드, setuid 프로그램(보안상 무시됨)에는 효과가 없다. 디버깅·계측용이지 보안 경계가 아니다.

마무리

프록시는 “실제 객체에 닿기 전에 한 번 거쳐 가는 자리”를 만든다. 그 자리에서 생성을 미루든, 권한을 확인하든, 락을 잡든, 클라이언트 코드는 바뀌지 않는다. 구조 패턴은 여기까지이고, 다음 글부터 행위 패턴으로 넘어가 요청을 처리할 객체를 사슬로 잇는 책임 연쇄(Chain of Responsibility)를 다룬다.

참고

답글 남기기