개발하는 너구리

저스트잇 프로젝트 본문

프로젝트

저스트잇 프로젝트

너구리개발자 2022. 12. 7. 14:21

 

 

팀프로젝트 프론트 GibHub 레퍼지토리

https://github.com/wecode-bootcamp-korea/justcode-7-2nd-Justit-front

 

GitHub - wecode-bootcamp-korea/justcode-7-2nd-Justit-front

Contribute to wecode-bootcamp-korea/justcode-7-2nd-Justit-front development by creating an account on GitHub.

github.com

 

 

Project Overview

지난번 프로젝트는 e-commerce를 경험해봤기에, 이번 프로젝트는 다른 분야를 모델링하고 싶었다.

모델링할 사이트를 찾던중 점핏이라는 채용사이트를 발견했고, 사이트 내 직무탐색 페이지의 카테고리&필터 기능을 너무 도전해보고 싶었다. 중복적용되는 필터만해도 카테고리,기술스택,지역,경력,태그로 5가지의 필터인데 이것을 어떻게 구현할지 생각만해도 머리가 띵했지만 도전해보고 싶었다. 원래 어려운것을 하나하나 풀어나가는게 제맛이 아닌가요 으ㅡ으

프로젝트 팀 회의 시간 중 다들 이 의견에 동의했고 점핏을 모델링한 사용자에게 개발자 직무정보,채용정보를 제공하는 사이트를 제작하기로했다.

 

 

React를 사용하여 프로젝트 진행하기

 - 목데이터가 아닌 백엔드 서버와 통신해 데이터를 fetch 해오는 프로젝트

 

작업 기간

2022.11.14 ~ 2022.11.25

 

기술 스택

프론트엔드 4명

 - HTML/CSS

 - SASS

 - JavaScript

 - React

 

백엔드 3명

 - Node.js

 - MySQL

 

 

저스트잇 프로젝트 전체 시연 영상

 

주요 구현 사항

🐻 표시는 내가 기여한 기능

 

 - 직무탐색 페이지 레이아웃 및 SCSS 스타일링 구현 🐻

 - 전체, 서버/백엔드, 프론트엔드, 웹 풀스택, 안드로이드, IOS 총 6개의 카테고리 기능 구현 🐻

 - Java, SpringBoot, Node.js, Python, Django, MySQL 등 총 27개의 기술스택 필터 기능 구현 🐻

 - 4.5일제, 재택특무, 유연근무제, 시차출근제 등 총 13개의 태그 필터 기능 구현 🐻

 - 전체, 서울, 분당, 마포, 인천 등 총 9개의 지역 필터 기능 구현 🐻

 - 전체, 신입, 경력1~10년 등 career 필터 기능 구현(해당 년수의 career필터 선택시 경력요구사항 범위 내에 포함된 결과만 렌더링) 🐻

 - '필터 더보기' 팝업 창 내에서 기술스택 검색기능 구현 및 검색결과를 필터기능으로 연결 🐻

 - 총 5개의 카테고리&필터 각각 useSearchParams를 활용한 쿼리스트링 기능 구현 🐻

 - Reset버튼(초기화) 기능 구현 🐻

 

 

 

카테고리 기능 구현

const [btnActive, setBtnActive] = useState([]); //카테고리
let [searchParams, setSearchParams] = useSearchParams(); //쿼리스트링

const toggleActive = e => {
    const currentQuery = e.target.dataset.query.toString();
    if (btnActive.filter(ele => ele == e.target.value).length > 0) {
      setBtnActive(btnActive.filter(el => el != e.target.value));
      //filter메소드를 활용해 활성화된 버튼은 재클릭시 비활성으로 변경
      removeParams('position', currentQuery);
      setSearchParams(searchParams);
    } else {
      setBtnActive(prev => [...prev, e.target.value]);
      //spread를 활용해 여러개의 카테고리 동시 적용가능하게 작성
      searchParams.append('position', currentQuery);
      setSearchParams(searchParams);
    }
  };
  
  <section className="cateContainer">
   <div className="cateBtn">
    <button className={btnActive.length > 0 ? '' : ' active'} onClick={handleTechOff}>
     전체</button>
     {cateData.map((item, idx) => {
       return (
         <button type="button" key={item.id} value={idx}
           className={
           item.className +
           (btnActive.filter(el => el == idx).length > 0 ? ' active' : '')}
           //카테고리가 클릭이 된다면 클래스네임에 ' active'를 붙여 css를 활용해 색상변경
           data-query={item.id}
           onClick={toggleActive}>
         {item.text} </button>);
      })}
    </div>
  </section>
  
  //SCSS
  &.active {
  background-color: rgb(0, 221, 109);
  color: rgb(255, 255, 255);
  border: 1px rgb(0, 221, 109) solid;
  font-weight: 700;
  }

