:ledger: React로 올림픽 메달 집계 CRUD를 구현해보자!

스파르타 코딩클럽 본 캠프 React 개인 과제 입니다.

:one: 과제 개요

  1. 제출 폼 UI 구현하기
    • 나라 이름과 금, 은, 동 메달 수를 입력할 수 있는 폼을 만듭니다.
  2. 메달 집계 CRUD 구현하기
    • Create: 새로운 나라와 그 나라가 획득한 메달 수를 추가합니다.
    • Read: 나라별 메달 집계 리스트를 보여줍니다.
    • Update: 기존에 추가된 나라의 메달 수를 수정할 수 있습니다.
    • Delete: 나라 정보를 삭제할 수 있습니다.
  3. 정렬
    • 메달 집계는 금메달 수를 기준으로 내림차순 정렬되어야 합니다.

:two: 요구사항

:pushpin: 2-1) 필수구현사항

  1. 나라 이름과 메달 수를 입력하고, [추가하기] 버튼을 클릭하면 메달 집계에 새로운 나라가 추가되고, input 필드는 다시 빈 값으로 바뀌도록 구성해주세요.
  2. 업데이트 기능 구현하기
    • 국가 이름과 메달 수를 입력하고, [업데이트] 버튼을 클릭하면 이미 추가된 국가의 메달 수를 수정합니다.
  3. 메달 리스트는 금메달 수를 기준으로 내림차순 정렬하여 화면에 표시됩니다.
  4. Layout의 최대 넓이는 1200px, 최소 넓이는 800px로 제한하고, 전체 화면의 가운데로 정렬해주세요.
  5. 컴포넌트 구조는 자유롭게 구현해보세요.
    • 반복되는 컴포넌트를 찾아서, 직접 컴포넌트를 분리해보시고, 분리한 컴포넌트를 README에 작성합니다.

:pushpin: 2-2) 도전과제

  1. 나라 이름을 입력했을 때 이미 등록된 국가라면 alert 메시지를 띄워 사용자에게 알립니다.
  2. 입력된 국가가 등록되지 않은 경우 alert 메시지를 띄워 사용자에게 알립니다.
  3. 금메달로만 순위를 결정하면 너무 섭섭하죠? 메달 총 개수도 함께 보여주도록 하고 금메달이 아닌, 획득한 메달 총 개수로 정렬을 할 수 있게 해봅시다.
  4. 매번 어플리케이션을 새로고침 할 때마다 데이터가 다 날아가서 속상하죠? 로컬스토리지에 입력한 정보를 항상 업데이트 되게끔하고 메달 정보에 관한 useState 의 초기값을 항상 로컬스토리지에서 가져오도록 해보세요.

:pushpin: 2-3) 사용한 기술

  • react.js, vite, jsx, yarn

:three: 폴더 구조

📦src
┣ 📂assets
┃ ┗ 📜react.svg
┣ 📂components
┃ ┣ 📜Button.jsx
┃ ┣ 📜Input.jsx
┃ ┗ 📜Table.jsx
┣ 📂pages
┃ ┗ 📜Olympic.jsx
┣ 📜App.css
┣ 📜App.jsx
┣ 📜index.css
┗ 📜main.jsx

:four: 기능 설명

  1. 페이지와 컴포넌트를 분리해주었습니다.
    • components
    • Button.jsx
    • Input.jsx
    • Table.jsx - pages
    • Olympic.jsx
  2. 각 컴포넌트의 역할
    • Buttons.jsx
    • 공통으로 사용되는 Button으로 사용자 인터페이스에서 버튼을 랜더링합니다. props를 통해 속성을 설정할 수 있습니다. - Input.jsx
    • 공통으로 사용되는 input과 label로 구성되어 사용자 인터페이스에서 랜더링합니다. props를 통해 속성을 설정할 수 있습니다. - Table.jsx
    • props으로 전달받은 items를 정렬하여 테이블 형식으로 랜더링합니다.
    • 데이터가 없을 경우 “등록된 메달이 없습니다” 문구 노출
    • 이 페이지도 Button,Input 컴포넌트와 같이 공통으로 사용할 수 있게 수정중입니다. - Olympic.jsx
    • 전체 페이지의 레이아웃을 구성하고 국가 정보 입력 및 관리하며 테이블에서 국가 목록을 랜더링합니다.
      • 입력값 상태 관리: 국가명과 메달 수를 상태로 관리합니다.
      • 국가 추가: 새로운 국가를 추가합니다.
      • 국가 업데이트: 기존 국가의 메달 수를 업데이트 합니다.
      • 국가 삭제: 국가를 목록에서 삭제합니다.
      • 국가 초기화: 입력 필드를 초기화합니다.

