구조 패턴 2: 퍼사드·플라이웨이트·프록시
복잡한 것을 한 창구로, 많은 것을 가볍게, 진짜 대신 대리인을 세우는 법이다.
목차 (7)
들어가며
구조 패턴 일곱 가지 가운데 나머지 셋을 다룬다. 퍼사드는 복잡한 것 앞에 창구 하나를 세우고, 플라이웨이트는 같은 상태를 여러 객체가 나눠 쓰게 하고, 프록시는 진짜 객체 앞에 같은 모양의 대리인을 세운다. 어댑터, 브리지, 컴포지트, 데코레이터는 구조 패턴 1에 있고, 글 끝에서 일곱 가지를 한 표로 비교한다. 패턴의 유래와 쓰지 말아야 할 때는 디자인 패턴이란, 결합도와 응집도 같은 설계 원칙은 소프트웨어 공학 노트에 있다.
퍼사드: 복잡한 하위 시스템에 창구 하나
의도
여러 클래스로 이루어진 하위 시스템 앞에 높은 수준의 단순한 인터페이스 하나를 두어, 클라이언트가 내부를 몰라도 흔한 일을 할 수 있게 한다.
문제 상황
동영상을 올리면 다른 형식으로 바꿔야 한다. 파일을 열고, 맞는 코덱을 고르고, 원본 비트레이트를 읽고, 오디오를 섞고, 결과를 써야 한다. 컨트롤러가 이 클래스 다섯 개를 직접 순서대로 부르면, 변환 라이브러리를 바꾸거나 순서를 고칠 때 이 라이브러리를 쓰는 모든 곳을 찾아다녀야 한다. 주문도 같다. 재고 예약, 결제, 배송 접수, 알림을 컨트롤러마다 따로 엮으면 결제 실패 때 예약을 푸는 처리가 어딘가에서 빠진다.
구조
- 퍼사드(Facade): 어떤 하위 객체를 어떤 순서로 부를지 안다. 클라이언트의 요청을 하위 시스템에 나눠 맡긴다.
- 하위 시스템(Subsystem): 실제 일을 하는 클래스들. 퍼사드의 존재를 모르고, 직접 써도 된다.
예시 코드
주문 퍼사드는 호출 순서와 실패했을 때의 되돌리기를 한곳에 둔다. 컨트롤러, 배치 작업, 관리자 화면이 모두 같은 placeOrder를 부르므로 "결제가 실패하면 예약을 푼다"는 규칙이 한 번만 쓰인다.
// 퍼사드: 주문 한 건에 필요한 하위 시스템 호출 순서와 되돌리기를 한곳에 모은다
public final class OrderFacade {
private static final Logger log = LoggerFactory.getLogger(OrderFacade.class);
private final InventoryService inventory;
private final PaymentGateway payment;
private final ShippingService shipping;
private final NotificationService notification;
public OrderFacade(
InventoryService inventory,
PaymentGateway payment,
ShippingService shipping,
NotificationService notification
) {
this.inventory = inventory;
this.payment = payment;
this.shipping = shipping;
this.notification = notification;
}
public OrderResult placeOrder(Cart cart, Address address) {
Reservation reservation = inventory.reserve(cart.items());
Receipt receipt;
try {
receipt = payment.pay(cart.total());
} catch (PaymentFailedException e) {
log.warn("결제 실패로 재고 예약을 푼다: {}", reservation.id(), e);
inventory.release(reservation);
throw e;
}
Shipment shipment = shipping.schedule(reservation, address);
notification.orderPlaced(cart.customerId(), shipment.trackingNumber());
return new OrderResult(receipt, shipment);
}
}
// 컨트롤러는 한 줄만 안다
OrderResult result = orderFacade.placeOrder(cart, address);// 동영상 변환 퍼사드를 쓰는 쪽: 코덱·비트레이트·오디오 혼합을 몰라도 된다
File converted = new VideoConverter().convert(new File("clip.ogg"), "mp4");실제 쓰임
- SLF4J: 이름부터 Simple Logging Facade for Java다. 사용자 매뉴얼은 SLF4J를 java.util.logging, logback 같은 여러 로깅 프레임워크의 "단순한 퍼사드 또는 추상"이라 소개하고, 쓰는 쪽이 원하는 로깅 프레임워크를 배포할 때 끼우게 한다고 적는다. 엄밀히는 백엔드를 갈아 끼우는 구조라 브리지의 성격도 함께 가진다.
- 서비스 계층과 SDK의 고수준 클라이언트: 여러 저장소와 외부 호출을 엮는 애플리케이션 서비스, 여러 저수준 API 호출을 한 메서드로 묶은 SDK의 편의 클래스가 퍼사드 자리다.
// 코드는 SLF4J API만 안다. 실제 출력은 배포할 때 클래스패스에 둔 백엔드(logback 등)가 한다
private static final Logger log = LoggerFactory.getLogger(OrderFacade.class);
log.info("주문 {} 접수", orderId);장단점
| 장점 | 단점 |
|---|---|
| 클라이언트가 의존하는 클래스 수가 준다(결합도가 낮아진다) | 무엇이든 넣다 보면 모든 것을 아는 거대한 클래스(god object)가 된다 |
| 호출 순서와 실패 처리가 한곳에 모인다 | 퍼사드가 감추는 세부 옵션이 필요한 클라이언트는 결국 하위 시스템을 직접 쓴다 |
| 하위 시스템을 바꿔도 퍼사드만 고친다 | 얇은 전달만 하는 퍼사드는 이름만 하나 더 생긴 것이다 |
주의
- 막는 벽이 아니라 창구다: 하위 시스템을 직접 쓰는 길을 막지 않는다. 막으려면 패키지 가시성이나 모듈 경계로 따로 정한다.
- 쓰임새마다 퍼사드를 나눈다: 주문 접수, 환불, 정산을 한 퍼사드에 넣지 말고 흐름마다 둔다.
- 어댑터와의 차이: 어댑터는 이미 정해진 인터페이스에 맞추고, 퍼사드는 쓰기 쉬운 인터페이스를 새로 정한다.
다른 언어에서는
- Go, Python: 클래스보다 패키지·모듈 수준의 공개 함수 몇 개가 퍼사드 역할을 한다. Go는 이름의 첫 글자가 대문자인지로 패키지 밖에 보일지가 정해지므로(Effective Go의 이름 절), 하위 타입은 소문자로 감추고 진입 함수만 대문자로 드러낸다.
- JavaScript: 라이브러리의
index.js가 내부 모듈 여럿을 다시 내보내며 고른 함수만 드러내는 것이 흔한 퍼사드다.
플라이웨이트: 같은 상태를 나눠 써서 가볍게
의도
아주 많은 객체가 똑같이 가진 상태를 공유 객체 하나로 빼내, 객체 수와 메모리를 줄인다.
문제 상황
게임 숲에 나무가 1만 그루 있다. 나무마다 메시와 텍스처를 들고 있으면, 실제로는 다섯 종류뿐인데도 같은 데이터가 1만 벌 메모리에 올라간다. 나무마다 다른 것은 위치와 크기와 회전 정도다. 같은 부분을 종류마다 한 벌만 두고 나무는 그것을 가리키게 하면 된다.
Wikipedia는 이 개념을 1990년 Paul Calder와 Mark Linton이 WYSIWYG 문서 편집기의 글리프(글자 모양) 정보를 다루려고 처음 깊이 다뤘다고 적는다. 글자마다 글꼴 윤곽을 들지 않고, 같은 글자는 같은 글리프 객체를 가리키고 각자는 위치만 든다. 공유하는 쪽을 고유 상태(intrinsic, 맥락과 상관없이 같은 것), 객체마다 다른 쪽을 외부 상태(extrinsic, 맥락에 따라 바뀌어 밖에서 넘겨받는 것)라 부른다.
구조
- 플라이웨이트(Flyweight): 공유되는 고유 상태를 든 불변 객체. 외부 상태는 메서드 인자로 받는다.
- 팩터리(Flyweight Factory): 키로 플라이웨이트를 찾고, 없을 때만 만든다. 공유를 보장하는 곳이다.
- 문맥(Context): 외부 상태와 플라이웨이트 참조를 든 가벼운 객체. 여기서는
Tree.
예시 코드
// 공유 상태(종류마다 한 벌): 불변으로 만든다
public record TreeType(String name, Color color, Mesh mesh) {
public void draw(Canvas canvas, float x, float y, float scale) {
canvas.drawMesh(mesh, color, x, y, scale);
}
}
// 팩터리: 같은 키에는 늘 같은 객체를 돌려준다
public final class TreeTypeFactory {
private final Map<String, TreeType> pool = new ConcurrentHashMap<>();
public TreeType get(String name, Color color, Supplier<Mesh> meshLoader) {
return pool.computeIfAbsent(name + "/" + color, key -> new TreeType(name, color, meshLoader.get()));
}
public int size() {
return pool.size();
}
}
// 고유 상태(그루마다 다름): 위치, 크기와 공유 객체 참조 하나
public record Tree(float x, float y, float scale, TreeType type) {
public void draw(Canvas canvas) {
type.draw(canvas, x, y, scale);
}
}아래 단계 그림은 나무 여섯 그루를 심는 동안 팩터리가 하는 일이다. 처음 보는 종류만 새로 만들고, 나머지는 있던 것을 돌려준다.
1번째 나무: 참나무는 처음이라 팩터리가 TreeType을 새로 만들어 풀에 넣는다. 공유 객체 1개.
실제 쓰임
Integer.valueOf: Javadoc은 이 메서드가 자주 쓰는 값을 캐시해 생성자보다 공간과 시간에서 훨씬 나을 수 있고, -128부터 127까지(양끝 포함)는 늘 캐시하며 그 밖의 값도 캐시할 수 있다고 적는다. 그래서 그 범위에서는valueOf가 늘 같은 객체를 돌려준다. 범위 밖은 보장이 없으므로==로 비교하면 안 된다.- 문자열 인턴:
String.intern()은 클래스가 따로 관리하는 문자열 풀에서 같은 내용의 문자열을 돌려주고, 없으면 풀에 넣는다. 모든 문자열 리터럴과 문자열 상수식은 이미 인턴되어 있다. 파이썬의sys.intern도 같은 일을 하며, 문서는 인턴된 키끼리는 사전 조회의 문자열 비교를 포인터 비교로 끝낼 수 있다고 설명한다. - 게임과 그래픽스: 같은 메시를 위치만 바꿔 여러 번 그리는 인스턴싱, 타일 맵의 타일 종류 표도 같은 생각이다.
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
// true: -128 ~ 127은 캐시가 보장된다
boolean sameSmall = a == b;
Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
// 보장되지 않는다(Javadoc은 범위 밖도 캐시할 수 있다고만 적는다). 값 비교는 늘 equals로
boolean sameLarge = c == d;
String s = new String("hello");
// true: intern()은 풀에 있는 같은 내용의 문자열을 돌려준다. 문자열 리터럴은 이미 풀에 있다
boolean pooled = s.intern() == "hello";장단점
| 장점 | 단점 |
|---|---|
| 같은 데이터가 한 벌만 올라가 메모리가 크게 준다 | 외부 상태를 매번 인자로 넘겨야 해서 호출이 번거롭다 |
| 객체 생성이 줄어 할당과 GC 부담이 준다 | 팩터리 조회(해시 계산, 동시성 제어)에 시간이 든다 |
| 같은 종류는 같은 객체라 참조 비교로 빨리 가를 수 있다 | 공유 객체를 고치면 그것을 가리키는 모두가 바뀐다 |
주의
- 공유 상태는 불변: 위 예시처럼
record로 만들고, 바뀌는 값은 모두 문맥 쪽으로 옮긴다. 공유 객체에 "선택됨" 같은 상태를 넣는 순간 같은 종류가 모두 선택된다. - 객체가 정말 많을 때만: 수십, 수백 개 수준이면 구조만 복잡해진다. 먼저 메모리를 재 보고 같은 데이터가 크게 겹치는지 확인한다.
- 캐시와 동일성을 헷갈리지 않기:
Integer캐시처럼 "같은 객체일 수도 있다"는 최적화를 동일성의 약속으로 믿지 않는다. 값 비교는equals로 한다.
다른 언어에서는
- Python:
sys.intern이 문자열 플라이웨이트이고, 팩터리는functools.cache를 씌운 함수 하나로 만들 수 있다. 같은 인자에는 같은 객체가 돌아온다. - Go: 공유 상태는 포인터로 들고(
*TreeType), 팩터리는map과sync.Mutex로 만든다. 값 타입 구조체를 복사해 넘기면 공유가 아니라 복사가 되는 점을 조심한다. - JavaScript: 큰 데이터(텍스처, 파싱한 설정)를
Map에 키로 모아 나눠 쓰는 것이 흔한 형태다. 여기서도 공유 객체는 고치지 않는다.
프록시: 진짜 대신 같은 모양의 대리인
의도
진짜 객체와 같은 인터페이스를 가진 대리 객체를 세워, 진짜 객체에 닿는 길(언제 만들지, 누가 부를 수 있는지, 어디 있는지)을 대신 맡는다.
문제 상황
사진 갤러리가 고해상도 이미지 300장을 목록에 담는다. 목록을 만들 때 모두 읽으면 화면에 보이지도 않는 이미지 때문에 몇 초를 기다리고 메모리를 다 쓴다. 갤러리 코드를 "보일 때만 읽기"로 고칠 수도 있지만, 그러면 이미지를 쓰는 모든 코드가 로딩 시점을 신경 써야 한다. 이미지와 똑같이 생긴 대리인이 처음 display()를 받을 때 진짜를 만들면 갤러리는 그대로 둘 수 있다.
구조
- 주체(Subject): 프록시와 진짜 객체가 함께 구현하는 인터페이스.
- 진짜 객체(Real Subject): 실제 일을 하는 객체.
- 프록시(Proxy): 진짜 객체를 가리키거나 만들고, 넘기기 전후에 접근을 다룬다.
프록시의 종류
Wikipedia는 쓰임으로 원격, 가상, 보호 프록시를 든다. 실무에서는 아래 둘을 더해 다섯 가지로 많이 나눈다. 모양은 모두 같고 프록시가 맡는 일만 다르다.
| 종류 | 하는 일 | 예 |
|---|---|---|
| 가상 프록시(virtual) | 만들기 비싼 객체를 처음 쓸 때 만든다 | 큰 이미지, JPA·Hibernate 지연 로딩 |
| 보호 프록시(protection) | 권한을 확인한 뒤에만 넘긴다 | 역할에 따라 메서드를 막는 래퍼, JS Proxy로 속성 막기 |
| 원격 프록시(remote) | 다른 주소 공간(다른 프로세스, 다른 기계)의 객체를 로컬 객체처럼 부르게 한다 | RPC 클라이언트 스텁, ATM이 은행 서버 정보를 대신하는 객체 |
| 캐시 프록시(cache) | 같은 요청의 결과를 저장해 두고 진짜 객체를 덜 부른다 | 느린 조회 서비스 앞의 메모 래퍼 |
| 로깅·스마트 참조(smart reference) | 호출을 기록하거나 시간을 재거나, 참조 수를 세고 잠금을 건다 | java.lang.reflect.Proxy 기반 계측, Spring AOP 어드바이스 |
예시 코드
가상 프록시를 손으로 짜면 이렇다. 프록시는 경로만 들고 있다가 처음 display()에서 진짜 객체를 만든다.
public interface Image {
void display();
}
// 진짜 객체: 만들 때 파일 전체를 읽는다(무겁다)
public final class HighResImage implements Image {
private final byte[] pixels;
public HighResImage(Path path) throws IOException {
this.pixels = Files.readAllBytes(path);
}
@Override
public void display() {
Screen.draw(pixels);
}
}
// 가상 프록시: 경로만 들고 있다가 처음 display()에서 진짜 객체를 만든다
public final class LazyImage implements Image {
private final Path path;
private HighResImage real;
public LazyImage(Path path) {
this.path = path;
}
@Override
public void display() {
if (real == null) {
real = load();
}
real.display();
}
private HighResImage load() {
try {
return new HighResImage(path);
} catch (IOException e) {
throw new UncheckedIOException("이미지를 읽지 못했다: " + path, e);
}
}
}메서드마다 손으로 넘기지 않고 실행 중에 프록시를 만드는 방법도 있다. java.lang.reflect.Proxy는 주어진 인터페이스들을 구현하는 프록시 클래스를 만들고, 프록시에 온 모든 호출을 InvocationHandler.invoke에 프록시, 불린 Method, 인자 배열과 함께 넘긴다. 넘길 수 있는 것은 인터페이스뿐이고 클래스는 안 된다.
// 인터페이스만 있으면 실행 중에 프록시 클래스를 만들어 모든 호출을 한 곳(InvocationHandler)으로 모은다
@SuppressWarnings("unchecked")
static <T> T timed(T target, Class<T> type) {
InvocationHandler handler = (proxy, method, args) -> {
long started = System.nanoTime();
try {
return method.invoke(target, args);
} catch (InvocationTargetException e) {
throw e.getCause();
} finally {
log.info("{} {} µs", method.getName(), (System.nanoTime() - started) / 1_000);
}
};
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, handler);
}
OrderRepository repository = timed(new JdbcOrderRepository(dataSource), OrderRepository.class);실제 쓰임
- JPA·Hibernate 지연 로딩: Hibernate 소개서는 프록시를 "진짜 엔티티나 컬렉션인 척하지만 아직 데이터베이스에서 가져오지 않아 상태를 들고 있지 않은 객체"라 설명한다. 프록시의 메서드를 부르면 그때 상태를 가져온 뒤 진짜 객체로 호출을 넘긴다. 식별자를 읽는 일과
getReference로 얻은 프록시로 연관을 거는 일은 조회 없이 된다. 세션이 끝난 뒤 채워지지 않은 프록시의 메서드를 부르면LazyInitializationException이 난다. - Spring AOP: Spring 문서에 따르면 대상이 인터페이스를 하나라도 구현하면 JDK 동적 프록시를, 없으면 대상의 하위 클래스를 실행 중에 만드는 CGLIB 프록시를 쓴다.
final이나private메서드는 덮어쓸 수 없어 어드바이스가 걸리지 않는다.
아래 단계 그림은 지연 로딩 프록시의 일생이다. 책을 불러오면 출판사는 식별자만 든 프록시로 따라온다.
책을 불러오면 출판사는 프록시로 들어온다. 저자도 getReference로 프록시만 받았다. 아직 출판사와 저자를 조회하지 않았다.
장단점
| 장점 | 단점 |
|---|---|
| 클라이언트를 고치지 않고 생성 시점, 권한, 위치, 계측을 더한다 | 호출이 언제 무거워지는지 코드에서 보이지 않는다(지연 로딩이 반복문 안에서 조회를 수십 번 낸다) |
| 무거운 자원을 필요할 때까지 미룬다 | 프록시는 진짜 객체와 다른 클래스라 getClass(), instanceof, ==가 기대와 다를 수 있다 |
| 동적 프록시로 횡단 관심사(트랜잭션, 로깅)를 한 곳에 모은다 | 프레임워크가 만든 프록시는 스택 트레이스와 디버거를 읽기 어렵게 한다 |
주의
- 자기 호출은 프록시를 건너뛴다: 프록시는 바깥에서 들어오는 호출만 가로챈다. 객체 안에서
this.place()로 자기 메서드를 부르면 프록시를 거치지 않는다. Spring 문서도 프록시 모드(기본값)에서는 자기 호출에@Transactional이 실제 트랜잭션을 만들지 않는다고 적는다.
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
// this.place()는 프록시를 거치지 않으므로 @Transactional이 걸리지 않는다
orders.forEach(this::place);
}
@Transactional
public void place(Order order) {
...
}
}- 지연 로딩과 N+1: Hibernate 문서는 엔티티 하나씩 왕복해 가져오는 것이 가장 비효율적인 접근이며 N+1 조회 문제로 이어진다고 경고한다. 목록에서 연관을 쓸 것이 뻔하면 조인 페치로 한 번에 가져온다.
- 다형 연관의 프록시: 같은 문서에 따르면 Hibernate는 프록시를 만들 때 하위 타입을 알 수 없어, 프록시에
instanceof나 형 변환이 제대로 동작하지 않는다. 엔티티의equals도getClass()대신instanceof로 짠다. - 데코레이터와의 차이: 코드 모양은 같다. 데코레이터는 클라이언트가 직접 골라 겹겹이 씌워 기능을 더하고, 프록시는 대개 진짜 객체의 수명과 접근을 맡으며 프레임워크가 만들어 클라이언트는 프록시인지 모른다. 의도가 "기능 추가"면 데코레이터, "접근 제어"면 프록시라 부른다.
다른 언어에서는
- JavaScript의
Proxy객체: 언어가 프록시를 내장한다. MDN에 따르면Proxy는 다른 객체의 기본 동작(속성 읽기, 쓰기 등)을 가로채 다시 정의하고, 가로채는 함수를 트랩(trap)이라 부른다. 원래 동작은 같은 이름의Reflect메서드로 이어 준다.
// 보호 프록시: 밑줄로 시작하는 속성은 읽기와 쓰기를 막는다
const guarded = (target) =>
new Proxy(target, {
get(object, key, receiver) {
if (typeof key === 'string' && key.startsWith('_')) {
throw new Error(`비공개 속성: ${key}`);
}
return Reflect.get(object, key, receiver);
},
set(object, key, value, receiver) {
if (typeof key === 'string' && key.startsWith('_')) {
throw new Error(`비공개 속성: ${key}`);
}
return Reflect.set(object, key, value, receiver);
},
});
const account = guarded({ owner: 'kim', _token: 'secret' });
account.owner;
// account._token → Error- Python:
__getattr__을 정의한 감싸개 클래스가 모르는 속성 접근을 모두 안쪽 객체로 넘기는 흔한 프록시다. 가로챌 메서드만 따로 정의한다. - Go: 인터페이스를 구조체에 임베딩하고 가로챌 메서드만 다시 쓴다. Effective Go가 말하듯 넘어간 메서드는 안쪽 값이 받는다.
// 인터페이스를 임베딩하면 나머지 메서드는 그대로 안쪽에 넘어가고, Delete만 가로챈다
type ReadOnlyStore struct {
Store
}
func (s ReadOnlyStore) Delete(key string) error {
return errors.New("읽기 전용 저장소")
}일곱 구조 패턴 비교: 모양이 비슷한데 의도가 다르다
구조 패턴은 모두 "객체가 다른 객체를 들고 일을 넘긴다"는 같은 재료로 만든다. 그래서 클래스 그림만 보면 셋씩 넷씩 닮았고, 특히 어댑터, 데코레이터, 프록시는 코드가 거의 똑같다. 이름을 가르는 것은 왜 감쌌는가다. 같은 LoggingClient라도 기능을 더하려고 골라 씌웠으면 데코레이터, 프레임워크가 접근을 가로채려고 세웠으면 프록시다.
| 패턴 | 감싸는 것 | 인터페이스 | 의도 | 헷갈리는 짝 |
|---|---|---|---|---|
| 어댑터 | 객체 하나 | 바꾼다 | 이미 있는 것의 인터페이스를 기대하는 모양으로 맞춘다 | 데코레이터, 브리지 |
| 브리지 | 구현 계층 | 추상과 구현이 따로 | 두 방향으로 늘어나는 것을 미리 떼어 놓는다 | 어댑터, 전략 |
| 컴포지트 | 자식 여럿 | 같다 | 낱개와 묶음을 같은 방법으로 다룬다 | 데코레이터(자식이 하나인 컴포지트처럼 보인다) |
| 데코레이터 | 객체 하나 | 같다 | 같은 모양을 지키며 기능을 겹겹이 더한다 | 프록시, 어댑터 |
| 퍼사드 | 하위 시스템 여럿 | 새 단순 인터페이스 | 복잡한 것 앞에 쓰기 쉬운 창구를 둔다 | 어댑터(퍼사드는 새 인터페이스를 정의한다) |
| 플라이웨이트 | 감싸지 않음(공유) | 공유 객체 + 인자 | 같은 상태를 나눠 써서 객체 수와 메모리를 줄인다 | 싱글턴(플라이웨이트는 키마다 하나) |
| 프록시 | 객체 하나 | 같다 | 진짜 객체에 닿는 길(생성 시점, 권한, 위치)을 대신 맡는다 | 데코레이터 |
- 인터페이스를 바꾸나: 바꾸면 어댑터(기존 것에 맞춤)나 퍼사드(새로 단순하게), 그대로면 데코레이터, 프록시, 컴포지트다.
- 몇 개를 감싸나: 하나면 어댑터, 데코레이터, 프록시, 여럿이면 컴포지트(같은 종류의 자식들)나 퍼사드(서로 다른 하위 시스템)다.
- 언제 정하나: 브리지는 설계할 때 미리 떼어 놓고, 어댑터는 이미 있는 것을 나중에 맞춘다. 플라이웨이트는 객체 수가 문제로 드러난 뒤 최적화로 들어오는 경우가 많다.
흔한 오해
- "프록시와 데코레이터는 다른 구조다" — 구조는 같다. 같은 인터페이스를 구현하고 안쪽을 들고 넘긴다. 가르는 것은 의도(기능 추가 대 접근 제어)와 누가 감싸는가(클라이언트가 골라 겹침 대 프레임워크가 세움)다.
- "퍼사드를 두면 하위 시스템을 직접 쓰면 안 된다" — 퍼사드는 흔한 일을 쉽게 하는 창구일 뿐, 세부가 필요한 코드는 하위 시스템을 직접 써도 된다. 막는 것은 모듈 경계의 일이다.
- "플라이웨이트는 캐시다" — 팩터리 안에 캐시 같은 풀이 있지만, 핵심은 상태를 공유(고유)와 개별(외부)로 나누는 설계다. 나누지 않고 객체를 통째로 저장하면 그냥 캐시다.
- "
Integer는 작은 수라서==로 비교해도 된다" — Javadoc이 보장하는 것은 -128~127뿐이다. 그 밖은 구현에 따라 다르므로 값은equals로 비교한다. - "
@Transactional을 달면 어디서 불러도 트랜잭션이 걸린다" — 프록시 방식에서는 프록시를 거쳐 들어온 바깥 호출만 가로챈다. 같은 객체 안의 자기 호출은 걸리지 않는다. - "지연 로딩은 늘 빠르다" — 미루기만 할 뿐 조회 수는 줄지 않는다. 목록을 돌며 연관을 하나씩 건드리면 N+1 조회가 된다.
핵심 자료
요지는 직접 정리한 한 줄이다.
| 자료 | 한 줄 요지 |
|---|---|
| Wikipedia — Facade pattern | 복잡한 하위 시스템 앞에 단순한 인터페이스 하나. 리팩터링의 출발점으로도 쓴다. |
| Wikipedia — Flyweight pattern | 고유(intrinsic) 상태는 공유하고 외부(extrinsic) 상태는 인자로. 문서 편집기의 글리프에서 시작했다. |
| Wikipedia — Proxy pattern | 원격, 가상, 보호 프록시의 쓰임. 프록시와 진짜 객체는 같은 인터페이스를 구현한다. |
| Oracle — java.lang.reflect.Proxy | 인터페이스만으로 실행 중에 프록시를 만들고, 호출은 InvocationHandler.invoke로 모인다. |
| Spring — Proxying Mechanisms | 인터페이스가 있으면 JDK 프록시, 없으면 CGLIB 하위 클래스. 자기 호출은 프록시를 건너뛴다. |
| Hibernate — An Introduction to Hibernate 6 | 지연 로딩 프록시의 동작과 함정(LazyInitializationException, instanceof, N+1). |
| MDN — Proxy | 언어가 프록시를 직접 지원한다. 트랩으로 속성 읽기, 쓰기 같은 기본 동작을 가로챈다. |
- Design Patterns (book) — Wikipedia
- Facade pattern — Wikipedia
- Flyweight pattern — Wikipedia
- Proxy pattern — Wikipedia
- SLF4J user manual
- Integer (Java SE 21) — Oracle
- String (Java SE 21) — Oracle
- sys.intern — Python 문서
- Proxy (Java SE 21) — Oracle
- InvocationHandler (Java SE 21) — Oracle
- Proxying Mechanisms — Spring Framework
- Using @Transactional — Spring Framework
- An Introduction to Hibernate 6
- Proxy — MDN Web Docs
- Effective Go — Embedding
- Effective Go — Names