[Javascript] 쿠키(cookie)의 생성, 전달, 사용

박기영·2023년 6월 20일
2

Javascript

목록 보기
43/45
post-custom-banner

팀 프로젝트를 하면서 처음으로 쿠키를 다뤄봤다.
지금까지 한다한다 해놓고 시도를 안하고 있었는데 좋은 경험이 되었다.
우리 팀은 express와 React로 백, 프론트를 구현했는데
이 둘이 쿠키를 어떻게 사용했는지 기록해놓고자 한다.
리프레쉬 토큰(refresh token)은 사용하지 않았다는 점 미리 참고해주시면 감사하겠습니다!

cookie를 사용하는 플로우

우선 cookie를 생성하고 전달하기 전에 어떤 방식으로 이 친구가 사용되는지 살펴보자.
우리 팀은 cookie에 JWT(access token)를 담아서 사용했다.

  1. 서버에서 쿠키를 생성한다.(우리 팀은 로그인할 때 생성했다.)
  2. 클라이언트로 쿠키를 보내준다.
  3. 클라이언트는 별도의 동작 없이 전달받은 쿠키를 브라우저에 저장하게 된다.
  4. 클라이언트에서 API 콜을 할 경우, 필요 시 cookie를 함께 전달한다.
  5. 서버는 미들웨어를 통해 cookie에서 필요한 정보를 얻어, 이를 통해 인가 여부를 판단한다.

위와 같은 흐름으로 쿠키를 사용하게 된다.

준비물

  1. cookie-parser 라이브러리
  2. cors 라이브러리

cookie의 생성 및 전달

쿠키를 생성하는 것은 백엔드에서 처리한다.
우리 팀은 로그인 시 JWT를 생성하고 이를 cookie에 담았기 때문에, API controller를 보며 설명하겠다.

// src/controllers/member-controllers.ts

export const loginUser = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
    // ... // 

    let customJWT;

    // JWT 생성
    customJWT = jwt.sign(
      {
        email: response[0].email,
        name: response[0].name,
        generation: response[0].generation,
        isAdmin: response[0].isAdmin === 0 ? 0 : 1,
      },
      process.env.JWT_SECRET_KEY,
      { expiresIn: '1h' },
    );

    // cookie 생성
    res.cookie('elice_token', customJWT, {
      path: '/',
      domain: process.env.COOKIE_DOMAIN,
      httpOnly: true,
      secure: true,
      sameSite: 'none',
      maxAge: 60 * 60 * 1000,
    });

    return res.status(200).json({
      token: customJWT,
      email: response[0].email,
      name: response[0].name,
      generation: response[0].generation,
      isAdmin: response[0].isAdmin === 0 ? 0 : 1,
    });
  } catch (err) {
    res.status(500).json({ message: '로그인 처리 중 에러가 발생했습니다.' });

    next(err);
  }
};

로그인 과정에서 res.cookie를 통해 쿠키를 생성하고, 클라이언트 쪽으로 전달한다.
코드를 좀 더 자세하게 살펴보자.

// elice_token이라는 cookie를 만든다.
// cookie에 customJWT를 담았다.
res.cookie('elice_token', customJWT, {
  path: '/',
  domain: process.env.COOKIE_DOMAIN,
  httpOnly: true,
  secure: true,
  sameSite: 'none',
  maxAge: 60 * 60 * 1000,
});

설정값에 대해서도 알아볼 필요가 있다.
설정 때문에 상당히 고생하기 때문에 사용하는 이유와 효과에 대해 숙지해놓으면 좋을 것 같다.
여러 옵션이 있지만, 우리 팀에서 사용한 옵션만 설명하고자 한다.

