상권's

TIL 46 (과제 코드 리뷰)(2021.11.25) 본문

~2022 작성 글/TIL

TIL 46 (과제 코드 리뷰)(2021.11.25)

라마치 2021. 11. 25. 21:27
-오늘의 코플릿 2021.11.26-
문제
아래와 같이 정의된 ugly numbers 중 n번째 수를 리턴해야 합니다.
ugly number는 2, 3, 5로만 나누어 떨어지는 수이다.1은 1번째 ugly number 이다.
1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15, 16, ...
// 각 수에 다가 2 3 5를 곱하면 ugly number 가 만들어진다.
// 1부터 2, 3, 5를 곱해줄 때, 곱해진 숫자가 숫자에 따라서 위치가 정해지지는 않을까?
// 아닌 거 같다.. 
// 해당 패턴을 찾아서 해결을 하면 좋을 거 같은데 일단은 조금 더 알아봐야겠습니다.
// 해당 숫자가 ugly num인지 여부 확인은 쉬운데
// 패턴을 찾아서 진행하기는 어려운 듯 하다..

첫 수도코드 => 해당 문제를 봤을 때, '아 간만에 내가 직접 풀 수 있을 거 같다'라는 생각을 했었는데, 역시나 방법을 찾는 게 쉽지 않았습니다. 해당 숫자가 ugly num인지에 대한 함수는 쉽게 만들 수 있을 거 같은데, 배열에 넣어야 되다 보니 어떤 패턴으로 들어가는 지에 대해서 파악을 하는 게 어려웠습니다. 레프런스를 통해서 해당 문제에 대해서 학습 해보았습니다.

const uglyNumbers = function (n) {
  let uglyNumArr = [1]
  let idx2 = 0,
  idx3 = 0,
  idx5 = 0
  // uglyNum을 만들어줄 인덱스를 정한다. idx마다 곱해질 숫자가 뒤에 숫자이다.

  for (let i = 0; i < n; i++ ) {
    // 인덱스에다가 2, 3, 5를 곱해서, 그 중 가장 작은 수는 배열에 추가를 한다.
    // 배열에 추가된 숫자에게 곱해졌던 수는 해당 인덱스에서 다시 구하지 않아도 되며, 배열에 추가된 숫자를
    //  uglyNum으로 만들어주기 위해 해당 인덱스에 1을 더해준다
    let mutipleByidx2 = uglyNumArr[idx2] * 2
    let mutipleByidx3 = uglyNumArr[idx3] * 3
    let mutipleByidx5 = uglyNumArr[idx5] * 5

    let minUgly = Math.min(
      mutipleByidx2, mutipleByidx3, mutipleByidx5
    )
    uglyNumArr.push(minUgly)

    if(minUgly === mutipleByidx2) idx2++
    if(minUgly === mutipleByidx3) idx3++
    if(minUgly === mutipleByidx5) idx5++
  }
  return uglyNumArr[n - 1]
};

앞 선 세션, 토큰, OAuth 과제를 진행할 때 구현했던 코드와 레프런스 코드를 함께 리뷰해보도록 하겠습니다.

먼저 세션입니다.

 

다른 글에서 해당 과제의 session을 따로 올렸었지만 한 번 더 학습할 수 있도록 추가하겠습니다.