:five: 적용 코드 풀이 01 (Olympic.jsx)

:pushpin: 5-1) 상태값 설정

리엑트를 공부하며 변경되는 값들은 useState를 사용하여 국가이름,메달(금,은,동),국가 데이터를 저장할 countrys를 정의해주었다.

// 입력값 세팅
// const countryId = document.getElementById("countryId");
const [countrysName, setCountrysName] = useState("");
const [countryMedalGold, setCountryMedalGold] = useState(0);
const [countryMedalSilver, setCountryMedalSilver] = useState(0);
const [countryMedalBronze, setCountryMedalBronze] = useState(0);

// 국가목록 세팅
const [countrys, setCountrys] = useState([]);

:pushpin: 5-2) 국가 추가 함수 생성

처음 UseStat로 값을 지정 후 데이터가 랜더링되는 List 먼저 작성했다가 생각해보니 현재 데이터가 없는데 어떻게 리스트를 랜더링하지? 하며 고민하다가 국가 추가를 해야되는구나! 라고 생각하기 까지 조금 오래 걸린 것 같다. 처음 some함수를 사용하지 않고 findIndex를 사용하여 -1(false), 해당 인덱스 배열(true)값으로 적용햇는데, 튜터님의 피드백 이후 이전에 공부했던 some함수를 사용하면 좋을 것 같아서 변경해주었다. 국가 추가함수에는 아래의 기능이 들어가있다.

  • Input에 값이 없을 경우 alert으로 “국가명이 입력되지 않았습니다” 출력 및 함수 종료
  • isCountry는 some 함수를 사용하여 country.countrysName 즉, 국가 목록 중 이름값과 countrysName.trim() 공백을 제거한 input의 value값을 비교하여 true, false를 반환해준다.
  • if문을 사용하여 같은 값이 없을 경우(isCountry) newCountry 변수에 객체로 아이디,입력한 국가명,금메달,은메달,동메달을 할당해주고 setCountrys를 통해 기존 countrys 배열을 원본을 지켜주며 newCountry에 저장된 객체를 넣어준다.
  • 만약 같은 값이 있을 경우 alert으로 “이미 등록된 국가입니다. 업데이트를 원하시면 업데이트 버튼을 클릭해주세요.” 출력한다.
    // 국가 추가
    const addCountryHanlder = () => {
    if (countrysName.trim() === "" || countrysName === "undefined") {
      alert("국가명이 입력되지 않았습니다.");
      return false;
    }
    
    const isCountry = countrys.some(country => country.countrysName === countrysName.trim());
    if (!isCountry) {
      const newCountry = {
        id: new Date().getTime(), // uuid : 공부 (고유값)
        countrysName,
        countryMedalGold,
        countryMedalSilver,
        countryMedalBronze,
      }
      setCountrys([...countrys, newCountry]);
    } else {
      alert("이미 등록된 국가입니다. 업데이트를 원하시면 업데이트 버튼을 클릭해주세요.");
    }
    // 값 초기화 해줌
    resetCountryHandler();
    }
    

    :pushpin: 5-3) 국가 업데이트 함수

    국가 추가 함수 이후 값이 잘 노출되는걸 확인 후 업데이트 함수도 만들어 주었다. 업데이트 함수에서 어려웠던 부분은 중복된 국가명이 있을 경우 국가명과 아이디값은 변경이 안되고 금,은,동메달만 변경해주어야 하는 부분을 어떻게 처리해야할까란 고민이 많았는데, 이 부분은 이전 구조분해할당에서 배웠던것이 생각이나서 적용을 해주었다. 해당 함수의 기능은 아래와 같다.

  • 국가명, 금,은,동메달을 입력 후 업데이트 버튼을 클릭했을 때, 해당 국가명이 이미 있는경우 금,은,동메달을 변경해줘야한다.
  • 업데이트가 완료될 경우 값을 전달하기 위해 updateChk란 변수를 boolean으로 생성함
  • 국가명이 입력되지 않으면 alert으로 “국가명이 입력되지 않았습니다.” 출력
  • 이후 countrys(국가목록)을 map메서드로 순회하여 if(입력한 국가명이 countrys에 있는 경우) updateChk를 true로 변경, ..country (원본 객체), 변경한 금,은,동메달을 return 해주고 해당 객체를 updateCountrys 로 return 해주었다.
  • 변경해줘야할 객체가 있는 경우 setCountrys(updateCountrys)로 변경된 값을 적용해주었다.