path : 쿠키가 적용되는 경로를 지정한다. 기본값은 '/'이며, 이는 모든 경로에서 쿠키를 사용할 수 있도록 해준다. 예를 들어, path: '/admin'으로 설정하면 /admin경로와 /admin/* 하위 경로에서만 쿠키를 사용할 수 있다.

domain : 쿠키의 도메인을 설정한다. 기본값은 현재 호스트의 도메인이다. 예를 들어, example.com 도메인에서 쿠키를 사용하려면 domain: 'example.com'으로 설정한다. 이렇게 설정하면 하위 도메인(sub.example.com)에서도 쿠키를 사용할 수 있다.

httpOnly : 기본값은 false이며, true인 경우 클라이언트 쪽에서 document.cookie로 접근할 수 없게 만든다. 따라서 JS로 건들 수 없다. 이를 통해 XSS(Cross-Site Scripting) 공격으로 쿠키가 탈취되는 것을 막는다.

secure : 기본값은 false이며, true인 경우 HTTPS에서만 쿠키를 사용할 수 있게 만든다. 따라서 쿠키는 암호화된 연결을 통해서만 서버로 전송되므로 네트워크 감청으로부터 보호할 수 있다. 이를 통해 중간자 공격(Man-in-the-Middle Attack)을 어렵게 만든다.

sameSite : strict, lax, none 중 선택할 수 있다.

  • strict : 쿠키가 동일 출처에서만 전송되게 한다.
  • lax : 쿠키가 일부 상황에서 다른 출처로 전송될 수 있도록 한다.
  • none : 쿠키가 동일한 출처에서도 전송되고, 다른 출처에서도 전송되는 상황에서 쿠키가 전송될 수 있도록 허용한다.

maxAge : 쿠키의 유효 기간을 설정한다. 유효 기간은 ms 단위로 지정된다. 예를 들어, maxAge: 60 * 60 * 1000은 쿠키를 1시간 동안 유지하도록 설정한다. 만약 이 값을 설정하지 않으면 세션 쿠키가 생성되며, 브라우저가 닫힐 때 쿠키가 삭제된다.

sameSite에서 동일 출처란 어떤 의미?

어떤 두 URL을 명시한다고 했을 때 프로토콜(protocol), 호스트(host), 포트(port)가 동일한 경우를 동일 출처라고 한다.
예시를 살펴보자.

기준 : http://store.company.com/dir/page.html 
URL결과이유
http://store.company.com/dir2/other.html성공경로만 다름
http://store.company.com/dir/inner/another.html성공경로만 다름
https://store.company.com/secure.html실패프로토콜 다름
http://store.company.com:81/dir/etc.html실패포트 다름 (http://는 80이 기본값)
http://news.company.com/dir/other.html실패호스트 다름

만약, 백엔드와 프론트엔드가 같은 도메인을 사용하고 있다면 strict를 사용해도 되겠지만,
다른 도메인을 사용한다면 lax 혹은 none의 사용을 고려해야할 것 같다.

참고로, 별도의 설정이 없다면 http80, https443 포트를 사용한다고 한다.

sameSite : none과 secure : true

만약 sameSitenone으로 사용한다면, secure는 반드시 true로 설정해야한다.
그렇지 않으면 아래와 같은 경고 문구가 보일 것이다.

참고 이미지

block이 된다고 하니 정상적인 작동을 기대할 수 없을 것이다.
주의해서 사용하도록 하자.

maxAge와 expires를 헷갈리지말자!

쿠키의 만료를 설정하는데에는 maxAge 외에도 expires라는 옵션이 있는데,
두 개가 어떤 차이가 있는지 짚고 넘어가자.

앞서, maxAge는 설명했으니 expires를 살펴보자.

expires : 쿠키의 만료 날짜를 설정한다. expires 옵션은 날짜 객체 형식이어야 하며, 쿠키가 해당 날짜 이후에 만료된다. maxAgeexpires 중 하나를 선택하여 사용할 수 있으며, expires를 사용하면 maxAge 대신 날짜 기반의 만료 시간을 지정할 수 있다.

그렇다. maxAge와 똑같은 기능을 하는 옵션인데...사용법만 다르다!
만약, 쿠키를 1시간 뒤에 만료하고자 한다면

res.cookie('elice_token', customJWT, {
  path: '/',
  domain: process.env.COOKIE_DOMAIN,
  httpOnly: true,
  secure: true,
  sameSite: 'none',
  // maxAge: 60 * 60 * 1000,
  expires: new Date(Date.now() + (60 * 60 * 1000)),
});

위와 같은 방법으로 값을 설정하면 된다. Date 객체를 사용한다는 점이 다르다.

cors 설정하기

왜 쿠키 설명하다가 갑자기 cors가 나오느냐?
여기서의 설정을 통해 클라이언트와 서버가 쿠키를 주고 받을 수 있기 때문이다.

// app.ts

app.use(
  cors({
    origin: [
	  // ... //
      'http://localhost:3000',
      process.env.CALLBACK_URL || '',
    ],
    credentials: true,
    allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept'],
    exposedHeaders: ['set-cookie'],
  }),
);

여기서 중요한 것은 origin, credentials, exposedHeaders 설정이다.
하나씩 살펴보자.

originCORS를 해결해서 요청을 허용할 도메인을 설정하는 것이다.

CORS 에러 해결하는거..중요하긴한데, 쿠키랑 무슨 상관이에요?

가끔 개발 단계에서, 혹은 배포해서까지도 origin"*"(와일드카드, wildcard)를 사용하시는 분들이 계신다.(바로 나^^)
와일드카드를 사용하면 뭐 입력해야하는지 따질 필요없이 그냥 다 허용되기 때문에 편해서 사용한다.

그런데, 와일드카드의 사용이 쿠키 사용을 방해한다.

참고 이미지

위 이미지는 origin에 와일드카드를 사용하고 있는 경우에 발생하는 에러이다.
credentials를 사용하려면 origin에 와일드카드를 사용하면 안되는 것이다!

그럼 credentials는 무엇이냐?
credentials는 쿠키 등과 같은 정보를 헤더에 저장할 수 있게 해준다.

즉, 쿠키를 주고 받기 위해 설정해줘야하는 부분이
origin이 와일드카드일 때에는 먹통이 된다는 것이다!

이런 이유가 있으니, origin에 와일드카드를 사용하는 것은 피하도록하자.
도메인을 정확히 입력하는 것으로 합시다 :)

마지막으로, exposedHeaders는 브라우저에 노출시킬 헤더 목록을 만드는 것인데,
set-cookie를 추가해주지 않으면 헤더의 Set-cookie 부분이 노출되지 않아서
브라우저에서 cookie를 저장할 수 없게된다.

브라우저에 저장된 cookie

이제 쿠키를 생성하고 클라이언트 쪽으로 보내줬으니, 잘 들어왔는지 확인해보자!
우선, 로그인 API에서 쿠키를 생성하고 보내주므로,
Network 탭에서 로그인 API 통신 부분을 확인해보자.

참고 이미지

Response HeadersSet-Cookie가 잘 들어와 있는 것을 확인할 수 있다.

이제 Application 탭에서 쿠키가 잘 저장되어 있는지도 확인해보자.

참고 이미지

쿠키가 잘 저장되어있는 것을 확인할 수 있다.

API 요청에 cookie 실어 보내기

이제 브라우저에 저장도 했으니, API 통신에 활용하는 일만 남았다.
이 부분이 처음 공부할 때는 상당히 막막했다.
쿠키를 body에 담아서 데이터처럼 보내는 줄 알았는데, 그게 아니었다.
설정만 해놓으면, Request Headers에 자동으로 담겨서 보내진다.

보통 API 통신을 할 때 fetch 혹은 axios를 사용하는데,
쿠키를 담아서 보내는 방법이 약간 다르니 둘 다 살펴보자.

fetch

const response = await fetch(
  `${process.env.REACT_APP_BACKEND_ADDRESS}/admin/reservations/${date}`,
  {
	method: 'GET',
	credentials: 'include',
  },
);

credentials: 'include' 설정을 통해 쿠키를 헤더에 담을 수 있다.

axios

const response = await axios.post(
  `${process.env.REACT_APP_BACKEND_ADDRESS}/access/login`,
  data,
  { withCredentials: true },
);

withCredentials: true 설정을 통해 쿠키를 헤더에 담을 수 있다.

이런 설정을 통해 API 통신을 진행하면 아래와 같이 Request Headers가 설정된다.

참고 이미지

서버에서 cookie 사용하기

이제 API 통신의 헤더에 쿠키가 담기는 것까지 확인했다.

끝!

아니다. 왜 이 고생을 하면서 쿠키를 헤더에 넣었는가...
헤더에 담겨온 쿠키를 얻고, 거기 들어있는 JWT를 얻고, JWT를 디코딩해서 유저 정보를 얻는 것이 목표다.
우리는 백엔드에서 이를 처리할 것이다.

우선, 헤더에서 쿠키를 파싱하기 위해 cookie-parser 라이브러리를 미들웨어로 등록한다.

// app.ts

const cookieParser = require('cookie-parser');

const app = express();

app.use(cookieParser());

이제, 이 미들웨어는 헤더에서 쿠키를 파싱해줄 것이다.
그리고 자동으로 req.cookies를 통해 쿠키 값에 접근할 수 있게 만들어준다.

인가 여부 판단 커스텀 미들웨어 만들기

앞서, 쿠키에는 JWT를 담아놨다고 언급한 바 있다.
JWT에는 유저 정보가 들어있고, 이를 디코딩해서 유저의 이메일이나 관리자 여부를 판단하고자 한다.

그런데...매번 req.cookies에 접근해서 값을 얻고, 디코딩하고, 예외 처리하면...
코드가 너무 반복될 것이다!

이를 해결하기 위해 커스텀 미들웨어를 만들어서 사용하고자한다.

// src/middlewares/check-auth.js

const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  try {
    // req.cookies를 통해 클라이언트에서 전달받은 쿠키에 접근할 수 있다.
    const token = req.cookies.elice_token;

    // 토큰이 없는 경우
    if (!token) {
      throw new Error('NO token!');
    }

    // 토큰 유효한지 검사
    const decodedToken = jwt.verify(token, process.env.JWT_SECRET_KEY);

    // 이메일 검증으로 로그인 여부 체크
    if (!decodedToken) {
      throw new Error('로그인 유저가 아닙니다.');
    }

    // 동적으로 req에 데이터 추가할 수 있다.
    // 따라서, 이 미들웨어를 거친 라우터에서는 req.user를 통해 email, isAdmin에 접근할 수 있다.
    req.user = {
      email: decodedToken.email,
      isAdmin: decodedToken.isAdmin,
    };

    next();
  } catch (err) {
    console.log(err);
    const error = new Error('Authentication failed!');

    return next(error);
  }
};

이런 미들웨어를 썼다는 것을 기록하고자 적어놓은 것도 있지만, 중요한 부분이 있다.
바로 이 부분이다.

const token = req.cookies.elice_token;

맨 처음 res.cookie로 쿠키를 생성하면서 지정했던 그 이름에 접근한다.

왜냐?

직전에 살펴본 Request HeadersCookie를 보면

elice_token=암호화된JWT값

위와 같은 형식의 값이 들어있었다.
이는 cookie-parser를 통해 다음과 같이 변경되어 req.cookies에 담긴다.

참고 이미지

개발자가 일일이 =을 기준으로 파싱하고 그런 것이 아니라, 자동으로 이렇게 담아준다.
그렇기 때문에 req.cookies.[cookie_key값]으로 접근하는 것이다.
좀 더 나은 코드를 작성하고자 한다면 cookie의 이름(key)도 환경 변수로 관리하는게 좋을 것 같다.

이 이후에는 본인이 구현하고자 하는 방향에 따라 자유롭게 코드를 작성해나가면 될 것이다 :)

참고 자료

동일 출처 - MDN docs
res.cookie - express docs
res.cookie - tutorialspoint 게시글
kjwsx23님 블로그
charles098님 블로그
ssmin님 블로그
inpa님 블로그
wangmin님 블로그
stackoverflow 질문글 1
stackoverflow 질문글 2
stackoverflow 질문글 3
stackoverflow 질문글 4
stackoverflow 질문글 5

profile
나를 믿는 사람들을, 실망시키지 않도록
post-custom-banner

5개의 댓글

comment-user-thumbnail
2023년 6월 20일

식사는 없어 배고파도
음료는 없어 목말라도

1개의 답글
comment-user-thumbnail
2023년 6월 20일

cors, 쿠키에 대해 궁금했는데 좋은 글 감사합니다!

1개의 답글