app.use(
  session({
    secret: '@codestates',
    // 필수 선택사항 세션 ID 쿠키 서명에 사용되는 암호입니다. 이것은 하나의 암호에 대한 문자열일 수도 있고 여러 암호의 배열일 수도 있습니다.
    resave: false,
    // 요청 중에 세션을 수정하지 않은 경우에도 세션을 다시 세션 저장소에 저장합니다.
    saveUninitialized: true,
    // 초기화되지 않은 세션을 저장소에 강제로 저장합니다. 세션은 새 세션이지만 수정되지 않은 경우 초기화되지 않습니다.
    cookie: {
      // 세션 ID 쿠키에 대한 설정 객체입니다. The default value is { path: '/', httpOnly: true, secure: false, maxAge: null }.
      domain: 'localhost',
      // 도메인 Set-Cookie 특성의 값을 지정합니다. 기본적으로 도메인이 설정되지 않으며 대부분의 클라이언트는 쿠키가 현재 도메인에만 적용되는 것으로 간주합니다.
      path: '/',
      // the Path Set-Cookie 값을 지정합니다. 기본적으로 도메인의 루트 경로인 '/'로 설정됩니다.
      maxAge: 24 * 6 * 60 * 10000,
      // Expires Set-Cookie 특성을 계산할 때 사용할 시간(밀리초)을 지정합니다.
      sameSite: 'none',
      // SameSite Set-Cookie 특성의 값이 될 boolean 또는 문자열을 지정합니다. true는 엄격한 동일한 사이트 적용을 위해 SameSite 특성을 Strict로 설정합니다.
      // None: 항상 쿠키를 보내줄 수 있습니다. 다만 쿠키 옵션 중 Secure 옵션이 필요합니다.
      httpOnly: true,
      // HttpOnly Set-Cookie 특성의 boolean 값을 지정합니다. truthy인 경우 HttpOnly 특성이 설정되고 그렇지 않은 경우에는 설정되지 않습니다. default로 HttpOnly 특성이 설정됩니다.
      secure: true,
      // 보안 Set-Cookie 특성의 boolean 값을 지정합니다. Truthy의 경우 Secure 특성이 설정되고 그렇지 않은 경우에는 설정되지 않습니다. 기본적으로 보안 특성은 설정되지 않습니다.
    },
  })
);

