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

행위 패턴 ② — 돌고, 알리고, 되돌린다

이 편의 넷은 객체 사이를 오가는 것을 다룬다. 컬렉션을 훑고(Iterator), 변화를 알리고(Observer), 통신을 한곳으로 모으고(Mediator), 과거로 되돌린다(Memento).

Observer 와 Mediator 는 "여러 객체를 이어 준다" 는 점이 같아 자주 섞인다. 통지가 한 방향이냐, 조정이 양방향이냐로 가른다.


Iterator — 일관된 순회

컬렉션의 내부 표현을 드러내지 않고 원소를 차례로 꺼낸다.

문제 — 자료구조마다 순회 방법이 다르다

for (int i = 0; i < array.length; i++)  use(array[i]);         // 배열
for (Node n = head; n != null; n = n.next) use(n.value);       // 연결 리스트
for (var e : map.entrySet()) use(e.getValue());                // 해시맵

순회 코드가 자료구조에 묶여 있다. ArrayListLinkedList 로 바꾸면 순회 코드도 바꿔야 한다면, 그것은 내부를 노출하고 있다는 뜻이다.

적용 — 꺼내는 방법을 객체로

public interface Iterator<E> {
    boolean hasNext();
    E next();
}

이 둘만 있으면 무엇이든 같은 방법으로 훑을 수 있다. 직접 구현해 보면 상태가 어디에 있는지 보인다.

실무에서 직접 만들게 되는 경우는 대개 페이지네이션을 감출 때다. 수십만 건을 한 번에 못 읽으니 나눠 읽는데, 그 사실을 쓰는 쪽이 몰라도 되게 한다.

class PagedOrders implements Iterable<Order> {
    private final OrderRepository repo; private final int size;

    public Iterator<Order> iterator() {
        return new Iterator<>() {
            private Long cursor = null;                    // ← 순회 위치는 이터레이터가 갖는다
            private Iterator<Order> page = Collections.emptyIterator();
            private boolean done = false;

            public boolean hasNext() {
                if (page.hasNext()) return true;
                if (done) return false;
                var next = repo.findAfter(cursor, size);   // 다음 페이지를 그때 가져온다
                if (next.isEmpty()) { done = true; return false; }
                cursor = next.get(next.size() - 1).id();
                page = next.iterator();
                return true;
            }
            public Order next() { return page.next(); }
        };
    }
}

for (Order o : new PagedOrders(repo, 500)) settle(o);   // 쓰는 쪽은 페이징을 모른다

위치(cursor)를 컬렉션이 아니라 이터레이터 갖는 것이 핵심이다. 그리고 여기서 Iterator 의 진짜 값어치가 드러난다 — 전부 메모리에 올리지 않고도 순회하는 것처럼 보이게 할 수 있다. 500건씩만 힙에 있다.

오프셋이 아니라 커서를 쓴 이유LIMIT ... OFFSET n 은 뒤로 갈수록 앞의 n 건을 세느라 느려지고, 순회 중에 앞쪽 행이 지워지면 건너뛰는 행이 생긴다. 마지막으로 읽은 키 이후를 조회하면 둘 다 해결된다.

ConcurrentModificationException — 왜 나는가

List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) list.remove(s);      // ConcurrentModificationException
}

ArrayList이터레이터modCount(구조 변경 횟수)를 기억해 두고, next() 마다 지금 값과 같은지 확인한다. 다르면 "내가 보고 있는 사이에 누가 바꿨다" 로 판단하고 즉시 던진다.

이것을 fail-fast 라 하는데, 목적은 오류를 막는 것이 아니라 일찍 드러내는 것이다. 조용히 원소를 건너뛰거나 두 번 읽는 것보다 즉시 터지는 편이 낫다는 판단이다.

Iterator<String> it = list.iterator();
while (it.hasNext()) if (it.next().equals("b")) it.remove();   // ✓ 이터레이터로 지운다
list.removeIf(s -> s.equals("b"));                              // ✓ 더 간단하다

it.remove() 는 이터레이터가 자기 modCount 도 같이 갱신하므로 안전하다.

모던 자바에서는

list.forEach(System.out::println);                    // 내부 반복 — 컬렉션이 돈다
list.stream().filter(…).map(…).forEach(…);            // 선언형 파이프라인

