행위 패턴 ② — 돌고, 알리고, 되돌린다
이 편의 넷은 객체 사이를 오가는 것을 다룬다. 컬렉션을 훑고(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()); // 해시맵
순회 코드가 자료구조에 묶여 있다. ArrayList 를 LinkedList 로 바꾸면 순회 코드도 바꿔야 한다면, 그것은 내부를 노출하고 있다는 뜻이다.
적용 — 꺼내는 방법을 객체로
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/Observable 은 Java 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 와 무엇이 다른가
| Observer | Mediator | |
|---|---|---|
| 방향 | 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 — 순회 위치를 컬렉션이 아니라 이터레이터가 갖는다.
ConcurrentModificationException은modCount검사에서 나오는 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·ArrayListmodCount) · Spring Framework Reference(Application Events ·@TransactionalEventListener)