클라이언트 부분 실행을 https로 실행시키기 위해 package.js 의 scripts 설정 부분입니다.

  "scripts": {
    "start": "HTTPS=true SSL_CRT_FILE=cert.pem SSL_KEY_FILE=key.pem react-scripts start",

아래에서 나오지만, 가장 혼란을 줬던 cors 설정 부분입니다.

app.use(cors({
  origin: "https://localhost:3000",
  methods: ['OPTIONS', 'POST', 'GET'],
  credentials: true
}
));

코드 상에서 mycode로 주석처리 된 부분은 과제 통과한 제 코드입니다.

 

해당 부분은 서버에서 Login controller의 일부입니다.  

module.exports = {
  post: async (req, res) => {
    const userInfo = await Users.findOne({
      where: { userId: req.body.userId, password: req.body.password },
    });
    if (!userInfo) {
        res.status(400).send({ message: "not authorized" });
        // res.status(400).send({ data: null, message: 'not authorized'})
      } 
      else {
        // req.session.userId = userInfo.userId;// my code
        // res.status(200).send({ message: 'ok'}) //my code
        req.session.save(()=> {
          req.session.userId = userInfo.userId;
          res.json({ data: userInfo, message: 'ok'})
        })
        // --------------------------------------
        // Session.save(callback)
        // 세션을 저장소에 다시 저장하고 저장소의 콘텐츠를 메모리에 있는 콘텐츠로 바꿉니다(저장소가 다른 작업을 수행할 수도 있지만 정확한 동작은 저장소 문서를 참조).
        // 이 메소드는 세션 데이터가 변경된 경우 HTTP 응답의 끝에서 자동으로 호출됩니다(이 동작은 미들웨어 생성자의 다양한 옵션으로 변경될 수 있음). 
        // 이 때문에 일반적으로 이 메서드는 호출할 필요가 없습니다.
        // 예를 들어 리디렉션, 오래 지속되는 요청 또는 웹소켓에서 이 메서드를 호출하는 것이 유용한 경우가 있습니다.


        // 응답에서 설정하고 요청에서 읽을 세션 ID 쿠키의 이름의 기본값 'connect.sid' 입니다.
      }
    
  }
}

유저가 로그인을 할 경우, 데이터베이스에서 유저의 정보를 찾고, 해당하는 유저가 있다면, session에 userId를 저장합니다.

 

레프런스에 나와있는 save() 메소드의 경우, 자동적으로 호출이 되는 부분인데, 왜 따로 사용했는 지에 대해서는 의문입니다. 그리고 보내는 데이터 없이 메세지만 보낼 때에도 data : null 은 꼭 추가를 하고, post 에 따른 response에 데이터를 추가할 수 있도록 노력해야겠습니다.

 

로그아웃 controller 입니다.

module.exports = {
  post: (req, res) => {

    // TODO: 세션 아이디를 통해 고유한 세션 객체에 접근할 수 있습니다.
    // 앞서 로그인시 세션 객체에 저장했던 값이 존재할 경우, 이미 로그인한 상태로 판단할 수 있습니다.
    // 세션 객체에 담긴 값의 존재 여부에 따라 응답을 구현하세요.

    if (!req.session.userId) {
      // res.status(400).end() my code
      res.status(400).send({ data: null, message: 'not authorized' })
    } else {
      // req.session.destroy(function(err) { my code
      // })
      // res.status(200).end() my code
      req.session.destroy();
      res.json({ data: null, message: 'ok'})
      // TODO: 로그아웃 요청은 세션을 삭제하는 과정을 포함해야 합니다.
    }
  },
};

로그인하지 않은 유저가 로그아웃을 요청할 경우에는 session.userId가 없기 때문에 'not authorized'라는 메세지가 가며, 성공했을 경우, session.destroy를 통해서 삭제합니다. 

 

로그아웃 또한, 로그인처럼 처리되었음에 대한 메세지와 data의 여부를 꼭 추가할 수 있도록 노력해야겠습니다.

 

유저 정보를 요청하는 controller입니다.

module.exports = {
  get: async (req, res) => {
    // console.log('이거슨', req) 
    // req.session.id => w1TNs8jNbzjDndax8X4L$#^%&*%(*&^%
    // req.session.cookie => 쿠키에 대한 설정을 확인할 수 있음

    // TODO: 세션 객체에 담긴 값의 존재 여부에 따라 응답을 구현하세요.
    // HINT: 세션 객체에 담긴 정보가 궁금하다면 req.session을 콘솔로 출력해보세요

    if (!req.session.userId) {
      // res.status(400).send({ message: 'not authorized'}) my code
      res.status(400).send({ data: null, message: 'not authorized'})
    } else {
      // res.status(200).send({ message: 'ok'}) //my code
      const result = await Users.findOne({
        where : { userId: req.session.userId }
      }).catch((err) => res.json(err));

      res.status(200).json({data: result, message: 'ok'})
      // TODO: 데이터베이스에서 로그인한 사용자의 정보를 조회한 후 응답합니다.
    }
  },
};

로그인에 성공하면, session의 userId여부에 따라 유저의 정보를 보내줍니다.

 

제가 코드를 구현한 부분에서는 session에 userId가 있다면, 바로 ok 메세지를 보냈었습니다. 이 부분에서는 client 쪽에서 userData를 받아야하는 데, 따로 보내질 않았지만, 테스트가 통과를 했었습니다.(clien에서 login에 대해서만 테스트가 있고, mypage에서는 테스트가 없어서 통과할 수 있었던 거 같습니다.) 과제에 대해서 기술해주는 부분을 대충 읽었고, 코드 전체를 훑어보는 과정 없이 테스트 통과에만 집중하다보니 발생한 실수였습니다.

 

client의 login 페이지 http post 부분입니다.

    axios
      .post(
        'https://localhost:4000/users/login',
        {
          userId: this.state.username,
          password: this.state.password,
        },
        { 'Content-Type': 'application/json', withCredentials: true }
        // Access-Control-Allow-Credentials 헤더는 XMLHttpRequest.withCredentials (en-US) 속성이나 
        // Fetch API 생성자의Request()의 credentials 옵션과 함께 작동합니다. 
        // 'Content-Type': 'application/json'은 없어도 테스트 통과나, 웹 실행시에 에러가 발생하질 않는데,
        // 정확한 사용방법에 대해서 숙지를 하고, 빼도 되는 지, 필수인지 대해서 학습해야겠습니다.
      )
      .then((res) => {
        this.props.loginHandler(true);
        return axios.get('https://localhost:4000/users/userinfo', {
          withCredentials: true,
        });
      })
      .then((res) => {
        let { userId, email } = res.data.data;
        this.props.setUserInfo({
          userId,
          email,
        });
      })
      .catch((err) => alert(err));
  }

이 부분을 구현할 때 withCredentials 부분은 없어도 테스트가 통과가 되었지만, 서버를 키고 직접 실행해보니 userInfo 에서 에러가 발생했었습니다. cors 의 credential에 대해서 제대로 학습을 했다면 쉽게 해결을 했겠지만, 아쉬움이 컸습니다.

 

client의 mypage에서 로그아웃을 요청하는 부분입니다.

