부수효과 가득한 프론트엔드에서 함수형으로 살아남기
프론트엔드의 거의 모든 코드가 액션이라는 현실에서, 액션을 격리하고 계산을 넓혀가는 과정을 직접 도구를 만들어가며 정리해보았습니다.
로딩중...
오래 걸리지 않기를 바라요..!
프론트엔드의 거의 모든 코드가 액션이라는 현실에서, 액션을 격리하고 계산을 넓혀가는 과정을 직접 도구를 만들어가며 정리해보았습니다.
이번에 우아한테크코스 레벨 1을 진행하며 함수형 원정대에 합류했고, 그 과정에서 미션에 함수형적 사고를 적용해보다 마주한 고민들을 정리해보았다.
예전부터 함수형 패러다임과 "좋은 코드"라는 단어에 막연한 관심이 있었다. 그러다 보니 우아한테크코스 레벨 1이 시작되자마자 함수형 원정대에 합류하게 되었다.
원정대에서는 『쏙쏙 들어오는 함수형 코딩』을 교재로 스터디를 진행했다. 공유회도 열고, 공유회를 위한 코드랩도 같이 만들면서 함수형 사고에 대한 인사이트를 많이 얻을 수 있었다.
함수형에서는 코드를 크게 3가지, 액션, 계산, 데이터로 분리한다.

