Will find a way

상태관리 : 약관동의 구현 (모든 약관, 필수약관) / TypeScript, JS 메서드 본문

FrontEnd/React

상태관리 : 약관동의 구현 (모든 약관, 필수약관) / TypeScript, JS 메서드

Jaka_Park 2025. 5. 6. 17:27

약관동의 구현 (모든 약관, 필수약관)

React + Typescript 회원가입 기능을 구현하고 있다.

그 중에 하나인 회원가입 전 약관동의를 하는 페이지를 만들었다.

그래서 내가 한 방법을 공유해보려고 한다.

 

원하던 것

- 모든약관 동의(체크)하면 필수/선택 약관 모두 체크 박스 체크

- 이 중 하나의 약관만 체크가 해제되면 모든 약관 해제

- 모든 약관 + 필수 약관 둘중 하나만 조건 충족되면 다음 페이지 활성화

 

기능구현

1. 체크박스가 여러개 쓰일 것을 고려하여 컴포넌트로 제작

CheckBox.tsx

type CheckBoxType = {
  id: string;
  className?: string;
  checked: boolean;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

const CheckBox = ({ id, className, checked, onChange }: CheckBoxType) => {
  // props로 id, className, check 상태, check의 변화를 주는 함수를 받음
  return (
      <input 
        id={id} 
        name={id} 
        type="checkbox"
        checked={checked}
        onChange={onChange}
        className={`accent-[#2c2c2c] bg-[#dedede] ${className}`}
      />
    )
  ;
};

export default CheckBox;

 

2. input(checkbox)가 있다면 label도 만들어야함

label 기능을 하는 컴포넌트 제작

TermsLabel.tsx

// 실제 작업할 때는 타입을 관리하는 파일을 따로 별도로 관리했다.
// 코드의 이해를 돕기위해서 타입을 다시 써본다.
type CheckBoxType = {
  id:string;
  className?: string;
  checked: boolean;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

import CheckBox from './CheckBox';

const TermsLabel = ({
  id,
  checked,
  onChange,
  label,
}: CheckBox & { label: string } ) => {
  return (
    <label htmlFor={id}>
      <CheckBox 
        id={id} 
        checked={checked}
        onChange={onChange} />
        &nbsp;&nbsp;{label}
    </label>
  );
  
  export default TermsLabel;
}

 

새로 알게된 것

const TermsLabel = ({ id, checked, onChange, label, }: CheckBox & { label: string } ...
타입을 2개 이상 적용할 때 & 로 적용

 

3. 약관 항목이 여러개가 있어서

ul, li와 map을 이용하여 순회하면서 출력할 컴포넌트 제작

TermsUl.tsx

import TermsLabel from './TermsLabel';

const TermsUl = ({
  termsText,
  checked,
  onCheckChange,
  isAllChecked
}: {
  termsText: object;
  checked: Record<string, boolean>;
  isAllChecked: boolean;
  onCheckChange: {id: string} => void;
}) => {
  const tersArr = Object.entries(termsText);
  
  return (
    <ul className="flex flex-col gap-y-1">
      {termsArr.map(([key, label]) => (
        <li className="flex justify-between" key={key}>
          <div>
            <TermsLabel 
              id={key}
              checked={isAllChecked || checked[key]}
              onChange={() => onCheckChange(key)}
              label={label}
            />
            <div className="text[#777] cursor-pointer>보기</div>
          </div>
        </li>
      ))}
    </ul>
  );
  
  export default TermsUl;
};

 

 

Typescript

Record<keys, values>

-> 객체의 key와 value 의 타입을 지정

( Record<string, boolean> : 객체의 key 타입 string, value 타입 boolean)
ex) { 'age' : true }

 

Javascript

|| 논리 연산자

checked={ isAllChecked || checked[key] }
왼쪽 값이 falsy 면 오른쪽 값을 반환 / 전체체크가 true면 왼쪽 반환을 함

 

Object.entries()

const termsText = {
  age: '[필수] 만 14세 이상입니다.',
  terms: '[필수] 이용약관 동의',
};

Object.entries(termsText);

결과
[ ['age', '[필수] 만 14세 이상입니다.'], ['terms', '[필수] 이용약관 동의'] ]

 

배열의 구조분해 할당

{termsArr.map(([key, label]) => ...

- 'age' -> key
- '[필수] 만 14세 이상입니다.' -> label

 

4. 전체 페이지 (상단 레이아웃)

TermsOfService.tsx

import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import TermsUl from './Termsul';
import CheckBox from './CheckBox';
import Button from '../../components/Button';

const TermsOfService = () => {
  const termsText = {
    age: '[필수] 만 14세 이상입니다.',
    terms: '[필수] 이용약관 동의',
    privacy: '[필수] 개인정보 수집 및 이용 동의',
    paidContent: '[필수] 유료작품 게시약관 동의',
    paidUse: '[필수] 유료이용약관 동의',
    thirdParty: '[선택] 개인정보 . 제 3자 제공 동의',
    marketing: '[선택] 마케팅 정보 수신 동의',
  };
  
  const [checks, setChecks] = useState<Record<string, boolean>>()
      Object.keys(termsText).reduce(
        (acc, key) => {
          return { ...acc, [key]: false };
        },
        {} as Record<string, boolean>, // type 지정 (안되면 acc의 타입이 추론이 되지 않아 eslint 에러를 띄움)
      ),
    );
    
    // 모든 항목이 체크됐는지 확인 (모두 동의 여부)
    const isAllChecked = Object.values(checks).every(Boolean);
    
    // 모든 동의 체크박스의 onChange 핸들러
    const handleAllCheck = (e: React.ChangeEvent<HTMLInputElement>) => {
      const newChecked = e.target.checked; // 체크 여부 (true/false) 추출
      
      // 모든 약관 체크 상태를 newChecked 값으로 통일해서 덮어쓰기
      setChecks((prev) => 
        Object.fromEntries( // key-value 쌍을 받아서 객체로 재구성하는 메서드
          Object.keys(prev).map((key) => [key, newChecked]);
          // key 현재 체크 상태 객체(prev)의 모든 key만 배열로 반환
          // map 모든 key에 대해 [key, true/false] 형태의 배열 생성
        )
      );
    };
    
    // 개별 체크박스를 토글하는 핸들러
    const handleCheckChange = (id: string) => {
      setChecks((prev) => ({
        ...prev, // 기존 상태 유지
        [id]: !prev[id], // 클릭한 체크박스의 상태만 반전 (토글)
      }));
    };
    
    // '[필수]' 텍스트가 포함된 약관 key만 필터링 (필수 항목 리스트 생성)
    const requiredKeys = Object.entries(termsText).filter(([_, value]) => value.includes('[필수]')).map(([key]) => key);
    
    const isRequiredAllChecked = requiredKeys.every((key) => checks[key]);
    
    return (
      <div className="w-[100%] h-screen relative">
        <div className="w-[300px] border-[2px] rounded-[4px] p-5 absolute left-[50%] top-[50%] translate-[-50%] flex flex-col gap-2.5 box-content">
          <h3 className="text-[1.5rem] font-bold">
            이용약관에
            <br />
            동의해주세요
          </h3>
          <div>
            <CheckBox
              id="allChecked"
              checked={isAllChecked}
              onChange={handleAllCheck}
            />{' '}
            아래 약관에 모두 동의합니다.
          </div>
          <form className="flex flex-col gap-y-2.5 border-t-2 border-[#999] py-3">
            <TermsUl
              termsText={termsText}
              checked={checks}
              onCheckChange={handleCheckChange}
              isAllChecked={isAllChecked}
            />
            <Button className={`text-[1.25rem] rounded-[4px] ${isRequiredAllChecked ? 'bg-[#2c2c2c] text-[#dedede]' : 'text-[#2c2c2c] bg-[#dedede]'}`}>
              {isRequiredAllChecked ? (
                <Link to="/auth/signup/account" className="block w-full py-2.5">
                  다음
                </Link>
                ) : (
                <span className="block py-2.5">다음</span>
              )}
            </Button>
          </form>
          <p className="text-[#888] text-[14px]">
            선택 항목에 동의하지 않아도 서비스 이용이 가능하며, 개인정보 수집 및
            이용에 대한 동의를 거부할 권리가 있습니다. 동의 거부 시 회원제 서비스
            이용이 제한됩니다.
          </p>
        </div>
      </div>
    );
  };

 

초기 상태 생성 (useState) 

Object.keys(termsText).reduce((acc, key) => ({ ...acc, [key]: false }), {});
- termsText의 key들 (age, terms, ...)을 전부 false로 초기화
- checks = { age: false, terms: false, ... }

 

전체 체크 여부 확인

const isAllChecked = Object.values(checks).every(Boolean);
- checks의 모든 value가 true인지 검사
- 전체 동의 체크박스 상태 결정에 사용

 

모두 동의 체크 시 상태 일괄 업데이트

Object.keys(prev).map((key) => [key, newChecked])
- 모든 key에 대해 [key, true/false] 배열 생성
- Object.fromEntries()로 다시 객체화 -> { age: true, terms: true, ... }

 

개별 체크 시 해당 key의 값만 반전

[id]: !prev[id]
- 기존 상태를 유지하고, 해당 체크 항목만 true/false 토글

 

필수 항목만 추출

Object.entries(termsText).filter(([_, value]) => value.includes('필수')).map(([key]) => key);
- termsText 중 [필수]가 들어간 항목만 골라 key 배열로 저장

 

필수 항목만 전부 체크됐는지 확인

requiredKeys.every((key) => checks[key]);

 

삼항연산자 ( 필수 항목 체크시 다음 버튼 활성화)

<button>
  {isRequiredAllChecked ? ( <Link to="/" className="block w-full py-2.5">
    다음
  </Link> ) : (
  <span className="block py-2.5">
    다음
  </span> )}
</button>

 

마무리

늘 생각하는거지만 코드를 짜면서 내가 기능을 구현하기 위해 어떤것이 필요한지 알아내는 것이 중요하다는 것을 이번 기능을 구현하면서 중요하다는 것을 느꼈고 역시 기본기의 중요성을 다시 한번 느끼게 됐다. 지금 정리하면서도 완벽히 이해 안 된것이 있다. 그것 또한 기본기가 부족해서 생겨난 일이라고 생각한다. 블로그에 올렸지만 다시 한번 정리해보려고 한다.