노션클론 리팩토링 (13) - Router, CORS

김영현·2024년 12월 18일
1

Router제작

컨트롤러 계층은 크게 처리할 로직이 없어서 간단하게 상태와 데이터를 리턴하기로 했다.

async function getDocumentListController(req, res) {
  const documentList = await getDocuments();
  res.writeHead(200);
  return res.end(JSON.stringify(documentList));
}

위 함수를 한번 라우팅해보자.

express에서는 라우터를 아래와 같은 방식으로 사용한다.
예를들어 /birds경로로 접근시 처리할 로직을 모듈화한다.

//birds.js
const express = require('express')
const router = express.Router()

// middleware that is specific to this router
const timeLog = (req, res, next) => {
  console.log('Time: ', Date.now())
  next()
}
router.use(timeLog)

// define the home page route
router.get('/', (req, res) => {
  res.send('Birds home page')
})
// define the about route
router.get('/about', (req, res) => {
  res.send('About birds')
})

module.exports = router

그리고 app.use()를 이용해 해당경로 접근시 birds.jsrouter를 반환한다.

//main.js
const birds = require('./birds')

// ...

app.use('/birds', birds)

이제 나만의 라우터를 만들어보자.

간단한 라우터 제작

필요한 기능은 다음과 같다.

  1. 경로와 모듈을 매핑하는 http메서드.
  2. http메서드의 요청 간 미들웨어.

별로 어렵지 않으니 후딱 만든다.

const router = () => {
  //경로와 모듈 매핑을 위한 객체
  const routes = {
    GET: {},
    POST: {},
    PUT: {},
    DELETE: {},
    OPTIONS: {},
  };

  return {
    post: (path, api) => {
      routes.POST[path] = api;
    },
    get: (path, api) => {
      routes.GET[path] = api;
    },
    put: (path, api) => {
      routes.PUT[path] = api;
    },
    delete: (path, api) => {
      routes.DELETE[path] = api;
    },
    handleRequest: (req, res) => {
      const { method, url } = req;

      const route = routes[method][url];

      if (route) {
        route(req, res); // 경로에 해당하는 핸들러 실행
      } else {
        //경로에 해당하는 핸들러가 없다면, 404를 반환한다.
        res.writeHead(404, { "Content-Type": "text/plain" });
        res.end("Not Found");
      }
    },

이제 실행해보자.

OPTIONS? 😮

커넥션 에러가 뜬다.

뭔일인가 싶어 서버측에서 req.method를 찍어보니 OPTIONS라는 의문의 메서드가 찍힌다.
OPTIONS가 대체 뭘까?


CORS

OPTIONS를 알고싶으면, CORS에 대해 알아야 한다.

알고있어서 생략하려 했으나 조금 더 깊게 파고들어보자.

CORS(Cross-Origin Resource Sharing)란, 브라우저가 FE의 JS코드가 교차출처(corss-origin)에 대한 응답에 접근하는 것을 차단하는지 여부를 결정하는 HTTP Headers전송으로 이루어진 시스템이다.

그러니까 임의의 웹페이지가 다른 웹페이지의 자원에 무분별하게 접근하는 것을 막아 XSS같은 보안 위협으로부터 페이지를 보호하는 역할을 수행한다.


출처 : MDN

예를들면 https://localhost:3000이 도메인이라고 했을때, 접근 불가능한 도메인은 다음과 같다.

CORS의 작동 방식

만약 CORS정책으로 인하여 다른 도메인에서 영영 api요청을 하지 못한다면 어떨까?
현대의 백엔드는 기본적으로 화면을 반환하는 웹서버와 서버측 비즈니스로직을 담당하는 api서버를 나눠 관리한다.
따라서 웹서버에서 api서버로의 요청을 처리할 수 있어야 한다. 이를 허용하려면 보통 서버측 헤더에 아래와 같은 응답을 실어 보내면 된다.

Access-Control-Allow-Origin:*

애스터리스크*를 이용해 모든 Origin을 허용하겠다는 뜻이다.

그렇다면 위 헤더가 어떻게 작용하여 CORS정책을 허가하는 걸까?

preflight request

사실 CORS의 동작 방식은 하나가 아니다.
총 3가지인데, 그중 첫번째인 사전 요청(preflight request)을 소개해보겠다.

브라우저가 서버에 요청을 보낼때, 실제 요청이 바로 진행되는 것이 아니다.
예를들어 클라이언트가 서버측으로 아래와 같은 요청을 보냈다고 가정해보자.

const fetchPromise = fetch("https://bar.other/doc", {
  method: "POST",
  mode: "cors",
  headers: {
    "Content-Type": "text/xml",
    "X-PINGOTHER": "pingpong",
  },
  body: "<person><name>Arun</name></person>",
});

fetchPromise.then((response) => {
  console.log(response.status);
});

POST요청을 https://foo.example에서 보낼 것이다.
또한 비표준 헤더(내가 만든 헤더)"X-PINGOTHER": "pingpong"도 넣어서 보낸다!

이제 브라우저는 서버에 POST요청을 보내기전, 서버가 안전하게 동작하는지 확인하기 위하여 사전 요청을 실행한다.
만약 사전요청에 성공할 경우 클라이언트측이 원하는 요청을 다시 전송하게된다.

사전요청또한 http통신이기에 메서드가 필요하며, 이때 사용하는 메서드가 바로 OPTIONS가 되시겠다!

사전요청-응답을 좀 더 구체적으로 살펴보자.

OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-pingother

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

첫번째 블록이 사전요청이며 두번째 블록이 사전요청의 응답이다.
사전요청은 OPTIONS메서드를 이용해 이루어졌으며, OriginAccess-Control-Request-Method를 이용해 어떤 도메인에서 어떤 메서드로 요청했는지 알려준다.
마지막으로 Access-Control-Request-Headers: content-type,x-pingother를 사용하여 클라이언트가 어떤 헤더를 보낼것인지 서버에 알려준다!

그러면 두번째 블록에서 서버측은 이렇게 답한다.
Access-Control-Allow-Origin: https://foo.example: https://foo.example라는 도메인만 교차출처 리소스 공유를 허용한다.
Access-Control-Allow-Methods: POST, GET, OPTIONS: 허용된 http 메서드는 POST, GET, OPTIONS다.
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type: 허용된 헤더는 X-PINGOTHER, Content-Type다.
Access-Control-Max-Age: 86400: 해당 사전요청을 86400초(24시간) 동안 캐싱하겠다.

이렇게 사전요청-응답이 성공적으로 이루어지면 실제 요청이 진행된다.

simple request

해당 용어는 사실 폐기된용어다. 현재 CORS스펙을 담당하는건 Fetch측이라서 그렇단다!

아무튼 simple request가 무엇인지 알아보자.

Preflight request에서 설명했듯, 클라이언트가 서버측에 어떠한 요청을 보낼땐 사전 검증이 필요하다.
하지만 해당 검증이 필요 없는 상황도 있다.

  1. GET, POST, HEAD 메서드를 사용한다.
  2. Accept, Accept-Language, Content-Language등 특정 헤더를 추가로 사용했다.
  3. Content-Type을 설정했을 경우, 다음 세가지만 가능하다.
    a. application/x-www-form-urlencoded (폼데이터)
    b. multipart/form-data (파일 업로드)
    c. text/plain (일반 텍스트)
  4. 업로드 관련 이벤트 리스너를 사용하지 않아야 한다. xhr.upload.addEventListener
  5. 요청 본문에 ReadableStream같은 스트림 데이터를 사용하지 않아야 한다.

조건이 생각보다 더 까다롭다. 대체 어떤 요청이 사전요청을 트리거 하지 않는걸까?

const fetchPromise = fetch("https://bar.other");

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  });