세 가지를 한 장으로 정리하면 위와 같다. 핵심은 가장 다루기 까다로운 것이 액션이라는 점이다. 호출 시점과 횟수에 따라 결과가 달라지니 예측도, 테스트도 어렵다.
책에서는 "액션은 쉽게 퍼지기 때문에 잘 격리해야 한다"고 말한다. 계산으로 뽑아낼 수 있는 부분은 뽑아내고, 외부 변수에 접근하거나 DOM을 조작하는 암묵적 입출력을 줄여야 한다는 이야기다.
이런 함수형의 논리가 너무 설득력 있고, 흥미가 생겨서 공부한 내용을 진행 중인 미션에 이 관점을 한번 적용해보기로 하였다.
마침 진행 중인 미션이 있었다. 넷플릭스와 비슷한 UI를 만들고 영화 검색, 상세 정보 조회, 별점 등록 기능을 구현하는 영화 리뷰 미션이었다.
하지만 코드를 작성해나가다 보니 작성하는 대부분의 코드들이 액션이라는 것을 깨닫게 되었다.
영화 아이템 클릭 시 상세 모달을 띄우는 코드를 가져왔다.
// 영화 아이템 클릭 시 상세 모달을 띄운다.
movieListEl.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const movieItem = target.closest<HTMLElement>(".movie-item");
if (movieItem?.dataset.movieId) {
openMovieDetailModal(movieItem.dataset.movieId);
}
});
// 모달이 열리면, 닫는 방법을 여러 개 등록한다.
function openMovieDetailModal(movieId: string) {
// ... 모달 그리기 ...
// ESC 키로 닫기
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") handleModalClose();
});
// 배경이나 닫기 버튼 클릭으로 닫기
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (target.closest(".close-modal") || target.classList.contains("modal-background")) {
handleModalClose();
}
});
}영화 아이템을 클릭하면 모달이 열리고, ESC 키나 배경 클릭, 닫기 버튼으로 모달을 닫을 수 있다. 평범한 시나리오다.
그런데 이 짧은 코드 안에 액션이 빼곡하다. DOM에 접근하고, 이벤트 리스너를 등록하고, 핸들러 안에서 또 다른 액션을 호출한다. 게다가 핸들러 안쪽을 자세히 보면 묘한 패턴이 반복된다. "이벤트를 받아서 → 어떤 조건인지 판별하고 → 액션을 실행한다" 는 흐름이 세 군데에서 거의 똑같이 등장한다.
도메인 로직을 주로 다루지 않는 프론트에서는, 계산으로 처리할 수 있는 것들이 많지 않아 보였다.
"아니 함수형 이거... 프론트엔드에서 쓸 수 있는 거 맞나..?" 🤔
책에서는 액션에서 계산으로 뽑아낼 수 있는 부분을 최대한 뽑아내라고 한다.
이를 위해 모달 닫기 코드에 줄마다 해당 로직을 계산으로 뺄 수 있을지를 주석으로 표시했다.
// 영화 아이템 클릭 → 모달 열기
movieListEl?.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
// [액션] DOM 탐색을 통해 클릭된 영화 아이템을 찾는다.
const movieItem = target.closest<HTMLElement>(".movie-item");
// [계산] movieId가 있는지 판별한다.
if (movieItem?.dataset.movieId) {
// [액션] 모달을 연다.
openMovieDetailModal(movieItem.dataset.movieId);
}
});
// 모달 닫기
document.addEventListener("keydown", (event) => {
// [계산] Escape 키인지 판별한다.
if (event.key === "Escape")
// [액션] 모달을 닫는다.
handleModalClose();
});
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
// [계산] 닫기 버튼인지 판별한다.
const isCloseButton = !!target.closest(".close-modal");
// [계산] 모달 배경인지 판별한다.
const isBackground = target.classList.contains("modal-background");
// [액션] 둘 중 하나라도 해당되면 모달을 닫는다.
if (isCloseButton || isBackground) handleModalClose();
});여기서 계산에 해당하는 판별 로직을 서브루틴으로 뽑으면 이렇게 된다.
// --- 계산 (순수 함수) ---
// Escape 키인지 판별한다.
const isEscapeKey = (event: KeyboardEvent) => event.key === "Escape";
// 클릭 대상이 모달을 닫아야 하는 영역인지 판별한다.
const isModalCloseTarget = (target: HTMLElement) =>
!!target.closest(".close-modal") || target.classList.contains("modal-background");
// 클릭된 요소에서 movieId를 꺼낸다.
const getMovieId = (target: HTMLElement) => target.closest<HTMLElement>(".movie-item")?.dataset.movieId;
// --- 액션 (얇아진 핸들러) ---
movieListEl?.addEventListener("click", (event) => {
const movieId = getMovieId(event.target as HTMLElement);
if (movieId) openMovieDetailModal(movieId);
});
document.addEventListener("keydown", (event) => {
if (isEscapeKey(event)) handleModalClose();
});
document.addEventListener("click", (event) => {
if (isModalCloseTarget(event.target as HTMLElement)) handleModalClose();
});판별 로직을 전부 순수 함수로 뽑아내니, 이벤트 핸들러 안에는 "언제 무엇을 한다"는 흐름만 남게 되었다.
이렇게 추출한 함수들은 전부 입력만 받아서 출력만 돌려주는 형태다. 외부 상태도 건드리지 않고, DOM을 조작하지도 않는다. 단위 테스트도 쉽고 재사용도 쉬운 코드다.
분명 아까보다 좋아지긴 했다. 하지만 이렇게 다듬어 놓고 보니 여전히 마음에 걸리는 게 있었다.
isEscapeKey 한 줄짜리 함수를 만드는 게 정말 의미 있는 분리일까? 게다가 이런 코드들을 모두 분리하다 보면 함수가 너무나도 많아질 것이다.이 문제들을 해결할 수는 없을까? 🤔
근본적으로 뭔가 잘못 접근하고 있는 것 같아서 바라보는 관점 자체를 바꿔보고자 했다.
프론트가 하는 일을 큰 그림에서 다시 생각해봤다.
결국 프론트엔드가 하는 일은 액션 → 계산 → 액션 이라는 기본 골격을 가지고 있었다.

