[ First Project 후기 ]

박재영·2020년 8월 22일
2
post-thumbnail

1. 아이디어 기획

     first project 시작하기 3일 전까지만 해도 어떤 걸 할 지 고민을 했다. 막연히 클론코딩을 하자고 생각했다. 2주 안에 완성해야 하기 때문에 어렵지도 않으면서 너무 쉬운 건 하고 싶지 않았다. 맨 먼저 생각한 건 핀터레스트 클론 코딩. 아.. 그런데 끌리지 않다. 수강생분 중 한 분과 첫번째 프로젝트 아이디어에 대해 고민을 털어놓았다. 얘기할 당시에는 여전히 핀터레스트 클론하겠다고 했는데 끝나자마자 갑자기 머릿속에서 번뜩인 아이디어가 있었으니..바로 '나와 대화하기(TALKME)'였다.

     나는 남에게 고민을 잘 얘기하지 않는 편이다. 입 밖으로 꺼내지 못한 고민,걱정거리들이 켜켜이 쌓여 기도를 막을 지경에 이르렀다. 저녁식사를 한 후 산책을 갔다와서도 답답함은 사라지지 않았다. 원래라면 자리에 앉아 프로그래밍 공부를 했지만 이 상태로는 안 되겠다 싶어 침대에 앉아 인형을 끌어안았다. 그렇게 몇 분을 가만히 있다가 인형에게라도 속마음을 말해보자 싶어 입을 뗐다. 혼자서 말을 걸고 답하는 식이였지만 인형이 내게 말을 건다고 상상했다. 처음엔 오글거리고 어색했지만 점차 익숙해졌다. 질문에 주절주절 답을 해보면서 그동안 내가 갖고 있던 고민을 마주해보고 솔직한 심정을 받아들이게 되었다. 이날 이후 다시 불안했던 마음이 누그러졌고 학습에 몰입할 수 있게 되었다.

     학부생 때 심리상담에 관심이 있어 얕게 공부를 했었다. 상담사는 고민에 대한 해결방법을 알려주는 사람이 아니라 내담자의 무의식 저편에 존재하는 '답'을 이끌어내는 사람이다. 상담이론에서는 내담자는 고민을 해결할 수 있는 사람이고 내면에 고민에 대한 답을 알고 있다고 본다. 다만 내담자는 자신을 들여다보는 연습을 해본 적이 없기 때문에 상담사가 그 과정을 돕는 것이다. 상담사의 역할을 보조적으로 도울 수 있지 않을까? 그 역할이란 바로 질문을 해주고 인내심있게 답변을 기다려주는 일을 가리킨다. 여기서 채팅앱이지만 질문을 던진 후 답변이 오기 전까지 가만히 대기하는 기능을 떠올렸다.

     '내 안에 답이 있다''나와의 대화를 통한 답 이끌어내기' 그리고 '누군가 내 고민을 알아채고 물어봐줬으면 하는 바램' 이 세가지 문장을 토대로 TALKME 웹어플리케이션이 탄생하였다.

2. TALKME 웹어플리케이션 소개

     TALKME자기 대화 치료 서비스(Self Talk Therapy Service) 를 제공하는 웹어플리케이션이다. 고민, 걱정거리에 대한 질문들을 스스로 작성할 수 있다. 작성한 질문들을 바탕으로 채팅이 진행된다. 특이한 점은 질문 하나가 출력된 후 유저가 답변을 하기 전까지 대기 상태를 유지한다는 것이다. 이를 통해 유저가 질문을 작성했음에도 불구하고 누군가와 대화하는 느낌이 들게 만든다. 이렇게 스스로 고민에 대한 질문들을 작성하고 답변해보면서 생각을 정리하고 잠재되어있는 '고민에 대한 스스로가 내린 답, 결론'을 수면 위로 올려 치유할 수 있도록 돕는다.

