[Team Project] StackOverFlow Clone

Bin2·2022년 9월 6일
2
post-thumbnail

1. Project Intro

1-1. 프로젝트 소개

코드스테이츠 FE 부트캠프 4개월간의 교육 과정을 마치고 첫 팀 프로젝트를 진행했습니다.
프로젝트는 코드스테이츠 측에서 정해준 스택오버플로우 클론코딩으로 진행하였고,
FE 3명, BE 2명으로 총 5명의 팀원들과 협업하였습니다.

1-2. 구성원

모상빈, 김성현, 심소영, 박소영, 양준영

1-3. 기간

2022년 8월 24일 ~ 2022년 9월 06일 (2 Weeks)

1-4. 기술 스택

  • React
  • typescript
  • redux-toolkit
  • styled-component
  • react-testing-library

1-5. 맡은 기능

  • 로그인, 로그아웃
  • 질문 상세페이지
    • 질문 (Read, Update, Delete)
    • 답변 (Create, Read, Update, Delete)
    • 투표, 페이지 공유
  • 태그 페이지
    • 태그 이름으로 검색
    • 필터링
    • 페이지네이션
  • 유저 페이지
    • 유저 이름으로 검색
    • 필터링
    • 페이지네이션

2. Main Feature

2-1. 로그인 페이지

DefaultInput 컴포넌트

로그인 페이지 뿐만 아니라 질문 등록, 수정 등 다른 컴포넌트에서도 공통적으로 사용할 수 있도록 Input 태그를 컴포넌트화 했습니다.

const DefaultInput = ({
  type = 'text',
  label,
  id,
  value,
  isError,
  errorMsg = ERROR_MSG_02,
  comment,
  placeholder,
  onChange,
}: Prop) => {
  return (
    <Wrapper isError={isError}>
      {label && <SLabel htmlFor={id}>{label}</SLabel>}
      {comment && <SCommentP>{comment}</SCommentP>}
      <SInputWrapper>
        <SInput
          type={type}
          id={id}
          value={value}
          isError={isError}
          placeholder={placeholder}
          onChange={(e) => onChange(e)}
        />
        {isError && (
          <>
            <MdError />
            <p>{errorMsg}</p>
          </>
        )}
      </SInputWrapper>
    </Wrapper>
  );
};

이메일, 패스워드 유효성 검사

이메일은 정규식을 이용하여 검증했고, 패스워드는 8글자 이상 입력하도록 유효성 검사 로직을 추가했습니다.

  const handleChangeEmail = (e: ChangeEvent<HTMLInputElement>) => {
    if (EMAIL_REGEX.test(e.target.value)) {
      setEmailError(false);
    }
    setEmailValue(e.target.value);
  };

  const handleChangePassword = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.value.length > 7) {
      setPasswordError(false);
    }
    setPasswordValue(e.target.value);
  };

  const handleSubmit = () => {
    if (!EMAIL_REGEX.test(emailValue) || passwordValue.length < 8) {
      if (!EMAIL_REGEX.test(emailValue)) setEmailError(true);
      if (passwordValue.length < 8) setPasswordError(true);
      return;
    }
    dispatch(
      loginUser({
        email: emailValue,
        password: passwordValue,
      })
    );
  };

로컬스토리지를 활용한 로그인 유지

유저 정보를 redux의 전역 상태로 관리했고, 로컬 스토리지를 활용하여 웹 사이트에 다시 접속하더라도 로그인이 유지될 수 있도록 구현했습니다.

// utils
export const addUserToLocalStorage = (user: User) => {
  localStorage.setItem('user', JSON.stringify(user));
};

export const removeUserFromLocalStorage = () => {
  localStorage.removeItem('user');
};

export const getUserFromLocalStorage = (): null | User => {
  const result = localStorage.getItem('user');
  const user = result ? JSON.parse(result) : null;
  return user;
};

