사진 썸네일 하나를 만들려면 파일을 읽고, 포맷에 맞게 디코딩하고, 비율을 유지하며 줄이고, JPEG로 인코딩해서 저장해야 한다. 이 다섯 단계를 화면마다 복사해 두면 순서나 품질 설정이 조금씩 달라지고, 하위 라이브러리를 바꿀 때 모든 화면을 고쳐야 한다. 퍼사드(Facade)는 복잡한 하위 시스템 앞에 자주 쓰는 흐름을 묶은 단순한 인터페이스를 하나 둔다. 하위 시스템을 감추는 것이 아니라 대부분의 사용자에게 지름길을 제공하는 것이다. 이 글에서는 썸네일 퍼사드, POSIX 파일 매핑을 감싼 RAII 퍼사드, 퍼사드가 클라이언트에서 감추는 선언, C의 원자적 파일 저장 함수를 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (8) — 컴포지트(Composite)
- C/C++ 디자인 패턴 (9) — 데코레이터(Decorator)
- C/C++ 디자인 패턴 (11) — 플라이웨이트(Flyweight)
- C/C++ 디자인 패턴 (12) — 프록시(Proxy)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| 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)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Facade
- man7.org — mmap(2)
- man7.org — rename(2)