// 국가 업데이트
  const updateCountryHanlder = () => {
    let updateChk = false;
    if (countrysName === "" || countrysName === "undefined") {
      alert("국가명이 입력되지 않았습니다.");
      return false;
    }
    const updateCountrys = countrys.map(country => {
      if (country.countrysName === countrysName) {
        updateChk = true;
        return {
          ...country,
          countryMedalGold,
          countryMedalSilver,
          countryMedalBronze,
        };
      }
      return country;
    })

    if (!updateChk) alert("현재 없는 국가입니다. 국가명을 정확히 입력해주세요.");
    else setCountrys(updateCountrys);

    resetCountryHandler();
  }

:pushpin: 5-4) 국가 삭제 함수

삭제 함수도 다행히 기존에 봤던 강의가 생각나서 처음에는 복사 붙여넣기를 하고 이후에 코드를 다시 분석해보았다.

  • setCountrys함수를 통해 filter 메서드로 걸러낸 새로운 배열을 setCountrys를 통해 countrys 상태로 설정한다
  • filter 메서드는 배열을 순회하며 각 요소를 조건에 따라 걸러내는데, country.id !== id 즉, 클릭한 id값과 일치하지 않은 것들은 다시 countrys 배열에 담아주어 일치한 것만 담지 않기에 삭제 기능을 수행한다.
    const deleteCountryHanlder = (id) => {
    setCountrys(countrys.filter(country => country.id !== id));
    }
    

:pushpin: 5-5) 국가 리스트 컴포넌트

해당 컴포넌트는 국가의 메달 정보를 테이블의 한 행으로 랜더링하고 해당 국가의 데이터를 삭제할 수 있는 버튼을 넣어줬다. 해당 기능은 아래와 같다.

  • 해당 컴포넌트는 country(국가 메달 정보 객체)와 onDelete(특정 국가 삭제 함수)를 props으로 전달받는다.
  • td 태그에 국가명,금메달,은메달,동메달을 표시해주었다.
    const Country = ({ country, onDelete }) => {
      return (
        <tr>
          <td>{country.countrysName}</td>
          <td>{country.countryMedalGold}</td>
          <td>{country.countryMedalSilver}</td>
          <td>{country.countryMedalBronze}</td>
          <td>
            <button type='button' className='delBtn' onClick={() => onDelete(country.id)}>삭제</button>
          </td>
        </tr>
      );
    }
    

:pushpin: 5-6) 입력값 초기화 함수

국가 추가, 업데이트 버튼을 클릭 시 input의 value값을 초기화 해주기 위해 생성한 함수이다. 해당 함수는 추가,업데이트 함수가 실행되고 마지막에 실행되어 초기화 기능을 수행한다.

function resetCountryHandler() {
  setCountrysName("");
  setCountryMedalGold(0);
  setCountryMedalSilver(0);
  setCountryMedalBronze(0);
}

:pushpin: 5-7) HTML 영역

아래는 최종적으로 올림픽 메달 집계 UI를 구성되어있다. 입력 폼과 버튼,테이블을 각각의 컴포넌트로 나누어 재사용성이 가능하게 작성하였다.

<div className='olympicArea'>
  <h1>PARIS 2024</h1>
  <div className='flexBox'>
    <div className="writeBox">
      <div className="inputArea">
        <div className="inputBox">
          <Input
            title="국가명"
            type="text"
            placeholder="국가 입력"
            value={countrysName}
            onChangeFunc={(e) => setCountrysName(e.target.value)}
          />
        </div>
        <div className="inputBox">
          <Input
            title="금메달"
            type="number"
            placeholder="0"
            value={countryMedalGold}
            onChangeFunc={(e) => setCountryMedalGold(e.target.value)}
          />
        </div>
        <div className="inputBox">
          <Input
            title="은메달"
            type="number"
            placeholder="0"
            value={countryMedalSilver}
            onChangeFunc={(e) => setCountryMedalSilver(e.target.value)}
          />
        </div>
        <div className="inputBox">
          <Input
            title="동메달"
            type="number"
            placeholder="0"
            value={countryMedalBronze}
            onChangeFunc={(e) => setCountryMedalBronze(e.target.value)}
          />
        </div>
      </div>
      <div className="btnArea">
        <Button
          title="국가추가"
          type="button"
          className="addBtn"
          clickFunc={addCountryHanlder}
        />
        <Button
          title="업데이트"
          type="button"
          className="updateBtn"
          clickFunc={updateCountryHanlder}
        />
      </div>
    </div>
    <div className="tableWrap">
      <Table
        tableProps={tableProps}
        items={countrys}
        List={Country}
        onDelete={deleteCountryHanlder}
      />
    </div>
  </div>