외부 반복(우리가 next() 를 부름)에서 내부 반복(컬렉션이 돌면서 우리 함수를 부름)으로 바뀌었다. 내부 반복은 순회 방식을 라이브러리가 정하므로 병렬화 같은 최적화를 대신해 줄 수 있다.

함정Stream한 번만 쓸 수 있다. 재사용하려 하면 IllegalStateException 이 난다. 이터레이터도 마찬가지로 소모품이다.

그리고 무한 순회가 가능한 이터레이터(Stream.iterate)를 만들 때는 반드시 limit 같은 종료 조건을 함께 준다.


Observer — 발행/구독

한 객체의 상태 변화를 구독자들에게 자동으로 알린다(1:N).

문제 — 알려야 할 곳이 늘어난다

class OrderService {
    void place(Order o) {
        repo.save(o);
        mailSender.send(o);        // 주문 확인 메일
        pointService.give(o);      // 적립금
        statService.count(o);      // 통계
        slackNotifier.post(o);     // 운영 채널  ← 요구가 생길 때마다 이 메서드가 자란다
    }
}

주문이라는 본질과 "그때 같이 해야 할 일" 이 한 메서드에 섞인다. 적립금 로직이 예외를 던지면 주문까지 실패한다.

적용 — 발행하고, 관심 있는 쪽이 구독한다

interface OrderListener { void onPlaced(Order o); }

class OrderService {
    private final List<OrderListener> listeners = new CopyOnWriteArrayList<>();

    void register(OrderListener l) { listeners.add(l); }

    void place(Order o) {
        repo.save(o);
        for (var l : listeners) l.onPlaced(o);    // 누가 듣는지 모른다
    }
}

OrderService누가 듣는지 모른다. 새 후속 처리는 리스너를 하나 더 등록하는 일이 되고, 주문 코드는 안 바뀐다.

CopyOnWriteArrayList 를 쓴 이유가 있다 — 통지 도중에 리스너가 등록/해제되면 일반 리스트는 ConcurrentModificationException 이 난다. 읽기가 압도적으로 많고 쓰기가 드문 전형적인 상황이다.

실무에서는 프레임워크가 해 준다

// 발행
record OrderPlacedEvent(String orderNo, long amount) { }
publisher.publishEvent(new OrderPlacedEvent(o.no(), o.amount()));

// 구독
@Component
class PointListener {
    @EventListener
    void on(OrderPlacedEvent e) { pointService.give(e.orderNo()); }
}

함정 — 기본은 동기이고 같은 트랜잭션이다.

@EventListener              // 발행 스레드에서 그대로 실행된다
void on(OrderPlacedEvent e) { slack.post(e); }   // 여기서 5초 걸리면 주문도 5초 걸린다

리스너가 예외를 던지면 발행 쪽 트랜잭션이 롤백된다. "메일 발송이 실패했는데 주문까지 취소됐다" 가 여기서 나온다.

커밋된 뒤에 실행하려면 @TransactionalEventListener(phase = AFTER_COMMIT) 를, 별도 스레드로 돌리려면 @Async 를 함께 쓴다. 다만 @Async 를 붙이면 예외가 호출자에게 안 돌아가므로 실패를 따로 기록해야 한다.

JDK 에서는

Flow.Publisher<Item> publisher = …;       // Reactive Streams (Java 9+)
publisher.subscribe(new Flow.Subscriber<>() {
    public void onSubscribe(Flow.Subscription s) { s.request(1); }   // 요청한 만큼만 받는다
    public void onNext(Item item) { … }
    public void onError(Throwable t) { … }
    public void onComplete() { … }
});

java.util.Observer/ObservableJava 9 부터 deprecated 다. 순서 보장이 없고 이벤트 타입이 Object 라 타입 안전하지 않았기 때문이다. 지금은 Flow, PropertyChangeListener, 또는 리액티브 라이브러리를 쓴다.

Flow 가 옛 Observer 와 다른 결정적인 점은 request(n) 이다. 구독자가 감당할 만큼만 요청하므로, 발행이 소비보다 빠를 때 구독자가 밀려 넘치는 일을 막는다.

함정 — 리스너를 등록하고 해제하지 않으면 메모리 누수가 된다. 발행자가 리스너 참조를 들고 있으므로 GC 되지 않는다. 수명이 다른 객체를 구독시킬 때는 해제를 반드시 챙긴다.