// redux
const initialState: UserInitialState = {
  user: getUserFromLocalStorage(),
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    logOutUser: (state) => {
      state.user = null;
      removeUserFromLocalStorage();
      toast.success('Logout completed successfully.');
    },
  },
  extraReducers: (builder) =>
    builder
      .addCase(loginUser.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(loginUser.fulfilled, (state, { payload }) => {
        state.isLoading = false;
        state.user = payload;
        addUserToLocalStorage(state.user);
        toast.success(`Hello, ${payload.displayName}!`);
      })
      .addCase(loginUser.rejected, (state, { payload }) => {
        state.isLoading = false;
        toast.error(payload);
      })
});

토큰을 활용한 로그인 인증

reudx-thunk를 사용해 서버와 통신했습니다.
토큰을 이용하여 어플리케이션의 보안을 높일 수 있었고, response의 상태 코드에 따라 다른 에러 메세지를 전달했습니다.

export const loginUser = createAsyncThunk<
  User,
  LoginPayload,
  CreateAsyncThunkTypes
>('user/loginUser', async (payload, thunkAPI) => {
  try {
    const { email, password } = payload;
    const response = await axiosInstance.post('/login', {
      email,
      password,
    });
    const userInfo = await axiosInstance.get('/v1/user', {
      headers: {
        Authorization: `Bearer ${response.headers.authorization}`,
      },
    });
    return {
      ...userInfo.data.data,
      token: response.headers.authorization,
    };
  } catch (error: any) {
    if (error.response.status === 401) {
      return thunkAPI.rejectWithValue('Check your email and password.');
    }
    return thunkAPI.rejectWithValue(error.message);
  }
});

2-2. 질문 상세 페이지

Content 컴포넌트

위의 그림과 같이 Question의 정보를 나타낼 컴포넌트, Answer의 정보를 나타낼 컴포넌트를 하나의 공통 컴포넌트로 관리했습니다.
Question, Answer 타입을 prop으로 받아 각각의 타입에 따라 다른 api 요청, 다른 기능을 하도록 구현했습니다.

투표 기능

