C/C++ 디자인 패턴 (10) — 퍼사드(Facade)

사진 썸네일 하나를 만들려면 파일을 읽고, 포맷에 맞게 디코딩하고, 비율을 유지하며 줄이고, JPEG로 인코딩해서 저장해야 한다. 이 다섯 단계를 화면마다 복사해 두면 순서나 품질 설정이 조금씩 달라지고, 하위 라이브러리를 바꿀 때 모든 화면을 고쳐야 한다. 퍼사드(Facade)는 복잡한 하위 시스템 앞에 자주 쓰는 흐름을 묶은 단순한 인터페이스를 하나 둔다. 하위 시스템을 감추는 것이 아니라 대부분의 사용자에게 지름길을 제공하는 것이다. 이 글에서는 썸네일 퍼사드, POSIX 파일 매핑을 감싼 RAII 퍼사드, 퍼사드가 클라이언트에서 감추는 선언, C의 원자적 파일 저장 함수를 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
Facade요청을 적절한 하위 시스템 객체에 위임하는 단순 인터페이스Thumbnailer, MappedFile, atomic_save()
Subsystem classes실제 기능 구현. 퍼사드를 모르며 직접 쓸 수도 있음Decoder, Resizer, POSIX mmap 등
Client대부분 퍼사드만 사용main()

하위 시스템을 직접 조율할 때

// subsystems.hpp — 썸네일 생성에 필요한 하위 시스템들(각각 독립적으로도 쓰인다)
#pragma once
#include <iostream>
#include <string>
#include <vector>

struct Bytes { std::vector<unsigned char> data; };
struct Image { int w, h; std::string fmt; };

struct FileReader { Bytes read(const std::string &p) { std::cout << "  read " << p << '\n'; return {std::vector<unsigned char>(1024)}; } };
struct Decoder    { Image decode(const Bytes &b, const std::string &fmt) { std::cout << "  decode " << fmt << " (" << b.data.size() << "B)\n"; return {4000, 3000, fmt}; } };
struct Resizer    { Image fit(const Image &i, int max) { double s = double(max) / std::max(i.w, i.h); std::cout << "  resize " << i.w << 'x' << i.h << " -> " << int(i.w * s) << 'x' << int(i.h * s) << '\n'; return {int(i.w * s), int(i.h * s), i.fmt}; } };
struct Encoder    { Bytes encode(const Image &i, int quality) { std::cout << "  encode jpeg q=" << quality << " " << i.w << 'x' << i.h << '\n'; return {std::vector<unsigned char>(i.w * i.h / 10)}; } };
struct FileWriter { void write(const std::string &p, const Bytes &b) { std::cout << "  write " << p << " (" << b.data.size() << "B)\n"; } };
// without.cpp — 클라이언트가 하위 시스템 순서와 세부를 모두 알아야 한다
#include "subsystems.hpp"

