Intro
Node.js 환경에서 서버를 만들 때, Express는 웹 애플리케이션과 소통할 수 있는 API 구축에 있어 중요한 역할을 합니다. 최근 많이 사용하는 Next.js조차도 내부적으로는 Express에 의존하고 있지요. 그만큼 Express는 널리 사용되는 웹 서버 프레임워크입니다.
그중에서도 반드시 한 번은 마주치게 되는 메서드가 있으니, 바로 app.use()입니다. 이 메서드는 라우팅과 미들웨어 처리를 위해 사용되는데, 재미있는 점은 매개변수에 따라 다른 동작을 처리한다는 것입니다. 라우팅에 사용할 때는 경로를 문자열로 전달하고, 미들웨어 처리에는 함수를 전달하지요. 이를 통해 우리는 예측 가능한 서버 동작을 설정할 수 있습니다.
저는 이 사용법에서 흥미로운 점을 발견했고, 오늘은 app.use()가 내부적으로 어떻게 두 가지 일을 동시에 처리하는지에 대해 함께 살펴보려 합니다.
( 들어가기전 알아두기: 본문에서의 ‘앱’ 호칭에 관해
이번 글에서는 ‘app’, ‘앱’, ‘애플리케이션’ 이라는 표현을 자주 보게 되실 텐데요. app 은 express 함수의 반환값, 즉 express의 인스턴스로, Express 애플리케이션의 인스턴스를 말합니다 . )
Express의 app.use()
그것이 알고싶다.
앞서 말했듯, app.use()는 Express 애플리케이션에 미들웨어를 등록하고, 실행 순서를 정하는 메서드입니다. 경로나 미들웨어를 추가하는 것이 핵심 역할이지만, 더 흥미로운 점은 매개변수에 따라 작동 방식이 달라진다는 것입니다. 예를 들면 다음과 같습니다:
1. app.use(middleware): 미들웨어 함수만 전달
2. app.use(path, expressApp): 경로와 앱을 전달
이처럼 app.use()는 매개변수에 따라 미들웨어 처리와 라우팅 처리를 각각 수행합니다. 물론 코드적으로는 매개변수에 따라 다른 동작을 처리하는 것이 어렵지 않을 수 있지만, 유지보수와 관심사 분리 관점에서 보면 한 메서드가 두 가지 역할을 처리하는 점이 다소 의문스러울 수 있습니다.
Express는 최근에 버전 5를 발표하며 오랜 서비스 역사를 자랑하고 있습니다. 이런 부분에 대한 고민을 분명히 했으리라는 생각이 들었기에, app.use() 메서드의 내부 동작 방식을 이해해보고자 합니다.
정확한 동작을 알기 위해 여러 자료를 참고할 수도 있지만, 가장 확실한 방법은 소스 코드를 직접 확인하는 것입니다. 시간이 조금 걸리더라도 말이죠.
소스코드 찾기
공식 레포 속에서 소스코드를 찾는 건 언제나 쉽지 않습니다. 서비스별로 구성과 패턴은 다양하기 때문이지요.
‘어디’ 있는지 찾는 것부터가 고생길이긴 합니다만, 이번 공부에서는 ‘무엇’이 진짜인지 분간해 내는 것이 보통 어려운 일이 아니었습니다. 왜냐하면… 같은 이름으로 2개의 리소스가 나왔기 때문입니다!
매개변수별로 2개의 함수를 준비한 것인가? 하고 살펴보니 그렇지 않았습니다. 그렇다면 어떤 게 진짜일까요?
함수명에서 파악할 수 있듯 둘 다 app.use 가 맞습니다. app.use 는 2개의 소스코드로 이루어져 있습니다. 하나는 app.use 이고 proto.use 입니다. 내부적으로 프록시 패턴을 사용하고 있기 때문입니다.
본체인 proto.use 는 콜백 함수들을 this.stack에 추가하여 차례대로 실행될 수 있게 합니다. 본체는 많은 관리 값을 거느리고 있기 때문에 빈번하게 일어나는 경로처리, 매개변수 처리 시 매번 직접 등장해 처리하게 된다면 부담이 상당할 것입니다. 그래서 프록시인 app.use 에서 매개변수 구분에 대한 동작을 선처리 한 뒤, proto.use 에서 경로에 따른 미들웨어들을 실행하도록 설계 되어 있습니다.
다시 원점으로 돌아와, 우리가 알고 싶은 부분은 매개변수에 대한 동작처리이기 때문에 프록시인 app.use 코드를 중심으로 동작을 살펴보겠습니다.
소스코드와 함께 살펴보기
1. 경로와 Express 애플리케이션 전달한 경우
이 경우 app.use 는 어디까지, 어떤 동작을 진행하고 있을까요? 경로와 어플리케이션을 함께 전달해 보겠습니다. 이 경우, 프록시 소스코드의 모든 과정을 거치게 됩니다.
app.use
꽤나 어질어질 할 수 있지만 천천히 읽어보며 동작을 정리해보니 아래와 같습니다.
- 현재 경로와 나머지 매개변수 ( = 미들웨어 )를 분리한다.
- 현재 경로에 미들웨어를 등록해 준다.
- 현재 경로를 상위 경로에 연결해 준다.
이 내용을 보니 app.use 가 상위 경로 - 현재 경로 - 미들웨어 간의 체이닝을 처리를 하면서 예상대로 로직이 흘러갈 수 있도록 정리하는 흐름을 확인할 수 있습니다.
흠, 이 동작 방식을 보고나니 미들웨어만 들어왔을 경우가 걱정됩니다. app.use 에서 주소를 통해 행동과 경로 상하 관계를 식별하고 있기 때문에 ‘주소(경로)’ 정보란 빠질 수 없는 정보가 됩니다.
미들웨어(함수만) 넣었을 때는 어떻게 되는지 소스코드를 다시 한번 살펴보겠습니다.
2. 미들웨어만 전달한 경우
미들웨어로 함수만 전달한 경우, 원본 코드중 남는 실행문은 이렇습니다.
혹시 눈치채셨을까요? 아까보다 코드가 많이 줄어든건 확실 한데, 늘어난 코드가 있습니다.
단 한줄, 바로 이 코드 입니다.
사실, app.use 함수 내부의 가장 첫줄에 선언되는 내용입니다. 위에서는 일부러 잠시 숨겨 두었습니다. 그만큼 큰 스포일러가 될 수 있는 중요한 동작이었기 때문입니다.
미들웨어만 넘기는 경우 경로를 지정되어있지 않기 때문에 app.use 함수 내부에서는 기본 경로인 “/”에 미들웨어를 추가합니다. ( 여기서 “/” 경로는 애플리케이션의 루트 경로라는 고정적인 경로가 아니라 현재 어플리케이션에 연관된 경로를 말합니다. )
Outro : app.use와 미들웨어의 흐름
두 경우를 살펴보고 나니 app.use 의 동작 방식이 명확해졌습니다. app.use 에게 중요한 것은 매개변수의 타입이 아니라 ‘경로’가 명시 되어 있느냐 하는 것이었습니다. 이 설계는 app.use 가 경로와 미들웨어를 구별하여 애플리케이션의 구조를 쉽게 확장할 수 있게 돕는 Express 의 핵심 요소이기 때문이었습니다.
app.use 의 동작을 정리하자면 아래와 같습니다.
- app.use는 경로가 없으면 기본 경로인 “/”에 미들웨어를 추가하고, 경로가 있으면 특정 경로에 추가합니다.
- 프록시 패턴을 통해 내부적으로 proto.use를 호출하여 미들웨어를 스택에 쌓아 실행하는 방식으로 미들웨어 실행 순서를 보장합니다.
마치며
소스 코드를 직접 들여다보는 일은 생각보다 번거롭고 어려운 작업입니다. 코드에 익숙하지 않을수록 그 난이도는 더욱 높아지죠. 하지만 그만큼 코드를 직접 분석하고 나면 동작 방식을 더욱 명확하게 이해할 수 있고, 이해를 위한 노력이 쌓일수록 시야가 넓어지는 느낌을 받게 됩니다.
이번 공부의 시작은 app.use라는 메서드가 다소 두루뭉술하게 느껴졌기 때문이었습니다. 또한 Express가 얼마나 많은 부분을 자동으로 처리해주는지에 대한 의문도 있었습니다. 이번 분석을 통해 Express의 내부 동작 방식과 미들웨어의 흐름을 조금 더 깊이 이해하게 되었고, 애플리케이션을 구성하는 데 있어 app.use의 역할과 중요성을 알게 되었습니다.
소소하게 뜯어 본 이 글이 저와 또 누군가에게 Express의 app.use와 미들웨어 처리 방식을 이해하는 데 작은 도움이 되었기를 바랍니다.
별첨부록
궁금할 수도, 궁금하지 않을 수도 있는 본문관련 짤지식!
1. express 소스코드에서는 왜 var를 쓰는지.
(TMI: Express 는 아직 타입스크립트로 구현되지 않습니다. 출시된 시기가 2010 년 경이고 많은 곳에서 사용하다 보니 찾는 건 호환을 위해 아직 자바스크립트로 구현되어 있으며, 내부적으로도 옛 문법(?)을 발견할 수 있었습니다. )
express에서 구버전을 호환하기 위해서 유지하고 있다고 보여집니다.
2. var self = this;
는 무슨 표현일까?
middleware 소스코드에서 볼 수 있는 코드 라인인데요,
this
바인딩을 안전하게 유지하기 위해서 자주 사용되는 반쯤은 고정적인 표현입니다. JavaScript에서는 함수 내부에서 콜백 함수 내부나 비동기 코드를 사용할 때, 그 안에서 this
의 참조가 바뀔 수 있습니다. 이를 해결하기 위해 보통 함수 시작 부분에서 현재의 this
를 참조하도록 변수에 선언을 해둡니다. 이렇게 하면, 내부 함수에서도 원래의 this
를 안전하게 참조할 수 있습니다.
3. ‘EventEmitter’란?
1. ‘emit’의 사전정의
emit ; 방출, 발생하다.
- (동사) to send (light, energy, etc.) out from a source
- (동사) to make (a certain sound)
‘emit’이란, 사전적 정의에 따르면 대략적으로 ‘(무엇인가를) 방출하거나 발생함’ 입니다.
그렇다면 ‘EventEmitter’ 는 무엇일까요? Event + Emitter 이니 ‘이벤트를 발생하는 것’ 정도로 해석할 수 있을 것 같습니다. ‘EventEmitter’는 특정 이벤트가 오면 그 이벤트와 관련된 정해진 행동들을 트리거 시킵니다.
2. ‘EventEmitter’ 란 무엇일까?
‘EventEmitter’는 언제발생할지 모르고, 언제든지 발생할 수 있는 이벤트를 처리할 수 있도록 도와주는 클래스입니다.
3. ‘EventEmitter’ 의 작동방식
‘EventEmitter’는 ‘emit’ 메서드와 ‘on’ 메서드를 가지고 있습니다.
- ‘on’ 메서드는 특정 이벤트에 대해 실행할 콜백함수를 등록시켜주는 메서드이고,
- ‘emit’ 메서드는 미리 등록해둔 이벤트에 대해 콜백을 실행시켜주는 메서드 입니다.
- 등록한 콜백함수는 등록한 이벤트에 대한 ‘이벤트 리스너’입니다. 한 이벤트에 대해 여러개 등록할 수도 있습니다.
4. EventEmitter
사용 이유
Node.js는 I/O 집약적인 작업을 효율적으로 처리하기 위해 비동기, 이벤트 기반 아키텍처를 채택하고 있습니다. 이벤트 발생시에 업무를 처리하는 방식이지요. 이러한 설계 위에서 이벤트 발생시의 처리를 위해 EventEmitter
를 사용하고 있습니다. (EventEmitter
는 발생할 이벤트에 대해 실행할 행동을 미리 등록시켜두어 그때그때 적절한 처리를 할 수 있게 도와줄 뿐입니다. 비동기 처리는 promise가 도와주겠지요? 😃)
단점: 여러개의 리스너를 등록했을 시, 리스너들 간의 작동 순서를 보장할 수 없습니다. 이것을 보완하게 위해 Express에서는 미들웨어를 스텍으로 넣어 next()를 통해 실행할 수 있도록 관리 하는 것 입니다.
4. 다음으로 넘어가게 해주는 next() 에서 ‘route’ 문자열은 또 별도 분기처리가 있어요.
⇒ next()에 매개변수로 ‘route’(변수 아닙니다. 진짜 문자열 ‘route’ 에요!)를 줄 수 있습니다. 이 경우엔 같은 경로에 등록된 다음 라우트 핸들러로 동작을 넘깁니다.
❌ 혼동주의 ❌ 다음 ‘미들웨어’가 아닌 같은 경로에 붙은 ‘라우트 핸들러’ 입니다.
-
next함수의 인자에 따른 행동
① next() ⇒ 다음 미들웨어 함수 진행.
② next(’route’) ⇒ 현재 진행중인 핸들러의 미들웨어를 종료하고 다음 핸들러로 진행.
③ next(err) ⇒ (인자 값이 없거나 ‘route’가 아니라면 다 이 경우에 해당됩니다.) err 인자와 함께 에러 핸들러로 진행.
5. React 에는 ‘응답’ 과 ‘요청’ 이 없는데 어떻게 ‘middleware’ 라는 표현이 존재할까?
React에서는 “미들웨어”라는 용어가 Redux와 관련이 있습니다. Redux 미들웨어는 액션을 디스패치한 후 리듀서에서 상태를 업데이트하기 전에 추가적인 작업을 할 수 있게 해줍니다. 이 미들웨어는 일종의 함수 체인이며, 각각의 미들웨어는 액션을 받아서 다음 미들웨어에게 전달하거나, 액션 처리를 중단할 수도 있습니다.
Redux 미들웨어는 store.dispatch
와 store.getState
를 중심으로 작동하며, 이를 통해 액션과 상태를 관리합니다. 이 과정에서 “request”와 “respond”라는 용어는 사용되지 않습니다. 대신에, Redux 미들웨어는 액션을 처리하고 상태를 업데이트하는 방식으로 동작합니다.
따라서 React 애플리케이션에서 Redux 미들웨어를 “미들웨어”라고 부르는 것은 Redux의 개념을 따르는 것입니다. React 자체에서는 HTTP 요청과 응답을 처리하는 데 사용되는 라이브러리나 미들웨어가 따로 존재하지만, Redux 미들웨어는 Redux 상태 관리 라이브러리에서 사용되는 개념입니다.
요약) 각각 요청은 디스패치, 응답은 스토어 업데이트를 통한 스테이트 반영과 대응 되는 뉘앙스!
request ⇒ dispatch , response ⇒ store update