코드핵심

 - 클래스네임을 변경해 해당 카테고리 클릭 시 활성화된 것처럼 표현

 - spread를 활용해 여러개의 카테고리 동시적용 가능

 - 배열메소드 filter를 이용해 활성화된 카테고리 재 클릭시 비활성으로 변경

 

 

 

 

 

 

기술스택 필터 기능

const [techBtnActive, setTechBtnActive] = useState([]); //기술스택
let [searchParams, setSearchParams] = useSearchParams(); //쿼리스트링
const [filterCount, setFilterCount] = useState(0); //적용필터갯수

  const toggleTechActive = e => {
    const currentQuery = e.target.dataset.query.toString();
    if (techBtnActive.filter(ele => ele == e.target.value).length > 0) {
      setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
      //배열메소드 filter를 활용한 활성필터 -> 비활성필터 변경
      setFilterCount(prev => prev - 1); //필터 비활성시 적용된 필터갯수 -1
      removeParams('techStack', currentQuery);
      setSearchParams(searchParams);
    } else {
      setTechBtnActive(prev => [...prev, e.target.value]);
      //spread를 활용한 여러개 기술스택 필터 동시적용 가능
      setFilterCount(prev => prev + 1);
      searchParams.append('techStack', currentQuery);
      setSearchParams(searchParams);
    }
  };
  
 <div className="techContainer">
 {mockTech
   .filter(el => techResult.some(i => i == el.id))
   .map((item, idx) => {
     return (
       <button
         type="button"
         key={item.id}
         value={item.id}
         className={item.className +
          (techBtnActive.filter(el => el == item.id).length > 0 ? ' active' : '')}
          //클래스네임 변경으로 필터 활성화 시 색상변경
         data-query={item.value}
         onClick={toggleTechActive}
        >
          <img src={item.src} width="20px" />
          {item.text}
        </button>
      );
    })}
</div>

//SCSS
  &.active {
    background-color: rgb(0, 221, 109);
    color: rgb(255, 255, 255);
    border: 1px rgb(0, 221, 109) solid;
    font-weight: 500;
  }

 

 

 

 

 

 

 

태그 필터

  const scrollRef = useRef();
  const [scrollX, setScrollX] = useState(0); //좌우슬라이더의 현재 위치
  const [maxScrollX, setMaxScrollX] = useState(); //슬라이더의 이동가능 최대위치
  
  const leftMove = () => {
    scrollRef.current.scrollTo({
      left: scrollRef.current.scrollLeft - 400, 
      //왼쪽버튼 클릭마다 scrollLeft값에 마이너스 값을 주어 슬라이드가 왼쪽으로 이동하게끔 구현
      behavior: 'smooth',
    });
  };

  const rightMove = () => {
    scrollRef.current.scrollTo({
      left: scrollRef.current.scrollLeft + 400,
      //오른쪽버튼 클릭마다 scrollLeft값에 플러스 값을 주어 슬라이드가 오른쪽으로 이동하게끔 구현
      behavior: 'smooth',
    });
  };
  
    useEffect(() => {
    scrollRef.current.addEventListener('scroll', () => {
      setScrollX(Math.ceil(scrollRef.current.scrollLeft));
      setMaxScrollX(
        scrollRef.current.scrollWidth - scrollRef.current.clientWidth
      );
    });
  }); //useEffect Hook을 활용해 좌우이동 버튼을 조건충족시 즉각적으로 사라지게끔 구현
  
  const toggleActive = e => {
    const currentQuery = e.target.dataset.query.toString();
    if (tagBtnActive.filter(ele => ele == e.target.value).length > 0) {
      setTagBtnActive(tagBtnActive.filter(el => el != e.target.value));
      //filter메소드를 이용해 활성필터 -> 비활성필터로 변경
      removeParams('tag', currentQuery);
      setSearchParams(searchParams);
    } else {
      setTagBtnActive(prev => [...prev, e.target.value]);
      searchParams.append('tag', currentQuery);
      setSearchParams(searchParams);
    }
  };
  
