CORS에 대해 알고 벗어나자!

Kim-DaHam·2023년 4월 4일
0

Server

목록 보기
6/10
post-thumbnail

🔥 학습목표

  • CORS가 무엇인지 알고 동작 방식에 대해 설명할 수 있다.
  • node.js를 이용하여 서버를 구축할 수 있다.
  • node.js 서버를 express로 리팩토링 해본다.



🟩 CORS

이전 프로젝트 경험에서 CORS 에러 문제는 질리도록 발견할 수 있었다.

하지만 늘 대충 '다른 주소로의 접근을 막는 것' 이라고 이해한 뒤 구글링하여 나오는 해결책을 그대로 따라하는 식으로 문제를 해결했었다.

!!그러다보니 몇개월이 지난 지금에도 CORS가 아주 낯선 친구가 되어 있어서!! 이번 기회를 타 공부해보았다.😅

🟣 SOP(Same-Origin Policy)

같은 출처(Origin)의 리소스만 공유 가능한 정책

여기서 말하는 출처(Origin)란, 아래와 같은 범위를 뜻한다.

출처는 프로토콜, 호스트, 포트의 조합으로 이루어져 있다.

이 중 하나라도 다르면 동일한 출처라 할 수 없다.

그냥 내 오리진과 서버의 오리진이 같은지 확인하는 거다.

그림으로 표현해봤다.

다른 서버에서 api를 끌어올 때 해당 주소에 대한 검사도 고려해야 한다.


⬜ 왜 생겨났을까?

  • SOP는 잠재적으로 해로울 수 있는 문서를 분리함으로써 공격받을 수 있는 경로를 줄여준다.

  • 예시로 브라우저에 로그인 정보가 남아있을 때, 해당 정보를 노리는 코드가 있는 다른 사이트에 방문하게 된다면 보안적으로 아주 치명적인 문제가 발생할 수 있다.

  • SOP이 있다면 다른 사이트와의 리소스 공유를 제한하기 때문에 로그인 정보가 타사이트의 코드에 의해 새어나가는 걸 방지한다.

  • SOP는 모든 브라우저에서 기본적으로 사용하는 정책이다.


그런데 다른 출처의 리소스를 사용하고 싶다면 어떡해야 할까?

그 문제를 해결하기 위해 필요한 게 바로 CORS다.


⬜ CORS(Cross-Origin Resource Sharing)

교차 출처 리소스 공유 => 추가 HTTP 헤더를 사용하여, 다른 출처의 자원에 접근할 수 있는 권한을 부여하도록 브라우저에게 알려주는 체제.

브라우저는 SOP에 의해 기본적으로 다른 출처의 리소스 공유를 막지만, CORS를 사용해 접근 권한을 얻는 것이다.

우리는 이제 CORS 설정을 통해 서버의 응답 헤더에 'Access-Control-Allow-Origin'을 작성하면 접근 권한을 얻을 수 있다!



🟣 CORS 동작 방식

CORS 동작 방식에는 다음 3가지가 있다.

⬜ 1. 프리플라이트 요청(Preflight Request)

실제 요청을 보내기 전, OPTIONS 메서드로 사전 요청을 보내 해당 출처 리소스에 접근 권한이 있는지부터 확인한다.

아래는 과제를 통해 요청 메서드를 출력한 결과이다.

버튼을 누르면 POST 요청을 보내는데, 그 전에 OPTIONS 메서드를 먼저 request.method로 보낸다. fetch 함수에 기입한 요청 경로에 접근 권한이 있는지 확인하는 것이다.

Access-Control-Allow-Origin이 응답 헤더에 있다는 걸 알게되면, 그 다음 자동으로 실제 요청 POST를 보낸다.

만약 보낸 출처가 접근 권한이 없다면, 즉 Access-Control-Allow-Origin이 응답 헤더에 없다면 브라우저에서 CORS 에러를 띄운다.

실제 요청은 전달되지 않는다.


