[퍼스트 프로젝트 / 리팩토링] weSeason v2.0.0 배포 완료, 리팩토링 회고록
Project

[퍼스트 프로젝트 / 리팩토링] weSeason v2.0.0 배포 완료, 리팩토링 회고록

반응형

weSeason 메인 화면

반응형

weSeason 소개

시시각각 변화하는 기온과 내 위치! 오늘은 뭘 입을까?
기온에 맞는 옷차림을 추천해드립니다!!

 

  • 접속 위치와 기온을 확인하고 옷차림을 추천합니다!
  • 현재 기온을 포함한 9시간의 데이터를 미리 확인할 수 있습니다!
  • 원하는 기온 데이터를 클릭하여 옷차림을 확인할 수 있습니다!
  • 다른 곳으로 이동을 해야 한다면? 동네 검색을 이용하여 해당 지역 정보를 확인할 수 있습니다!
  • 구글, 깃허브로 편리한 로그인이 가능합니다!

1. 기획

 

퍼스트 프로젝트는 2주 정도의 상당히 짧은 기간이었다.

그 중 1주일은 sr을 계획하고, 피그마로 UI, UX를 모두 실사화로 만들어 계획하는 데에 사용했다.

그 후 1주일은 코딩을 진행했다.

첫 번째 프로젝트이고, 내 아이디어가 직접 구체화 되어 구현되는 것만큼 재밌는 것은 없다는 생각이 들었다.

 

프로젝트 진행 당시에는 마이페이지, 메인 날씨, 회원탈퇴 페이지를 맡으면서 주요 컨텐츠가 위치한 메인 컴포넌트는 후에 작업하는 것으로 태스크를 분배했었는데, 마이페이지에 생각보다 시간을 오래 소요했다. 각 잡고 진행하는 프로젝트가 처음이었기에 감이 잘 안왔던 것이다.

 

마이페이지라고 마이페이지 컴포넌트 하나만 만드는 게 아니라, 그에 필요한 모든 요소들을 최대한 소형화해서 재사용할 수 있게끔 컴포넌트 분기처리를 해야만 했다. 프로젝트가 마무리 되었을 때 최종적으로 마이페이지 관련 컴포넌트는 총 5개였다.

부랴부랴 밤새며 프로젝트를 결국에는 완성해냈고, 당일날 급하게 발표도 진행했었다. (일정이 너무 촉박해서 원테이크 발표 영상 찍는데 곤혹)

 

프로젝트를 마무리 했지만, 아쉬운 점이 몇 개 있었다.

첫 번째로는 반응형을 구현하지 못한 것이었다

접속자의 위치를 파악하거나 지도를 이용하여 위치 정보를 가져오고, 그에 맞는 옷차림을 추천한다는 건 좋지만 실제로 유저의 접근성이 좋은가?

제작자 입장에서도 아니라고 답할 수 있었다. 프로젝트를 마무리 한 후 나갈 일이 생겨 옷차림을 보려했는데 사이트에 들어가지 않고 그냥 네이버에 검색해서 차려 입고 나갔었다. 

그렇기 때문에 우선순위로 반응형이 구현되어야만 한다고 생각했고, 모바일과 태블릿에서는 웹과 다른 느낌으로 렌더링 되는 것이 목표였다.

 

두 번째로는 리덕스를 통한 상태관리를 진행하지 못했던 것이었다.

로그인 기능이 포함되어 있는 서비스이기때문에 로그인 여부에 따라 렌더링이 달라지는 특징이 있었는데, 이전 작업에서는 이를 모두 props로 내려주었고, 이 과정에서 이렇기 때문에 리덕스를 통해서 상태관리를 하는 것이구나 하고 리덕스의 필요성을 강하게 느꼈던 것 같다.

 

세 번째로는 js, react 만을 사용한 스택이 아쉬웠다.

 