3. 사용된 기술

     프론트앤드는 리액트를 사용했다. 2주안에 프로젝트를 완료하기 위해서는 낯선 기술보다는 그동안 써봤던 기술을 선택하는 편이 현명하다고 판단이 들었기 때문이다. 또 전체적으로 동일한 페이지인데 폰 안에 있는 화면만 전환한다는 점 그리고 반복되는 UI 처리에 컴포넌트 기반인 리액트가 적합하다고 생각했다.

     백앤드도 마찬가지로 익숙한 express로 api를 작성하기로 했다. 모델은 총 User, Room, Question 이렇게 세 개이고 유저와 Room은 1대 다 관계, Room과 Question은 1 대 다 관계이다. User의 삭제에 Room과 Question이 영향을 받고 User와 Room, Question의 연관성을 매 api 요청마다 체크할 필요가 있다. 모델 간 관계가 중요한 웹애플리케이션이기 때문에 RDB의 대표적인 mysql과 프로그래밍적으로 간편하게 작성할 수 있도록 돕는 Sequelize ORM을 사용했다. E2E 테스트를 위해 mocha와 chai를 선택했다. chai-http가 비동기 api 테스트를 처리하는 기능을 제공하고 무엇보다 러닝커브가 없이 곧바로 적용할 수 있어서 선택했다.

     배포는 서버의 경우 AWS의 EC2와 RDBS를 사용했다. S3는 세션 문제가 발생하여 클라이언트는 배포를 하지 못했다.

4. 맡은 역할

기획, 디자인, 팀장, 백앤드 , 프론트앤드 css 보조

4-1. 기획

    - 웹애플리케이션을 구성하는 페이지들을 정하고 각 페이지에 필요한 기능과 css 요소를 문서화

4-2. 디자인

    - Figma 툴을 사용하여 모든 페이지 디자인

4-3. 팀장

    - 매일 아침 스탠드업 미팅 실시
   - github wiki 문서 정리
   - 팀별 진행상황 보고
   - 킥오프 미팅 대표로 SR 설명
   - 스프린트 회고 진행
   - 프로젝트 발표

4-4. 백앤드

    - AWS EC2, RDS 배포
   - schema 설정
   - API 문서 작성
   - 모든 API마다 mocha, chai로 test case 작성
   - GET /room/:roomId/questions API 구현 : 채팅화면에서 question 출력용 데이터 제공
   - DELETE /room API 구현 : 채팅방목록화면에서 채팅방 삭제 요청시 DB에 해당 채팅방 제거
   - GET /room/:roomId API 구현 : 채팅방목록화면에서 edit 버튼 클릭시 DB에서 해당 채팅방 정보와 question 정보를 JOIN한 결과물 응답
   - GET /isLogin API 구현 : 유저가 로그인 상태인지 session 객체 유무로 판단
   - 패스워드 암호화 처리하여 Read, Update 구현

4-5. 프론트앤드 css 보조

    - setTimeout, grid와 scrollTop을 엘리먼트의 height로 고정하여 채팅애니메이션 구현

5. 프로젝트 과정에서 겪은 문제와 해결과정

