[프로젝트] 메인 프로젝트 : 3주차 일지 (:3)

Jade·2023년 1월 19일
10

프로젝트

목록 보기
13/28
post-thumbnail

비실거리는 코드를... 처음 보시나욥? 🐙

🟡 Main-Project Goals

  • 기획부터 디자인, 개발, 배포까지 ! = 우리 팀만의 어플리케이션 개발
  • 우리 '쓰앵님'조가 만들기로 한 어플리케이션 이름은 '과외차이'
  • 팀장 직책을 자원해서 맡은 만큼 항상 팀원들보다 '조금 더!' 하기
  • 기능 구현 내에서 도전 과제 설정 및 수행
  • 단순한 기능 구현을 넘어 클린 코드 작성, 효율적 코드 작성에 신경쓰기
  • as always 소통에 힘쓰자
  • 매주 일지 남기기


🟡 Process

  • 서버와의 데이터 통신 시작
  • 서비스단인 메인 섹션, 프로필 섹션, 메시지 섹션으로 나누어 작업, merge 및 자잘한 에러들 수정 완료
  • accessToken 재발급 로직 리팩토링
  • 어드민단 = 과외 관리 파트 데이터 통신 시작 (일정표 기준 다음주 월요일까지 작업 예정)


🌕 Hard Points & Solutions

🌄 이미지 로더 & formData 콤비와 싸우고 우는 바보가 있다?

(안 울었음. 마음 속으로는 조금 울었을지도.)

프로필 섹션에 꼭 필요한 것 중 하나가 프로필 이미지를 추가하고, 프로필 이미지 추가 API와 연결하는 것이었다. 저번 주말에 모달 지옥을 경험하고 한층 강해진 나는 커스텀 모달로 imgLoadModal을 만들기로 했다.

input type을 file로 설정하면 손쉽게 파일 로더를 만들 수 있었다. 다만 기본 input은 생김새가 예쁘지 않아서 label을 만들어주고, input 자체는 overflow : hidden을 통해서 숨겨주었다. (input과 label을 htmlFor로 연결하면 라벨을 클릭했을 때에도 input 관련 동작이 가능하다.)

문제는 이미지를 로드한 뒤 해당 이미지 정보를 바로 API 요청 body에 넣으려고 하면서 일어났다. 요청을 보내도 자꾸 에러가 떠서 백엔드 분들께 여쭤보니 form-data 형식으로 들어와야 하는 데이터라고 알려주셨다.

생각해보면 단순히 string, text와 확연하게 다른 데이터임에도 그냥 보내면 되겠지~ 안일하게 생각했던 것이 부끄럽다..ㅎㅎ.. form-data라는 형식 자체를 처음 만나봐서 한참 서치를 했는데도 적용하는 데 시간이 오래 걸렸다. 폼 데이터를 생성하는 것은 new FormData()를 통해 가능하고, 만들어진 FormData에 append 매서드를 통해 키와 값을 추가시킬 수 있었다.

const formData = new FormData();
formData.append('image', imgFile); 

위와 같이 작성하면 image라는 키에 값으로 imgFile 상태에 담긴 값이 들어가게 된다.
imgFile은 imgLoadModal에서 setImgFile 함수를 통해서 조작하는데, 파일 로더인 input에 onChange의 Handler를 통해 e.target.files를 받아올 수 있다. 이 files는 배열이고, 프로필에는 여러 파일을 받아오지 않으므로 e.target.files[0]을 해주면 프로필 이미지 파일의 정보를 받아올 수 있다.
이 정보를 imgFile에 저장해주었다.

formData를 axios.patch의 body에 담아주고, 여기서 중요한 게 headers에 'Content-Type': 'multipart/form-data'을 추가해주는 것이었다. 그냥 json 형태가 아닌 다른 형태의 데이터이므로 제대로 표시해주지 않으면 큰일남!

//ImgLoadModal.jsx 

const ImgLoadModal = ({ setImgFile, setImgSrc }) => {
  const [imgName, setImgName] = useState('');
  const [imgBlob, setImgBlob] = useState({});
  const reset = useResetRecoilState(ModalState);

  const changeHandler = (e) => {
    console.log(e.target.files[0]);
    if (e.target.files[0].size >= 5 * 1024 * 1024) {
      alert('파일의 크기는 최대 5MB입니다.');
      return;
    }
    if (e.target.files) {
      setImgFile(e.target.files[0]);
      setImgBlob(e.target.files[0]);
      setImgName(e.target.value);
    }
  };

  const saveHandler = (file) => {
    console.log(file);
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => {
      setImgSrc(reader.result);
    };
  };

  const deleteHandler = () => {
    setImgName('');
    setImgFile({});
    setImgBlob({});
    setImgSrc();
    reset();
  };

  return (
    <div
      className={styles.view}
      onClick={(e) => e.stopPropagation()}
      role="dialog"
      aria-hidden
    >
      <div className={styles.text}>프로필 이미지를 업로드 해주세요.</div>
      <form className={styles.fileBox}>
        <input placeholder="프로필 이미지" value={imgName} readOnly={true} />
        <label htmlFor="file">파일 찾기</label>
        <input
          className={styles.fileInput}
          type="file"
          id="file"
          accept="image/jpg, image/jpeg, image/png"
          name="profile_loader"
          multiple={false}
          onChange={changeHandler}
        ></input>
      </form>
      <div className={styles.buttonBox}>
        <ButtonNightBlue
          buttonHandler={() => {
            saveHandler(imgBlob);
            reset();
          }}
          text="저장"
        />
        <ButtonRed buttonHandler={deleteHandler} text="삭제" />
        <ButtonSilver buttonHandler={() => reset()} text="취소" />
      </div>
    </div>
  );
};