함수형 관점에서는 호출 시점과 횟수에 의존적이라 예측이 어렵고 테스트가 어려운 액션을 최대한 고립시키고, 많은 부분을 계산으로 가져가는 것이 좋다고 한다.
그렇다면 어떻게 액션의 영향력을 최대한 고립시킬 수 있을까? 위 그림처럼 프론트의 흐름 상에서 양쪽 끝의 액션부를 최대한 얇게 만들고, 가운데 계산부를 넓게 가져가보고자 하였다.
이 관점으로 기존 핸들러들을 다시 봤다.
// keydown 이벤트를 받는다 → [계산] Escape 키인지 판별한다 → [액션] 모달을 닫는다.
document.addEventListener("keydown", (event) => {
// [입력] keydown 이벤트를 받는다
// [계산] Escape 키인지 판별한다.
if (event.key === "Escape")
// [결과 출력] 모달을 닫는다.
handleModalClose();
});
// click 이벤트를 받는다 → [계산] 닫기 버튼인지 혹은 모달 배경인지 판별한다 → [액션] 둘 중 하나라도 해당되면 모달을 닫는다.
document.addEventListener("click", (event) => {
// [입력] click 이벤트를 받는다
const target = event.target as HTMLElement;
// [계산] 닫기 버튼인지 판별한다.
const isCloseButton = !!target.closest(".close-modal");
// [계산] 모달 배경인지 판별한다.
const isBackground = target.classList.contains("modal-background");
// [결과 출력] 둘 중 하나라도 해당되면 모달을 닫는다.
if (isCloseButton || isBackground) handleModalClose();
});모든 핸들러가 정확히 같은 구조였다.
"이벤트를 받는다 → 필터링 / 추출 및 변형을 한다 → 액션을 실행한다"
여기서 한 가지 생각이 들었다.
함수형에서 말하는 데이터란 "이벤트에 대한 사실, 여러 연산이나 액션의 결과물"이다.
그런데 생각해보면, DOM에서 발생하는 click이나 keydown도 결국 "사용자가 무언가를 했다는 사실"이자, 브라우저 내부에서 일어난 일의 결과물이다. 서버 응답도 마찬가지다.
그렇다면 이벤트도 데이터로 바라볼 수 있지 않을까? 데이터로 본다면, 이 데이터를 필요에 따라 가공하고 사용할 수 있어야 한다.
아까 서브루틴 추출을 마치고 발견한 패턴을 다시 보겠다.
"이벤트를 받는다 → 필터링 / 추출 및 변형을 한다 → 액션을 실행한다"
가운데의 "필터링"과 "추출 및 변형" — 이 둘을 이벤트 데이터에 적용할 수 있는 재사용 가능한 유틸 함수로 만들면 어떨까?
filter(predicate)map(transformer)JS의 함수형 메서드 filter와 map에서 이름을 가져와 사용해보겠다.
이렇게 정의한 유틸 함수를 어떻게 제공하는 게 좋을까?
이 유틸 함수는 이벤트 데이터만을 다루는 함수다. 그러니 응집도 있게 가져갈 수 있으면 좋을 것 같았다. 마치 map, filter 메서드를 배열 객체에서만 사용할 수 있는 것처럼 말이다.
이를 위해 이벤트를 감싸는 일관된 형식의 래퍼 객체를 만들고, 그 위에 filter와 map을 메서드로 제공하기로 하였다.
// 사용 예시 — 이벤트를 감싼 래퍼 객체에 메서드를 체이닝한다.
eventSource.map((event) => event.target).filter((target) => isModalCloseTarget(target));인터페이스를 정의해본다.
type Cleanup = () => void;
interface EventSource<T> {
filter(predicate: (value: T) => boolean): EventSource<T>;
map<U>(fn: (value: T) => U): EventSource<U>;
subscribe(handler: (value: T) => void): Cleanup;
}체이닝하려면 같은 타입을 돌려줘야 하니 filter와 map은 새로운 EventSource를 돌려준다. 마지막에 subscribe로 실제 핸들러를 붙일 수 있도록 하였다.
먼저 EventSource 객체를 만드는 팩토리 함수 makeSource를 구현해보자.
makeSource는 EventSource를 만들어 돌려준다. 이때 만들어진 EventSource는 자신이 어떻게 동작할지, 어떤 핸들러를 받아 어떻게 처리할지에 대한 정보를 들고 있어야 한다. 이 정보를 어딘가에서 받아야 하니, makeSource가 그것을 인자로 받게 한다. 그 정보를 registerFn이라는 이름의 함수 형태로 받기로 하자.
function makeSource<T>(registerFn: (handler: (value: T) => void) => Cleanup): EventSource<T> {
return {
// filter, map, subscribe 메서드는 아래에서 차례로 채워나간다.
};
}이제는 DOM 이벤트로부터 EventSource를 만드는 진입 함수 fromDOMEvent를 구현해보자.
fromDOMEvent는 EventSource를 돌려줘야 하므로 makeSource를 호출해 리턴한다.
makeSource에 넘길 registerFn 은 핸들러를 받아 그 핸들러가 동작하도록 등록하고, 등록 해제 함수를 돌려주는 함수로 구현할 수 있다.
registerFn 를 실행하게 되면 target.addEventListener()가 실행되어 이벤트 리스너가 등록된다.
// DOM 이벤트를 EventSource로 감싸는 진입 함수.
function fromDOMEvent<E extends Event>(target: EventTarget, type: string): EventSource<E> {
return makeSource<E>((handler) => {
const listener = (e: Event) => handler(e as E);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
});
}다음은 subscribe 차례다. EventSource에 핸들러를 붙이는 일을 맡는다.
subscribe는 사용자가 건넨 핸들러가 실제로 동작하도록 만들어야 한다. EventSource는 registerFn 안에 "핸들러를 받으면 어떻게 동작시킬지"에 대한 정보를 이미 들고 있다. 그러므로 subscribe는 받은 핸들러를 registerFn에 그대로 넘겨 실행하면 된다.
subscribe(handler) {
return registerFn(handler);
},registerFn이 실행되는 지점에서 addEventListener도 함께 실행된다.
마지막으로 이벤트를 변형하고 거를 map과 filter가 남았다.
둘 다 EventSource를 반환해야 하니, 여기서도 makeSource를 호출해 그 결과를 돌려준다. 이때 makeSource에 주는 handler는 subscribe의 구현과 동일하게 (handler)⇒registerFn(handler)를 전달하도록 하여 전달받은 handler를 등록한 함수에 넣어 실행시키도록 하였다.
map은 변환식을 받아 이벤트 값에 적용한 결과를 다음으로 전달해야한다. 이를 위해서 handler를 전달하기 전, 변환식을 적용하여 전달하도록 구현하였다.
map<U>(fn: (value: T) => U): EventSource<U> {
return makeSource<U>((handler) =>
registerFn((value) => handler(fn(value))),
);
},filter 의 경우 평가식을 통과한 값만 다음으로 흘려보내므로 아래와 같이 작성할 수 있다.
filter(predicate) {
return makeSource((handler) =>
registerFn((value) => {
if (predicate(value)) handler(value);
}),
);
},map과 filter를 체이닝하면 어떤 일이 벌어지는지 살펴보자. fromDOMEvent가 만든 첫 EventSource의 등록 함수를 r0이라고 하자. 여기에 .map(fn)을 걸면, 새 EventSource가 들고 있는 등록 함수는 r0 바깥에 변환식 한 겹을 두른 모양이 된다. (handler) => r0((value) => handler(fn(value)))처럼 말이다. 여기에 .filter(p)를 한 번 더 걸면, 그 위에 또 한 겹이 둘러진다. 체이닝이 길어질수록 등록 함수는 가장 안쪽의 r0을 중심으로 한 겹씩 바깥으로 합성되어간다.
이렇게 합성된 함수를 실제로 실행하는 지점이 subscribe다. subscribe는 그동안 쌓인 등록 함수에 사용자의 핸들러를 그대로 넘긴다. 그러면 가장 바깥쪽부터 차례로 풀려 안쪽으로 들어가고, 가장 안쪽에 자리 잡은 r0의 addEventListener까지 닿는다. 이 시점에 비로소 DOM에 리스너가 걸린다.
전체 코드를 합치면 이렇게 된다.
function makeSource<T>(registerFn: (handler: (value: T) => void) => Cleanup): EventSource<T> {
return {
// 평가식을 통과한 값만 다음 단계로 흘려보내는 콜백을 한 겹 두른 새 EventSource를 반환한다.
filter(predicate) {
return makeSource((handler) =>
registerFn((value) => {
if (predicate(value)) handler(value);
}),
);
},
// 변환식을 적용해 다음 단계로 넘기는 콜백을 한 겹 두른 새 EventSource를 반환한다.
map<U>(fn: (value: T) => U): EventSource<U> {
return makeSource<U>((handler) => registerFn((value) => handler(fn(value))));
},
// 지금까지 쌓아 올린 등록 함수를 그대로 실행한다. 이 시점에 addEventListener가 걸린다.
subscribe(handler) {
return registerFn(handler);
},
};
}마지막으로 여러 EventSource를 하나로 합치는 merge 까지 구현하면 끝이다.
// 여러 EventSource를 하나로 합친다.
// 어느 소스에서 이벤트가 발생해도 handler가 호출되며, cleanup 한 번으로 모든 소스가 해제된다.
function merge<T>(...sources: EventSource<T>[]): EventSource<T> {
return makeSource((handler) => {
const cleanups = sources.map((s) => s.subscribe(handler));
return () => cleanups.forEach((c) => c());
});
}아까의 모달 닫기 로직을 다시 써보았다.
// Escape 키로 모달을 닫는 스트림.
const escapeKey$ = fromDOMEvent<KeyboardEvent>(document, "keydown").filter((event) => event.key === "Escape");
// 배경 / 닫기 버튼 클릭으로 모달을 닫는 스트림.
const closeClick$ = fromDOMEvent<MouseEvent>(document, "click").filter((event) => {
const target = event.target as HTMLElement;
return !!target.closest(".close-modal") || target.classList.contains("modal-background");
});
// 두 스트림을 합쳐서, 어느 쪽이든 발생하면 모달을 닫는다.
const cleanup = merge<Event>(escapeKey$, closeClick$).subscribe(() => handleModalClose());영화 아이템 클릭도 같은 식으로 옮길 수 있다.
// 영화 리스트 클릭 → 영화 아이템 추출 → movieId 있는 것만 통과 → movieId만 꺼내기.
const movieClick$ = fromDOMEvent<MouseEvent>(movieListEl, "click")
.map((event) => (event.target as HTMLElement).closest<HTMLElement>(".movie-item"))
.filter((item): item is HTMLElement => item?.dataset.movieId !== undefined)
.map((item) => item.dataset.movieId as string);
// 마지막에 액션을 붙인다.
movieClick$.subscribe((movieId) => openMovieDetailModal(movieId));
코드와 위 그림을 함께 보면, 영화 클릭 이벤트가 map과 filter라는 계산을 차례로 거치고, 가장 마지막의 subscribe에서야 모달을 여는 액션이 실행되는 것을 볼 수 있다. 액션이 파이프라인의 끝으로 밀려나고, 가운데에는 계산만 남는다. 이벤트를 값의 흐름으로 보고, 그 흐름에 변형과 필터링을 메서드로 끼워 넣은 형태가 되었다.
서브루틴 추출 또한 좋은 방법이었으나 아래와 같은 몇몇의 문제점들이 있었다. 이를 EventSource로 바꾸자 그 문제들이 풀렸다.
첫째, 뽑아낸 계산들이 너무 작고 많았던 문제.
EventSource를 도입하니 짧은 코드를 굳이 따로 함수로 빼지 않아도 된다. 이전에는 isEscapeKey 같은 한 줄짜리 판별을 작은 함수로 분리해서 이름을 붙여줘야 했다. 그러다 보니 함수 개수가 너무 늘어났었다. 이제는 분리하지 않아도 되니까 한 줄짜리 함수가 더는 늘어나지 않는다.
둘째, 핸들러 안에 계산과 액션이 섞여 있던 문제.
핸들러 콜백 안에 변형(계산)과 호출(액션)이 한 곳에 같이 살고 있었다. EventSource로 바꾸니 액션이 파이프라인의 끝, 즉 subscribe로 밀려난다. 그 결과 가운데에는 필터링과 변형이라는 계산만 남는다. 한 덩어리로 섞여 있던 계산과 액션이 자연히 갈라진다.
셋째, 같은 패턴이 자꾸 반복되던 문제.
핸들러마다 반복되던 "이벤트 받음 → 필터링 → 추출 및 변형 → 액션 실행" 중 가운데 부분, 즉 필터링과 추출 및 변형을 .filter와 .map이라는 재사용 가능한 도구로 추상화했다. 매번 손으로 쓰던 작업이 이제 도구 호출 한 번으로 끝난다.
이로 인해 두 가지를 얻었다. 먼저 새 이벤트 처리를 추가할 때 같은 일을 다시 작성할 필요가 없다는 것. 그리고 도구 자체에 (debounce나 distinctUntilChanged 같은)새 연산을 추가하면, 모든 스트림이 별 다른 코드 변경 없이 즉시 그 기능을 갖게 된다.
지금까지 만든 도구를 돌이켜보면, 우리가 한 일은 이벤트를 값의 흐름으로 보고, 그 흐름에 변형과 필터링을 메서드로 끼워 넣고, 마지막에 구독으로 액션을 실행한 것이다. 이런 관점으로 비동기를 해석한 라이브러리가 이미 있다. 바로 RxJS다.
RxJS는 비동기 이벤트와 시간에 따라 흐르는 값들을 스트림이라는 단위로 다루는 라이브러리다. 클릭, 키 입력, 서버 응답, 타이머처럼 시점이 다른 입력들을 같은 방식으로 합성하고 변형할 수 있게 해준다.
이 라이브러리가 풀려는 문제는 우리가 미션에서 마주한 그 문제와 같다. 비동기 흐름이 코드 곳곳에 흩어지고, 각 흐름의 변형과 필터링이 이벤트 핸들러 내부에 묻혀버리고, 자원 해제가 흩어지는 문제다.
지금까지 만든 도구로 작성했던 모달 닫기 코드를 RxJS로 옮기면 이렇게 된다.
import { fromEvent, merge } from "rxjs";
import { filter } from "rxjs/operators";
// Escape 키로 모달을 닫는 스트림.
const escapeKey$ = fromEvent<KeyboardEvent>(document, "keydown").pipe(filter((event) => event.key === "Escape"));
// 배경 / 닫기 버튼 클릭으로 모달을 닫는 스트림.
const closeClick$ = fromEvent<MouseEvent>(document, "click").pipe(
filter((event) => {
const target = event.target as HTMLElement;
return !!target.closest(".close-modal") || target.classList.contains("modal-background");
}),
);
// 두 스트림을 합쳐서, 어느 쪽이든 발생하면 모달을 닫는다.
const subscription = merge(escapeKey$, closeClick$).subscribe(() => handleModalClose());영화 아이템 클릭 쪽도 RxJS로 그대로 바꿔보면 이렇다.
import { fromEvent } from "rxjs";
import { map, filter } from "rxjs/operators";
// 영화 리스트 클릭 → 영화 아이템 추출 → movieId 있는 것만 통과 → movieId만 꺼내기.
const movieClick$ = fromEvent<MouseEvent>(movieListEl, "click").pipe(
map((event) => (event.target as HTMLElement).closest<HTMLElement>(".movie-item")),
filter((item): item is HTMLElement => item?.dataset.movieId !== undefined),
map((item) => item.dataset.movieId as string),
);
movieClick$.subscribe((movieId) => openMovieDetailModal(movieId));RxJS는 pipe로 연산자를 묶는 형태를 쓴다. 그리고 debounce, distinctUntilChanged, throttle처럼 풍부한 연산자를 이미 갖추고 있으며, 에러 처리와 완료 신호 같은 것까지 스트림 안에서 다룬다.
지금까지 미션 코드에 함수형의 원칙을 적용해보려다 거의 모든 코드가 액션이라는 것을 마주했고, 핸들러 안에 섞여 있던 계산과 액션을 분리하는 도구를 직접 만들어 보았다.
프론트엔드에서 액션은 사라지지 않는다. 잘 격리하고 관리하는 것이 우리가 할 수 있는 일이다.
프론트엔드의 구조 자체가 "액션 → 계산 → 액션"이라는 골격을 가진다는 시각으로 바라보고, 양쪽 끝의 액션부를 얇게, 가운데 계산부를 두껍게 가져가는 것이 프론트엔드에서 함수형을 실천하는 하나의 방식이었다.
그리고 액션을 격리하는 방식에는 다양한 관점과 답이 있다. 이번에 다뤄본 RxJS의 관점은 그중 하나다. 이벤트를 데이터의 흐름으로 바라보고, 변형을 메서드로 추상화하고, 액션을 가장 끝으로 모으는 방식. 이런 관점 또한 있다는 것을 알고, 그게 어떤 문제를 풀려는 것인지 알고 있다는 것만으로도 코드를 작성할 때 가져갈 수 있는 선택지가 늘어날 것이라고 생각한다.