4. 이벤트 처리의 여섯 재앙 퇴치하기
이제 앞서 언급한 여섯 재앙을 자세히 살펴보며 FRP가 각 문제를 어떻게 해결하는지 간략히 알아보자.
재앙 1: 예측 불가능한 순서
다이어그램을 그리는 프로그램을 개발한다고 하자. 그 프로그램에서 그래픽 원소들을 선택하거나 선택 해제할 수 있다. 그 규칙은 다음과 같다.
다음 그림은 다이어그램 프로그램에서 수행할 수 있는 단계 세 개를 보여준다.
이 시점에서 마우스를 한 번 클릭하면 두 가지 이벤트가 생긴다.
다음은 얼마나 많은 아이템이 선택됐는지에 따라 커서 모양을 바꿔주는 코드다.
public interface SelectionListener {
void selected(Item i);
void deselected(Item I);
}
public class CursorMonitor implements SelectionListener {
private HashSet<Item> selectedItems = new HashSet();
public void selected(Item i) {
selectedItems.add(i);
updateCursor();
}
public void deselected(Item i) {
selectedItems.remove(i);
updateCursor();
}
private void updateCursor() {
if (selectedElts.isEmpty()) crosshairCursor();
else arrowCursor();
}
}
이제 고객이 이런 시나리오에서 커서가 중간에 다른 모양으로 잠시 바뀌는 일 없이 계속 화살표를 유지해야 한다고 말한다면, 어떻게 이를 해결할 수 있을까?
화살표를 유지하려면 선택을 해제하기 전에 처리하도록 보장하거나, 전체를 일종의 트랜잭션으로 감싸서 트랜잭션이 끝날 때 커서를 한 번만 갱신해야 한다. 첫 번째 방법은 이벤트가 도착하는 시점을 예측할 수 없으니 사용하기가 어렵다. 이벤트 도착 시점은 리스너가 등록된 순서에 따라 달라질 수 있고, 우리는 그에 따른 커서 변경 로직 부분만을 수정할 수 있으니 전체를 통제할 수 없다. 두 번째 방법은 가능하지만 코드가 상당히 복잡해질 수 있다. 반면 FRP는 트랜잭션을 사용하기 때문에 이런 문제를 아주 깔끔하게 다룰 수 있다.
재앙 2: 첫 번째 이벤트 소실
요청을 받으면 서버에 연결하는 클래스를 만든다고 가정하자.
Connector conn = new Connector();
conn.requestConnect(true);
세션이 연결된 다음 서버와 통신하기 위해 필요하면 리스너를 등록할 수 있다. 이 클래스의 공개 인터페이스는 다음과 같다.
public class Connector {
public interface Listener {
public void online(Session s);
public void offline(Session s);
}
public void addListener(Listener l);
public void removeListener(Listener l);
public void requestConnect(boolean toConnect);
...
}
프로그램을 초기화할 때는 다음 3줄이 들어갈 것이다.
Connector conn = new Connector();
Demander dem = new Demander(conn);
Talker tkr = new Talker(conn);
Demander의 역할은 여러분이 세션의 시작을 원하는지를 정하는 것이다. Talker는 서버와의 통신을 수행한다. Talker 생성자 안에서 Talker는 conn.addListener()를 통해 자신을 리스너로 등록한다.
프로그램은 잘 동작할 것이다. 하지만 누군가 다음과 같이 코드를 변경한다.
서버와의 연결이 비동기적으로 수행되기 때문에 이런 변경도 잘 작동할 것이다. 따라서Talker는 실제로는 연결이 만들어지기 전에 초기화될 여지가 생긴다.
고객이 로컬 기계에서 수행되는 일종의 프록시를 사용하기로 결정하기 전까지는 모든 것이 잘 작동했다. 고객은 프로그램이 localhost에 있는 서버와 통신하도록 설정했다. 이런 경우, 소켓 프로그램에서는 소켓이 바로 연결을 완료하도록 돼 있다. 그에 따라 Talker가 만들어지기 전에 연결이 발생한다. 이제 Talker는 첫 번째 online() 이벤트를 잃어버리고, 그에 따라 프로그램은 연결됐지만 Talker가 서버와 이야기하지는 않는 사태가 발생한다.
이런 종류의 문제들은 대부분 초기화 순서와 처리 순서에 대한 문제에서 기원한다. FRP는 코드에서 처리 순서를 고려할 필요를 아예 없앰으로써 이런 문제를 해결한다.
재앙 3: 지저분한 상태
상태 기계(state machine)는 내부 상태가 있고 그 상태 사이에 전이(transition)가 이뤄지는 프로그램 로직을 말한다. 상태 전이는 비동기적인 외부 이벤트에 의해 촉발될 수 있다. 상태 기계는 여러 원(상태를 표현함) 사이를 화살표(이벤트에 의한 상태 전이를 표현함)로 연결한 다이어그램으로 표현할 수 있다.
관찰자 패턴은 여러분을 전통적인 상태 기계 스타일로 밀어붙이는 경향이 있다. 클래스가 여러 이벤트 소스를 리슨(listen)하게 되면 이런 상태 기계는 매우 지저분해질 수 있다.
예를 들어 앞의 예제에서 본 Connector에 다음 기능을 추가한다고 가정해보자.
이 새로운 인터페이스의 코드는 다음과 같다.
public interface Listener {
public void online(Session s);
public void offline(Session s);
public interface TearDownCallBack {
public void tornDown();
}
public void tearDown(Session s, TearDownCallBack cb);
}
Connector에는 이제 가능한 상태가 네 가지가 된다.
다음은 여러분이 받을 수 있는 이벤트들이다. 각 이벤트가 매 상태에서 제대로 처리되도록 만들어야 한다.
4개의 상태에 5개의 입력 이벤트를 곱하면 모두 20개의 조합이 생긴다. 그중 상당수는 잘못된 조합이다. 하지만 그런 모든 잘못된 조합이 정말로 발생하지 않는지에 대해 주의 깊게 생각해야만 한다.
몇 가지 일반적이지 않은 부분이 있다.
우리는 코드를 다루지는 않을 것이다. 하지만 여러분은 Connector의 구현이 나쁠지는 몰라도 형편없지는 않다는 사실을 상상할 수 있을 것이다. 하지만 더 복잡한 요소를 추가하면 더 형편 없는 방향으로 구현이 바뀔 가능성이 높다. 네트워킹은 그런 복잡함이 발생하기 쉬운 대표적인 예다.
이런 방식의 코딩에서는 실수를 저지르기 쉽고, 그런 실수를 디버깅하기는 어렵다. 예를 들어 Talker 중 하나에 간헐적인 버그가 있어서 tornDown()을 호출해주지 않는 경우가 있다면? 확인할 수 있는 것이 이벤트 로그밖에 없을 때 과연 로그만 보고 이런 버그를 잡아낼 수 있을까? FRP는 이런 혼란에 상당히 많은 질서를 부여해준다.
재앙 4: 스레드 문제
앞 예제가 여러분에게 복잡했는가? 이제 그것을 스레드 안전하게 만들자.
다섯 가지 입력 이벤트는 여러 다른 스레드에서 들어올 수 있다. 게다가 addListener()와 removeListener( ) 호출도 여러 스레드에서 이뤄질 수 있다. 그리고 removeListener()가 그것을 호출한 쪽에 반환되고 나면 더 이상 해당 콜백이 호출되지 않음을 보장해야만 한다.
아무 처리도 하지 않는다면 경합 조건(race condition)으로 인해 프로그램이 자체 붕괴되어 폐허만 남을 것이다. synchronized 키워드를 사용할 수도 있을 것이다. 그렇게 하면 자바가 클래스 인스턴스(여기서는 Connector)에 연결된 뮤텍스를 잠그게 된다. 대부분의 경우에는 이 방식도 잘 작동한다. 다음 코드는 notifyOnline()을 스레드 안전하게 만드는 방법을 하나를 보여준다. 하지만 이 코드도 약간 위험하다.
protected synchronized void notifyOnline(Session s)
{
for (l : listeners) l.online(s);
}
이것이 위험한 이유는 리스너의 핸들러가 어떤 일을 할지 알지 못하기 때문이다. 핸들러가 다른 어떤 것을 잠글 수도 있다. 1백만 번까지도 완벽하게 작동할 수도 있다. 하지만 교착상태(deadlock)라는 유령이 아주 가까이서 배회하면서 여러분에게 그림자를 드리우고 있다.
만약 그 유령이 여러분의 등뼈에 차가운 손가락을 슬그머니 대고 있는 것을 느끼지 못한다면 새로 프로그래머라는 직업을 시작한 것을 환영한다(그렇다는 것은 여러분이 아직 어리고, 경험도 적은 개발자라는 뜻이다).
이런 공포에서 벗어나기 위해서 보통은 synchronized 블록 밖에서 리스너에 통지할 수 있도록 스레드 안전한 리스너 리스트의 복사본을 유지한다. 다음 코드에서 그것을 확인할 수 있다.
protected void notifyOnline(Session s) {
List<Listener> ls;
synchronized (this) {
ls = listeners.clone();
}
for (l : ls) l.online(s);
}
문제가 해결됐다.
아, 정말 그렇게 생각하나? 정말? 다음 코드는 스레드 안전한 removeListener()를 구현하기 위한 시도를 보여준다.
public synchronized void removeListener(Listener l) {
listeners.remove(l);
}
이제 이전 코드의 notifyOnline()을 다시 보자. removeListener()에서 호출한 쪽으로 돌아온 다음 리스너가 불리는 일이 없다고 어떻게 보장할 수 있을까? 이것은 간단한 문제가 아니다.
FRP를 사용하면 이런 스레드 문제가 해리포터의 마법과 비슷한 방식으로 펑! 하고 공기속으로 사라진다.
재앙 5: 콜백 누수
어떤 이벤트 소스에 리스너를 등록한다고 하자. 그런데 작업을 다 마친 다음 removeListener()를 호출하는 일을 잊어버렸다. 흔한 실수다. 여러분의 리스너가 계속 이벤트 소스의 리스너 목록에 들어 있다. 따라서 그 리스너는 실제로는 필요 없음에도 계속 메모리에 남아 있게 된다. 그뿐 아니라 리스너가 호출될 때마다 CPU 시간을 낭비한다. 쓰레기 수집이 여러분에게 제공하기로 했던 안전성에 어떤 일이 벌어진 것일까?
관찰자 패턴의 요점은 자연스러운 의존관계를 뒤집어서 생산자가 소비자에게 의존하지 못하게 만드는 것이다. 하지만 생산자는 여전히 소비자를 살려둔다. 이상적이라면 이 관계도 역전시켜야 한다. FRP는 정확히 그런 일을 수행한다.
재앙 6: 의도치 않은 재귀
Connector 예제로 돌아가자. 다음은 협력적으로 세션을 정리하는 것을 구현한 코드다. 모든 것을 잡아 흔드는 버그를 조금 추가했다.
public class Connector {
public interface Listener {
public void online(Session s);
public void offline(Session s);
public interface TearDownCallBack {
public void tornDown();
}
public void tearDown(Session s, TearDownCallBack cb);
}
private boolean shouldBeOnline;
private Map<Session, Listener> activeSessions = new HashMap<>();
...
public void requestConnect(boolean req) {
this.shouldBeOnline = req;
update();
}
private void notifyTearDown() {
for (Map.Entry<Session, Listener> e : activeSessions.entrySet()) {
final Session s = e.getKey();
final Listener l = e.getValue();
Listener.TearDownCallBack cb =
new Listener.TearDownCallBack() {
void tornDown() {
activeSessions.remove(s);
update();
}
};
e.getKey().tearDown(s, cb);
}
}
private void update() {
switch (state) {
...
case State.ONLINE:
if (!shouldBeOnline) {
notifyTearDown(); // ① 버그 1
state = State.TEARING_DOWN;
}
break;
case State.TEARING_DOWN:
if (activeSessions.isEmpty()) {
notifyOffline(); // ② 버그 2
state = State.OFFLINE;
}
else
break;
...
}
}
...
}
① notifyTearDown()을 호출하는 동안, 핸들러 중 하나가 정리해야 할 일이 없기 때문에 tornDown()을 즉시 호출했다. 아직 상태를 TEARING_DOWN으로 바꾸기 전이므로 이 코드는 제대로 작동하지 않는다. isEmpty() 체크는 결코 실행되지 못한다. 이 문제는 다음과 같이 두 줄의 순서를 바꾸면 쉽게 고칠 수 있다.
state = State.TEARING_DOWN;
notifyTearDown();
② 어떤 이유가 생겨서 오프라인 핸들러가 여러분을 호출하는, 세션이 오프라인임을 통지하는 경우에도 같은 문제가 발생한다.
앞에서와 마찬가지로 이 경우도 쉽게 수정할 수 있다. 하지만 이런 실수를 저지르지 않는지 조심스럽게 생각해야만 한다. 이런 오류가 아예 불가능하다면 더 좋지 않을까? FRP에서는 정말로 그런 실수를 저지르는 것이 불가능하다.
우리가 여기서 빠뜨린 버그를 찾았는가? 이 코드는 세션이 없는 경우를 제대로 처리하지 못한다. 여러분은 else 안으로 break를 넣어서 notifyTearDown()을 호출한 다음에 자동으로 State.TEARING_DOWN 쪽을 처리하도록 해야 한다. 수정한 코드를 보자.
case State.ONLINE:
if (!shouldBeOnline) {
state = State.TEARING_DOWN;
notifyTearDown();
}
else
break;
이런 방식의 코딩은 그냥 나쁜 것이다. FRP가 이런 문제에 대한 해결책을 독점하고 있지는 않다. 하지만 이 책이 이 세상에서 나쁜 코드의 양을 줄이는 데 기여하기를 바란다.
* 이 글은 『함수형 반응형 프로그래밍』의 내용을 재구성해 꾸몄습니다.
최신 콘텐츠