[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (7)”

[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (7)”

11월 24, 2022

frontend 단에서의 몇 가지 변경사항

https://github.com/TGoddessana/flastagram/commit/90a92fe1deddeb1aaf05091d21816aaf41e49f11

를 참고하시면 되겠습니다. :)

불필요한 자바스크립트 코드를 삭제하였습니다. 또한 게시물을 붙일 때 알맞는 id 를 붙일 수 있도록 변경되었습니다!

게시물 API 에 프로필 사진 붙여서 응답하기

위의 사진을 확인해 보면, 인스타그램에서는 게시물 작성한 사람의 프로필 사진이 보이죠? 그것을 구현해 봅시다.

먼저 현재의 게시물 API 의 응답 형태는 아래와 같습니다. 사진 정보가 없네요. 당연하게도 백엔드에서 사진 정보를 보내줘야 프론트엔드 단에서 가져다가 쓸 수가 있겠죠. 지금은 닉네임만 응답하고 있습니다.

이 곳에서 프로필 사진을 나타낸다면, 아래와 같을 겁니다.

{
    "image": "daf",
    "created_at": "2022-11-19,02:19:48",
    "updated_at": "2022-11-19,02:55:19",
    "author_name": "철수",
    "id": 52,
    "title": "으아아앙...",
    "content": "상특)번아웃 따위 없음",
    "author":{
        "id" : 2,
        "profile_image" : "이미지 경로",
        "username" : "미미"
    }
}

이번에는 기존의 응답 형식과 다른 것이, 객체가 author 라는 키 안에 들어가 있네요. 이를 나타내기 위해서, 우리는 중첩 필드를 사용해 보겠습니다. Marshmallow 에서는, 아래와 같이 nested 를 사용해서 위와 같이 중첩된 필드를 나타낼 수 있습니다.

위와 같이 api/schemas/post 의 PostSchema 를 수정합니다.

16번 줄을 살펴보면 새로운 스키마가 Nested 안에 들어가 있는 것을 볼 수 있죠? 아래와 같이 api/schemas/user 에 AuthorSchema 를 작성합니다.

그러면, 게시물 목록을 조회했을 때 아래처럼 필드가 중첩되어서 응답해주는 것을 볼 수 있습니다!

그러고 보니, 이제 프로필 사진도 업로드할 필요가 있을 것 같아요. 좋습니다. 이참에, 내 프로필을 조회할 수 있는 페이지, 수정 기능까지 한번 만들어 봅시다!

마이페이지 구현하기 (프론트엔드)

일단, 우측 상단의 프로필 사진을 클릭했을 때에 마이페이지처럼 내 정보를 수정할 수 있는 창이 띄워지도록 처리하겠습니다. html 을 아래처럼 수정합니다. post_list.html

자바스크립트 함수를 호출하고 있네요. 함수 하나 정의해 줍시다. post_list.js 에 아래의 함수를 넣어줍니다.

/**
 * 프로필 정보를 수정하거나 조회하기 위한 팝업창을 띄웁니다.
 */
function showProfile() {
  var width = 800;
  var height = 950;
  var left = window.screen.width / 2 - width / 2;
  var top = window.screen.height / 4;

  var windowStatus = `width=${width}, height=${height}, left=${left}, top=${top}, resizable=no, toolbars=no, menubar=no`;

  const url = "http://localhost:3000/flastagram/profile";

  window.open(url, "something", windowStatus);
}

그리고, 이 곳에서는 프론트엔드 서버에서 /profile 이라는 URL 로 새 창을 호출하고 있습니다. 아래의 세 개의 파일을 만들어줍니다.

@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,500;0,700;1,400;1,900&display=swap');


body {
    max-width: max-content;
    margin-top: 10px;
    margin-bottom: 10px;
    font-family: 'roboto', sans-serif;
    font-size: large;
}

div {
    text-align: center;
    margin: 15px;
}

.logo-img {
    width: 300px;
    margin-bottom: 10px;
}

.created-at {
    margin-bottom: 20px;
}

.input {
    margin-bottom: 100px;
}
// 아무 내용 없음..
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/assets/css/profile.css" type="text/css" />
    <title>Profile</title>
</head>



<body>
    <nav>
        <div class="logo-wrapper">
            <img src="/assets/img/logo.png" class="logo-img" alt="brand image">
        </div>
    </nav>
    <form id="profile-form">
        <div class="profile-pic">
            <img src="" style="width: 700px; height:700px;" />
            <input type="file" id="imagefile" name="imagefile" accept="image/*" />
        </div>
        <div class="email">
            <label for="email">이메일 :</label>
            <input type="email" value="hello@www.gdsanadevlog.com" id="email-input" name="email" disabled>
        </div>
        <div class="username">
            <label for="username">이름 :</label>
            <input type="text" value="goddessana" id="username-input" name="username" disabled>
        </div>
        <div class="created-at">
            <span>가입일자 :</span>
            <span id="created-at">2022-01-01</span>
        </div>
        <div class="input">
            <input type="submit" value="프로필 수정하기">
        </div>

    </form>
</body>

</html>

새로운 html 을 만들어 두었으므로 이를 express 에서 연결해 줘야겠죠? server.js 에서 아래 코드를 추가합니다.

그리고, 위의 프로필 사진을 누르면 그럴듯한, 그럴듯해 보였으면 좋겠는, 영혼을 갈아 만든 CSS 로 완성된 프로필 수정 페이지가 뜨게 됩니다.

그냥 좋다고 해 줘,.

여러가지 필요한 것들은 더 보이지만, 일단 여기까지 하고 마무리하겠습니다.

마이페이지 구현하기 (백엔드)

마음이 편안해지는 백엔드 시간입니다. 프로필 페이지를 구현하기 위해서 백엔드에서 구현할 것은 간단합니다. 일단 조회를 생각해 봅시다.

위에서 만들어 놓은 html 팝업 페이지를 보면 “프로필 사진”, “이메일”, “이름”, “가입일자” 를 일단 보여줘야 하겠네요.

/api/__init__.py 에 아래의 코드를 추가합니다. 정의되지 않은 MyPage 일단 import!

그리고 리소스를 등록해 줍니다.

리소스를 등록했으니 이를 작성해야겠네요. 우리는 일단은 회원탈퇴 기능은 구현하지 않도록 하겠습니다. 그러므로,

나의 마이페이지 조회하기 : GET, 나의 마이페이지 수정하기 : PUT 이 되겠습니다!

더불어, 기능을 확실히 하면 우리는 사용자의 “프로필 사진” 만 수정할 수 있도록 하겠습니다.

이제 리소스를 만들러 가 봅시다. api/resources/user.py

기본 골격은 위와 같겠습니다. 각각의 로직을 처리해 볼까요?

모델에 정의되어 있는 User 를 직렬화/ 역직렬화하여 다루어야 하므로, 우리는 스키마를 하나 정의해야 한다는 것을 알고 있습니다. 자연스런 생각의 흐름이 되겠죠? api/schemas/user 에 아래의 스키마를 하나 추가 정의해 줍니다. 이미지 필드를 필수로 받고 있고, 비밀번호 수정은 하지 않을 것이므로 password 를 제외하였고, 이메일과 유저 이름 또한 바꾸지 않을 것이므로 읽기 전용 필드에 넣어주었네요.

그리고 이를 활용해서, 우리의 비즈니스 로직이 담긴 리소스를 작성해 주면 되겠네요.

/api/resources/user 에 아래의 코드를 추가해 줍니다. 현재 대부분의 CRUD 로직들이 어떻게 구현되고 있는지를 알아채셨을 것 같아요. 스키마를 적극 활용하고 있는 걸 알 수 있죠? Django REST Framework 의 serializer 역할을 수행하고 있습니다.

좋습니다. “철수” 의 토큰과, “미미” 의 토큰을 사용해서 각각 마이페이지에 조회해 보세요. 아래와 같아야 합니다.

민수로 미미의 마이페이지를 접근하려 하는 경우:

민수로 민수의 마이페이지를 접근하려 하는 경우:

그러면, 수정도 처리해 주겠습니다. 그것은 PUT 메서드를 구현하면 되겠죠? 모델에 수정을 위한 메서드도 추가로 구현하겠습니다.

api/models/user.py 의 UserModel 클래스 아래에 이 메서드를 정의해 주겠습니다.

모델 단에서 메서드를 정의해 주었으므로 우리는 이걸 잘 이용하면 되겠네요!

이제 진짜 테스트를 해 봅시다..! jwt 를 담아서, jwt의 주인의 마이페이지를 수정해 보세요.

성공!

그런데, created_at 부분을 조금 더 뿌려주기 쉽게 보내주면 좋겠네요. 그리고 시간까지는 필요가 없죠.

위의 코드를 추가합니다!

편안하네요..! :)