</div>

:six: 컴포넌트

:pushpin: 6-1) Button 컴포넌트

Button 컴포넌트는 재사용이 가능한 버튼을 정의했으며, props로 4개의 인자를 전달받는다. 이후 추가될 사항은 props를 추가하여 적용할 예정이다. 개인적으로 개선해야될 부분은 clickFunc 네이밍을 onEvent로 변경하여 다른 이벤트가 들어올 수 있게 변경하는 것이 좋을 것 같다란 생각이든다.

const Button = ({ title, type, className, clickFunc }) => {
  return (
    <button
      type={type}
      title={title}
      className={className}
      onClick={clickFunc}
    >{title}</button>
  )
}

export default Button

:pushpin: 6-2) Input 컴포넌트

Input 컴포넌트는 재사용이 가능한 인풋과 라벨을 정의했으며, props로 5개의 인자를 전달받는다. 이 컴포넌트도 onChangeFunc 네이밍을 클릭,체인지 등 다른 이벤트가 들어올 수 있을거라 생각이 들어 onEvent로 하면 좋지 않을까란 생각이 든다.

const Input = ({ title, type, placeholder, value, onChangeFunc }) => {
  return (
    <>
      <label>{title}</label>
      <input
        type={type}
        placeholder={placeholder}
        value={value}
        onChange={onChangeFunc}
      />
    </>
  )
}

export default Input

:pushpin: 6-3) Table 컴포넌트

Table 컴포넌트도 재사용이 가능하게 사용하려면 sortedItems 도 if문으로 items 이 countrys일 경우 추가해줘야할 것 같고 tr = tableProps 부분도 가능하다면 map 메서드를 사용하여 전달받은 객체의 수에 맞춰 랜더링하면 좋지 않을까란 생각이든다.

const Table = ({
  tableProps,
  items,
  List,
  onDelete,
}) => {
  // 메달 순위 측정
  const sortedItems = items.sort((a, b) => {
    if (b.countryMedalGold !== a.countryMedalGold) {
      return b.countryMedalGold - a.countryMedalGold;
    }
    if (b.countryMedalSilver !== a.countryMedalSilver) {
      return b.countryMedalSilver - a.countryMedalSilver;
    }
    return b.countryMedalBronze - a.countryMedalBronze;
  });

  return (
    <table>
      <thead>
        <tr>
          <th>{tableProps.th_01}</th>
          <th>{tableProps.th_02}</th>
          <th>{tableProps.th_03}</th>
          <th>{tableProps.th_04}</th>
          <th>{tableProps.th_05}</th>
        </tr>
      </thead>
      <tbody>
        {
          sortedItems.length === 0 ? (
            <tr>
              <td colSpan="5">등록된 메달이 없습니다.</td>
            </tr>
          ) : (
            // countrys 의 메달 값을 비교한 후 로직 실행
            sortedItems.map(country => {
              return (
                <List
                  key={country.id}
                  country={country}
                  onDelete={onDelete}
                />
              )
            })
          )
        }
      </tbody>
    </table>
  )
}

export default Table

:fire: 마무리

이로써 첫 번째 리액트 개인 과제가 마무리 되었다. 1주 동안 짧으면 짧고 길면 긴데 공부하고 과제를 진행하며 어려움이 많았지만 결과적으로 리액트를 전혀 다루지 못했던 1주일 전을 생각하면 그래도 많은 성장이 있지 않을까 싶다.. 이번 과제를 통해 리엑트의 useState, props, component에 대해 알 수 있었는데 처음 컴포넌트의 개념을 잘 이해하지 못해서 모든 요소를 다 컴포넌트로 만들었던 순간이 있었다. 피드백 이후 Button,Input,Table의 컴포넌트로 수정해서 적용했는데 막상 TIL을 작성하며 다시보니 아직 수정할 것이 많다는것…! 그리고 과제를 진행하며 useState와 반복되는 코드를 어떻게 축약하고 유지보수가 쉽게 작성하는 방법?에 대해 너무 어려웠던 것 같다. 이런 부분을 개선하면 다음 리액트 프로젝트에서 조금 더 나은 코드를 작성할 수 있을 수도… 8/14 오늘은 과제를 재 제출하는 날인데, 리팩토링을 통해 조금 더 개선해서 제출할 수 있도록 노력해봐야겠다.

:pushpin: 링크