구조 패턴 ② — 감추고, 공유하고, 대신한다
남은 구조 패턴 셋. 복잡한 내부를 창구 하나로 감추고(Facade), 같은 것을 여러 번 만들지 않고 공유하고(Flyweight), 대상 앞에 서서 접근을 통제한다(Proxy).
Facade 와 Proxy 는 둘 다 "앞에 서는" 모양이라 헷갈린다. 인터페이스가 달라지느냐로 가른다.
Facade — 단순화된 창구
여러 서브시스템 호출을 하나의 고수준 메서드로 감싼다. 숨기는 것이 아니라 쉬운 길을 하나 내주는 것이다.
문제 — 쓰려면 내부를 다 알아야 한다
// 주문 하나 넣는 데 네 개를 순서대로, 예외까지 챙겨서 불러야 한다
var stock = inventoryClient.reserve(items);
try {
var pay = payGateway.charge(user.card(), total);
try {
var order = orderRepository.save(new Order(user, items, pay.id()));
mailSender.send(user.email(), Template.ORDER_DONE, order);
} catch (Exception e) { payGateway.cancel(pay.id()); throw e; }
} catch (Exception e) { inventoryClient.release(stock.id()); throw e; }
이 뭉치가 컨트롤러마다 복사된다. 순서를 하나만 틀려도 결제는 됐는데 재고는 안 잡히는 상태가 남는다.
적용 — 고수준 메서드 하나로
@Service
public class OrderFacade {
public OrderResult place(User user, List<Item> items) { // 창구는 이것 하나
var stock = inventory.reserve(items);
try {
var pay = payGateway.charge(user.card(), total(items));
…
return OrderResult.of(order);
} catch (Exception e) { … }
}
}
orderFacade.place(user, items); // 쓰는 쪽은 이 한 줄
서브시스템을 못 쓰게 막는 것이 아니다. 특별한 흐름이 필요하면 여전히 payGateway 를 직접 부를 수 있다. Facade 는 "대부분의 경우 이렇게 쓰면 된다" 는 기본 경로를 하나 제공할 뿐이다.
JDK·프레임워크에서는
// JDBC 를 직접 쓰면 — 열고, 준비하고, 실행하고, 닫는다
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement("select * from member where id=?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { … }
}
// JdbcTemplate 은 그 전부를 한 줄로
jdbcTemplate.queryForObject("select * from member where id=?", rowMapper, id);
JdbcTemplate 은 Facade 이면서 동시에 Template Method 이기도 하다. 패턴은 배타적이지 않다 — 한 클래스가 여러 패턴으로 읽히는 것이 정상이다.
로깅의 SLF4J 도 전형적이다. 우리는 Logger 하나만 알면 되고, 뒤에 Logback 이 있든 Log4j2 가 있든 바뀌지 않는다.
실무에서 조심할 것
Facade 가 God Object 로 자라는 것이 가장 흔한 실패다. "여기에 넣으면 편하니까" 로 메서드를 계속 더하면 결국 모든 것을 아는 클래스가 된다.
판단 기준은 응집도다.
OrderFacade에 정산·쿠폰발급·회원탈퇴가 들어오기 시작하면 쪼갤 때다. 창구가 하나여야 한다는 규칙은 없다 — 도메인마다 하나씩 두면 된다.
Adapter 와의 차이
Adapter 는 1:1 변환이고 Facade 는 N:1 단순화다.
Flyweight — 공유로 메모리 절약
같은 값을 가진 객체를 여러 벌 만들지 않고 공유한다. 핵심은 상태를 둘로 가르는 것이다.
상태를 가른다
- 내부 상태(intrinsic) — 객체마다 고유하고 변하지 않는 것 → 공유 가능
- 외부 상태(extrinsic) — 문맥에 따라 달라지는 것 → 인자로 넘긴다
주문 100만 건을 읽어 정산하는 배치를 생각해 보자. 각 주문에는 상품 카테고리 정보가 붙는데, 카테고리 종류는 수백 개뿐이다.
// ✗ 주문마다 카테고리 객체를 새로 만든다 — 100만 개가 힙에 쌓인다
record Category(String code, String name, BigDecimal feeRate, String settlementCycle) { }
for (var row : rows) {
var c = new Category(row.get("cat_code"), row.get("cat_name"), …); // 매번 new
settle(row.amount(), c);
}
// ✓ 카테고리는 종류당 하나만 만들어 공유한다
class Categories {
private static final Map<String, Category> CACHE = new ConcurrentHashMap<>();
static Category of(String code) { // 내부 상태 — 불변, 공유 가능
return CACHE.computeIfAbsent(code, Categories::loadFromMaster);
}
}
for (var row : rows) {
settle(row.amount(), Categories.of(row.get("cat_code"))); // 외부 상태(금액)는 인자로
}
주문 100만 건은 여전히 100만 건이지만, Category 객체는 수백 개만 힙에 있다. 무거운 공유 부분과 가벼운 개별 부분을 갈라, 공유 가능한 쪽만 재사용하는 거래다.
GC 압박이 눈에 띄게 준다 — 짧게 살다 죽는 객체가 100만 개 덜 생기기 때문이다. 대량 배치에서 "힙이 계속 차오른다" 는 증상의 원인이 이런 반복 생성인 경우가 많다.
JDK 에서는 — 그리고 유명한 함정
Integer a = Integer.valueOf(127), b = Integer.valueOf(127);
System.out.println(a == b); // true — 캐시된 같은 객체
Integer c = Integer.valueOf(128), d = Integer.valueOf(128);
System.out.println(c == d); // false — 범위 밖이라 새로 만든다
Integer 는 −128 ~ 127 을 미리 만들어 캐시한다. 그래서 == 가 작은 값에서만 true 다.
Integer count = 0;
if (count == someInteger) { … } // ✗ 127 까지만 맞다. 128 부터 조용히 틀린다
if (count.equals(someInteger)) { } // ✓
이것이 왜 고약하냐면 테스트 데이터가 작으면 통과하고 실제 데이터에서만 틀리기 때문이다. 상한은 -XX:AutoBoxCacheMax=N 으로 올릴 수 있지만, 그것에 기대는 코드를 쓰면 안 된다.
문자열 상수 풀도 같은 원리다.
String a = "hello", b = "hello";
a == b; // true — 컴파일 시점에 풀에 하나만 만든다
String c = new String("hello");
a == c; // false — 명시적으로 새 객체를 만들었다
a == c.intern(); // true — 풀에 있는 것을 가져온다
실무에서는
// enum 이 사실상 완성된 Flyweight 다
public enum Currency { KRW, USD, JPY } // 인스턴스는 종류당 하나뿐
새로 만들 일이 거의 없는 이유가 여기 있다 — enum·문자열 풀·박싱 캐시로 언어와 라이브러리가 이미 해 두었다. 직접 구현하는 경우는 게임 스프라이트, 폰트 글리프, 대량 파싱 시 반복되는 토큰 정도다.
함정 — 캐시가 메모리 누수가 되기 쉽다.
Map에 넣기만 하고 안 빼면 계속 자란다. 키가 무한히 다양할 수 있는 값(사용자 입력)을 캐시하면 특히 위험하다.그리고 공유 객체는 반드시 불변이어야 한다. 하나만 존재하는데 누군가 상태를 바꾸면 그것을 쓰는 전부가 영향을 받는다.
Proxy — 대리·접근 제어
대상과 같은 인터페이스를 가진 대리인이 앞에 서서, 호출을 가로채 접근을 통제한다. 쓰는 쪽은 진짜인 줄 안다.
종류
| 종류 | 무엇을 하나 |
|---|---|
| 가상 프록시 | 진짜 객체 생성을 미룬다(지연 로딩) |
| 보호 프록시 | 권한을 검사해 거른다 |
| 원격 프록시 | 다른 프로세스·머신의 객체를 대신한다(RMI) |
| 로깅·캐싱 프록시 | 앞뒤에 부가 동작을 끼운다 |
직접 만들어 보면 구조가 보인다
interface ImageLoader { byte[] load(String path); }
class RealImageLoader implements ImageLoader {
public byte[] load(String path) { return readFromDisk(path); } // 무겁다
}
class CachingImageProxy implements ImageLoader { // 같은 인터페이스
private final ImageLoader target; // 진짜를 품는다
private final Map<String, byte[]> cache = new ConcurrentHashMap<>();
public byte[] load(String path) {
return cache.computeIfAbsent(path, target::load); // 앞에서 가로챈다
}
}
ImageLoader loader = new CachingImageProxy(new RealImageLoader());
loader.load("a.png"); // 첫 번째는 디스크, 두 번째부터는 캐시
호출부는 ImageLoader 만 안다. 캐시가 끼었다는 사실을 모른다.
동적 프록시 — 클래스를 실행 중에 만든다
위 방식은 메서드가 늘 때마다 프록시에도 똑같이 써 줘야 한다. JDK 는 그것을 자동화하는 기능을 제공한다.
ImageLoader proxy = (ImageLoader) Proxy.newProxyInstance(
ImageLoader.class.getClassLoader(),
new Class<?>[]{ ImageLoader.class },
(p, method, args) -> {
long t0 = System.nanoTime();
try {
return method.invoke(real, args); // 진짜 호출
} finally {
log.info("{} {}ms", method.getName(), (System.nanoTime()-t0)/1_000_000);
}
});
JVM 이 $Proxy0 같은 클래스를 그 자리에서 만들어 로딩한다. method.invoke 앞뒤에 무엇을 넣느냐만 다르면 로깅·트랜잭션·캐시가 전부 이 하나로 나온다.
두 가지 방식과 한계
| JDK 동적 프록시 | CGLIB · ByteBuddy | |
|---|---|---|
| 방식 | 인터페이스를 구현한 클래스 생성 | 대상 클래스를 상속한 서브클래스 생성 |
| 필요 조건 | 인터페이스가 있어야 한다 | 없어도 된다 |
| 못 하는 것 | 클래스만 있으면 불가 | final 클래스·메서드, private 메서드 |
코틀린 클래스가 기본 final 이라 스프링에서 프록시를 못 만드는 것도 이 표의 오른쪽 칸 때문이다.
프록시는 밖에서 들어오는 호출만 본다
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
for (Order o : orders) save(o); // this.save() — 프록시를 안 거친다
}
@Transactional
public void save(Order o) { … } // 트랜잭션이 안 걸린다
}
호출자가 프록시를 거쳐 placeAll() 에 들어온 순간 this 는 이미 진짜 객체다. 그 안에서 자기 메서드를 부르면 프록시를 다시 탈 이유가 없다. 버그가 아니라 원리적 한계이고, @Cacheable·@Async 도 똑같이 겪는다. 이 함정과 프레임워크 실제 소스는 실무 적용 심화 편에서 더 파고든다.
JDK·실무에서는
// JPA 지연 로딩 — 진짜 객체 대신 프록시를 넣어 두고, 건드릴 때 쿼리를 날린다
Order o = em.getReference(Order.class, 1L); // 이 시점엔 SELECT 가 안 나간다
o.getAmount(); // 여기서 나간다
가상 프록시의 실제 사례다. 그래서 영속성 컨텍스트가 닫힌 뒤 건드리면 쿼리를 못 날려 예외가 난다.
Decorator 와 무엇이 다른가
new BufferedInputStream(new FileInputStream(f)); // Decorator — 내가 조립했다
@Transactional class OrderService { } // Proxy — 컨테이너가 몰래 감쌌다
구조가 같아 보여도 누가 감쌌는지를 보면 갈린다.
한눈에 정리
- Facade — N:1 단순화. 서브시스템을 막는 것이 아니라 기본 경로를 하나 내주는 것. God Object 로 자라는 것이 최대 위험
- Adapter vs Facade — 1:1 변환이냐 N:1 단순화냐
- Flyweight — 상태를 내부(공유)·외부(인자) 로 가른다.
Integer캐시(−128~127)와 문자열 풀이 실제 사례이고,==가 작은 값에서만 맞는 버그의 원인이다 - 공유 객체는 반드시 불변 — 하나뿐인데 누가 바꾸면 전부가 영향받는다
- Proxy — 같은 인터페이스로 앞에 서서 통제한다. JDK 동적 프록시는 인터페이스 필요, CGLIB 은 상속이라
final을 못 넘는다 - 자기 호출에는 안 걸린다 — 프록시는 객체 바깥에 있어 안에서 일어나는 호출을 볼 수 없다. 원리적 한계다
- Decorator vs Proxy — 기능 추가냐 통제냐, 그리고 내가 조립했나 남이 감쌌나
출처 — GoF Design Patterns(1994) §4 Structural Patterns · Java SE 21 API Docs(
java.lang.reflect.Proxy·Integer.valueOf·String.intern) · JLS §3.10.5(문자열 리터럴 인터닝) · Jakarta Persistence 3.2(지연 로딩)