int main()
{
	FileReader r; Decoder d; Resizer z; Encoder e; FileWriter w;
	auto img = d.decode(r.read("photo.png"), "png");
	auto small = z.fit(img, 256);
	w.write("photo.thumb.jpg", e.encode(small, 80));
	// 다른 화면에서도 같은 5단계를 복사해 쓴다...
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o without without.cpp && ./without
  read photo.png
  decode png (1024B)
  resize 4000x3000 -> 256x192
  encode jpeg q=80 256x192
  write photo.thumb.jpg (4915B)

퍼사드

// facade.cpp — 퍼사드: 자주 쓰는 흐름을 하나의 단순한 연산으로
#include "subsystems.hpp"

class Thumbnailer {
public:
	explicit Thumbnailer(int maxSide = 256, int quality = 80) : max_(maxSide), q_(quality) {}
	std::string make(const std::string &path)
	{
		std::string fmt = path.substr(path.rfind('.') + 1);
		std::string out = path.substr(0, path.rfind('.')) + ".thumb.jpg";
		writer_.write(out, encoder_.encode(resizer_.fit(decoder_.decode(reader_.read(path), fmt), max_), q_));
		return out;
	}
private:
	int max_, q_;
	FileReader reader_; Decoder decoder_; Resizer resizer_; Encoder encoder_; FileWriter writer_;
};

int main()
{
	Thumbnailer t;
	std::string a = t.make("photo.png");
	std::string b = Thumbnailer(128, 60).make("scan.tiff");
	std::cout << "-> " << a << "\n-> " << b << '\n';

	// 퍼사드는 하위 시스템을 막지 않는다: 세밀한 제어가 필요하면 직접 쓴다
	Decoder d;
	auto meta = d.decode(FileReader{}.read("raw.cr2"), "cr2");
	std::cout << "direct use: " << meta.w << 'x' << meta.h << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o facade facade.cpp && ./facade
  read photo.png
  decode png (1024B)
  resize 4000x3000 -> 256x192
  encode jpeg q=80 256x192
  write photo.thumb.jpg (4915B)
  read scan.tiff
  decode tiff (1024B)
  resize 4000x3000 -> 128x96
  encode jpeg q=60 128x96
  write scan.thumb.jpg (1228B)
-> photo.thumb.jpg
-> scan.thumb.jpg
  read raw.cr2
  decode cr2 (1024B)
direct use: 4000x3000

단계 순서와 출력 파일 이름 규칙이 make() 한 곳에 모였다. 마지막처럼 RAW 메타데이터만 필요한 경우에는 하위 시스템을 그대로 쓸 수 있다.

POSIX API 앞의 RAII 퍼사드

파일을 메모리에 매핑해 읽으려면 open, fstat, mmap, close, munmap과 각각의 오류 처리가 필요하다. 이를 클래스 하나로 묶으면 클라이언트는 std::string_view만 받는다.

// mapped_file.hpp — POSIX 파일 매핑 5단계를 숨기는 퍼사드(RAII)
#pragma once
#include <string>
#include <string_view>

class MappedFile {
public:
	explicit MappedFile(const std::string &path);   // open + fstat + mmap + close
	~MappedFile();                                    // munmap
	MappedFile(const MappedFile &) = delete;
	MappedFile &operator=(const MappedFile &) = delete;
	std::string_view view() const { return {static_cast<const char *>(addr_), size_}; }
private:
	void *addr_ = nullptr;
	std::size_t size_ = 0;
};
// mapped_file.cpp — 하위 시스템(POSIX API)을 아는 곳은 여기뿐
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <stdexcept>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "mapped_file.hpp"

MappedFile::MappedFile(const std::string &path)
{
	int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
	if (fd < 0)
		throw std::runtime_error(path + ": " + std::strerror(errno));
	struct stat st;
	if (::fstat(fd, &st) < 0) {
		int e = errno;
		::close(fd);
		throw std::runtime_error(path + ": " + std::strerror(e));
	}
	size_ = static_cast<std::size_t>(st.st_size);
	if (size_ > 0) {
		addr_ = ::mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd, 0);
		if (addr_ == MAP_FAILED) {
			int e = errno;
			::close(fd);
			throw std::runtime_error(path + ": " + std::strerror(e));
		}
	}
	::close(fd);                                  // 매핑은 fd를 닫아도 유지된다
}

MappedFile::~MappedFile()
{
	if (addr_)
		::munmap(addr_, size_);
}
// count_lines.cpp — 클라이언트는 퍼사드 헤더 하나만 본다
#include <algorithm>
#include <iostream>
#include "mapped_file.hpp"

int main(int argc, char **argv)
{
	for (int i = 1; i < argc; ++i) {
		try {
			MappedFile f(argv[i]);
			auto v = f.view();
			std::cout << argv[i] << ": " << std::count(v.begin(), v.end(), '\n')
				  << " lines, " << v.size() << " bytes\n";
		} catch (const std::exception &e) {
			std::cout << "error: " << e.what() << '\n';
		}
	}
}
$ seq 1 100000 > numbers.txt && : > empty.txt && g++ -std=c++20 -Wall -Wextra -O2 -o count_lines count_lines.cpp mapped_file.cpp && ./count_lines numbers.txt empty.txt /nonexistent
numbers.txt: 100000 lines, 588895 bytes
empty.txt: 0 lines, 0 bytes
error: /nonexistent: No such file or directory

빈 파일(mmap에 크기 0을 넘기면 실패)과 없는 파일의 오류 처리가 퍼사드 안에 있다. 퍼사드 덕분에 클라이언트 번역 단위에는 POSIX 선언이 하나도 들어오지 않는다.

$ for f in count_lines.cpp mapped_file.cpp; do echo "$f: mmap decl=$(g++ -std=c++20 -E $f | grep -cw mmap) MAP_PRIVATE macro=$(g++ -std=c++20 -dM -E $f | grep -cw MAP_PRIVATE)"; done
count_lines.cpp: mmap decl=0 MAP_PRIVATE macro=0
mapped_file.cpp: mmap decl=2 MAP_PRIVATE macro=1

read, close 같은 흔한 이름의 전역 함수와 매크로가 클라이언트 코드와 충돌할 일도 없다.

C 구현: 원자적 저장 함수

C 라이브러리의 공개 함수 하나가 곧 퍼사드인 경우가 많다. “설정 파일을 깨지지 않게 저장한다”는 요구 뒤에는 임시 파일 생성, 쓰기, fsync, rename, 실패 시 정리가 숨어 있다.

/* atomic_save.c — "파일을 안전하게 저장" 한 함수 뒤에 POSIX 6단계를 숨긴 C 퍼사드 */
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* 공개 API는 이것 하나: 성공 0, 실패 -1 (원본은 그대로 남는다) */
int atomic_save(const char *path, const void *data, size_t len)
{
	char tmp[4096];
	int fd;

	if (snprintf(tmp, sizeof(tmp), "%s.tmp.%ld", path, (long)getpid()) >= (int)sizeof(tmp))
		return -1;
	fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);     /* 1. 임시 파일 */
	if (fd < 0)
		return -1;
	if (write(fd, data, len) != (ssize_t)len ||                         /* 2. 쓰기 */
	    fsync(fd) < 0) {                                                 /* 3. 디스크로 */
		close(fd);
		unlink(tmp);
		return -1;
	}
	if (close(fd) < 0 || rename(tmp, path) < 0) {                       /* 4~5. 원자적 교체 */
		unlink(tmp);
		return -1;
	}
	return 0;   /* 6. (엄밀하게는 디렉터리 fsync까지 해야 rename이 영속된다) */
}