그렇게 해서, 타입스크립트, 리덕스, 리액트를 적용하여 새로운 버전을 만들기로 기획했고, 전체 UI, UX 디자인을 피그마 툴을 사용해 다시 만들고, 수정했다.

 

 

2. 작업

 

 

웹 / 태블릿 / 모바일 반응형 디자인

이번 프로젝트는 기본 스택이 타입스크립트로 변경되었기때문에, 처음부터 다시 작업을 해야했다.

파트 분배는 첫번째 프로젝트에서 담당하지 않았던 파트를 서로 바꿔서 진행하기로 했다.

 

담당 파트

1. 메인 추천 의류 렌더링

2. 의류 리스트 렌더링

3. 검색 버튼을 통한 지도 노출 및 검색 기능 렌더링

4. 로그인 페이지 렌더링

5. 회원가입 페이지 렌더링

 

 

반응형 로직 설명

 

weSeason의 경우 메인을 제외한 페이지들의 디자인이 웹 / (모바일 / 태블릿) 에 따라 UI가 크게 달라지도록 기획했다.

 

핵심 기능

1. 인풋 값 입력 시 css 변화

2. 유효성 검사 미통과시 웹 : 모달 노출 / 모바일, 태블릿 : 오류 문구 노출

3. 유효성 검사 통과 시 모바일/태블릿 : 가입하기 버튼 css 변화

 

미디어 쿼리를 사용하여 반응형 조건에 따라 css 만 변화시키면 되는 것 아닐까 생각했었다.

하지만, 웹/ 태블릿 / 모바일에 따라 모달 노출인지, 오류 문구 노출인지 구분이 되어야 했다.

웹 모달이 노출된 채로 태블릿 이하 사이즈로 변경한다면 그것 또한 인식하여 모달을 강제로 닫는 기능이 필요했고, 이를 코드로 구현했다.

 

 

function App({ pageWidth, modifyCilentWidth, ... }: AppProps) {
  ...,
  
  useEffect(() => {
    (...)
    const resizeListener = async () => {
      let currentWidth = await getWidth();
      if (
        (pageWidth < 1024 && currentWidth >= 1024) ||
        (pageWidth >= 1024 && currentWidth < 1024)
      ) {
        modifyCilentWidth(getWidth());
      }
    };
    window.addEventListener('resize', resizeListener);

    (...)

    };
  }, [dispatch, modifyCilentWidth, pageWidth, ...]);
  const getWidth = () => window.innerWidth;

  return (...)
}

const mapStateToProps = (state: any) => {
  return {
    pageWidth: state.pageWidth.width,
    ...
  };
};