Mediator — 중재자

여러 객체가 서로를 직접 알지 않고 중재자를 통해서만 소통하게 한다.

문제 — 연결선이 제곱으로 는다

// 서비스들이 서로를 직접 호출한다
@Service class PaymentService {
    private final OrderService order; private final InventoryService inventory;
    private final NotificationService noti; private final PointService point;

    void onPaid(Payment p) {
        order.markPaid(p.orderNo());
        inventory.confirm(p.orderNo());
        noti.sendPaymentDone(p);
        point.accrue(p);                  // 결제 서비스가 적립까지 안다
    }
}
@Service class InventoryService {
    private final NotificationService noti;   // 얘도 남을 안다
    private final OrderService order;
    …
}

서비스가 n 개면 연결이 최대 n(n−1)/2 다. 하나를 빼거나 시그니처를 바꾸면 그것을 참조하던 모두를 고쳐야 한다. 순환 참조가 생겨 스프링이 기동에 실패하는 것도 대개 이 모양에서다.

적용 — 흐름을 조정하는 곳을 하나 둔다

@Service
public class OrderProcessMediator {
    private final OrderService order; private final InventoryService inventory;
    private final PaymentService payment; private final NotificationService noti;

    public void handle(DomainEvent e) {              // 조정 규칙이 여기 모인다
        switch (e) {
            case PaymentCompleted p -> {
                order.markPaid(p.orderNo());
                inventory.confirm(p.orderNo());
                noti.sendPaymentDone(p);
            }
            case StockShortage s -> {
                payment.refund(s.orderNo());          // 순서와 보상이 한눈에 보인다
                order.cancel(s.orderNo(), "재고 부족");
                noti.sendCancelled(s);
            }
            default -> { }
        }
    }
}

// 각 서비스는 자기 일만 하고 이벤트를 낸다 — 남을 모른다
@Service class PaymentService {
    void complete(Payment p) { … ; publisher.publishEvent(new PaymentCompleted(p.orderNo())); }
}

각 서비스는 중재자 하나만(정확히는 이벤트 발행만) 안다. 연결이 n(n−1)/2 에서 n 으로 줄고, "결제 후 무슨 일이 일어나는가" 를 한 파일에서 읽을 수 있다.

대가

조정 규칙이 전부 중재자로 모이므로, 중재자가 God Object 가 되기 쉽다. 연결선을 줄인 대신 복잡도를 한곳에 몰아넣은 것이다.

화면 하나·기능 하나 단위로 중재자를 나누고, 중재자가 자라면 쪼갠다. "복잡도를 없앤 것이 아니라 옮긴 것" 이라는 자각이 필요하다.

실무에서는

// 채팅방 — 참가자끼리 직접 연결하지 않는다
class ChatRoom {
    private final List<User> users = new ArrayList<>();
    void send(User from, String msg) {
        for (var u : users) if (u != from) u.receive(from, msg);
    }
}

메시지 브로커도 개념적으로는 큰 중재자다. 생산자와 소비자가 서로를 모르고 브로커만 안다.

Observer 와 무엇이 다른가

ObserverMediator
방향Subject → Observer 단방향 통지참여자 ↔ 중재자 양방향 조정
아는 것관찰자는 주제를 안다참여자는 서로를 모른다
로직 위치각 관찰자가 자기 일을 한다중재자가 조정 규칙을 갖는다

"알림" 이면 Observer, "조정" 이면 Mediator 다. 결정이 필요하면 중재자가 있어야 한다.


Memento — 스냅샷·복원

객체의 내부 상태를 캡슐화를 깨지 않고 저장했다가 되돌린다.

캡슐화를 깨지 않는다는 말의 뜻

// ✗ 캡슐화를 깨는 방법 — 되돌리려고 내부를 다 열었다
class Editor {
    public String getText()  { … }     // 밖에서 다 볼 수 있게 됐다
    public int getCursor()   { … }
    public void setText(String t) { … }
}

되돌리기 하나 때문에 내부가 전부 공개된다. 이제 아무나 상태를 바꿀 수 있다.

적용 — 불투명한 스냅샷을 넘긴다

class Editor {
    private String text; private int cursor;