ImgLoadModal.propTypes = {
  setImgFile: PropTypes.func,
  setImgSrc: PropTypes.func,
};

export default ImgLoadModal;
//ProfileCard.jsx 속에 있는 patchImg 함수 

//isAdd는 프로필 수정과 프로필 생성을 구분짓기 위한 변수 (동일 컴포넌트 사용)
const patchImg = async (id, isAdd = false) => {
  //요청 바디에 담아 보낼 formData 생성
    const formData = new FormData();
    formData.append('image', imgFile);
  
  //아래 for문은 formData를 콘솔창에서 보기 위한 코드 
  //그냥 console.log(formData)를 하면 빈 객체만 보임 
    for (const key of formData.keys()) {
      console.log(key);
    }
    for (const value of formData.values()) {
      console.log(value);
    }
  
  //post 요청 (헤더 필수)
    await axios
      .patch(`/upload/profile-image/${id}`, formData, {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      })
      .then(({ data }) => {
        const profileImage = data.data[0];
        if (isAdd) navigate(`/admin`);
        else {
          setProfile((prev) => ({
            ...prev,
            url: profileImage.url,
          }));
          navigate(`/myprofile/${profileId}`);
        }
      })
      .catch(({ response }) => {
        console.log(response);
      });
  };


🥹 &&, ?. , initialState 중에 뭘 써야할까...? 타입스크립트는 대단한 녀석이었구나...

가능하면 props drilling이 일어나지 않도록 하려고 노력하지만, 어쩔 수 없이 두 단계 정도 props를 내려야 할 때가 있는데, 보통은 렌더링과 데이터 통신이 동시에 일어난다고 말하지만, 렌더링을 먼저 하고 데이터 통신을 하므로 그 때에 state값이 제대로 들어오지 않은 상태로 undefined라면 그리고 그 undefined에 있는 키 값을 찾으려고 하면 개발자 도구에서는 Type Error를 띄운다. ('마! 니 도랏나! undefined에 key가 어딨노!')

이런 문제는 프리 프로젝트 때에도 자주 만나곤 해서 그때마다 '&&'연산자를 통해서 && 앞에 있는 값이 undefined이면 뒤의 값을 부르는 시도를 하지 않도록 하고, 값이 존재하면 랜더링을 시키는 식으로 구현하곤 했었다. 하지만 실제로 이 && 연산자가 어떤 역할을 하는지 제대로 파악하고 있지는 못했는데, 이번에 메인 팀원들과 이야기를 하면서 조금이나마 알게 되었다.

&&와 비슷하게 사용할 수 있는 것이 ?., optional chaining 연산자이다.
a?.b일 때 a가 undefined나 null이더라도 에러를 표시하지 않고 undefined로 남겨주는 역할을 한다고 한다. 코딩애플에서도 ?. 에 대해서 설명하고 있음. 흥미로우니 추천!

두가지 연산자에 대해서 이야기를 나누기는 했지만, 결국 팀원들과는 상태의 initialState를 잘 만들어두도록 하자고 결론을 내렸다. 그 이유는 우리가 타입스크립트의 interface와 같이 상태의 프리셋을 지정해줄 수 없기 때문이다. 이렇게 이야기꽃을 피우고 나니 Ts가 얼마나 유용한 언어인가에 대해서도 느낄 수 있었다. 그 전에는 단순히 취업에 중요한 언어 정도로 생각했었는데 프로젝트를 하면서 Props-Type을 이용해보고, 위와 같은 에러도 만나보면서 type을 지정해주는 게 에러 핸들링에 중요한 일이라는 것을 알게 되었다. 값지다 ~~ 💰💰💰💰💰💰💰💰



🦖 axios의 interceptors를 아시나욥?

프리 프로젝트 때는 access, refresh Token을 모두 서버에서 전달받아서 access Token을 재발급 했었다. 하지만 이번 메인 프로젝트에서는 백엔드에서 발급해준 access Token이 만료되면 토큰 재발급 API를 통해 재발급 요청을 보내고, 서버에 저장되어 있는 refresh Token을 이용한 access Token을 재발급 받을 수 있다.

이때, access Token이 만료되면 서버에서는 403 'EXPIRED ACESS TOKEN'이라는 에러를 보내오는데, 이런 경우를 핸들링 하기 위해서 reIssueToken이라는 재사용이 가능한 함수로 만들었었다. 해당 함수를 이용해서 액세스 토큰 재발급을 좀 더 효율적으로 하고 있었으나, axios default header를 설정해주면서 평화가 산산조각 나게 된다...

