프로젝트/Next+TypeScript

[Coworkers] 팀 생성 페이지 작업 (API 연동)

dev-hpk 2025. 1. 22. 14:20

공통 컴포넌트 작업을 마무리하고, 오늘부터는 페이지 작업 시작입니다.

팀 생성 페이지를 먼저 작업해 보겠습니다❗

 

작업 목표

팀 생성 페이지 작업 목표

 

작업에 앞서 피그마 디자인을 먼저 확인해 볼게요👀

팀 생성 페이지

 

팀 이름 Input이 팀원분이 공통으로 작업해 주신 Input 컴포넌트와 유사하게 생겼으니 공통 Input을 사용하면 되겠네요👍

공통 Input 예시

 

공통 Input 코드

더보기

 

import { MouseEvent, ReactNode, useState } from 'react';
import { useFormContext, RegisterOptions } from 'react-hook-form';
import IconVisibility from '@/app/components/icons/IconVisibility';
import IconInVisibility from '@/app/components/icons/IconInVisibility';

type AuthInputProps = {
  name: string; // 필드 이름 (폼 데이터의 키)
  title: string; // 라벨 제목
  type: string; // input 타입 (예: text, password 등)
  placeholder: string; // 플레이스홀더
  autoComplete: string; // 자동 완성 옵션
  validationRules?: RegisterOptions; // react-hook-form 유효성 검증 규칙
  backgroundColor?: string; // 입력 필드 배경색
  customButton?: ReactNode; // 추가 버튼 컴포넌트
};

function AuthInput({
  name,
  title,
  type = 'text',
  placeholder,
  autoComplete,
  validationRules,
  backgroundColor = 'bg-background-secondary',
  customButton,
}: AuthInputProps) {
  const [isVisibleToggle, setIsVisibleToggle] = useState(false);
  const {
    register,
    formState: { errors },
  } = useFormContext();

  const isPassword = type === 'password';
  const inputType = isPassword ? (isVisibleToggle ? 'text' : 'password') : type;

  const handleToggleClick = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    setIsVisibleToggle(!isVisibleToggle);
  };

  const inputBorderClass = errors[name]
    ? 'border-status-danger' // 에러시 border 색상
    : 'border-[#F8FAFC1A]'; // 기본 border 색상

  return (
    <div className="flex flex-col gap-3">
      <label className="text-text-primary text-base font-medium" htmlFor={name}>
        {title}
      </label>

      <div className="relative">
        <input
          className={`focus:border-interaction-focus placeholder:text-text-danger text-text-primary h-full w-full rounded-xl border px-4 py-[0.85rem] placeholder:text-lg focus:outline-none ${backgroundColor} ${inputBorderClass}`}
          {...register(name, validationRules)}
          type={inputType}
          id={name}
          placeholder={placeholder}
          autoComplete={autoComplete}
        />
        {isPassword && customButton && (
          <div className="absolute right-4 top-3 z-20">{customButton}</div>
        )}
        {isPassword && !customButton && (
          <button
            className="absolute right-4 top-3 z-10"
            type="button"
            onClick={handleToggleClick}
          >
            {isVisibleToggle ? <IconVisibility /> : <IconInVisibility />}
          </button>
        )}
      </div>

      {errors[name] && (
        <span className="text-status-danger text-sm">
          {errors[name]?.message as string}
        </span>
      )}
    </div>
  );
}

export default AuthInput;

컴포넌트 이름을 보니 로그인, 회원가입에 관련된 Input만 고려하신 것 같네요🤔

공통 컴포넌트를 사용하지 말아야 하나 고민했지만, React Hook Form을 사용해 작업해 주셔서 공통 Input을 사용하는 게 효율적일 것 같아 사용해야겠습니다.

 

저번 코드 리뷰 때 배운 점을 바탕으로 팀 회의 때 명확하고 부드러운 소통으로 컴포넌트와 변수의 이름을 바꾸는 내용을 건의해 보겠습니다✨

 

axios를 추가하고 instance를 세팅해 보겠습니다.

 

Axios Instance 생성

import axios from 'axios';

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
    // 로그인 기능이 구현되면 TOKEN 추가하겠습니다.
    // Authorization: `Bearer ${}`
  },
});

export default instance;

 

이제 axios도 설정했으니, api 작업을 시작해 볼게요.

팀 생성 API는 body에 image와 name을 필요로 하는데요, image는 필수는 아니지만  http:// 또는 https://로 시작하는 문자열을 받네요🤔

팀 생성 API
팀 생성 스키마

 