{scrollX != 0 ? ( <button className="prevBtn" onClick={leftMove}></button>) : null}
//scrollX의 값 즉 좌우슬라이드의 위치가 맨 처음이면 왼쪽 이동버튼을 사라지게끔 구현
{scrollX == maxScrollX ? null : (
<button className="nextBtn" onClick={rightMove}></button>)}
//좌우슬라이드의 위치가 maxScrollX라면 즉, 마지막이라면 오른쪽 이동버튼을 사라지게끔 구현
  
        <div className="tagContainer" ref={scrollRef}>
        {tagData.map((item, idx) => {
          return (
            <button
              type="button"
              key={item.id}
              value={idx}
              className={
                item.className +
                (tagBtnActive.filter(el => el == idx).length > 0
                  ? ' active'
                  : '')
                  //태그 필터 활성화시 클레스네임 변경으로 활성화 스타일링 구현
              }
              data-query={item.filter}
              onClick={toggleActive}
            >
              {item.icon}
              {item.text}
            </button>
          );
        })}
      </div>
      
 
 //SCSS
 &.active {
  color: rgb(255, 255, 255);
  font-weight: 500;
  background-color: rgb(61, 61, 61);
 }

 

 

 

 

 

지역 필터

const [locaBtnActive, setLocaBtnActive] = useState('전체'); //지역
const [filterCount, setFilterCount] = useState(0); //적용필터갯수

  const handleLoca = e => {
    const currentQuery = e.target.dataset.query.toString();
    setLocaBtnActive(e.target.value);
    searchParams.set('location', currentQuery);
    setSearchParams(searchParams);

    if (e.target.value == '전체') {
      locaBtnActive == '전체'
        ? setFilterCount(prev => prev)
        : setFilterCount(prev => prev - 1);
    } else {
      locaBtnActive == '전체'
        ? setFilterCount(prev => prev + 1)
        : setFilterCount(prev => prev);
    } //지역필터에서 '전체'는 필터갯수에 포함이 안되기때문에 제거하기위한 로직
  };
  
  const handleLocaTotal = e => {
    setLocaBtnActive(e.target.value);
    searchParams.delete('location');
    setSearchParams(searchParams);
  };
  
<label>  
 <input
   type="radio" 
   name="location"
   value="전체"
   checked={locaBtnActive == '전체'}
   onChange={handleLocaTotal}>
 </input>
  전체
</label>

 

 

 

 

 

Career 필터

const [careerBox, setCareerBox] = useState(false);
 
  const handleCareerBtn = e => {
    setCareerBtnActive(e.target.value);
    const currentQuery = e.target.dataset.query.toString();
    searchParams.set('career', currentQuery);
    setSearchParams(searchParams);
  };
  const handleCareerTotal = e => {
    setCareerBtnActive(e.target.value);
    searchParams.delete('career');
    setSearchParams(searchParams);
  };
  const handleCareerReset = () => {
    setCareerBtnActive('전체');
    searchParams.delete('career');
    setSearchParams(searchParams);
  };
  
  
<label>
  <input
    type="radio"
    name="career"
    value="전체"
    checked={careerBtnActive == '전체'}
    onChange={handleCareerTotal}
  />
  전체
</label>


<div className="careerReset">
  <button onClick={handleCareerReset}>초기화</button>
</div>

 

 

 

 

 

기술스택 검색기능 구현 및 검색결과를 필터기능으로 연결

  const [techOnClick, setTechOnClick] = useState(false); //기술스택 필터의 활성,비활성 상태
  const [techOnClickId, setTechOnClickId] = useState([]); //활성화된 기술스택 필터의 id값  
  const [search, setSearch] = useState(''); //검색창에 입력된 검색어 저장
  
  const handleTechOn = e => {
    const currentQuery = e.target.dataset.query.toString();
    setTechOnClickId(prev => [...prev, e.target.value]);
    //spread를 이용해 여러개의 활성화된 기술스택 필터의 id값 저장
    setTechBtnActive(prev => [...prev, e.target.value]);
    //spread를 이용해 여러개의 기술스택 필터 활성화
    setTechOnClick(true);
    setSearch('');
    setFilterCount(prev => prev + 1);
    searchParams.append('techStack', currentQuery);
    setSearchParams(searchParams);
  };
  
  
  const handleTechOff = e => {
    const currentQuery = e.target.dataset.query.toString();
    setTechOnClickId(techOnClickId.filter(el => el != e.target.value));
    //비활성시킨 기술스택 필터의 id값 기준으로 filter메소드로 필터 제거
    setTechOnClick(techOnClickId.length > 1 ? true : false);
    setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
    setFilterCount(prev => prev - 1);
    removeParams('techStack', currentQuery);
    setSearchParams(searchParams);
  };
  
  const onChangeSearch = e => {
    setSearch(e.target.value);
  };
  
  const filterTitle = mockTech.filter(p => {
    return p.text.toLocaleLowerCase().includes(search.toLocaleLowerCase());
    //search state에 저장된 검색어를 includes메소드 이용해 mockTech데이터에서 해당 기술스택 검색
  });
  
  
{search ? (
 <div className="techSearchResult">
   <ul>
     {filterTitle.map(mockTech => (
       <li
         key={mockTech.id}
         value={mockTech.id}
         onClick={handleTechOn}
         data-query={mockTech.text}
       >
         <img
           src={mockTech.src}
           alt="필터아이콘"
           width="20px"
         />
         {mockTech.text}
       </li>
      ))}
    </ul>
  </div>
 ) : null}

 

 

 

 

 

 

 useSearchParams를 활용한 쿼리스트링 기능