function Mypage(props) {
  const handleLogout = () => {
    // TODO: 서버에 로그아웃 요청을 보낸다음 요청이 성공하면 props.logoutHandler를 호출하여 로그인 상태를 업데이트 해야 합니다.
    axios({
      method : 'POST',
      url : url,
      withCredentials: true,
    })
    .then(() => {
      props.logoutHandler()
    })
    .catch((error) => {
      console.log(error)
    })
  };
  // const handleLogout = () => { mycode
  //   axios
  //     .post(url, null,
  //       {withCredentials: true}
  //     )
  //     .then(() => props.logoutHandler())
  //     .catch((e) => alert(e));
  // };

logout은 지난 번에 블로그에 올렸던 별칭 부분에서 axios.post(url, data, config) 순으로 진행이 되어야 하는데 데이터가 null이라는 이유로 빼먹는 바람에 실제로 구현이 안되었습니다.(위에서 말씀드렸던, 테스트는 통과되었지만 실제 작동이 안되었던 부분입니다..)

학습했던 만큼 에러를 바로 발견할 수 있어서 다행이었습니다.

 

logout 요청하는 부분에서 credentials이 없을 경우와 있을 경우 request.session 객체를 console로 찍었을 때의 차이입니다.

credential : true 가 있을 경우에는, 유저의 개인정보인 userId가 노출이 되지만, 반대일 경우에는 유저의 개인정보가 노출이 되질 않고, 단순 쿠키에 관한 사항만 출력됨을 알 수 있습니다.

이거슨 logout req Session {
  cookie: {
    path: '/',
    _expires: 2021-11-27T05:22:12.956Z,
    originalMaxAge: 86400000,
    httpOnly: true,
    secure: true,
    domain: 'localhost',
    sameSite: 'none'
  },
  userId: 'kimcoding'
}
POST /users/logout 200 3.075 ms - 28
OPTIONS /users/login 204 0.856 ms - 0
POST /users/login 200 12.947 ms - 184
GET /users/userinfo 304 3.710 ms - -
이거슨 logout req Session {
  cookie: {
    path: '/',
    _expires: 2021-11-27T05:22:28.752Z,
    originalMaxAge: 86400000,
    httpOnly: true,
    domain: 'localhost',
    sameSite: 'none',
    secure: true
  }
}

에러처리

첫번째 TypeError 의 경우에는 스택오버플로우에서 html 문서보다 js 파일이 먼저 실행되어서 발생하는 문제라는 것이 있는데 에러가 조금 달라서 확실치는 않으며, 크롬 확장 프로그램을 삭제하니 해결되었다는 블로그글이 있어서 일단은 그대로 두고 진행을 했습니다.

 

그 다음 부분이 credentials의 부재로 인한 에러입니다.

 

다음은, 과제 진행 중에 발생했던 문제는 캡쳐를 못해서 동일한 에러를 복사해서 갖고 왔습니다.

(node:4796) UnhandledPromiseRejectionWarning: Unhandled promise rejection (r                                                                                                     ejection id: 1): Error: spawn cmd ENOENT
[1] (node:4796) DeprecationWarning: Unhandled promise rejections are deprecated.
In the future, promise rejections that are not handled will terminate the Node.
js process with a non-zero exit code.

스택오버플로우에서 해당 문제에 대한 답변을 통해서 promise에 .catch()부분의 미작성으로 발생한 부분임을 확인할 수 있었습니다.

The origin of this error lies in the fact that each and every promise is expected to handle promise rejection i.e. have a .catch(...). you can avoid the same by adding .catch(...) to a promise in the code as given below.

다음은 accessToken과 refreshToken을 이용해서 유저의 정보를 받아오는 웹입니다.

 

유저의 정보를 받아서 로그인을 하고, accessToken과 refreshToken을 만드는 부분입니다.

module.exports = async (req, res) => {
  // TODO: urclass의 가이드를 참고하여 POST /login 구현에 필요한 로직을 작성하세요.
  let userId = req.body.userId
  let userPassword = req.body.password
  const userInfo = await Users.findOne({
    where : {
      userId : userId,
      password : userPassword
    }
  })
  if (!userInfo) {
    res.status(400).send({"data":null,"message":'not authorized'})
  }
  else {
    const accessToken = jwt.sign(
      {
        id : userInfo.dataValues.id,
        userId : userInfo.dataValues.userId,
        email : userInfo.dataValues.email,
        createdAt : userInfo.dataValues.createdAt,
        updatedAt : userInfo.dataValues.updatedAt
      }, process.env.ACCESS_SECRET, {expiresIn : '1d'})
    const refreshToken = jwt.sign(
      {
        id : userInfo.dataValues.id,
        userId : userInfo.dataValues.userId,
        email : userInfo.dataValues.email,
        createdAt : userInfo.dataValues.createdAt,
        updatedAt : userInfo.dataValues.updatedAt
      }, process.env.REFRESH_SECRET, {expiresIn : '7d'})
      // console.log('이거슨 refreshToken', refreshToken)
    res.cookie("refreshToken", refreshToken).send({ "data": {"accessToken": accessToken} , "message": 'ok'})
  }
};

 

다음은 accessToken을 이용해서 유저의 정보를 받아오는 부분입니다.

module.exports = (req, res) => {
  // TODO: urclass의 가이드를 참고하여 GET /accesstokenrequest 구현에 필요한 로직을 작성하세요.
  let authorization = req.headers['authorization']
  if(!authorization) {
    return res.status(400).send({ "data": null, "message": "invalid access token" })
  }
  let token = authorization.split(' ')[1]
  let data = jwt.verify(token, process.env.ACCESS_SECRET);
  // if(!token) {
  //   res.status(400).send({ "data": null, "message": "invalid access token" })
  // }
  Users.findOne({
    where : { userId :  data.userId }
  })
  .then((respose)=> {
    if(!respose) {
      return res.send({ "data": null, "message": "access token has been tempered" })
    }
    let userInfo = {
      id : respose.dataValues.id,
      userId : respose.dataValues.userId,
      createdAt : respose.dataValues.createdAt,
      updatedAt : respose.dataValues.updatedAt,
      email : respose.dataValues.email
    }
    res.send({ "data": {"userInfo": userInfo} , "message": 'ok'})
  })
  .catch((err) => {
    console.log(err)
  })
};

 

디음은 accessToken이 만료되었을 때, refreshToken을 이용해서 accessToken을 새로 받는 부분입니다.

module.exports = (req, res) => {
  // TODO: urclass의 가이드를 참고하여 GET /refreshtokenrequest 구현에 필요한 로직을 작성하세요.
  // access token이 만료되어 refresh token으로 새로운 access token을 발급받고, 유저가 요청한 정보를 반환하는 라우트입니다.
  let cookie = req.cookies.refreshToken
  if(!cookie) {
    res.status(401).send({ "data": null, "message": "refresh token not provided" })
  }
  if(cookie === "invalidtoken") {
    res.status(400).send({ "data": null, "message": "invalid refresh token, please log in again" })
  }
  let token = jwt.verify(cookie, process.env.REFRESH_SECRET)
  const accessToken = jwt.sign(
    {
      id : token.id,
      userId : token.userId,
      email : token.email,
      createdAt : token.createdAt,
      updatedAt : token.updatedAt
    }, process.env.ACCESS_SECRET, {expiresIn : '1d'}
    )
    let userInfo = {
      id : token.id,
      userId : token.userId,
      createdAt : token.createdAt,
      updatedAt : token.updatedAt,
      email : token.email
    }
  res.send({ "data": {"accessToken": accessToken, "userInfo":userInfo } , "message": 'ok'})
};

 

토큰 과제에서는 session을 통해서 학습하게 된 부분으로 쉽게 구현할 수 있어서 큰 어려움은 없었습니다.


다음은 OAuth를 이용한 과제입니다.

 

깃헙의 유저 정보를 이용해서 로그인을 하고, 유저 정보의 일부를 사용하는 기능을 구현했습니다. 깃헙 앱으로 부터 로그인 정보를 확인받고 나면 authorization code를 url로 받아옵니다. 그리고 authorization code를 통해서 access token으로 교환을 받고, access token으로 resource에 접근이 가능합니다.

 

로그인 하는 client 부분입니다.

  async getAccessToken(authorizationCode) {
    return axios.post('http://localhost:8080/callback',{authorizationCode : authorizationCode})
    .then((result) => {
      this.setState({
        isLogin:true,
        accessToken : result.data.accessToken
      })
    })
    // 받아온 authorization code로 다시 OAuth App에 요청해서 access token을 받을 수 있습니다.
    // access token은 보안 유지가 필요하기 때문에 클라이언트에서 직접 OAuth App에 요청을 하는 방법은 보안에 취약할 수 있습니다.
    // authorization code를 서버로 보내주고 서버에서 access token 요청을 하는 것이 적절합니다.

    // TODO: 서버의 /callback 엔드포인트로 authorization code를 보내주고 access token을 받아옵니다.
    // access token을 받아온 후
    //  - 로그인 상태를 true로 변경하고,
    //  - state에 access token을 저장하세요
  }

 

authorization 코드로 access token에 접근하는 callback 서버입니다.

module.exports = (req, res) => {
  // req의 body로 authorization code가 들어옵니다. console.log를 통해 서버의 터미널창에서 확인해보세요!
  // console.log(req.body);
  axios({
    method : 'POST',
    headers : {
      'Accept': 'application/json'
    },
    data : {
      client_id : clientID,
      client_secret : clientSecret,
      code : req.body.authorizationCode
    },
    url : 'https://github.com/login/oauth/access_token',
  })
  // // })
  // axios.post('https://github.com/login/oauth/access_token', 
  //     {
  //       client_id : clientID,
  //       client_secret	: clientSecret,
  //       code : req.body.authorizationCode
  //     },
  //     { headers : {'Accept': 'application/json' }})
  .then((result) => {
    res.status(200).send({accessToken : result.data.access_token})
  })
  .catch((err) => {
    res.status(401).send(err)
  })

  // TODO : 이제 authorization code를 이용해 access token을 발급받기 위한 post 요청을 보냅니다. 다음 링크를 참고하세요.
  // https://docs.github.com/en/free-pro-team@latest/developers/apps/identifying-and-authorizing-users-for-github-apps#2-users-are-redirected-back-to-your-site-by-github

}

access token을 받아오는 post에서 hearders 에 accept 를 넣어주지 않더라도 테스트는 정상적으로 통과했지만, 실제 웹에서는 access token을 어떤 형식으로 받아오는 지를 지정하지 않아서, 사용하기 어려운 형태로 들어왔었습니다. 그리고 별칭 형태로 axios를 구현하다보니 config 위치를 잘 못 둬서 찾는 데 오랜 시간을 소비했었습니다.(이후에 별칭과 access token을 받는 부분에 대해서 학습을 했습니다.) 

 

Content-Type 헤더와 Accept 헤더 둘 다 데이터 타입(MIME)을 다루는 헤더이지만 Content-Type 헤더는 현재 전송하는 데이터가 어떤 타입인지에 대한 설명이고 Accept 헤더는 클라이언트가 서버에게 웬만하면 데이터 전송할때 이러이러한 타입으로 가공해서 보내라 라고 하는것과 같습니다.

 

accept를 설정하지 않을 경우, default로 설정되어 있는 형식은 아래와 같습니다.

access_token=blahblahblahblahblahblahblahblah&scope=&token_type=bearer

accept : application/json으로 설정했을 경우 받아지는 형식은 아래와 같습니다.

{
  access_token: 'blahblahblahblahblahblah',
  token_type: 'bearer',
  scope: ''
}

공식문서

 

마지막으로, access token을 이용해서 유저의 정보를 받아오는 부분입니다.

  async getGitHubUserInfo() {
    // TODO: GitHub API를 통해 사용자 정보를 받아오세요.
    // https://docs.github.com/en/free-pro-team@latest/rest/reference/users#get-the-authenticated-user
    return axios.get('https://api.github.com/user', {headers : { authorization: `token ${this.props.accessToken}` }})
    .then((result) => {
      this.setState({
        userInfo : result.data
      })
      // console.log(result.data)
    })
  }

  async getImages() {
    // TODO : 마찬가지로 액세스 토큰을 이용해 local resource server에서 이미지들을 받아와 주세요.
    // resource 서버에 GET /images 로 요청하세요.
    console.log(this.props)
    return axios({
      method : 'GET',
      url : 'http://localhost:8080/images',
      headers : { Authorization : `token ${this.props.accessToken}` }
    })
    .then((result) => {
      this.setState({
        images : result.data.images
      })
      })
  }

OAuth 과제를 구현하는 과정에서는, access token의 형식을 바꾸는 것과 axios 별칭 사용에 대한 부분 말고는 큰 어려움은 없었습니다.

Comments