바로 위와같은 상황. 즉, 다른 도메인으로부터 JSON데이터만 가져올때는 사전요청이 발생하지 않는다.

간단한 요청-응답이 이루어진다.

해당 요청을 좀 더 구체적으로 살펴보면 이렇다.

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

Origin헤더에 요청한 서버의 도메인이 담겨있다. 해당 요청에 서버가 어떻게 응답하는지 살펴보자.

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

사전요청에서 살펴본 내용보다 심플하다!

Requests with credentials

자격증명(credential)을 포함한 요청 또한 CORS를 사용한다.

const url = "https://bar.other/resources/credentialed-content/";

const request = new Request(url, { credentials: "include" });

const fetchPromise = fetch(request);
fetchPromise.then((response) => console.log(response));

해당 요청은 쿠키에 자격증명을 위한 값이 존재한다고 가정한다.

요청과 응답을 구체적으로 살펴보자.

GET /resources/credentialed-content/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: https://foo.example/examples/credential.html
Origin: https://foo.example
Cookie: pageAccess=2

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

[text/plain content]

자격증명이 담긴 요청시 특이한점은 Access-Control-Allow-Credentials헤더가 존재한다는 것이다.
해당 헤더의 값이 true가 아니라면, 클라이언트가 쿠키에 자격증명을 위한 값을 지니고 있어도 응답이 무시된다.

결론

OPTIONS 메서드는 CORS를 사용할때 사전요청을 위해 사용되는 메서드다.


Router를 수정해보자

현재 라우터는 그 어떤 헤더도 반환하지 않는다. 사전요청을 위한 OPTIONS메서드와 문서의 CRUD를 위한 http 메서드도 허용하고, 개발 단계니 모든 도메인에서 리소스 공유를 허용해보자.

handleRequest: async (req, res) => {
      const { method, url } = req;
  
      //사전요청에 대응!!
      if (method === "OPTIONS") {
        res.writeHead(204, {
          //모든 도메인에서 리소스 공유를 허용, 필요한 http메서드 허용, 파일 전송을 위한 Content-Type헤더 허용
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type",
        });
        res.end();
        return;
      }
  
      if (routes[method] && routes[method][url]) {
        routes[method][url](req, res);
      } else {
        res.writeHead(404, { "Content-Type": "text/plain" });
        res.end("Not Found");
      }
    },

이렇게 수정하고 서버를 구동하면

짜잔! 이제 오류 없이 작동하는 모습을 볼 수 있다. 실제로 네트워크 요청이 잘 갔는지도 확인해보자.
네트워크 탭의 기타 메뉴를 누르면...

사전요청이 잘 왔다갔다 한 모습을 볼 수 있다!

이후 실제 요청도 아래와 같이 잘 왔다갔다 한 모습을 볼수있다.


profile
모르는 것을 모른다고 하기

0개의 댓글