위 흐름을 시퀀스 다이어그램으로 표현하면 아래와 같다.

과제에서 upper, lower 버튼을 클릭했을 때 preflight 메서드가 우선 전송되고, 그 후에 fetch로 보낸 실제 요청이 수행되는 걸 확인할 수 있다.


🌠 프리플라이트 요청은 왜 필요할까?

  • 실제 요청을 보내기 전에 미리 권한 확인을 할 수 있기 때문에, 실제 요청을 처음부터 통째로 보내는 것보다 리소스 측면에서 효율적이다.
  • CORS에 대비가 되어있지 않은 서버를 보호할 수 있다.(CORS 이전에 만들어진 서버들은 SOP 요청만 들어오는 상황을 고려하고 만들어져서, 다른 출처에서 들어오는 요청에 대한 대비가 되어있지 않다.)

  • 만약 서버에 바로 실제 요청을 보내면, 응답을 보내기 전에 우선적으로 요청을 처리하게 된다. 그 다음 브라우저는 응답을 받은 후에야 CORS 권한이 없다는 걸 인지한다. 하지만 정작 브라우저가 에러를 띄운 후에는 이미 요청이 수행된 상태다. 그 요청이 DELETE나 PUT 처럼 중요한 요청이었다면 서버의 정보가 잘못 변경되는 대참사가 벌어진다...

⬜ 2. 단순 요청(Simple Request)

특정 조건이 만족되면 프리플라이트 요청을 생략하고 요청을 보낸다.

  • GET, HEAD, POST 요청 중 하나여야 한다.

  • 자동으로 설정되는 헤더 외, Accept, Accept-Language, Content-Language, Content-Type 헤더의 값만 수동으로 설정할 수 있다.

    • Content-Type 헤더에는 application/x-www-form-urlencoded, multipart/form-data, text/plain 값만 허용된다.

⬜ 3. 인증정보를 포함한 요청(Credentialed Request)

요청 헤더에 인증 정보를 담아 보내는 요청. 출처가 다를 경우 별도의 설정을 하지 않으면 쿠키를 보낼 수 없다. 프론트, 서버 양측 모두 CORS 설정이 필요하다.

  • 프론트 측에서 요청 헤더에 withCredentials : true 를 넣어줘야 한다.

  • 서버 측에서는 응답 헤더에 Access-Control-Allow-Credentials : true를 넣어줘야 한다.

  • 서버 측에서 Access-Control-Allow-Origin 을 설정할 때, 와일드카드(*)로 설정하면 에러가 발생한다. 인증 정보를 다루는 만큼 출처를 정확하게 설정해줘야 한다!



🟣 CORS 설정 방법

⬜ Node.js 서버

const http = require('http');

const server = http.createServer((request, response) => {
  // 모든 도메인
  resposne.setHeader("Access-Control-Allow-Origin", "*");
  
  // 특정 도메인
  resposne.setHeader("Access-Control-Allow-Origin", "https://alldone.com");
  
  // 인증 정보를 포함한 요청을 받을 경우
  resposne.setHeader("Access-Control-Allow-Origin", "true");
})

⬜ Express 서버

const cors = require("cors");
const app = express();

// 모든 도메인
app.use(cors());

// 특정 도메인
const options = {
  origin: "https://alldone.com", // 접근 권한을 부여하는 도메인
  credentials: true, // 응답 헤더에 Access-Control-Allow-Credentials 추가
  optionsSuccessStatus: 200, // 응답 상태 200으로 설정
};

app.use(cors(options));

// 특정 요청
app.get("/example/:id", cors(), function(req, res, next) {
  res.json({msg:"example"});
});



🟩 Node.js 서버 구축하기

서버는 클라이언트(브라우저)의 HTTP 요청에 맞게 응답을 보낼 수 있도록 코드를 작성해야 한다.

HTTP 요청을 처리하고 응답을 보내주는 프로그램을 웹 서버(Web Server)라고 부른다.