let [searchParams, setSearchParams] = useSearchParams();
const [postData, setPostData] = useState([]); //DB데이터

 useEffect(() => {
    fetch(`http://localhost:8000/posts?${searchParams}`, {
      method: 'GET',
    })
      .then(res => res.json())
      .then(result => setPostData(result.result));
  }, [searchParams]);
  
  const removeParams = (key, value) => {
    const allParams = searchParams.getAll(key);
    //현재 쿼리스트링에 저장된 Params를 배열로 가져온 뒤
    allParams.splice(allParams.indexOf(value), 1); 
    //지우고자하는 params값을 spilce메소드로 배열에서 삭제
    searchParams.delete(key);
    //params 초기화
    
    allParams.forEach(item => {
      searchParams.append(key, item);
    }); //반복문을 이용해 지우고 남은 값으로 새로운 Params 세팅    
  };
  
  
  const toggleActive = e => {
    const currentQuery = e.target.dataset.query.toString();
    if (btnActive.filter(ele => ele == e.target.value).length > 0) {
      setBtnActive(btnActive.filter(el => el != e.target.value));
      removeParams('position', currentQuery);
      //만들어놓은 params제거함수를 이용해 해당 쿼리스트링제거
      setSearchParams(searchParams);
    } else {
      setBtnActive(prev => [...prev, e.target.value]);
      searchParams.append('position', currentQuery);
      setSearchParams(searchParams);
    }
  };
  
  const toggleTechActive = e => {
    const currentQuery = e.target.dataset.query.toString();
    if (techBtnActive.filter(ele => ele == e.target.value).length > 0) {
      setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
      setFilterCount(prev => prev - 1);
      removeParams('techStack', currentQuery);
      //만들어놓은 params제거함수를 이용해 해당 쿼리스트링제거
      setSearchParams(searchParams);
    } else {
      setTechBtnActive(prev => [...prev, e.target.value]);
      setFilterCount(prev => prev + 1);
      searchParams.append('techStack', currentQuery);
      setSearchParams(searchParams);
    }
  };

 

 

 

 

 

Project Review

이번 프로젝트는 기능구현에 집중할 수 있었던 프로젝트였다. 이 점이 너무 좋았다. 지난번 프로젝트에선 기능구현보단 상세페이지의 디테일에 더 중점을 두었기 때문에 많은 기능구현을 못해봤어서 이번 프로젝트를 진행하면서 구현해야할 많은 기능들에 처음에는 숨이 턱 막혀왔지만 하나하나 풀어나가는 내 자신에 대해 스스로 칭찬도 했다..여러가지의 필터들의 기능을 구현하는데 많은 노력과 시간이 들었지만, 하나하나 필터들의 기능이 정상 작동할때마다 신나서 방방뛰었다..

지난번 프로젝트에서 제대로 경험해보지 못했던 쿼리스트링에 대해서 많은 공부를 할수 있었고, 그 영향일까 이제는 웹서핑을 하다가 상단에 떠있는 URL들이 반가웠다. 아.. 이 사이트는 쿼리스트링을 이렇게 사용하겠구나.. 아니까 보인다랄까?..ㅎ

특히나 기본중에 기본이라고 할수 있는 JavaScript에 대한 공부도 더 심도있게 할수있던 계기였다. 필터들의 기능을 구현하면서 많은 메소드들을 사용했고 이제는 JS에 익숙하다고 착각했던 내 자신에 대한 반성을 했다. JS실력이 너무나도 중요하다! JS를 더더더 공부해야겠다는 뼈저린 경험을 했다.

 

JS!! JS!! JS!!! 공부하고 또 공부하자!

 

'프로젝트' 카테고리의 다른 글

구멍마켓 프로젝트  (0) 2022.11.14