Communication

     초반에 채팅방을 생성 후 API로 채팅방 정보를 넘기고 다른 페이지로 전환할 때 props로 넘겨주는 방식으로 진행했다. 문득 props로 넘겨주는 게 좋을 지 혹은 매 페이지 마다 API로 받으면 좋을 지 의문이 들었다. API를 매번 요청하는 것보다 클라이언트에서 생성된 데이터를 곧바로 넘겨주는 게 효율적으로 보였다. 한 편으로는 어렴풋이 매 페이지마다 API로 정보를 넘겨받아야 한다는 걸 들었는데 그 이유를 정확히 알 지 못했다. 프로젝트 오피스아워시간에 의문을 해소했다. 엔지니어님께서 매 페이지마다 API로 정보를 받아서 렌더링하는 것이 안정적이다라고 했다. SPA의 특성상 새로고침시 state에 보관된 값들이 초기화되는 문제점 그리고 해당 url로 직접 접속했을 경우 props로 넘겨줄 수 없는 문제 때문이다.

     다음날 스탠드업 미팅 시간에 기존에 props로 넘겨주는 방식 대신 API로 데이터를 가져오는 방식을 하자고 제안했다. 팀원 한 분이 갑작스런 변경사항에 당황하셨다. 그는 내게 props 대신 API를 사용해야 하는 까닭이 무엇인지 물었다. 나는 대략적으로 이해한 바대로 API를 사용해야하는 이유를 두루뭉실하게 말했고 그에따라 팀원은 전혀 이해를 하지 못했다. 그의 반박이 계속되니 나도 모르게 감정이 격해졌다. 그를 이해시키는 것보다 내 의견이 더 맞다는 걸 강조하기 위해 오피스아워 시간을 들먹였다. '경력이 있는 사람이 그렇게 말했으니까...' 하는 식의 논리로 분위기를 조성했고 팀원은 그 분위기에 못 이겨 의견을 따르기로 했다.

     결정을 내린 후, 뭔가 잘못되었다는 생각이 들었다. 억지로 의견을 따르는 팀원의 모습을 보는 게 마음이 좋지 않았다. 좀 더 대화를 시도해볼까 했지만 서로 감정이 누그러지지 않은 상태라 안 하는 게 좋다는 판단을 했다. 타이밍 좋게 점심시간이 되었고 잠깐 혼자 시간을 가지면서 내 태도에 대한 문제점을 곱씹었다. 설득의 근거 이유가 부족했고 촉박한 프로젝트 기간에 급작스런 변경사항에 느꼈을 팀원의 감정을 알지 못했었다. 점심시간이 끝나고 다시 모였을 때 팀원도 나도 감정이 많이 누그러진 상태였다. 하지만 아까전 있었던 의견충돌에 대한 문제를 꺼내긴 이른 것 같았다. 그렇게 하루가 끝나고 생각을 정리한 후 팀원에게 따로 글을 남겼다. 오늘 있었던 일에 대한 사과와 그가 프로젝트에 기여했던 점에 대한 고마움을 표현했고 남은 기간에도 감정 상하지 않고 함께 하면 좋겠다는 내용을 담았다. 팀원은 너그럽게 사과를 받아들였고 그 또한 감정적이었음을 인정하며 사건은 마무리되었다.

     의견충돌을 경험 한 후, 다음에 비슷한 일이 생기면 얼버부리거나 권위있는 사람을 이용하지 않아야겠다고 생각했다. 만약 나 조차 제대로 이해하지 못했고 명확한 이유, 근거를 대지 못한다면 우선 해당사항은 보류하고 이유, 근거를 충분히 조사하여 설득할 것이다. 그리고 팀원이 느꼈을 감정상태를 이해하고 공감해줘야겠다.

Programming

5-1. 패스워드 변경 후 변경된 패스워드로 로그인 안 되는 현상

(1) 구현 기능

     클라이언트로 부터 받은 새 패스워드를 기존의 DB에 저장된 salt값으로 암호화하여 변경사항 반영하기

(2) 전략

     - session객체에 저장된 userId로 user의 salt값을 가져온다
    - request의 body에 담겨진 password와 salt값으로 암호화한다
    - 암호화한 패스워드를 userId로 해당 user 정보를 update한다.

(3) 에러

     - 변경된 패스워드로 로그인할 때 패스워드 불일치 에러가 발생한다.

(4) 에러 해결과정 중 삽질

     - 팀원이 구글링하다가 발견한 공짜코드를 그대로 복사붙여넣기하고 난 후 제대로 코드 분석을 하지 않음
    - 암호화에 문제가 없다는 코드에 대한 무한 신뢰를 바탕으로 삽질 시작
    - setSaltAndPasswordbeforeUpdate hook에 들어있는 점이 미심쩍었지만 DB에는 salt가 유지되고 있으니 genereateSalt는 무조건 같은 값을 돌려준다는 생각을 함 ( 떡하니 Math.random이 있는데 그런 생각을 했음;;)

  // 문제의 소스코드 

  User.generateSalt = function () {
    return Math.round(new Date().valueOf() * Math.random()) + "";
  };
  User.encryptPassword = function (plainText, salt) {
    return crypto
      .createHmac("sha512", salt)
      .update(plainText)
      .update(salt)
      .digest("hex");
  };
  const setSaltAndPassword = (user) => {
    if (user.changed("password")) {
      user.salt = User.generateSalt();
      user.password = User.encryptPassword(user.password(), user.salt());
    }
  };

  User.beforeValidate(setSaltAndPassword);
  // update할 때도 새로운 salt값이 생성하고 있는데 이 부분이 잘못되었다고 생각을 전혀 못했다. 
  User.beforeUpdate(setSaltAndPassword);

  User.associate = function (models) {
    this.hasMany(models.Room, {
      foreignKey: "userId",
      onDelete: "cascade",
    });
  };

     - 기존 코드를 계속 수정할수록 답이 안 나와서 아예 새로 작성하기로 함.

     - afterValidate는 CRUD가 일어나면 무조건 호출해서 최초로 생성할 때만 salt값만 부여하고 이후부터는 DB에 저장된 salt값을 들고오는 식으로 구현해야 했기 때문에 afterValidate 보다는 beforeCreate를 사용

     - beforeUpdate가 제대로 작동이 되지 않아 UserModel.update 를 커스터마이징하여 처리


