자바 디자인 패턴 학습 노트 목차

구조 패턴 ② — 감추고, 공유하고, 대신한다

남은 구조 패턴 셋. 복잡한 내부를 창구 하나로 감추고(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);

JdbcTemplateFacade 이면서 동시에 Template Method 이기도 하다. 패턴은 배타적이지 않다 — 한 클래스가 여러 패턴으로 읽히는 것이 정상이다.

로깅의 SLF4J 도 전형적이다. 우리는 Logger 하나만 알면 되고, 뒤에 Logback 이 있든 Log4j2 가 있든 바뀌지 않는다.

실무에서 조심할 것

FacadeGod Object 로 자라는 것이 가장 흔한 실패다. "여기에 넣으면 편하니까" 로 메서드를 계속 더하면 결국 모든 것을 아는 클래스가 된다.

판단 기준은 응집도다. OrderFacade 에 정산·쿠폰발급·회원탈퇴가 들어오기 시작하면 쪼갤 때다. 창구가 하나여야 한다는 규칙은 없다 — 도메인마다 하나씩 두면 된다.

Adapter 와의 차이

  • Adapter — 인터페이스를 다른 모양으로 바꾼다. 기능은 그대로
  • Facade — 인터페이스를 더 단순한 것으로 새로 만든다. 여러 개를 하나로

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    — 컨테이너가 몰래 감쌌다
  • Decorator기능을 더한다. 여러 겹이 정상이고 쓰는 쪽이 조립한다
  • 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(지연 로딩)

구조 패턴 ① — 맞추고, 나누고, 쌓는다행위 패턴 ① — 요청을 객체로 다룬다