각각의 Question, Answer 마다 좋아요와 같이 투표 기능을 추가했습니다.
현재 vote 점수를 기준으로 +- 1 만 가능하도록 vote 값을 메모이제이션 했고,
로그인 하지 않았을 경우 모달 창을 띄워 로그인을 하도록 유도했습니다.

  const currentVote = useMemo(() => vote, []);

  const upVote = useCallback(() => {
    if (!loginUser) {
      openModal(<VoteModal type="upvote" />);
      return;
    }
    if (vote > currentVote) return;
    if (type === 'question') {
      dispatch(increaseQuestionVote(questionId));
      dispatch(changeQuestionVote(questionId);
    }
    if (type === 'answer') {
      dispatch(increaseAnswerVote(answerId));
      dispatch(changeAnswerVote(answerId);
    }
  }, [vote]);

질문 수정, 삭제

백엔드에서 토큰을 검증한 후 작성한 유저의 토큰이 아닐 경우 수정, 삭제를 못하도록 했지만
클라이언트에서도 1차적으로 작성자의 유저 정보와 현재 로그인한 유저의 정보를 비교하여
같은 경우에만 edit, delete 버튼을 보여주도록 했습니다.

edit 버튼을 누르고 수정 페이지로 넘어가면 작성했던 정보가 각각의 input에 있도록 하였고,
각 input 마다 유효성 검증 로직을 추가했습니다.

현재 페이지 공유

window.location.href navigator.clipboard.writeText() 메서드를 활용하여
현재 페이지의 url을 클립보드에 복사할 수 있도록 구현했습니다.

Share 모달 이외의 곳을 클릭 할 때 모달 창이 닫히도록 구현하는 과정에서 event bubbling 현상이 나타났고, stopPropagation() 메서드를 활용하여 해결할 수 있었습니다.

  const currentUrl = window.location.href;

  const handleCopyClick = () => {
    navigator.clipboard.writeText(currentUrl);
    toast.success('Link copied to clipboard.', {
      theme: 'colored',
    });
  };

  const toggleShareModal = (e: React.MouseEvent) => {
    e.stopPropagation();
    setShareModal((prev) => !prev);
  };

답변 추가

프로젝트를 진행하며 가장 애를 먹었던 기능이었습니다.
toast-ediotr 라는 라이브러리를 사용했는데, 답변을 추가한 이후 textArea 안의 값들이 초기화가 되지 않는 현상이 발생했습니다. 많은 레퍼런스를 찾아보았지만 명확한 해결 방법이 없었고, 다음과 같은 방법들을 시도해봤습니다.

  • setState('') 을 이용하여 textArea의 상태를 초기화 하기 -> 실패
  • useRef()를 이용하여 DOM에 직접 접근하여 editorRef.current.value를 초기화 하기 -> 실패
  • submit 버튼을 누르면 로딩스피너를 렌더링 시켜 화면 자체를 다시 렌더링 시키기
    • 초기화는 되었지만 모든 화면을 새롭게 렌더링 시켜 깜빡임 현상 발생 (성능에도 좋지 않을거라 판단)

따라서 에디터 부분만 새롭게 그리는 방법을 선택했습니다.


// redux
      .addCase(addAnswer.pending, (state) => {
        state.isPostLoading = true;
      })
      .addCase(addAnswer.fulfilled, (state, { payload }) => {
        state.isPostLoading = false;
        state.data = payload;
      })
      .addCase(addAnswer.rejected, (state, { payload }) => {
        state.isPostLoading = false;
        toast.error(payload);
      })

// component
	return (
      <>
         {/* editor */}
         <h3>Your Answer</h3>
         {!isPostLoading && <AnswerEditor />}
      <>
    );

여전히 에디터 부분은 깜빡임 현상이 나타나서 매끄러운 느낌은 아니지만 현재로써 할 수 있는 최선의 방법이었습니다. 해결 방법을 알려주신다면 감사하겠습니다. ㅠㅠ

2-3. Tags 페이지

백엔드 쪽에서 모든 태그들을 DB에 저장하기 어려웠고, 시간상의 이유 때문에 태그의 정보들은
stackexchange의 public api를 이용했습니다.

react-js-pagination 라이브러리를 이용해 페이지네이션을 구현했고
keyword, page, sort 옵션을 useEffect의 dependency array에 추가해 상태가 변경될 때마다 서버의 데이터를 업데이트 시켰습니다.

  // redux
  export const getTags = createAsyncThunk<
  Array<Tag>,
  undefined,
  CreateAsyncThunkTypes
>('tag/getTags', async (_, thunkAPI) => {
  try {
    const { page, sortOption, inName } = thunkAPI.getState().tag;
    const response = await axios.get(
      `${STACK_EXCHANGE_URL}/tags?page=${page}&pagesize=90&order=desc&sort=${sortOption}&inname=${inName}&site=stackoverflow`
    );
    return response.data.items;
  } catch (error: any) {
    return thunkAPI.rejectWithValue(error.message);
  }
});

  // component
  useEffect(() => {
    dispatch(getTags());
  }, [dispatch, page, sortOption, inName]);

또한 css의 grid media query를 이용하여 반응형으로 구현했습니다.

export const TagsContainer = styled.section`
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
  margin-bottom: 40px;

  & > div:last-child {
    position: absolute;
    top: 40%;
    left: 50%;
  }

  @media screen and (min-width: 720px) {
    grid-template-columns: repeat(4, 1fr);
  }

  @media screen and (min-width: 980px) {
    grid-template-columns: repeat(5, 1fr);
  }

  @media screen and (min-width: 1265px) {
    grid-template-columns: repeat(6, 1fr);
  }
`;

2-4. Users 페이지

주요 기능은 위의 Tags 페이지와 비슷합니다. 테스트 장면을 촬영하며 request를 너무 많이 보내서 request 제한을 당한 상태입니다. Users 페이지를 개발 할 당시에도 12시간씩 제한을 당해 정말 힘겹게 개발을 했던 기억이 있습니다. ㅠㅠ

Tags 페이지보다 더 까다로웠던 점은 week month quarter year 를 기준으로 필터링 기능을 구현한 부분입니다.

현재 시간을 기준으로 1주 전, 한달 전, 6개월 전, 1년 전의 시간을 구해야 했고,
stackexchange에서 요구하는 timeStamp의 형식으로 변환하여 request를 보내야 했습니다.

먼저 특정 날짜의 timeStamp 형식으로 포맷 해주는 함수를 만들었고, sortOption에 따라 실행되도록 했습니다.


// utils
// getSpecificDate('-', -7) => 7일 전 timeStamp (1692305000)
export const getSpecificDate = (pattn: '-' | '/', num: number) => {
  const current = new Date().toString();
  const temp = new Date(Date.parse(current) + num * 1000 * 60 * 60 * 24);
  const year = temp.getFullYear();
  let month: number | string = temp.getMonth() + 1;
  let day: number | string = temp.getDate();

  month = month < 10 ? `0${month}` : month;
  day = day < 10 ? `0${day}` : day;

  return year + pattn + month + pattn + day;
};

// redux
    changeUserDateOption: (state, { payload }: PayloadAction<string>) => {
      state.page = 1;
      state.dateOption = payload;
      switch (payload) {
        case 'all':
          state.timeStamp = '';
          break;
        case 'week':
          state.timeStamp = new Date(getSpecificDate('-', -7)).getTime();
          break;
        case 'month':
          state.timeStamp = new Date(getSpecificDate('-', -30)).getTime();
          break;
        case 'quarter':
          state.timeStamp = new Date(getSpecificDate('-', -180)).getTime();
          break;
        case 'year':
          state.timeStamp = new Date(getSpecificDate('-', -365)).getTime();
          break;
        default:
          state.timeStamp = '';
      }
    },

3. Test

일정에 맞춰 개발을 끝내고 시간이 남을 때마다 jest react-testing-library를 사용하여 테스트 코드를 작성했습니다.

test-util 함수를 만들어 테스트 코드 작성의 생산성을 높였고, route, redux, component 테스트 코드를 작성했습니다.

테스트 코드를 작성하며 많은 오픈소스들을 참고했지만 작성하는 방식이 제각각이어서 best practice를 찾기 어려웠고, 내가 지금 하는 방식이 맞는건지 확신이 없었습니다.

따라서 최대한 공식문서에 나와있는 방식을 참고하여 테스트 코드를 작성해봤습니다.

test util

interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: PreloadedState<RootState>;
  store?: AppStore;
  route?: string;
}

function render(
  ui: React.ReactElement,
  {
    preloadedState = {},
    store = setupStore(preloadedState),
    route = '/',
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  window.history.pushState({}, '', route);

  const Wrapper = ({ children }: PropsWithChildren<object>): JSX.Element => {
    return (
      <Provider store={store}>
        <ThemeProvider theme={theme}>
          <Router>{children}</Router>
        </ThemeProvider>
      </Provider>
    );
  };
  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}

export * from '@testing-library/react';
export { render };

route test

describe('routing', () => {
  it("사이트 접속 시 '/' path로 이동한다.", () => {
    render(<App />);
    expect(location.pathname).toBe('/');
  });

  it("Tags 탭 메뉴 클릭 시 '/tags' path로 이동한다.", () => {
    render(<App />);
    const tagMenu = screen.getByText(/tags/i);
    userEvent.click(tagMenu);
    expect(location.pathname).toBe('/tags');
    expect(screen.getByText(/show all tag synonyms/i)).toBeInTheDocument();
  });

  it("Users 탭 메뉴 클릭 시 '/users' path로 이동한다.", () => {
    render(<App />);
    const userMenu = screen.getByText(/users/i);
    userEvent.click(userMenu);
    expect(location.pathname).toBe('/users');
    expect(screen.getByRole('heading', { name: /users/i })).toBeInTheDocument();
  });

  it("Questions 탭 메뉴 클릭 시 '/' path로 이동한다.", () => {
    render(<App />, { route: '/users' });
    const tabs = screen.getAllByRole('link', { name: /questions/i });
    userEvent.click(tabs[0]);
    expect(location.pathname).toBe('/');
  });

  it('잘못된 path로 이동 시 Not Found 창이 나타난다.', () => {
    render(<App />, { route: '/a/a' });
    expect(
      screen.getByRole('heading', { name: 'Page not found' })
    ).toBeInTheDocument();
  });
});

redux test

const initialState = {
  page: 1,
  tagList: [],
  isLoading: false,
  sortOption: 'popular',
  inName: '',
  errorMsg: '',
};

const prevState = {
  page: 3,
  tagList: [],
  isLoading: false,
  sortOption: 'name',
  inName: '',
  errorMsg: '',
};

describe('tagReducer', () => {
  it('initial state', () => {
    expect(tagReducer(undefined, { type: undefined })).toEqual(initialState);
  });

  it('changeTagPage action은 page를 변경한다.', () => {
    expect(tagReducer(initialState, changeTagPage(100))).toEqual({
      page: 100,
      tagList: [],
      isLoading: false,
      sortOption: 'popular',
      inName: '',
      errorMsg: '',
    });
  });

  it('changeTagSortOption action은 page, sortOption을 변경한다.', () => {
    expect(tagReducer(prevState, changeTagSortOption('name'))).toEqual({
      page: 1,
      tagList: [],
      isLoading: false,
      sortOption: 'name',
      inName: '',
      errorMsg: '',
    });
  });

  it('changeTagInName action은 page, sortOption, inName을 변경한다.', () => {
    expect(tagReducer(prevState, changeTagInName('sangbin'))).toEqual({
      page: 1,
      tagList: [],
      isLoading: false,
      sortOption: 'popular',
      inName: 'sangbin',
      errorMsg: '',
    });
  });

  it('getTags.pending action은 isLoading을 true로 변경한다.', () => {
    const action = getTags.pending;
    const state = tagReducer(initialState, action);
    expect(state).toEqual({
      page: 1,
      tagList: [],
      isLoading: true,
      sortOption: 'popular',
      inName: '',
      errorMsg: '',
    });
  });

  it('getTags.fulfilled action은 isLoading, tagList를 변경한다.', () => {
    const payload = [
      {
        has_synonyms: true,
        is_moderator_only: true,
        is_required: true,
        count: 999,
        name: 'javascript',
      },
    ];
    const action = getTags.fulfilled(payload, '', undefined);
    const state = tagReducer(initialState, action);
    expect(state).toEqual({
      page: 1,
      tagList: [
        {
          has_synonyms: true,
          is_moderator_only: true,
          is_required: true,
          count: 999,
          name: 'javascript',
        },
      ],
      isLoading: false,
      sortOption: 'popular',
      inName: '',
      errorMsg: '',
    });
  });
});

component test

describe('DateButton Component', () => {
  let fn: jest.Mock<any, any>;

  beforeEach(() => {
    fn = jest.fn();
    render(
      <DateButton nameList={['week', 'all']} clickedName="week" onClick={fn} />
    );
  });

  it('nameList prop의 길이에 맞게 버튼이 생성된다.', () => {
    const buttons = screen.getAllByRole('button');
    expect(buttons).toHaveLength(2);
  });

  it('clickedName과 같은 name의 버튼은 폰트 두께가 변경된다.', () => {
    const clickedButton = screen.getByRole('button', { name: 'week' });
    expect(clickedButton).toHaveStyle('font-weight: 700');
  });

  it('버튼을 클릭하면 콜백 함수가 실행된다.', () => {
    const buttons = screen.getAllByRole('button');
    userEvent.click(buttons[0]);
    expect(fn).toBeCalledTimes(1);
    userEvent.click(buttons[1]);
    expect(fn).toBeCalledTimes(2);
  });

  it('버튼을 클릭하면 콜백 함수의 인자로 name을 전달한다.', () => {
    const clickedButton = screen.getByRole('button', { name: 'week' });
    userEvent.click(clickedButton);
    expect(fn).toBeCalledWith('week');
  });
});

4. Review

4-1. 팀장

프로젝트의 팀장을 맡게 되어 팀장으로써의 역할을 잘 할수 있을까 라는 걱정을 정말 많이 한 것 같습니다.
짧은 기간 안에 프로젝트를 잘 마무리 할 수 있도록 깃헙 이슈, 칸반을 통해 일정 관리를 하였고
매일 아침 데일리 스크럼을 통해 진행 상황, 이슈 등을 공유하며 일정에 차질이 없도록 하였습니다.

다행스럽게도 팀원 모두 너무나 열심히 잘 해주셨고 팀원 모두 만족 할만한 결과물이 나올 수 있었습니다.

4-2. 깃

프로젝트 시작 전 팀원 모두가 깃에 익숙하지 않은 상황이었습니다.
주말 이틀 동안 깃 공부에만 모든 시간을 쏟아부어 깃 커맨드, 깃 브랜치 전략 등을 공부했습니다.
레포지토리에 브랜치를 수십번 만들고 삭제하며 깃에 익숙해지도록 연습했고, 저희 수준에서 가장 쉽고 효율적인 브랜치 전략을 짜서 팀원들에게 사용 방법을 설명해드렸습니다.

이러한 노력을 통해 약 2주간의 개발 기간동안 깃으로 인한 큰 이슈 없이 프로젝트를 잘 마무리 할 수 있었습니다.

4-3. 커뮤니케이션

프로젝트를 진행하며 가장 중요하다고 느낀 부분이 커뮤니케이션 입니다.
분명 회의를 통해 모두가 같은 생각을 하고 있다고 느꼈는데 막상 개발을 진행하다 보면 모두가 다른 생각을 갖고 있었던 경험이 많았습니다.

이로 인해 이미 작성한 코드를 수정하는 일이 빈번했고, 다시 입을 맞추기 위해 또 다시 회의를 하는 문제가 반복되어 생산성이 저하되는 것 같았습니다.

이러한 문제를 해결하기 위해 정규 회의 시간이 아니더라도 언제든지 궁금한 점이나 애매한 부분이 생기면 팀원 분에게 음성 통화를 걸어 그때 그때 해결하도록 했고, 회의에서 나온 내용들은 깃헙 위키와 협업 툴을 이용해 문서화 시켰습니다.

4-4. 상태 관리

이번 프로젝트에서 리덕스 툴킷을 사용하여 클라이언트 상태와 서버 상태 모두를 관리했습니다.
프로젝트를 마치고 코드를 쭉 보니, 리덕스로 관리한 전역 상태의 90%는 서버 상태 였습니다.

리덕스는 클라이언트에서 전역 상태를 관리하기 위한 라이브러리 인데, 이런식으로 서버와의 통신을 위한 서버의 상태를 관리하기 위한 용도로 사용하는 것이 맞나? 라는 생각이 들었습니다.

관련된 레퍼런스, IT 기업의 테크 블로그 등을 보니 이러한 문제점으로 인해 서버 상태는 react-query 등과 같은 라이브러리를 사용하는 것 같았습니다.

내일부터 시작되는 메인 프로젝트에서는 react-query 를 도입해서 redux 와의 차이점을 비교해보고 어떤 장단점이 있는지 직접 느껴보고 싶습니다.

profile
Developer

0개의 댓글