const mapDispatchToProps = (dispatch: any) => {
  return {
    modifyCilentWidth: (width: number) =>
      dispatch(changeCurrentPageWidth(width)),
    ...
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

 

이전 포스팅에서 페이지 사이즈를 구분하기 위해 리듀서를 통해 리사이징을 적용했다.

우선 pageWidth 상태를 리듀서에서 가져오고, modifyClientWidth 메소드를 통해 상태를 변경할 수 있다.

다만, 실시간으로 리사이징 이벤트가 작동된다면 불필요한 상태 변경이 지속적으로 일어나게 된다.

 

이를 위해 현재 사이즈를 getWidth 메소드를 통해 확인한다.

- 저장되어있던 pageWidth 상태가 웹인데 현재 currentWidth가 모바일 / 태블릿 사이즈일 경우

- pageWidth 상태가 모바일 / 태블릿 사이즈인데 currentWidth가 웹인 경우

 

이 두경우에 한해 modifyClinetWidth를 사용하여 상태 변경을 진행했다. 

 

- 실 사용 코드

const SignUp = ({ userInfo, pageWidth, ...rest }: any) => {
  const name = userInfo.name;
  const nickName = userInfo.nickName;
  const email = userInfo.email;
  const password = userInfo.password;
  const passwordCheck = userInfo.passwordCheck;

  const [errorMessage, setErrorMessage] = useState<string>("");
  const [resError, setResError] = useState<boolean>(false);
  const [webError, setWebError] = useState<boolean>(false);
  const [webJoin, setWebJoin] = useState<boolean>(false);
  const [defaultBtnColor, setDefaultBtnColor] = useState<boolean>(true);
  const [resMessage, setResMessage] = useState<string>("");

  const history = useHistory();
  const infoForm: InfoDataType[] = infoFormData;
  useEffect(() => {
    ...
  });

  ...
  
  const checkEmptyOrFaultPassword = () => {
    if (!name || !nickName || !email || !password || !passwordCheck) {
      setErrorMessage("정보를 모두 입력해주세요");
    } else if (password.length < 6) {
      setErrorMessage("6자리 이상의 비밀번호를 입력해주세요");
    } else if (password !== passwordCheck) {
      setErrorMessage("비밀번호를 재확인 해주세요");
    } else {
      setErrorMessage("");
      setDefaultBtnColor(true);
    }
  };

  const joinUserOrCheckEqualUser = async () => {
    try {
      await createUserInfo(name, nickName, password, email);
      // 성공할 경우 모달 or 모바일 안내
      // 스테이트 초기화
      formatUserInfo();
      if (pageWidth >= 1024) {
        setErrorMessage("회원가입 완료");
        setResMessage("확인을 누르면 로그인 페이지로 이동합니다");
        setWebJoin(true);
      } else {
        history.push("/login");
      }
    } catch {
      setErrorMessage("이미 존재하는 이메일입니다");
      if (pageWidth < 1024) {
        setResError(true);
      } else if (pageWidth >= 1024) {
        setWebError(true);
      }
    }
  };

  ...

  const checkPageWidthErrorConcepts = () => {
    if (pageWidth < 1024 && errorMessage) {
      setResError(true);
    } else if (pageWidth >= 1024 && errorMessage) {
      // 웹 에러 모달
      setWebError(true);
    } else if (!errorMessage && !defaultBtnColor) {
      // ajax 호출
      joinUserOrCheckEqualUser();
    }
  };

  const handleClickJoinBtn = async () => {
    await checkEmptyOrFaultPassword();
    await checkPageWidthErrorConcepts();
  };

  const handleFindModalClose = () => {
    setWebError(false);
    if (errorMessage === "이미 존재하는 이메일입니다") {
      setErrorMessage("");
    }
    if (resError) {
      setResError(false);
    }

    if (resMessage) {
      setResMessage("");
      history.push("/login");
    }
  };

  return (
    <div id="signUpPage">
      <h1 id="signUp__ment">회원가입</h1>
      <div id="signUp__userInfo">
        {userInfoListItem}
        {resError ? <div id="userInfo__errorView">{errorMessage}</div> : null}
      </div>
      <button
        id={
          defaultBtnColor ? "signUp__joinBtn-basic" : "signUp__joinBtn-extend"
        }
        onClick={handleClickJoinBtn}
      >
        가입하기
      </button>
      {webError ? (
        <OneBtnModal
          message={errorMessage}
          info=""
          handleFindModalClose={handleFindModalClose}
        />
      ) : null}
      {webJoin ? (
        <OneBtnModal
          message={errorMessage}
          info={resMessage}
          handleFindModalClose={handleFindModalClose}
        />
      ) : null}
    </div>
  );
};
...

signUp 컴포넌트의 코드 일부이고, pageWidth 상태에 따라 resError, webError의 처리가 계속 변경된다는 것만 파악하면 될 것 같다.

 

크게 위와 같은 로직으로 회원가입, 로그인 페이지의 기본 로직을 구현했다.

 

 

- 메인 의류 렌더링

 

v1.0.0 디자인 당시 메인 의류 렌더링 아이콘을 4~6개의 경우만 있는 것으로 판단, 그에 맞게 display none값만 수정하여 고정 위치에서 안보이게 하는 그런 로직이었다. 

 

v2.0.0을 하면서 우선적으로 메인의류 렌더링의 뒷 배경 아이콘을 구름으로 수정했다.

의류 데이터

그리고 필요한 로직에 대해 생각했다.

 

의류 데이터는 총 49개, 렌더링 가능한 아이콘을 보유하고 있는 의류 데이터 25개, 기온별 노출되는 아이콘의 갯수는 1 ~ 6개 이상으로 다양했기 때문에 이를 어떻게 렌더링하여 노출 시킬 지 고민을 했다.

 

 

iconList.ts

export const iconList = [
  {
    name: "7부바지",
    class: "clothesItem__cloth clothesItem__seven-piece-pants",
  },
  { name: "긴바지", class: "clothesItem__cloth clothesItem__long-pants" },
  { name: "니트", class: "clothesItem__cloth clothesItem__neat" },
  { name: "두꺼운코트", class: "clothesItem__cloth clothesItem__thick-coat" },
  ...,
  { name: "후드", class: "clothesItem__cloth clothesItem__hood" },
];

생각해낸 방법은, props를 통해 받아온 옷차림 중 아이콘을 갖고 있는 요소들을 필터링 한 후 상태로 저장해 사용하는 것이었다.

import { iconList } from "./iconList";
...

const ClothesItem = ({ clothes, temp }: any) => {
  const [iconLength, setIconLength] = useState<number>(0);
  const [iconNames, setIconNames] = useState<any[]>([]);

  useEffect(() => {
    let validItemList = clothes
      .filter((el: any) => {
        for (let cloth of iconList) {
          if (cloth.name === el) {
            return true;
          }
        }
      })
      .map((el: any) => {
        for (let cloth of iconList) {
          if (cloth.name === el) {
            return cloth;
          }
        }
      });
      
      
    if (validItemList.length >= 6) {
      validItemList = validItemList.slice(0, 5);
    }

    setIconLength(validItemList.length);
    setIconNames(validItemList);
  }, [clothes]);
    
    const clothesItemTag = iconNames.map((cloth: any, idx: any) => {
      return (
        <div
          className={`clothesItem__item clothesItem__item--length-${iconLength}`}
          key={idx}
        >
          <img
            className={`clothesItem__cloud clothesItem__cloud-${idx}`}
            src={cloud}
            alt='cloud'
          ></img>
          <div
            className={`${cloth.class} clothesItem__cloth-length--${iconLength} clothesItem__cloth-${idx}`}
          ></div>
        </div>
       );
    });
  }

props로 받은 의류 데이터를 순회하면서, iconList의 요소와 일치하는 지 확인하고 같다면 name, class의 키를 갖고 있는 객체 데이터 자체를 가져올 수 있도록 array map 메소드를 사용했다.

 

그리고 아이콘을 가질 수 있는 데이터의 길이가 6이상이라면, 5개의 아이콘 리스트만 가져 올 수 있도록 구현했다.

이를 각각의 iconNames, iconLength 상태에 저장한다.

 

상태로 저장된 데이터들을 map을 통해 다시 한 번 순회하면서 각각 데이터들의 길이와 맞는 클래스명을 부여했다.

 

위와 같이 로직을 구현한 이후, 유효 아이콘이 1 ~ 5개인 경우에 대한 css를 작성했다. 

 

 

 

위와 같이 렌더링 가능하다.

 

애니메이션

설정한 cloth 아이템들에 대해서도 각각 애니메이션 설정을 다르게 하여 둥둥 떠다니는 듯한 애니메이션을 부여했다.

 

의류 아이콘 밑에 있는 의류 리스트도 해당 기온에 맞는 옷차림들을 모두 렌더링하며, hover 속성으로 동적 애니메이션을 부여했다.

 

- 동네 검색 버튼

동네 검색 버튼 클릭

메인 하단 동네 검색 버튼을 클릭할 경우 원하는 장소를 키워드 또는 지도를 클릭하여 서비스를 이용할 수 있다.

 

kakao map api를 통해 구현을 했다.

 

일단 사이트에 접속할 경우 geoLocation을 통해 접속자의 위치를 받아온 후 리듀서에 좌표 값을 저장한다.

 

locationReducer.ts

import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  lat: 37.55519305862982,
  lon: 126.9707879543135,
};

const locationSlice = createSlice({
  name: 'locationReducer',
  initialState,
  reducers: {
    userLat(state, action) {
      const lat = action.payload;
      state.lat = lat;
    },
    userLon(state, action) {
      const lon = action.payload;
      state.lon = lon;
    },
  },
});

export const { userLat, userLon } = locationSlice.actions;

export const locationReducer = locationSlice.reducer;

 

App.tsx

function App({ ..., modifyLat, modifyLon }: AppProps) {
  useEffect(() => {
    ...,
    
    navigator.geolocation.getCurrentPosition((position) => {
      modifyLat(position.coords.latitude);
      modifyLon(position.coords.longitude);
    });

    
  }, [dispatch, modifyLat, modifyLon, ...]);
...
}
const mapStateToProps = (state: any) => {
  return {
    pageWidth: state.pageWidth.width,
    lat: state.locationReducer.lat,
    lon: state.locationReducer.lon,
  };
};

const mapDispatchToProps = (dispatch: any) => {
  return {
    ...,
    modifyLat: (lat: number) => dispatch(userLat(lat)),
    modifyLon: (lon: number) => dispatch(userLon(lon)),
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

 

App.tsx 파일에서 geolocation api를 사용하여 접속자의 lat, lon 을 받아 상태를 수정한다.

(위치 동의가 확인되지 않는 경우 기본 상태값 저장)

 

이후 메인 렌더링이 진행된다.

 

지도

 

동네 검색 버튼을 클릭하면 카카오 지도가 노출되며, 저장되어있는 lat, lon 상태를 리듀서에서 확인한 후 메세지와 함께 초기 렌더링이 진행된다.

지도를 클릭하거나 키워드 입력하고 마커를 클릭한 이후 옷 추천받기 버튼을 클릭하면 선택한 장소의 좌표정보로 리듀서에 저장된 lat, lon 상태가 업데이트 된다. 

 

- 소셜 로그인

 

소셜 로그인

oauth 소셜 로그인은 구글과 깃허브를 지원한다.

 

소셜 로그인 버튼을 클릭하면 유저의 승인을 받은 이후 성공 시 메인으로 리디렉션된다.

 

Main.tsx

const Main = ({ accessToken, modifyAccessToken }: any) => {
  const [clickModal, setClickModal] = useState<boolean>(false);
  const location = useLocation();
  ...

  useEffect(() => {
    let { code } = queryString.parse(location.search);
    let pathname = location.pathname;

    if (code) {
      axios
        .post(`${API_URL}${pathname}`, { code }, { withCredentials: true })
        .then((data) => {
          modifyAccessToken(data.data.accessToken);
        })
        .catch((err) => console.log(err));
      // 수정중
    }
  }, [accessToken, location.search, modifyAccessToken]);

  return (
    ...
  );
};

const mapStateToProps = (state: any) => {
  return { accessToken: state.appReducer };
};

const mapDispatchToProps = (dispatch: any) => {
  return {
    modifyAccessToken: (token: string) => dispatch(setAccessToken(token)),
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(Main);

 

메인으로 리디렉션이 되면서, redirect_Url를 갖게 되는데, 메인페이지에서 이를 처리했다.

타입스크립트와 리액트를 사용하여 url 쿼리를 가져오는 방법에 대해서 계속 구글링을 진행했었는데 삽질이 무색하도록 간단한 방법을 찾았다.

react hook , useLocation을 이용하여 쿼리와 pathname을 가져올 수 있었다.

 

oauth를 통해 엑세스 토큰을 받으려면 인증 코드가 필요하기 때문에 location.search를 가져오고, query-string 라이브러리를 이용해 변환했다. 이를 서버 api 호출 시 바디 데이터로 전달한 후 응답으로 오는 access token을 리듀서에 상태를 저장했다.

 

구글 ( '/auth/google' ) , 깃허브 ( '/auth/github' ) 로 설정된 엔드포인트를 각각 가지고 있었기때문에

문자열 리터럴을 사용하여 요청 url 링크가 소셜마다 변경될 수 있도록 작성했다. 

 

 

3 . 느낀 점

수료 직후 첫번째 프로젝트 리팩토링을 시작했다. 데드라인 2주를 설정했지만 버그를 잡고, 다른 공부도 병행하였기 때문에 실제 배포까지는 5일이 더 소요된 것 같다.

 

내 아이디어로 진행했던 프로젝트이고, 상당히 재미있게 진행했기 때문에 마음에 많이 남아있는 것 같다.

 

프로젝트를 시작하기 전까지 사실 디자인에 대해 아무것도 모르는 상태였는데, 이 프로젝트를 계기로 디자인에 많은 흥미를 느끼게 되었다.

따로 공부를 하는 것도 인터렉티브 쪽으로 계속 끌리고, 좀 더 유저 입장에서 편하고 눈에 들어오고 계속 이용하고 싶은 디자인은 뭘까? 생각하게 되는 것 같다.

 

이번에는 그렇게 사용하고 싶었던 타입스크립트와 리덕스, 애니메이션을 결국 사용하고 구현해서 그런지 프로젝트에 대한 애정도가 더 올라갔다. 

 

그리고 사실 작성한 코드를 보고 있자면 분명 줄일 수 있을 것 같은데... 엄두가 안나서 손대지 못한 코드가 조금 있다.

그림이 그려질때마다 수정은 하고 있지만, 좀더 가독성 있고 이해하기 쉬운 코드를 작성하고 싶다.

 

공부를 하다 보면 이따금씩 내가 알고 있다고 생각했는데, 사실 깊게 들어가면 들어갈 수록 내가 알고 있는 것은 먼지 한 톨에 불가하다는 것을 느끼게 되는 경우가 있다. 

 

프로젝트 리팩토링을 하면서 여러가지 감정이 참 많이 교차했던 것 같다.

어느 날은 뭐야 나 조금 아는 건가? 하다가도

어느 날은 나는 애송이구나 싶은 날이 있었다.

 

그럴 땐 갑자기 쳐지기도 하는데 

 

생각해보면 그 유명한 어떤 개발자도 언어가 되었든 어떤 게 되었든 100% 완벽한 실력을 갖고 있다고 말하는 사람은 못본 것 같다.

코드란 자율성이 뛰어나고 운전자가 누구냐에 따라, 어떤 생각을 하고 있냐에 따라 코드의 결이 달라지기 때문에 많은 사람들이 코드엔 정답이 없다곤 한다.

그리고 서로의 코드를 보면서 이런 코드가 작성될 수도 있구나 생각하고 배우고 느끼게 된다.

 

그렇기 때문에 모두가 트렌드 집중하고, 꾸준한 공부를 하는 거 아닐까?

개발자는 무한 번뇌의 삶인 듯 하다.

 

내 실력에 내가 만족할 수 있는 날이 아마 평생 오지 않을 수도 있지만, 분명히 어제보단 오늘이 더 낫다. 

이 또한 내가 성장하기 위한 자극제이고, 나를 움직이게하는 역할을 하기에 충분한 것 같다.

 

자만하지 않고, 꾸준하게 고통받는 걸 즐길 수 있는 그런 개발자가 되고싶다.

 

이제 리팩토링이 끝났으니 코딩테스트와 기본 js를 다시 한 번 공부하려고 한다.

 

부족한만큼 더 열심히!!!

 

 

 

 

 

 

 

 

 

 

반응형