    public Memento save() { return new Memento(text, cursor); }      // 원본만 만들 수 있고
    public void restore(Memento m) { this.text = m.text; this.cursor = m.cursor; }

    public static final class Memento {                              // 밖에서는 못 읽는다
        private final String text; private final int cursor;
        private Memento(String text, int cursor) { this.text = text; this.cursor = cursor; }
    }
}

class History {                       // 보관자 — 들고만 있지 내용은 모른다
    private final Deque<Editor.Memento> stack = new ArrayDeque<>();
    void backup(Editor e) { stack.push(e.save()); }
    void undo(Editor e)   { if (!stack.isEmpty()) e.restore(stack.pop()); }
}

세 역할이 나뉜다 — Originator(상태를 가진 원본), Memento(스냅샷), Caretaker(보관자). 보관자는 스냅샷을 들고만 있고 안을 들여다볼 수 없다. 필드가 private 이고 생성자도 private 이라 원본만 만들 수 있기 때문이다.

Command 의 undo 와 어떻게 다른가

  • Command.undo()반대 동작을 수행한다. 메모리는 적게 쓰지만 반대 동작을 정의할 수 있어야 한다
  • Memento이전 상태를 통째로 저장한다. 반대 동작이 필요 없지만 메모리를 쓴다

"삽입 ↔ 삭제" 처럼 반대가 명확하면 Command 가, "복잡한 필터 적용" 처럼 되돌릴 방법이 없으면 Memento 가 맞다. 실무에서는 둘을 섞어 쓴다 — 가벼운 동작은 반대 동작으로, 무거운 것은 스냅샷으로.

실무에서는

// record 는 그 자체로 좋은 스냅샷이다 — 불변이라 나중에 바뀔 걱정이 없다
record FormDraft(String title, String body, List<String> tags, Instant savedAt) { }

void autoSave() { drafts.push(new FormDraft(title, body, List.copyOf(tags), Instant.now())); }

함정 — 스냅샷이 가변 객체를 참조하면 되돌리기가 깨진다. 위에서 List.copyOf 를 쓴 이유다. 원본 리스트를 그대로 담으면 나중에 그것이 바뀌면서 "저장해 둔 과거" 도 같이 바뀐다.

그리고 스냅샷을 무한히 쌓으면 메모리를 먹는다. 개수 상한을 두거나, 큰 문서라면 전체가 아니라 변경분만 저장하는 방식으로 바꾼다.


한눈에 정리

  • Iterator — 순회 위치를 컬렉션이 아니라 이터레이터가 갖는다. ConcurrentModificationExceptionmodCount 검사에서 나오는 fail-fast 로, 오류를 막는 게 아니라 일찍 드러내는 장치다
  • 순회 중 삭제는 it.remove()removeIf 로. Stream 과 이터레이터는 한 번 쓰면 끝이다
  • Observer — 발행자가 구독자를 모른다. 후속 처리가 늘어도 본체는 안 바뀐다
  • 스프링 이벤트기본이 동기·같은 트랜잭션 — 리스너 예외가 발행 쪽을 롤백시킨다. AFTER_COMMIT·@Async 로 분리한다
  • 리스너 해제를 잊으면 메모리 누수가 된다
  • Flow 가 옛 Observable 과 다른 점은 request(n) — 구독자가 감당할 만큼만 받는다
  • Mediator — 연결을 n(n−1)/2 에서 n 으로 줄인다. 대신 복잡도를 중재자로 옮긴 것이라 God Object 가 되기 쉽다
  • Observer vs Mediator — 알림(단방향)이냐 조정(양방향)이냐. 결정이 필요하면 중재자다
  • Memento — 보관자가 안을 못 보게 하는 것이 요점. 스냅샷은 불변이어야 하고(List.copyOf) 개수 상한을 둔다
  • Command.undo vs Memento — 반대 동작이냐 이전 상태냐. 실무에서는 섞어 쓴다

출처 — GoF Design Patterns(1994) §5 Behavioral Patterns · Java SE 21 API Docs(Iterator · Flow · CopyOnWriteArrayList · ArrayList modCount) · Spring Framework Reference(Application Events · @TransactionalEventListener)

행위 패턴 ① — 요청을 객체로 다룬다행위 패턴 ③ — 알고리즘과 상태를 갈아 끼운다