이번 과제에서는 Node.js의 http 모듈을 이용해 웹 서버를 만들 것이며 서버에 요청을 보내기 위해 fetch 메서드를 사용한다.

HTTP 모듈은 HTTP 요청과 응답을 다루도록 도와준다.

🟣 클라이언트 화면

⬜ Upper/Lower 버튼

구현 된 기능은 간단하다.

요청으로 보낼 문자열을 입력하고, 하단 버튼을 클릭하면, 입력한 문자열을 대문자화/소문자화 한 결과를 응답으로 출력한다.

각 버튼의 클릭 이벤트 함수는 아래와 같다.

toLowerCase() {
    const text = document.querySelector('.input-text').value;
    this.post('lower', text);
  }
toUpperCase() {
  const text = document.querySelector('.input-text').value;
  this.post('upper', text);
}

post 함수는 fetch 를 통해 요청 메서드를 보낸다.

post(path, body) {
      fetch(`http://localhost:4999/${path}`, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: {
          'Content-Type': 'application/json'
        }
      })
        .then(res => res.json())
        .then(res => {
          this.render(res);
        });
  }



🟣 서버

⬜ http 모듈

먼저 HTTP 통신을 하기 위해서 http 모듈을 불러온다.

포트는 4999번을 사용하며 ip주소는 로컬 주소인 localhost 로 보기 좋게 저장해둔다.

const http = require('http');
const PORT = 4999;
const ip = 'localhost';

⬜ node.js 웹 서버

node 웹 서버 애플리케이션을 만들려면 웹 서버 객체가 필요하다.

이때 createServer를 이용한다.

const server = http.createServer((request, response)=>{
  // 서버 작업 영역
});

server.listen(PORT, ip, ()=>{
  console.log(`http server listen on ${ip}:${port}`);
});

createServerServer 객체로 EventEmitter을 반환한다.

listen 메서드을 통해 해당 ip주소와 포트에서의 연결 요청을 대기한다. 요청을 받으면 콘솔에 연결됨을 알린다.

이제 해당 서버에 HTTP 요청이 올 때마다 createServer에 전달 된 함수가 한 번씩 호출된다.


⬜ 서버 작업 영역

서버에 HTTP 요청이 들어오면, node가 트랜잭션(업무처리)을 다루기 위해 requestresponse 객체를 전달하고 핸들러 함수를 호출한다.

request 객체에는 브라우저에서 보낸 headers, method, url이 담겨있다.

이것들을 각각 변수에 저장하고, 브라우저가 보낸 요청 메서드에 따라 각기 다른 작업을 하도록 코드를 구현한다.


가장 먼저 OPTIONS 메서드가 들어왔을 때이다.

const server = http.createServer((request, response) => {
  const {headers, method, url} = request;
  let body = [];

  if(method === 'OPTIONS') {
    response.writeHead(200, defaultCorsHeader);
    response.end();
  }
...
 
});
  
const defaultCorsHeader = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Accept',
    'Access-Control-Max-Age': 10
};

OPTIONS 메서드는 실제 요청이 수행되기 전, CORS 검사를 할 때 들어오는 메서드이다.

defaultCorsHeader를 작성하여 Access-Control-Allow-Origin을 모든 주소 가능 * 으로 지정한다.

response.end() 를 통해 클라이언트에게 요청에 대한 응답이 끝났음을 알린다.


POST 요청 메서드가 들어왔을 때이다.

POSTPUT 요청은 바디 데이터를 받아 처리해야 한다는 점에서 GET과 다르다.

가장 먼저 바디 데이터를 받는 과정은 아래와 같다.