이미지를 첨부했을 때 정해진 패턴으로 변경해 줄 수 있게 Image 업로드 API를 먼저 작업해 볼게요.

팀 생성 페이지 구조 및 Image 업로드 API

<FormProvider {...method}>
  <form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
    <div>
      <span className="mb-3 inline-block">팀 프로필</span>
      <label
        htmlFor="profile"
        className="relative block h-16 w-16 cursor-pointer"
      >
        <input
          id="profile"
          {/* 화면에 표시되지 않지만 파일 선택을 위해 sr-only 추가 */}
          className="sr-only"
          type="file"
          accept="image/*"
          {...register('profile')}
          onChange={handleFileChange}
        />
        {profileImage ? (
          <Image src={profileImage} fill alt="profile image" />
        ) : (
          <IconProfile />
        )}
      </label>
    </div>
    <AuthInput
      name="name"
      title="팀 이름"
      type="text"
      placeholder="팀 이름을 입력해주세요."
      autoComplete="off"
    />
  </form>
</FormProvider>

 

공통 Input을 useFormContext로 작업해 주셔서 form 상태를 관리하기 위해 FormProvider를 사용했어요❗

import axios from '@/app/lib/instance';

const uploadImage = (imageData: FormData) => {
  const res = axios.post('images/upload', imageData, {
    headers: {
      'Content-Type': 'multipart/form-data',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
    },
  });

  return res;
};

export { uploadImage };

 

  • Content-Type : React Hook Form의 Form 데이터를 사용하기 위해 multipart/form-data로 설정
  • Authorization : 로그인 기능이 아직 구현 전이라 .env에 인증 토큰을 임의로 저장하고 사용
// 파일 처리하는 함수
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file); // 미리보기 URL 생성
      setProfileImage(url); // 미리보기 이미지 업데이트

      setValue('profile', file); // form data 설정
    }
  };

  const onSubmit = async (data: PostGroupData) => {
    try {
      const formData = new FormData();
     
     formData.append('image', data.profile);

      const img = await uploadImage(formData);
    } catch (error) {
      alert(`팀 생성에 실패했습니다`);
    }
    return null;
  };​

 

API를 완성했으니 결과를 확인해 봐야겠죠👀

 

이미지 업로드 요청 결과

 

무슨 에러인지 응답과 페이로드로 서버에 전달한 데이터를 확인해 볼게요.

에러 응답
페이로드

 

image에 File이 아니라 FileList가 보내지고 있네요 😭

확인해 보니 아래 두 경우에 data.profile의 값이 FileList입니다.

 

1️⃣ 이미지를 선택하지 않은 경우

2️⃣ 이미지를 선택한 여러 번 선택한 경우(이미 선택한 상태에서 변경하는 경우)

 

1️⃣번 문제는 간단한 조건문을 통해 해결할 수 있을 것 같아요.

if (data.profile && data.profile instanceof File) {
  const formData = new FormData();

  formData.append('image', data.profile);

  const img = await uploadImage(formData);
}
  • data.profile : formData의 profile 데이터가 있는지 확인
  • data.profile instanceof File : formData의 profile 데이터가 File 객체인지 확인

이미지 없는 경우

 

이미지를 선택하지 않은 경우 API 요청을 안 하도록 잘 적용되었네요👍

 

2️⃣번 문제를 해결하려고 이미지를 추가하던 중 디버깅을 해보니 해결된 게 아니었네요.

formData - 프로필 이미지

 

profile가 FileList여서 이미지 업로드 API가 동작을 안 하고 있었어요😭

 

코드를 살펴보다가 의문이 드는 부분이 생겼습니다.

// handleFileChange
setValue('profile', file);

// 파일 업로드 input
<input
  id="profile"
  className="sr-only"
  type="file"
  accept="image/*"
  {...register('profile')}
  onChange={handleFileChange}
/>;

 

이미 파일 업로드 input에 register로 Form State를 연결했는데, 파일 change 핸들러에 setValue로 register filed의 profile 값을 다시 변경해주고 있네요...🤔

 

아마 register를 등록하기 전에 setValue로 관리하던 로직을 삭제하지 않은 것 같아요.

삭제하고 다시 테스트해 보겠습니다.

이미지 업로드 성공

 

 

로직을 꼼꼼하게 확인하지 못해 발생한 문제로 너무 많은 시간을 투자한 것 같아 아쉬운 마음이 드네요😅

이번 경험을 통해 React Hook Form의 공식 문서를 깊이 이해하는 계기가 되었고, 앞으로는 더 꼼꼼하게 코드를 점검할 자신감이 생긴 것 같습니다👍