// 수정된 코드 

  User.generateSalt = function () {
    return crypto.randomBytes(16).toString("base64");
  };
  User.encryptPassword = function (plainText, salt) {
    return crypto
      .createHash("RSA-SHA256")
      .update(plainText)
      .update(salt)
      .digest("hex");
  };

  // signup할 때만 salt값 생성 
  User.beforeCreate((user) => {
    user.salt = User.generateSalt();
    user.password = User.encryptPassword(user.password, user.salt);
  });

  // password 업데이트 시 기존 salt값을 가져와 암호화 처리 
  User.updatePassword = async (email, newPassword) => {
    let user = await User.findOne({ attributes: ["salt"], where: { email } });
    if (user !== null) {
      const { salt } = user;
      const encryptedPassword = User.encryptPassword(newPassword, salt);
      const [isUpdated] = await User.update(
        { password: encryptedPassword, secretKey: null },
        { where: { email } }
      );
      return isUpdated;
    }

    return false;
  };

  // login할 때 password를 기존 salt값을 가져와 암호화한 것과 DB에 저장된 '암호화된 패스워드' 일치여부 판단 
  User.findOneByEmailAndPassword = async (email, password) => {
    let user = await User.findOne({ attributes: ["salt"], where: { email } });
    if (user !== null) {
      const { salt } = user;
      const encryptedPassword = User.encryptPassword(password, salt);
      user = await User.findOne({
        where: { email, password: encryptedPassword },
      });
    }
    return user;
  };

(5) 결론 및 교훈

     - 외부 소스코드 그대로 복붙하지 말고 코드 로직을 제대로 분석하고 적용하자.

5-2. google openId로 social 로그인 구현

(1) 구현 기능

     - Continue with Google 버튼을 클릭하면 구글 인증처리 페이지로 전환하고 권한허가 후 전달 받은 id_token에 들어있는 유저정보를 User 테이블에 추가 및 채팅방리스트화면으로 리다이랙트

(2) 전략

     - 클라이언트에서 code와 id_token 받는 과정 구현
    - 서버에 id_token을 전달
    - 서버는 id_token을 검증 및 decode하여 email 정보 획득
    - 서버는 email이 DB에 없으면 새로 record를 생성
    - 서버는 새로 생성했거나 DB에서 가져온 user record에서 id를 세션객체에 저장하여 로그인 기록
    - 서버는 클라이언트에 채팅리스트화면으로 리다이랙트 응답

(3) 전략 진행 과정

     - Continue with Google 버튼을 클릭할 때, 구글 인증페이지로 전환 및 code 값 받을 리다이랙트 url을 클라이언트로 둔다.

// client 소스코드 