다시 프론트엔드로..

좋아요! 이제 대부분의 작업이 끝났습니다. 사용자는, 아래의 과정을 거치며 프로필 사진을 수정하거나 업로드하게 됩니다.

  1. 유저는 오른쪽 위의 자신의 프로필사진을 클릭함으로서 자신의 프로필사진을 수정할 수 있는 폼에 접근하게 됩니다.
  2. 프로필 사진 수정하기 폼에는 자신의 현재 프로필 사진이 미리 나타나 있어야 합니다.
  3. 유저가 프로필 사진 폼에서 파일을 선택하면, 서버는 해당 프로필 사진을 profiles/ 폴더 아래에 업로드합니다.
  4. 유저가 프로필 사진 폼에서 “프로필 수정하기” 버튼을 누르면, 서버는 프로필 수정 API 에 이미지 경로를 담아 요청을 보내게 됩니다.
  5. 프로필 수정이 완료됩니다. :)

이는 대부분 게시물 작성에서 이루어졌던 로직과 비슷하죠? profile.js 에 아래의 코드를 작성합니다.

profile.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/assets/css/profile.css" type="text/css" />
    <title>Profile</title>
</head>



<body>
    <nav>
        <div class="logo-wrapper">
            <img src="/assets/img/logo.png" class="logo-img" alt="brand image">
        </div>
    </nav>
    <iframe name="dummyframe" id="dummyframe" style="display: none;"></iframe>
    <form id="profile-form" target="dummyframe">
        <div id="preview-image"></div>
        <div class="profile-pic">
            <input type="file" class="imagefile" name="imagefile" accept="image/*"
                onchange="getImageResponse(event);" />
            <!-- 이미지 경로를 저장하기 위한 숨겨진 input -->
            <input type="text" class="image" id="image" name="image" hidden="true">
        </div>

        <div class="email">
            <label for="email">이메일 :</label>
            <input type="email" value="hello@www.gdsanadevlog.com" id="email-input" name="email" disabled>
        </div>
        <div class="username">
            <label for="username">이름 :</label>
            <input type="text" value="goddessana" id="username-input" name="username" disabled>
        </div>
        <div class="created-at">
            <span>가입일자 :</span>
            <span id="created-at">2022-01-01</span>
        </div>
        <div class="input">
            <button onclick="submitProfileData();">프로필 사진 수정하기</button>
        </div>

    </form>
    <script src="/assets/js/profile.js"></script>