추가해준 디폴트 헤더는 아래와 같다.

axios.defaults.headers.common['Authorization'] =
  sessionStorage.getItem('authorization') ||
  localStorage.getItem('authorization');

에러가 발생하는 방식은 아래와 같았다.

예를 들어 프로필 수정 API 요청 보내는 patchProfile에서 403 에러로 인해 토큰이 재발급되고 난 뒤, 재발급된 토큰이 localStroage나 sessionstorage에 저장이 되어도
(스토리지 상에서 재발급된 토큰으로 업데이트 되는 것은 개발자 도구창에서 확인이 됨.)
해당 parchProfile은 새롭게 발급 받기 전의 토큰을 default header로 가지고 있어서 해당 토큰으로만 요청이 들어가서 무한으로 토큰 재발급이 실행됨.

default header를 사용하면 코드를 줄일 수 있는 여지가 많아서 가능하면 사용하고 싶었는데, 무한 루프를 도는 에러 때문에 서버 로그에도 민폐를 끼치게 되고, 정말... 눈물만 나오는 상황이었다.

침울하게 구글링을 하다가 그때 발견한 것이 'interceptors'라는 axios의 API였다.
뭔가 이 인터셉터를 통해서 request의 요청을 가로채서 header를 설정해줄 수 있지 않을까 싶었다. 하지만 확신이 없어서 멘토님께 에러에 대해 설명하고 조언을 부탁드리게 되는데, 그때 받은 링크도 인터셉터에 관련된 내용이어서 확신을 가지게 되었다.

공식문서에서는 이렇게 짧게 설명을 하고 있었고, 여러 블로그들을 참고하게 되는데 아래 블로그들이다.

참고 블로그 1
참고 블로그 2
공식문서 request config
공식문서 response schema

App.jsx 파일에 추가해준 axios 설정은 아래와 같다.
그런데 interceptors를 사용하더라도 무한루프가 도는 경우가 있어서 메인 팀원 중 도사님이 좀 더 구글링을 하시다 발견하신 이 블로그의 "(2) axios interceptors response" 부분을 참고해서 해결하게 되었다.

//axios 모든 요청 시 로컬이나 세션 스토리지에 있는 액세스 토큰으로 업데이트 해주는 설정  
 axios.interceptors.request.use(
    (config) => {
      config.headers.Authorization =
        sessionStorage.getItem('authorization') ||
        localStorage.getItem('authorization');
      return config;
    },
    (err) => {
      console.log(err);
      return Promise.reject(err);
    }
  );

//axios로 요청을 보낸 뒤, 응답을 받을 때 403 EXPIRED ACCESS TOKEN 에러인 경우 재발급 요청 보낸 뒤, 원래 보내려고 했던 요청을 보내는 로직 
  axios.interceptors.response.use(
    (response) => response,
    async (error) => {
      console.log(error);

      const {
        config,
        response: { status },
      } = error;
      
      //무한루프 해결 위함
      if (config.sent) return Promise.reject(error);

      if (
        status === 403 &&
        error.response.data.message === 'EXPIRED ACCESS TOKEN'
      ) {
        const originReq = config;
        //무한루프 해결 위함 
        originReq.sent = true;

        const userId = await (sessionStorage.getItem('userId') ||
          localStorage.getItem('userId'));
        try {
          const {
            data: { authorization },
          } = await axios.get(`/auth/reissue-token/${userId}`);

          localStorage.setItem('authorization', authorization);
          sessionStorage.setItem('authorization', authorization);

          originReq.headers.Authorization = authorization;
          return axios(originReq);
        } catch (err) {
			//catch문은 refresh Token 조차 만료되었을 때 
          	//로컬, 세션에 있는 액세스 토큰 등의 데이터를 삭제하고 
          	//로그인 페이지로 리다이렉션 시키는 역할을 함 
          console.log(err);
          localStorage.clear();
          sessionStorage.clear();
          resetProfile();
          location.href = '/login';
        }
      }
      return Promise.reject(error);
    }
  );

  axios.defaults.baseURL = process.env.REACT_APP_BASE_URL;

오늘 이 axios 설정 때문에 정말 오랫동안 머리를 싸매고 끙끙대야했는데, 그래도 이렇게 설정을 해두고 나니 axios 설정을 할 때 훨씬 가뿐한 느낌이다... 최고.
다음에는 이렇게 안 헤매겠지...ㅎㅎ..값졌다고 생각하려고 한다.
유능한 팀원 덕분에 개념만 떠올렸던 것을 잘 적용할 수 있었다. 🥹🥹🥹



🟡 Needs

  • Demo-Day 준비
  • 주말 휴식 갖기, 까치랑 즐거운 시간 보내기~
  • 빠르게 내 할 일 끝내고 남은 일들 품앗이 하러 가자~
profile
키보드로 그려내는 일

0개의 댓글