const server = http.createServer((request, response) => {
  ...

  if(method === 'POST'){
    request
    .on('error', (err) => {
      console.error(err);
    })
    .on('data', (chunk) => {
      body.push(chunk)
    })
    .on('end', ()=>{
      body = Buffer.concat(body).toString();
      ...
  }
});

아까 위에서 말한 Server 객체 EventEmitter가 여기서 돋보인다.

다시 제대로 EventEmitter에 대해 정의하자면,

특정 이벤트에 리스너 함수를 달아서, 이벤트가 발생했을 때 이를 캐치할 수 있도록 만들어진 api이다.

핸들러에 전달 된 request 객체는 ReadableStream 인터페이스를 구현하고 있는데, 이 Stream에 이벤트 리스너를 등록하거나 다른 Stream에 파이프로 연결할 수 있다.

이게 뭔 소리냐... 처음에는 조금 당황 했다.

당장 너무 깊게 들어갈 필요는 없고, 간단히 정리하자면 addEventListener와 같이 특정 이벤트에 이벤트 리스너를 등록해서 데이터를 받는 것이다.

위 코드에서 on은 Stream의 이벤트를 지정해준다. on의 callback 함수는 전부 비동기로 이벤트가 발생할 때 마다 시시각각 호출된다.

이벤트 발생 시점은 공식문서에서 확인할 수 있는데 당장은...😔


어쨌든.
이벤트 리스너를 errordataend 대로 연결하면 잘못 된 데이터를 가져올 문제 없이 순차적으로 처리 된다.

각 이벤트에서 발생하는 리스너 함수 기능은 아래와 같다.

  • error : request 스트림에 문제 발생 시 에러를 출력한다.
  • data : 데이터 조각을 body 변수에 저장한다. 데이터는 주로 Buffer 형식으로 오는 경우가 많아서 그렇게 처리했다.
  • end : 다 받은 Buffer 데이터를 문자열로 변환한다.

이제 데이터를 다 받았으면 응답으로 보낼 결과를 만든다.

이번 과제의 경우 /upper 경로의 요청에선 대문자로 바꾸고, /lower 경로 요청의 경우 소문자로 바꾼다.

.on('end', ()=>{
      ...

      if(url === '/upper'){
        body = body.toUpperCase();
      }

      if(url === '/lower'){
        body = body.toLowerCase();
      }

      response.writeHead(201, defaultCorsHeader);
      response.end(body);
    })

🎁 HTTP 트랜잭션 해부



🟩 Express 서버 구축하기

사실 Express에 대해서도 옛날에 작성한 블로그 글이 있다.

다만 오늘은 다시 천천히 복습 겸, 그때 너무 대충 기록했던 것 같은 부분을 추가하려고 한다.

옛날 블로그는 실습 위주로 작성했다면, 이번에는 node.js 서버와 각각 비교해보며 둘의 차이를 명확하게 이해하고자 한다.

🟣 Express 시작하기

⬜ Express 설치하기

npm install express

⬜ 간단한 웹 서버 만들기

🎁 [Express 공식문서]HelloWorld 예제

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

⬜ 라우팅(메서드와 url에 따라 분기)

🎁 [Express 공식문서]기본 라우팅

클라이언트의 요청에 해당하는 특정 엔드포인트에 따라 서버가 응답하는 방법을 결정하는 것

클라이언트는 특정한 HTTP 요청 메서드와 함께 서버의 특정 URI로 HTTP 요청을 보낸다.

위에서 설명한 과제에서도

엔드포인트가 /upper 라면 대문자로 변환한 결과를 요청하는 것이고,

/lower 일 때는 소문자로 변환한 결과를 요청하는 것이었다.

추가적인 라이브러리를 사용하지 않고 순수 Node.js로 코드를 작성했을 땐, request.method을 조건문에 통과 시켜 기능을 분리했었다. 가독성이 별로 안좋았다.


node.js 서버의 경우

const requestHandler = (req, res) => {
  if(req.url === '/lower') {
    if (req.method === 'GET') {
      res.end(data)
    } else if (req.method === 'POST') {
      req.on('data', (req, res) => {
        // do something ...
      })
    }
  }
}

Express 서버의 경우

const router = express.Router()

router.get('/lower', (req, res) => {
  res.send(data);
})

router.post('/lower', (req, res) => {
  // do something
})

훨씬 깔끔하다는 걸 알 수 있다!


⬜ 미들웨어(Middleware)

요청과 응답 중간에서 어떤 동작을 하는 프로그램(함수).

자동차 공장의 컨베이어 벨트에 비교하면 이해하기 편하다. 컨베이어 벨트 위에 올라간 요청(request)에 각종 기능을 추가하는 것이다.


미들웨어를 사용하는 상황은 다음과 같다.

  • POST 요청 등에 포함된 body(payload)를 구조화할 때(쉽게 얻어내고자 할 때)
  • 모든 요청/응답에 CORS 헤더를 붙여야 할 때
  • 모든 요청에 대해 url이나 메서드를 확인할 때
  • 요청 헤더에 사용자 인증 정보가 담겨있는지 확인할 때

⬜ 1. POST 요청 등에 포함된 body 구조화

node.js 서버의 경우

let body = [];
request.on('data', (chunk) => {
  body.push(chunk);
}).on('end', () => {
  body = Buffer.concat(body).toString();
});

Express 서버의 경우

body-parser 미들웨어를 사용한다.

const bodyParser = require('body-parser');
const jsonParser = bodyParser.json();

// 생략
app.post('/users', jsonParser, function (req, res) {

})

Express v4.16.0 부터는 body-parser를 따로 설치하지 않고, Express 내장 미들웨어인 express.json()을 사용한다고 한다.

🌠 JSON으로 들어오는 요청을 구문 분석하며 body-parser를 기반으로 한다.
JSON 부분만 파싱하여 미들웨어를 반환하고, Content-Type 헤더가 request와 옵션이 같은지 확인한다.

const jsonParser = express.json({strict: false});

// 생략
app.post('/api/users', jsonParser, function (req, res) {

})

express.json() 미들웨어를 사용하던 중 에러가 발생한다면 express.json([options])를 확인해봐야 한다.


⬜ 2. 모든 요청/응답에 CORS 헤더 붙이기

순수 Node.js 서버를 만들 때 제일 보기 싫었던 부분 중 하나다. CORS 헤더 일일이 작성하기.

Express는 cors 미들웨어를 사용하여 이를 훨씬 가독성 좋게 한꺼번에 처리할 수 있다.


node.js 서버의 경우

if (req.method === 'OPTIONS') {
  res.writeHead(200, defaultCorsHeader);
  res.end()
}

const defaultCorsHeader = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Accept',
  'Access-Control-Max-Age': 10
};

Express 서버의 경우

body-parser 미들웨어를 사용한다.

const cors = require('cors');

// 모든 요청에 대해 CORS 허용
app.use(cors());

// 특정 요청에 대해 허용
app.get('/products/:id', cors(), function (req, res, next) {
  res.json({msg: 'This is CORS-enabled for a Single Route'})
})

⬜ 3. 모든 요청에 대해 url/메서드 확인

미들웨어는 일반적으로 다음과 같이 구성되어 있다.

만약 특정 엔드포인트가 아니라 모든 요청에 대한 미들웨어를 적용하려면 어떻게 해야할까?

app.use() 를 사용한다.

const express = require('express');
const app = express();

const myLogger = function (req, res, next) {
  // 미들웨어 기능 구현
  next();
};

app.use(myLogger);

app.get('/', function (req, res) {
  res.send('Hello World!');
});

app.listen(3000);

⬜ 4. 요청 헤더에 사용자 인증 정보가 담겨있는지 확인

app.use((req, res, next) => {
  // 토큰이 있는지 확인, 없으면 받아줄 수 없음.
  if(req.headers.token){
    req.isLoggedIn = true;
    next();
  } else {
    res.status(400).send('invalid user')
  }
})
profile
다 하자

0개의 댓글