</body>

</html>
/**
 * 사용자가 이미지 선택을 완료하면,
 * 1. 업로드한 이미지를 띄워주고
 * 2. 서버에 이미지를 업로드합니다.
 * 3. 이미지 업로드의 성공 여부에 따라 에러 메시지를 띄워줍니다.
 * 4. 이미지 업로드가 성공한다면 그것의 path 를 숨겨져 있는 input 태그의 value 로 넣어줍니다.
 */
async function getImageResponse(event) {
  loadPreviewImage(event);
  result = await submitImage();
  // 201로 성공적으로 이미지가 업로드되었다면,
  // 성공 메시지를 띄워주고 해당 이미지의 경로를 반환
  // 그렇지 않다면, 에러 메시지를 띄워줌
  let response = await result.json();
  if (result.status == 201) {
    alert(response["message"]);
    const path = response["path"];
    const imageInput = document.querySelector(".image");
    imageInput.setAttribute("value", path);
  } else {
    alert(JSON.stringify(response));
  }
}

/**
 * 업로드한 이미지를 미리 확인합니다.
 */
function loadPreviewImage(event) {
  var reader = new FileReader();
  reader.onload = function (event) {
    var img = document.createElement("img");
    img.setAttribute("src", event.target.result);
    document.querySelector("div#preview-image").appendChild(img);
  };
  reader.readAsDataURL(event.target.files[0]);
}

/**
 * input 태그에서 선택한 이미지를 서버에 전송합니다.
 * fetch() 의 결과를 반환합니다.
 */
async function submitImage() {
  // 이미지 파일을 서버에 전송하기 위해 form 생성
  const fileInput = document.querySelector(".imagefile");
  const formData = new FormData();

  // header 설정
  var myHeaders = new Headers();
  myHeaders.append(
    "Authorization",
    "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNjY4OTMwMDA2LCJqdGkiOiIwOTU3NDAxNi04YmE2LTQyZjgtODhhZC0wYTkzMzBjNTY5N2UiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiXHViYmZjXHVjMjE4IiwibmJmIjoxNjY4OTMwMDA2LCJleHAiOjE2NjkwMTY0MDZ9.LnixDrHFAMusZhMSc2vIsfru4D61UAMrIx9ca3kTX3c"
  );

  formData.append("image", fileInput.files[0]);

  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: formData,
  };

  // 이미지 업로드 API 요청
  const result = await fetch(
    "http://127.0.0.1:5000/upload/profile/image/",
    requestOptions
  );

  return result;
}

/**
 * form 태그 안에 있는 내용을 JSON 으로 변환합니다.
 */