<div className="socialLoginBtn" onClick={() =>
            window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${CLIENT_ID}&scope=openid%20profile%20email&redirect_uri=http://localhost:3000/sociallogin`}>
</div>

     - 리다이랙트 url에서 받은 code값으로 id_token 교환하기 위해 code, clientId, client secret, 리다이랙트 url 정보를 구글에 넘겨준다.
    - 구글로부터 받은 id_token을 서버에 전송한다.


// client 소스코드 

 componentDidMount() {
    //리다이렉트되어서 왔을 때 쿼리를 받아서 있으면 post요청
    const query = this.props.location.search;
    console.log(query);
    if (query) {
      const split = query.split("?code=")[1];
      const code = split.slice(0, split.indexOf("&"));
      fetch(`https://oauth2.googleapis.com/token?code=${code}&client_id=${CLIENT_ID}&client_secret=${CLIENT_PASSWORD}&redirect_uri=http://localhost:3000/sociallogin&grant_type=authorization_code`, {
        method: "post"
      })
        .then(res => res.json())
        .then(({ id_token }) => {
          if (id_token) {
            console.log("post성공")
            return fetch('/auth/social', {
              method: 'POST',
              headers: { 'content-type': 'application/json' },
              credentials: 'include',
              body: JSON.stringify({ id_token }),
            })
              .then(res => {
                console.log(res.status)
                if (res.status === 200 || 201 || 304) {
                  console.log("here")
                  this.props.history.push("/roomlist")
                } else {
                  alert("인증 실패 다시 시도해주세요")
                  this.props.history.push("/intro")
                }
              })
          }
        })
    }
  }
// server 소스코드 

const path = require("path");
require("dotenv").config(path.join(__dirname, "../../", "env"));
const { OAuth2Client } = require("google-auth-library"); // token 검증 라이브러리
const axios = require("axios");
const { User } = require("../../models");

const client = new OAuth2Client(process.env.CLIENT_ID);

// token 검증 및 user정보 반환
const verifyTokenAndGetUserInfo = async (idToken) => {
  try {
    const ticket = await client.verifyIdToken({
      idToken,
      audience: process.env.CLIENT_ID,
    });
    const payload = ticket.getPayload();
    return payload;
  } catch (err) {
    throw Error(err);
  }
};

module.exports = {
  post: async (req, res) => {
    try {
      //코드를 통해 id_token 획
      const { id_token } = req.body;
      // 토큰 검증 후 email과 sub 값 받아서
      const { email, sub } = await verifyTokenAndGetUserInfo(id_token);
      // 기존 유저가 있는지 여부 확인 및 존재하지 않을 경우 자동 생성
      const [user] = await User.findOrCreate({
        where: { email },
        defaults: { password: sub },
      });

      // 로그인 기록 저장
      req.session.userId = user.id;
      req.session.save((err) => {
        if (err) {
          throw Error(err);
        }
        // 채팅방목록 화면으로 리다이렉트
        res.redirect('http://localhost:3000/roomlist');
      });
    } catch (err) {
      console.log(err);
      res.status(500).json({ message: "server error" });
    }
  },
};

(4) 문제 1

     - 소셜로그인 기능은 잘 되었으나 문제는 클라이언트에서 보안이슈가 있었다. client secret은 노출되면 안 되기 때문에 .env를 사용하면 되겠거니 했다. 하지만 AWS에 S3에 .env를 같이 올려야 해서 그점이 마음에 걸렸다. 듣기로는 S3 파일 중 private할 수 있다고 하는 데 그렇게 하면 괜찮을지 의문이 들었다.

     - 프로젝트 오피스 아워시간에 클라이언트에서 secret 처리를 어떻게 하면 좋을 지 조언을 구했다. 엔지니어님은 클라이언트 단에서 어떤 식으로든 secret과 같이 보안 이슈가 될 만한 사항은 처리하지 않는 것이 좋다고 했다.

     - 엔지니어님의 조언을 따라 code는 클라이언트에서 받고 code로 부터 id_token을 얻는 과정은 서버에서 하도록 구현하려고 했다.


// 변경된 client 소스코드

  componentDidMount() {
    const query = this.props.location.search;
    if (query) {
      const split = query.split("?code=")[1];
      const code = split.slice(0, split.indexOf("&"));
      fetch("/auth/social", {
        method: "post",
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({code}),
        credentials: 'include'
      })
      .catch(err =>{
        console.log(err);
        alert('login 실패');
      });
        
    }
  }

// 변경된 server 소스코드 

const getIdTokenFromGoogle = async (code) => {
  const url = `https://oauth2.googleapis.com/token?code=${code}&client_id=${process.env.CLIENT_ID}&client_secret=${process.env.CLIENT_PASSWORD}&redirect_uri=${process.env.REDIRECT_URI}&grant_type=authorization_code`;
  try {
    const {
      data: { id_token },
    } = await axios.post(url);
    return id_token;
  } catch (err) {
    console.log(err);
    throw Error(err);
  }
};

module.exports = {
  post: async (req, res) => {
    try {
      //code를 통해 id_token 획득
      const id_token = await getIdTokenFromGoogle(req.body.code);
      // 토큰 검증 후 email과 sub 값 받아서
      const { email, sub } = await verifyTokenAndGetUserInfo(id_token);
      // 기존 유저가 있는지 여부 확인 및 존재하지 않을 경우 자동 생성
      const [user] = await User.findOrCreate({
        where: { email },
        defaults: { password: sub },
      });

      // 로그인 기록 저장
      req.session.userId = user.id;
      req.session.save((err) => {
        if (err) {
          throw Error(err);
        }
        // 채팅방목록 화면으로 리다이렉트
        res.send("hello");
      });
    } catch (err) {
      console.log(err);
      res.status(500).json({ message: "server error" });
    }
  },
};

(5) 문제 2

     - 코드를 수정하는 과정에서 다른 client id와 client secret으로 변경하는 바람에 어느 쪽에서는 이전 client id로 요청하고 어느 쪽에는 새로운 client id로 요청하는 등 자잘한 실수로 삽질을 했다.

     - 문제는 통일된 client id를 사용했지만 400 bad request만 응답이 날아왔다. 긴 에러코드를 자세히 보지 않고 곧장 'google openId 400 error'와 같은 키워드로 검색하고 혹은 새로 생성한 client id가 허가가 제대로 나지 않은 것인가 하는 잘못된 추측을 하며 삽질했다.

     - 다시 첫번째 방식으로 새 client id를 사용해 소셜로그인 기능을 테스트 하니 이상이 없었다. 이말은 client id에 문제가 있지 않다는 걸 의미했다.

     - 두 번째 방식으로 재 시도하면서 에러메세지를 찬찬히 살피니 redirect url이 불일치 한다는 문구가 눈에 들어왔다.

     - 혹시나 하는 마음에 code를 받는 리다이랙션 url을 server로 변경했더니 id_token을 받을 수 있었다.

// 변경된 client 소스 코드 
<div className="socialLoginBtn" onClick={() =>
            window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${CLIENT_ID}&scope=openid%20profile%20email&redirect_uri=http://localhost:4000/auth/social`}>
</div>

// 변경된 server 소스 코드 
module.exports = {
  get: async (req, res) => {
    try {
      //리다이랙트 되어 url에 담긴 code를 통해 id_token 획득
      const idToken = await getIdTokenFromGoogle(req.query.code);
      // 토큰 검증 후 email과 sub 값 받아서
      const { email, sub } = await verifyTokenAndGetUserInfo(idToken);
      // 기존 유저가 있는지 여부 확인 및 존재하지 않을 경우 자동 생성
      const [user] = await User.findOrCreate({
        where: { email },
        defaults: { password: sub },
      });

      // 로그인 기록 저장
      req.session.userId = user.id;

      // 채팅방목록 화면으로 리다이렉트
      res.redirect('http://localhost:3000/roomlist');
    } catch (err) {
      console.log(err);
      res.status(500).json({ message: 'server error' });
    }
  },

(5) 문제 3

     - id_token도 잘 넘겨받고 DB에 잘 저장이 되는데 또 다른 문제가 발생했다. social login한 유저는 session이 유지가 되지 않았다.

     - 이번에도 프로젝트 오피스 아워시간에 엔지니어님께 도움을 요청했다. 알고보니 세션을 지정해주는 대상이 클라이언트가 아니라 구글 인증 페이지였다.

     - 내가 기대했던 플로우

     - 실제 플로우

     - code를 받는 리다이랙트 url이 클라이언트여야한다고 했다. 하지만 리다이랙트 url 불일치 문제가 남아있었다. 어떻게 해야 클라이언트에서 보안이슈가 없으면서 리다이랙트 url 불일치 문제를 해결할 수 있을 지 구글링을 반복했다.

(6) 문제 해결

     - 구글 문서를 찬찬히 읽어보니 클라이언트 전용 라이브러리가 있었다. 이 라이브러리를 사용하면 client secret 없이도 id_token을 가져올 수 있다. 다시 첫 번째 방법으로 진행하면 잘 된다.


<html lang="en">
  <head>
    <meta name="google-signin-scope" content="profile email">
    <meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
    <script src="https://apis.google.com/js/platform.js" async defer></script>
  </head>
  <body>
    <div class="g-signin2" data-onsuccess="onSignIn" data-theme="dark"></div>
    <script>
      function onSignIn(googleUser) {
        // Useful data for your client-side scripts:
        var profile = googleUser.getBasicProfile();
        console.log("ID: " + profile.getId()); // Don't send this directly to your server!
        console.log('Full Name: ' + profile.getName());
        console.log('Given Name: ' + profile.getGivenName());
        console.log('Family Name: ' + profile.getFamilyName());
        console.log("Image URL: " + profile.getImageUrl());
        console.log("Email: " + profile.getEmail());

        // The ID token you need to pass to your backend:
        var id_token = googleUser.getAuthResponse().id_token;
        console.log("ID Token: " + id_token);
      }
    </script>
  </body>
</html>

(7) 결론 및 교훈

     - 에러가 났다고 무조건 구글링 하는 대신 에러메세지를 잘 읽어보자
    - 라이브러리도 구현된 것을 사용하기 전에 실제 code를 받고 id_token을 교환하는 과정을 직접 구현해보면서 어떤 식으로 OAuth가 진행되는지 이해할 수 있었다.
    - 거의 프로젝트가 끝날 무렵이라 소셜로그인을 드롭시켰던 점이 아쉽다.

5-3. S3 배포시 session 유지 안 되는 현상

(1) 구현 기능

     - S3에 빌드한 파일을 올려 배포한다.
    - EC2 서버와 연동하여 모든 API가 잘 작동되는 지 확인한다.

(2) 에러

     - 그동안 리액트 package.json에 proxy로 EC2서버 주소를 입력하여 클라이언트와 서버의 API 테스트를 진행했다.

     - S3에 배포하면서 proxy를 사용할 수 없어서 클라이언트에서 fetch에 입력한 모든 요청 url을 전면 수정했다.

     - 그런데 proxy를 통해 상대경로에서는 잘 되던 API가 전체 url을 입력하니 API가 작동이 되지 않았다.

(3) 에러 해결과정

     - 코드스테이츠 help-desk에 도움을 요청했다.
    - 질문을 하기 전에 구글링을 통해서 크롬 보안 강화 정책을 얼핏 보았으나 얼마전에 구현했던 과제에서는 session문제가 없었기에 관련이 없다고 생각했다.

     - 하지만 답변으로 크롬 보안 강화 정책인 것으로 보인다는 말을 들었고 이를 해결하기 위해서는 https로 통신을 하거나 jwt 토큰 인증방식을 사용할 것을 권했다.

     - 역시나 이 기능도 프로젝트 끝무렵에 진행했던 터라 드롭할 수밖에 없었다.

(4) 결론 및 교훈

     - 비록 S3 배포에 실패를 했지만 프록시 개념을 더 잘 이해할 수 있었다. 프록시가 중간 대리인 역할을 한다. 상대경로로 api를 요청한다는 것에 포인트를 뒀었는데 그 점은 핵심이 아니었다. 상대경로라는 말은 '클라이언트의 url'을 기준으로 한다는 말이다. 즉, 실제로는 EC2서버 주소로 요청을 보내지만 브라우저를 속여서 마치 클라이언트 url(same-site)로 api 요청하는 것처럼 보이게 한다. 그래서 프록시 설정을 했을 때는 same-site 문제가 없었던 것이다.

     - 이때도 구글링을 바로 했는데 network탭에서 노란경고창을 마우스로 올려보기라도 했다면 문제의 원인을 쉽게 파악할 수 있었을 것이다.

6. 프로젝트 후 느낀 점

6-1 . 잘한 점

    - 모각코(모여서 각자 코딩) : 팀룰을 정할 때 제일 먼저 제안했다. 따로 기능을 구현하다보면 집중이 흐트러질 수 있기 때문에 서로서로 감시할 수 있게 정규시간 내내 화면을 공유하기로 했다. 또한 팀장으로서 팀원들이 중간 중간 의견을 나누는 것을 들으면서 전체적으로 어떤 작업을 하고 있고 어떤 문제를 겪고 있는지 바로바로 파악할 수 있어 좋았다.

    - 팀원의 성취/기여 칭찬 및 큰 리액션 : 같이 백앤드 포지션을 맡았던 팀원분이 누군가 구현한 코드, 브라우저 상의 작동과정을 보고 엄청 대단한 것을 본 듯 큰 리액션을 했다. 제 삼자가 보면 평범하다고 할 지도 모를 기능에 큰 리액션은 성취감과 뿌듯함을 느끼게해줬다. 팀원분을 따라 나 또한 최대한 세심하게 작은 기여도 칭찬을 듬뿍 담으려고 애썼다. 이를 통해 팀 분위기는 매일 웃음이 일고 서로 으쌰으쌰하는 방향으로 나아갔다.

    - 선 테스트 후 구현 : 처음 테스트를 작성해보니 참고자료를 보는 데도 시간이 걸렸다. 점차 테스트 작성이 익숙해지니 테스트를 짜는 것 자체가 수도코드를 작성하는 것이었고 여러 실패할 경우의 수를 고려하여 버그나 에러 발생을 미연에 방지할 수 있었다. 그리고 중간에 모든 API에 session을 적용하는 로직을 추가할 때도 테스트 돌려보면서 곧바로 오류없이 잘 구현되었는 지 확인할 수 있었다. 그리고 최소한 이 정도는 커버하고 있다는 자신감을 가질 수 있었다.

    - 한 api 구현마다 팀원에게 코드 리뷰 및 검증과정 보여주기 : 모든 팀원이 매 코드 작성마다 코드 리뷰를 하기엔 여의치 않아 최소한 같은 포지션(백앤드)에는 코드 리뷰와 검증과정을 하려고 노력했다. 이를 통해 안정적인 api가 구현될 수 있었고 전체 api 흐름을 파악할 수 있었다. 이는 이후 버그가 발생했을 때 혼자서 처음부터 해석하는 게 아닌 이전에 팀원이 설명했던 것을 떠올려보며 빠르게 코드를 해석하고 버그를 수정할 수 있도록 했다.

    - 매번 새로운 task를 진행할 때마다 팀원에게 보고하기 : '나는 현재 이 기능을 구현했고 이제 이 기능을 구현할 것이다'라는 말을 팀원들에게 알렸다. 프론트앤드가 백앤드보다 빠르게 기능을 구현하고 있었다. 다른 기능을 구현하면서 대기 중인 프론트앤드를 위해서 백앤드가 api를 완성할 때마다 task 완료여부를 알려줄 필요가 있었다. 덕분에 프론트앤드에서 곧장 적용해 작동여부를 파악할 수 있었고 api가 수정사항이 있으면 바로 수정하고 반영할 수 있었다.

6-2. 부족한 점

    - api 문서의 디테일함 : 어떨 때 이 api를 사용하는 지에 대한 설명이 부족해 클라이언트쪽에서 혼동을 줘서 그때마다 설명을 하느라 시간을 보냈다.

    - S3 배포를 늦게 한 점, https 통신 늦은 구현 : EC2 서버만 빨리 배포하느라 S3를 신경쓰지 않았다. 프로젝트 마감 이틀 전에 S3 배포를 하면서 프록시 설정으로 가려졌던 크롬 보안정책 문제를 발견하여 결국 최종적으로 배포를 못하게 되었다.

    - bare minimum requirement에 2주 task 분량을 집어넣은 점 : 최소한의 기능이라고 생각하여 넣은 기능들이 결국엔 2주 내내 해야 할 분량임을 알고 있었지만 신경을 쓰지 않았다. 스프린트를 진행하면서 bare에 넣었던 것들이 다음 스프린트까지 넘어가게 되면서 테스크 진행률이 불균형을 보였다.

    - consumed time의 부정확성 : 시작 시간을 기록하지 않아 어림짐작으로 작성했다.

    - 반나절 이상 도움 요청없이 혼자 삽질 : 소셜로그인 구현할 때 조금만 더 하면 되겠지하고 혼자 삽질하다가 시간이 많이 지체되었다.

    - 테스트시 실제 사용하는 DB를 대상으로 함 : 팀원이 생성한 데이터가 매 테스트마다 삭제되는 불편함이 생겼다.

6-3. 개선 방안

    - api 문서에 클라이언트의 입장에서 디테일한 설명 추가하기

    - EC2 서버뿐만 아니라 S3도 초기에 배포하기

    - 웹서비스의 핵심기능과 부가기능을 명확히 한 후 task로 나누기

    - timer를 사용해 정확한 consumed time 작성하기

    - estimate time에 맞춰 코드 작성 시도 후 못했을 경우 문제점과 그동안 시도했던 흔적들을 정리하여 팀원 혹은 엔지니어분께 도움 요청하기

    - 태스트 전용 DB를 따로 만들어서 하기

1개의 댓글