int main(void)
{
	const char *v1 = "version=1\n", *v2 = "version=2\n";

	printf("save v1: %d\n", atomic_save("settings.conf", v1, strlen(v1)));
	printf("save v2: %d\n", atomic_save("settings.conf", v2, strlen(v2)));
	printf("save to missing dir: %d\n", atomic_save("no/such/dir/settings.conf", v1, strlen(v1)));
	return 0;
}
$ rm -f settings.conf && gcc -std=c11 -Wall -Wextra -O2 -o atomic_save atomic_save.c && ./atomic_save && cat settings.conf
save v1: 0
save v2: 0
save to missing dir: -1
version=2

퍼사드가 실제로 어떤 시스템 호출을 대신하는지 strace로 보면 다음과 같다.

$ strace -e trace=openat,write,fsync,rename ./atomic_save 2>&1 >/dev/null | grep -E 'settings|fsync|write\(3'
openat(AT_FDCWD, "settings.conf.tmp.34991", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = 3
write(3, "version=1\n", 10)             = 10
fsync(3)                                = 0
rename("settings.conf.tmp.34991", "settings.conf") = 0
openat(AT_FDCWD, "settings.conf.tmp.34991", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = 3
write(3, "version=2\n", 10)             = 10
fsync(3)                                = 0
rename("settings.conf.tmp.34991", "settings.conf") = 0
openat(AT_FDCWD, "no/such/dir/settings.conf.tmp.34991", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = -1 ENOENT (No such file or directory)

호출하는 쪽은 한 줄이지만, 저장 한 번마다 openat → write → fsync → rename이 정해진 순서로 실행된다(close는 추적 대상에서 뺐다). 디렉터리가 없을 때는 첫 단계에서 실패하고 원본 파일은 건드리지 않는다.

어댑터·중재자와 비교

패턴대상목적하위 객체가 아는가
퍼사드하위 시스템 여러 개사용을 단순화퍼사드를 모름
어댑터객체 하나인터페이스 변환어댑터를 모름
중재자서로 협력하는 동료 객체들객체 간 통신 집중중재자를 앎

주의사항

  • 신(God) 객체가 되지 않게: 요청이 들어올 때마다 퍼사드에 메서드를 추가하면 하위 시스템 전체를 복제한 거대 클래스가 된다. 자주 쓰는 흐름만 담고, 나머지는 하위 시스템을 직접 쓰게 둔다.
  • 설정은 생성자에서: 예제의 Thumbnailer(128, 60)처럼 흐름의 매개변수는 퍼사드 생성 시 받고, 개별 호출은 단순하게 유지한다.
  • 오류를 삼키지 않는다: 하위 시스템의 오류(errno, 예외)를 퍼사드가 의미 있는 형태로 전달해야 한다. 예제의 MappedFile은 경로와 strerror를 담아 던진다.
  • 원자적 저장의 마지막 단계: rename 자체를 정전에도 남기려면 대상 디렉터리를 열어 fsync해야 한다. 예제는 주석으로만 표시했다.
  • mmap의 수명: view()가 돌려준 string_view는 MappedFile보다 오래 살면 안 된다. 매핑된 파일이 다른 프로세스에 의해 잘리면 접근 시 SIGBUS가 날 수 있다.

마무리

퍼사드는 가장 자주 무의식적으로 쓰는 패턴이다. 라이브러리의 “편의 함수”, RAII 래퍼, 공개 C API 하나하나가 모두 퍼사드다. 다음 글에서는 수많은 작은 객체가 상태를 공유해 메모리를 아끼는 플라이웨이트(Flyweight)를 다룬다.

참고

답글 남기기