function getFormJson() {
  let form = document.querySelector("#profile-form");
  let data = new FormData(form);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 태그 안에 있는 내용을 dictionary 형태로 반환합니다.
 */
function serialize(rawData) {
  let serializedData = {};
  for (let [key, value] of rawData) {
    console.log(value);
    if (key == "imagefile") {
      continue;
    }
    if (value == "") {
      serializedData[key] = null;
    }
    serializedData[key] = value;
  }
  console.log(serializedData);
  return serializedData;
}

/**
 * 정제된 데이터를 넣어 프로필 수정 요청을 보냅니다.
 */
async function submitProfileData() {
  // 인증을 위한 header 설정
  var myHeaders = new Headers();
  myHeaders.append(
    "Authorization",
    "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNjY4OTMwMDA2LCJqdGkiOiIwOTU3NDAxNi04YmE2LTQyZjgtODhhZC0wYTkzMzBjNTY5N2UiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiXHViYmZjXHVjMjE4IiwibmJmIjoxNjY4OTMwMDA2LCJleHAiOjE2NjkwMTY0MDZ9.LnixDrHFAMusZhMSc2vIsfru4D61UAMrIx9ca3kTX3c"
  );
  myHeaders.append("Content-Type", "application/json");

  // 보낼 데이터 설정
  var raw = getFormJson();

  // 최종 옵션 설정
  var requestOptions = {
    method: "PUT",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  };

  // 프로필 정보 수정 요청
  const response = await fetch(
    "http://127.0.0.1:5000/mypage/4/",
    requestOptions
  );
  console.log(response.status);
  if (response.status == 200) {
    // window.location.href = "http://localhost:3000/flastagram/posts/";
    alert(JSON.stringify(await response.json()));
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

프로필 페이지에서, 프로필 사진을 업로드해 볼까요?

이미지가 업로드되고, 이미지의 경로가 데이터베이스에 저장된 것을 확인할 수 있네요.

이제 해야 할 것은 업로드된 이미지를 화면에 그려주는 것이 되겠죠? 자바스크립트 함수를 조금만 수정하면 되겠어요.

post_list.js

// API 기본 URL들을 정의합니다.
const postListBseUrl = "http://127.0.0.1:5000/posts/";
const imageRetrieveBseUrl = "http://127.0.0.1:5000/statics/";

/** Flask API 로부터 데이터를 가져옵니다.
 * promise 객체를 반환합니다.
 */
async function getPostListDatafromAPI(page = 1) {
  try {
    const somePromise = await fetch(postListBseUrl + "?page=" + page);
    const result = somePromise.json();
    return result;
  } catch (error) {
    console.log(error);
  }
}

/**
 * post Div 전체를 복사해 반환합니다.
 */
function getCopyDiv() {
  const postDiv = document.querySelector(".post");
  const newNode = postDiv.cloneNode(true);
  newNode.id = "copied-post";
  newNode.style = "display=inline";
  return newNode;
}

/**
 * id, 제목, 내용, 저자, 사진을 받아 해당 div를 하나의 게시물로 완성합니다.
 */
function getCompletedPost(
  idValue, // 게시물의 id
  titleValue, // 게시물의 제목
  feedImgValue, // 게시물의 피드 이미지
  contentValue, // 게시물의 내용
  authorNameValue, // 저자의 이름
  authorImageValue // 저자의 프로필 사진
) {
  div = getCopyDiv();
  let authorUpImg = div.children[0].children[0].children[0].children[0];
  let authorUpName = div.children[0].children[0].children[1];
  let feedImg = div.children[1];
  let authorDownName = div.children[2].children[3];
  let title = div.children[2].children[4];
  let content = div.children[2].children[5];
  let postTime = div.children[2].children[6];

  div.id = idValue;
  title.innerText = titleValue;
  feedImg.src = feedImgValue;
  content.innerText = contentValue;
  authorUpName.innerText = authorNameValue;
  authorUpImg.src = authorImageValue;
  authorDownName.innerText = authorNameValue;

  return div;
}

/**
 * 게시물 데이터를 받아온 다음,
 * 일정한 조건이 되면 호출되는 메서드입니다.
 * 페이지를 받아서, 적절한 데이터를 받아 화면에 그립니다.
 */
function loadMorePosts(page) {
  getPostListDatafromAPI(page).then((result) => {
    const postDiv = document.querySelector(".post-wrapper");
    for (let i = 0; i < result.length; i++) {
      // 게시물의 id
      const id = result[i]["id"];
      // 게시물의 제목
      const title = result[i]["title"];
      // 게시물의 피드 이미지
      const image = imageRetrieveBseUrl + result[i]["image"];
      // 게시물의 내용
      const content = result[i]["content"];
      // 저자의 이름
      const authorName = result[i]["author"]["username"];
      // 저자의 프로필 사진
      const authorImage = imageRetrieveBseUrl + result[i]["author"]["image"];

      postDiv.append(
        getCompletedPost(
          (idValue = id),
          (titleValue = title),
          (feedImgValue = image),
          (contentValue = content),
          (authorNameValue = authorName),
          (authorImageValue = authorImage)
        )
      );
    }
  });
}

/**
 * 프로필 정보를 수정하거나 조회하기 위한 팝업창을 띄웁니다.
 */
function showProfile() {
  var width = 800;
  var height = 950;
  var left = window.screen.width / 2 - width / 2;
  var top = window.screen.height / 4;

  var windowStatus = `width=${width}, height=${height}, left=${left}, top=${top}, resizable=no, toolbars=no, menubar=no`;

  const url = "http://localhost:3000/flastagram/profile";

  window.open(url, "something", windowStatus);
}

/**
 * 무한 스크롤을 수행합니다.
 */
function executeInfiniteScroll() {
  let pageCount = 1;
  var intersectionObserver = new IntersectionObserver(function (entries) {
    if (entries[0].intersectionRatio <= 0) {
      return;
    }
    // 게시물을 더 로드합니다.
    loadMorePosts(pageCount);
    pageCount++;
  });
  intersectionObserver.observe(document.querySelector(".bottom"));
}

function main() {
  executeInfiniteScroll(); // 스크롤을 내릴 때마다 게시물을 로드 (무한스크롤)
}

main();
이번 올림피아 우승도, 빅 라미가 차지할 수 있을까?

위와 같이 업로드된 프로필 사진이 잘 나타나는 것을 확인할 수 있네요!

프론트엔드 단에서 로그인 구현하기

앞서 우리는 회원 인증을 위해, “나 서울사는 미미요” 를 서버에 알려주기 위해 미미의 신분증인 액세스 토큰과 그것의 보조 신분증 리프레시 토큰을 구현했습니다. 이제 그것을 진짜 사용해볼 차례입니다.

액세스 토큰과 리프레시 토큰을 저장하는 방식에는 여러 가지가 있지만, 오늘은 그것들을 로컬 스토리지에 저장하는 방식으로 구현하겠습니다.

명색이 프론트엔드 단에서 구현하기인데, 폼 정도는 있어 줘야겠죠. 만들어 주겠습니다.

파일들을 만들었으므로 서버에 등록해 줍시다.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://fonts.googleapis.com/css?family=Indie+Flower|Overpass+Mono" rel="stylesheet">
    <link rel="stylesheet" href="/assets/css/login.css" type="text/css" />
    <title>Login - Flastagram</title>
</head>

<body>
    <div id="wrapper">
        <div class="main-content">
            <div class="header">
                <img src="/assets/img/logo.png" class="brand-img" alt="brand image">
            </div>
            <form class="login-form">
                <input type="Email" placeholder="Email" name="email" class="email-input" />
                <input type="password" placeholder="Password" name="password" class="password-input" />
                <input type="button" value="Log in" class="btn" onclick="submitLoginData()" />
            </form>
        </div>
        <div class="sub-content">
            <div class="s-part">
                Don't have an account? <a href="#">Sign up</a>
            </div>
        </div>
    </div>
    <script src="/assets/js/login.js"></script>
</body>

</html>
* {
    margin: 0px;
    padding: 0px;
}
  
  body {
    background-color: #eee;
    background-image: url("https://i.picsum.photos/id/155/1920/1080.jpg?blur=5&hmac=YSIxbkwRYVPjY4sbu8PxWeFS2p-N3lCajlBU1HeU3C0")
  }
  
  #wrapper {
    width: 500px;
    height: 50%;
    overflow: hidden;
    border: 0px solid #000;
    margin: 50px auto;
    padding: 10px;
  }
  
  .main-content {
    width: 400px;
    height: 300px;
    margin: 10px auto;
    background-color: #fff;
    border: 2px solid #e6e6e6;
    padding: 40px 50px;
    opacity:0.9;
  }
  
  .login-form {
    margin-top: 75px;
  }
  
  .header {
    border: 0px solid #000;
    margin-bottom: 25px;
    text-align: center;
  }
  
  .header img {
    width: 300px;
    margin: auto;
    position: relative;
  }
  
  .email-input,
  .password-input {
    width: 100%;
    margin-bottom: 5px;
    padding: 8px 12px;
    border: 1px solid #dbdbdb;
    box-sizing: border-box;
    border-radius: 3px;
    font-size: 18px;
  }
  
  
  .btn {
    width: 100%;
    background-color: #3897f0;
    border: 1px solid #3897f0;
    padding: 5px 12px;
    color: #fff;
    font-weight: bold;
    cursor: pointer;
    border-radius: 3px;
    font-size: 15px;
  }
  
  .sub-content {
    width: 400px;
    height: 40%;
    margin: 10px auto;
    border: 1px solid #e6e6e6;
    padding: 20px 50px;
    background-color: #fff;
  }
  
  .s-part {
    text-align: center;
    font-family: 'Overpass Mono', monospace;
    word-spacing: -3px;
    letter-spacing: -2px;
    font-weight: normal;
  }
  
  .s-part a {
    text-decoration: none;
    cursor: pointer;
    color: #3897f0;
    font-family: 'Overpass Mono', monospace;
    word-spacing: -3px;
    letter-spacing: -2px;
    font-weight: normal;
  }
/**
 * form 을 선택하고 직렬화된 JSON 을 반환합니다.
 */
function getFormJson() {
  let loginForm = document.querySelector(".login-form");
  let data = new FormData(loginForm);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 데이터를 받아서 JSON으로 직렬화합니다.
 */
function serialize(rawFormData) {
  let result = {};
  for (let [key, value] of rawFormData) {
    let sel = document.querySelectorAll("[name=" + key + "]");
    if (sel.length > 1) {
      if (result[key] === undefined) {
        result[key] = [];
      }
      result[key].push(value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

/**
 * 로그인 정보를 서버에 전송하고, 로컬 스토리지에 JWT를 저장합니다.
 */
async function submitLoginData() {
  var myHeaders = new Headers();
  myHeaders.append("Content-Type", "application/json");

  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: getFormJson(),
    redirect: "follow",
  };
  const response = await fetch("http://127.0.0.1:5000/login/", requestOptions);
  if (response.status == 200) {
    loginResponse = await response.json();
    const access_token = loginResponse["access_token"];
    const refresh_token = loginResponse["refresh_token"];
    localStorage.setItem("access_token", access_token);
    localStorage.setItem("refresh_token", refresh_token);
    alert(JSON.stringify(loginResponse));
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

좋아요. 위의 자바스크립트 코드에서는, 우리의 로그인 API 에 로그인 요청을 보낸 후 적절한 토큰을 받아올 겁니다. 받아와서 그것을 로컬 스토리지에 저장했으므로, 우리는 브라우저에서 언제든 로컬 스토리지 안에 있는 액세스 토큰을 가져와 사용할 수 있죠.

모든 게 이리 쉽게 끝난다면 참 좋겠지만 생각해봐야 할 것이 하나 더 있습니다. 바로 만료기간입니다. 토큰에는 만료기간이 있습니다. 현재 로컬 스토리지에 들어있는 액세스 토큰이 만료되었을 경우, 프론트엔드에서는 자동으로 리프레시 토큰을 사용해서 새로운 액세스 토큰을 가져와야 하겠죠.

받아온 토큰을 사용하기 전 우리가 어디서 토큰이 필요한지를 잠깐 생각해 볼까요? 인스타그램에서는 사실 모든 페이지가 로그인을 해야만 사용 가능합니다. 우리의 게시물 목록, 게시물 상세에 대한 모든 요청에 jwt_required 장식자를 붙여줍시다.

api/resources/post

이제, 조회 또한 JWT가 필요하다.

그러면, 당연히 jwt를 담아 요청을 보내지 않았으므로 게시물에 대한 요청은 실패할 것이고, 아래와 같은 화면이 뜨겠죠?

그러면 우리가 건드려야 하는 자바스크립트 파일은 post_list.js, post_create.js, profile.js 가 되겠네요. 게시물을 조회하기 위해서, 우리는 posts/ 에 요청을 보냈었죠? 이제 해야 하는 것은 posts에 GET 요청을 보낼 때, 헤더에 토큰을 담아 보내는 것입니다.

post_list.js 에 위의 url 을 하나 추가합시다. 만약 액세스 토큰이 만료되었다면, 우리는 위의 url 을 통해서 새로운 액세스 토큰을 받아올 겁니다.

그리고 위와 같이 로컬 스토리지에서 액세스 토큰과, 리프레시 토큰을 가져옵니다.

만약, 액세스 토큰이 로컬스토리지에 저장되어 있지 않다면 로그인 페이지로 자동 이동되게끔, 위와 같이 함수를 하나 정의합니다.

만약 – 액세스 토큰이 만료되었을 경우, 현재 로컬 스토리지에 저장되어있는 리프레시 토큰으로 새 액세스 토큰을 받아오는 함수를 하나 정의합니다.

/**
 * Flask API 로부터 게시물 목록 데이터를 가져옵니다.
 * 만약, API 요청에 대한 응답 상태 코드가 401이라면,
 * 가지고 있는 리프레시 토큰으로 액세스 토큰을 재발급 요청한 후,
 * 게시물 목록 API 요청을 다시 보냅니다.
 * 그것에 대한 응답 상태 코드 또한 401이라면,
 * 로그인 페이지로 리다이렉트 처리합니다.
 */
async function getPostListDatafromAPI(page = 1) {
  try {
    var myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");

    var requestOptions = {
      method: "GET",
      headers: myHeaders,
    };

    let rawResult = await fetch(
      postListBseUrl + "?page=" + page,
      requestOptions
    );
    // 만약 액세스 토큰이 만료되었다면, 새로운 액세스 토큰을 받아옵니다.
    if (rawResult.status == 401) {
      getNewJWT();
    }
    rawResult = await fetch(postListBseUrl + "?page=" + page, requestOptions);

    // 만약 리프레시 토큰도 만료되었다면, 로그인 페이지로 리다이렉트 처리합니다.
    if (rawResult.status == 401) {
      window.location.href = "http://localhost:3000/flastagram/login";
    }
    const result = rawResult.json();
    return result;
  } catch (error) {
    console.log(error);
  }
}

그리고, 위처럼 getPostListDatafromAPI 함수를 수정해줍니다. 이전과는 다르게 header 에 액세스 토큰을 담아 보내고, 서버가 401 응답 (unauthorized) 를 보내온다면 리프레시 토큰으로 액세스 토큰을 다시 받아온 다음, 그것을 통해서 다시 요청을 보내는 로직을 진행하고 있네요.

좋아요. 메인 함수에, 우리가 작성한 함수를 등록합시다.

post_list.js 전체 코드:

// API 기본 URL들을 정의합니다.
const postListBseUrl = "http://127.0.0.1:5000/posts/";
const imageRetrieveBseUrl = "http://127.0.0.1:5000/statics/";
const refreshTokenBseUrl = "http://127.0.0.1:5000/refresh/";
// localStorage 로부터 토큰을 가져옵니다.
let ACCESS_TOKEN = localStorage.getItem("access_token");
let REFRESH_TOKEN = localStorage.getItem("refresh_token");
/**
 * 액세스 토큰과 리프레시 토큰이 localStorage 에 존재하지 않으면,
 * 로그인 페이지로 리다이렉트 처리합니다.
 */
function handleUserLogin() {
  // localStorage 에 access_token 이 존재하지 않으면 리다이렉트
  if (!localStorage.getItem("access_token")) {
    window.location.href = "http://localhost:3000/flastagram/login";
  }
}
/**
 * loacalStorage 에 존재하는 리프레시 토큰으로,
 * 새로운 액세스 토큰과 리프레시 토큰을 받아옵니다.
 * 받아온 새로운 토큰들을 localStorage에 저장합니다.
 */
async function getNewJWT() {
  var myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${REFRESH_TOKEN}`);
  myHeaders.append("Content-Type", "application/json");
  var requestOptions = {
    method: "POST",
    headers: myHeaders,
  };
  const refreshResponse = await (
    await fetch(refreshTokenBseUrl, requestOptions)
  ).json();
  const access_token = refreshResponse["access_token"];
  const refresh_token = refreshResponse["refresh_token"];
  localStorage.setItem("access_token", access_token);
  localStorage.setItem("refresh_token", refresh_token);
}
/**
 * Flask API 로부터 게시물 목록 데이터를 가져옵니다.
 * 만약, API 요청에 대한 응답 상태 코드가 401이라면,
 * 가지고 있는 리프레시 토큰으로 액세스 토큰을 재발급 요청한 후,
 * 게시물 목록 API 요청을 다시 보냅니다.
 * 그것에 대한 응답 상태 코드 또한 401이라면,
 * 로그인 페이지로 리다이렉트 처리합니다.
 */
async function getPostListDatafromAPI(page = 1) {
  try {
    var myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");
    var requestOptions = {
      method: "GET",
      headers: myHeaders,
    };
    let rawResult = await fetch(
      postListBseUrl + "?page=" + page,
      requestOptions
    );
    // 만약 액세스 토큰이 만료되었다면, 새로운 액세스 토큰을 받아옵니다.
    if (rawResult.status == 401) {
      getNewJWT();
    }
    rawResult = await fetch(postListBseUrl + "?page=" + page, requestOptions);
    // 만약 리프레시 토큰도 만료되었다면, 로그인 페이지로 리다이렉트 처리합니다.
    if (rawResult.status == 401) {
      window.location.href = "http://localhost:3000/flastagram/login";
    }
    const result = rawResult.json();
    return result;
  } catch (error) {
    console.log(error);
  }
}
/**
 * post Div 전체를 복사해 반환합니다.
 */
function getCopyDiv() {
  const postDiv = document.querySelector(".post");
  const newNode = postDiv.cloneNode(true);
  newNode.id = "copied-post";
  newNode.style = "display=inline";
  return newNode;
}
/**
 * id, 제목, 내용, 저자, 사진을 받아 해당 div를 하나의 게시물로 완성합니다.
 */
function getCompletedPost(
  idValue, // 게시물의 id
  titleValue, // 게시물의 제목
  feedImgValue, // 게시물의 피드 이미지
  contentValue, // 게시물의 내용
  authorNameValue, // 저자의 이름
  authorImageValue // 저자의 프로필 사진
) {
  div = getCopyDiv();
  let authorUpImg = div.children[0].children[0].children[0].children[0];
  let authorUpName = div.children[0].children[0].children[1];
  let feedImg = div.children[1];
  let authorDownName = div.children[2].children[3];
  let title = div.children[2].children[4];
  let content = div.children[2].children[5];
  let postTime = div.children[2].children[6];
  div.id = idValue;
  title.innerText = titleValue;
  feedImg.src = feedImgValue;
  content.innerText = contentValue;
  authorUpName.innerText = authorNameValue;
  authorUpImg.src = authorImageValue;
  authorDownName.innerText = authorNameValue;
  return div;
}
/**
 * 게시물 데이터를 받아온 다음,
 * 일정한 조건이 되면 호출되는 메서드입니다.
 * 페이지를 받아서, 적절한 데이터를 받아 화면에 그립니다.
 */
function loadMorePosts(page) {
  getPostListDatafromAPI(page).then((result) => {
    const postDiv = document.querySelector(".post-wrapper");
    for (let i = 0; i < result.length; i++) {
      // 게시물의 id
      const id = result[i]["id"];
      // 게시물의 제목
      const title = result[i]["title"];
      // 게시물의 피드 이미지
      const image = imageRetrieveBseUrl + result[i]["image"];
      // 게시물의 내용
      const content = result[i]["content"];
      // 저자의 이름
      const authorName = result[i]["author"]["username"];
      // 저자의 프로필 사진
      const authorImage = imageRetrieveBseUrl + result[i]["author"]["image"];
      postDiv.append(
        getCompletedPost(
          (idValue = id),
          (titleValue = title),
          (feedImgValue = image),
          (contentValue = content),
          (authorNameValue = authorName),
          (authorImageValue = authorImage)
        )
      );
    }
  });
}
/**
 * 프로필 정보를 수정하거나 조회하기 위한 팝업창을 띄웁니다.
 */
function showProfile() {
  var width = 800;
  var height = 950;
  var left = window.screen.width / 2 - width / 2;
  var top = window.screen.height / 4;
  var windowStatus = `width=${width}, height=${height}, left=${left}, top=${top}, resizable=no, toolbars=no, menubar=no`;
  const url = "http://localhost:3000/flastagram/profile";
  window.open(url, "something", windowStatus);
}
/**
 * 무한 스크롤을 수행합니다.
 */
function executeInfiniteScroll() {
  let pageCount = 1;
  var intersectionObserver = new IntersectionObserver(function (entries) {
    if (entries[0].intersectionRatio <= 0) {
      return;
    }
    // 게시물을 더 로드합니다.
    loadMorePosts(pageCount);
    pageCount++;
  });
  intersectionObserver.observe(document.querySelector(".bottom"));
}
function main() {
  handleUserLogin(); // 로컬스토리지에 JWT가 존재하지 않는다면 로그인 페이지로 이동합니다.
  executeInfiniteScroll(); // 스크롤을 내릴 때마다 게시물을 로드 (무한스크롤)
}
main();

좋아요, 그러면 해야 할 것은 현재 유저에 대한 프로필 사진을 네비게이션 바에 뿌려주는 겁니다.

어떻게 하면 될까요? 생각의 흐름은 아래와 같습니다.

1. 현재 클라이언트는 JWT를 가지고 있습니다.

2. JWT를 디코딩하면 우리는 jwt 주인의 이름을 알 수 있습니다.

민수씨!

3. 좋아요. 그리고 사용자 이름은 데이터베이스에서 유일하므로, 사용자 이름을 통해서 데이터베이스에서의 사용자 id 를 찾을 수 있습니다.

4. jwt를 가지고 있는 사람의(현재 로그인한 사람) 의 id 를 알고 있으면, 우리의 profile 조회 API를 통해서 프로필 사진을 얻어올 수 있겠네요!

좋습니다. 이제 위의 글을 코드로 옮기면 됩니다.

일단, 사진은 저 곳의 소스네요. 삭제합니다.

전체 다 지워버리면 안 된다! 너 말하는 거 맞음!

말했듯이, 현재 가지고 있는 JWT를 디코딩하면 payload 부분에서 jwt 주인장의 이름을 알 수 있겠죠? 아래와 같은 함수를 post_list.js 에 작성합니다.

좋습니다. 이제 위의 함수를 사용하여 JWT를 디코딩할 수 있으므로, 그 디코딩한 정보로 JWT에 맞는 유저의 프로필사진을 얻어오면 되겠네요. 그런데, 현재 JWT를 디코딩하면 payload 에는 유저의 닉네임이 담기지, id가 담기지 않습니다. 그러므로 우리는 JWT가 만들어지는 로직을 약간 수정할 필요가 있습니다.

api/resources/user 의 UserLogin 클래스의 post 메서드를 아래와 같이 수정합니다.

api/resources/user/RefreshToken 클래스의 post 메서드를 아래와 같이 수정합니다.

달라진 건 아래와 같습니다. 일단 로그인을 진행해서 토큰을 받아 보세요.

그리고, 받은 토큰을 jwt.io 에서 디코딩하면,

우리가 원하던 정보인 유저의 id까지 담기는 걸 확인할 수 있네요! 이제 우리가 만들어둔 프로필 API 에 id 를 담아 GET 요청을 보내고, 프로필 사진을 얻어올 수 있겠어요. “디코딩된 JWT 정보로, 우리의 플라스크 서버에 요청을 보내 프로필 사진을 얻어오는 함수” 를 post_list.js에 정의해 줍니다.

이제 이미지를 얻어왔으니 (이미지 경로를 알고 있으니), 이미지를 찾아 뿌려주는 함수만 작성하면 되겠네요.

/**
 * 이미지 경로를 받아 프로필 사진 이미지에 뿌려줍니다.
 */
async function loadProfileImage() {
  userId = await decodeJWT(ACCESS_TOKEN)["user_id"];
  profileElement = document.getElementsByClassName("user-profile");
  let src = imageRetrieveBseUrl + (await getProfileImagebyId(userId));
  console.log(src);
  profileElement[0].src = src;
}

메인 함수에 우리가 작성한 메서드를 호출하면,

민수의 프로필사진이 잘 나타나는 것을 확인할 수 있네요!

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.