[CRUD 실습 - React + Vite] POST (데이터 추가하기)

Chan의 기술 블로그·2025년 11월 19일

산출물 링크
- GitHub
- 배포 페이지

지난 글에서는 미리 구성해 둔 UI에 API 데이터를 연동해서, 초기 화면 진입 시 데이터를 출력하고 loading / error 상태에 따라 다른 UI를 보여주는 단계까지 진행했다.

이번 글에서는 fetchPOST 메서드를 활용해 새로운 데이터를 리스트에 추가하는 기능을 구현한다.
이 과정에서 사용할 프로필 이미지(avatar)는 미리보기 기능도 함께 다룬다.


프로필 이미지 미리보기 기능 구현

1. 로컬 이미지 파일을 활용한 미리보기 구조

이번 프로젝트에는 별도의 백엔드 서버나 스토리지가 없기 때문에, 로컬 이미지를 실제 서버로 업로드하는 방식은 사용할 수 없다.

대신, 파일 선택 시 브라우저가 파일을 File 객체로 메모리에 보관하고, 이를 URL.createObjectURL()을 통해 임시 blob URL(blob:...)로 변환해 UI에서 미리보기를 표시하는 방식을 사용한다.

즉, 실제 서비스에서의 다음 과정 중 “업로드 → 스토리지 저장” 단계만 빠져 있고, 프론트에서 임시 URL을 만들어 렌더링하는 방식으로 마지막 단계를 흉내 내는 구조라고 보면 된다.

파일 업로드 → 스토리지 저장 → DB에 URL 기록 → URL을 프론트로 응답 → 프론트에서 이미지 렌더링

이번 프로젝트에서는 이 중 “프론트에서 이미지 렌더링” 단계를, blob URL을 직접 생성하는 방식으로 구현한 형태다.

2. 파일 업로드 UI 변경

input type="file"로 파일 선택 UI를 만들고, 시각적 편의성을 위해 label을 클릭했을 때 파일 선택 창이 열리도록 구성했다.

<input 
  id={`userItem_${id}`} 
  type="file" 
  accept="image/*" 
  hidden 
  onChange={handleChangeImage} 
/>
<label htmlFor="userFormImg">프로필 추가</label>

onChange 이벤트에서 파일 정보를 state에 저장하고, URL.createObjectURL(file)로 blob URL을 생성해 미리보기에 사용할 예정이다.

3. 이미지 파일 → 미리보기 처리 코드

const [file, setFile] = useState<File | null>(null)

const handleChangeImage = (e: React.ChangeEvent<HTMLInputElement>) => {
  const selected = e.target.files?.[0] || null
  setFile(selected)
}

const previewUrl = useMemo(() => {
  if (!file) return null
  return URL.createObjectURL(file)
}, [file])

useEffect(() => {
  if (!previewUrl) return
  return () => URL.revokeObjectURL(previewUrl)
}, [previewUrl])

이 코드는 선택한 파일을 상태로 관리하고, 해당 파일로부터 blob URL을 생성한 뒤, 컴포넌트가 언마운트되거나 URL이 바뀔 때 revokeObjectURL로 정리해 주는 구조다.

4. URL.createObjectURL / revokeObjectURL / Blob 개념 정리

  1. URL.createObjectURL()
    • 메모리에 저장된 File / Blob 데이터를 blob URL로 변환한다.
    • 서버 통신 없이도 <img src="blob:..."> 형태로 즉시 렌더링할 수 있다.
  2. URL.revokeObjectURL()
    • 생성된 blob URL을 해제해 관련된 메모리를 정리한다.
    • 파일을 여러 번 바꿔 가며 미리보기를 할 때 메모리 누수를 방지하는 데 필수적이다.
  3. Blob 이란?
    • 브라우저 메모리에 존재하는 바이너리 데이터 덩어리다.
    • 이미지, 동영상, PDF 등 대부분의 바이너리 파일을 표현할 수 있다.
    • FileBlob을 확장한 구조이며, FormData 업로드나 createObjectURL 같은 Blob 기반 API에서 그대로 사용할 수 있다.

5. 적용 대상: UsersNewForm(새로운 유저 추가 에디터) & UsersItem(개별 유저)

프로필 사진을 변경할 수 있는 UI는 UsersNewForm뿐 아니라 개별 수정을 담당하는 UsersItem에서도 동일한 방식으로 적용한다.


POST 구현하기

GET을 구현했을 때와 동일하게, api 폴더와 hooks 폴더에 POST 관련 로직을 추가해 보자.

1. 새로 받아올 Type 지정하기

새로운 User를 추가할 때 필수 값, 선택 값, 불필요한 값을 먼저 정리했다.

  • 필수 입력: email, first_name, last_name
  • 선택 입력: avatar (이미지 미등록 시 기본 이미지 사용)
  • 불필요한 값: id (서버 응답에서 새로 생성됨)

따라서 기존 User 타입을 재사용하여 다음과 같이 새 타입을 만들었다.

// src/types/users.ts
type UserKeys = keyof User
export type EditableUserKey = Exclude<UserKeys, 'id'>
// type EditableUserKey = "avatar" | "email" | "first_name" | "last_name"
  
export type RequiredEditableUserKey = Exclude<EditableUserKey, 'avatar'>
// type RequiredEditableUserKey = "email" | "first_name" | "last_name"
export type PayloadNewUser = Pick<User, RequiredEditableUserKey> & { avatar?: User['avatar'] }
  • keyofUser 객체 타입의 key 들만 모아 UserKeys를 만든다.
  • Excludeid만 제거한 유니온 타입을 만들어 EditableUserKey를 만든다. (편집 가능한 키 값들만 모은 타입)
  • 필수 입력과 선택 입력을 분리하기 위해 EditableUserKey에서 다시 Excludeavatar를 제거해 RequiredEditableUserKey를 만든다.
  • User에서 필수 입력 키 값인 RequiredEditableUserKey만 골라낸 객체와, 선택 입력값인 avatar를 병합하여 PayloadNewUser 타입을 만든다.

이 타입을 사용해서 id를 제외하고, avatar는 선택 값으로, 나머지는 필수 입력 값으로 보낼 수 있도록 했다.
API fetch 요청 시 받을 데이터의 형태는 PayloadNewUser 형태로 받아 POST요청을 할 것이다.

2. API 함수 만들기

// src/api/users.api.ts
export const createUserApi = async (payload: PayloadNewUser) => {
  const response = await fetch('https://reqres.in/api/users', {
    method: 'POST',
    headers: {
      'x-api-key': 'MY_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) throw Error('유저 데이터를 추가할 수 없습니다.')
  const result = await response.json()
  return result
}
  • method를 반드시 POST로 설정해야 한다.
  • 요청 본문의 데이터 형식을 Content-Type 헤더로 명시해야 한다. (application/json)
  • body에 객체를 담을 때는 JSON.stringify()로 직렬화해서 전달해야 한다.

3. POST 요청을 수행하는 Custom Hook 만들기

// src/hooks/useUsersQuery.ts
const createUser = useCallback(async (payload: PayloadNewUser) => {
  try {
    const result = await createUserApi(payload)
    // users 업데이트 !!
  } catch (err) {
    console.error(err)
    if (err instanceof Error) setError(err.message)
  }
}, [])

이 훅은 payload를 인자로 받아 createUserApi를 호출하고, 이후에 users 상태를 업데이트하는 로직을 추가하기 위한 형태로 정의되어 있다.

reqres.in은 mock API이기 때문에 실제 서버의 데이터베이스에 값이 저장되거나, 이후 GET /api/users 응답에 반영되지는 않는다.
그래서 응답으로 받은 데이터를 기존 users 배열에 직접 추가해, UI에서만 반영되도록 처리할 예정이다.

4. UsersProvider 컴포넌트로 createUser 보내기

createUser 함수는 최종적으로 UsersProvider 내부에서 호출해야 한다.

흐름은 다음과 같다.

createUsers → (import) UsersContainer.tsx → (props) UsersProvider.tsx
// src/features/users/containers/UsersContainer.tsx
export default function UsersContainer() {
  const { createUser } = useUsersQuery()

  return (
    <UsersProvider onCreate={createUser}>
      {/* ... */}
    </UsersProvider>
  )
}

UsersProvider에서는 이 onCreate props를 받아, 실제로 POST를 실행해야 하는 시점에 호출하게 된다.

5. UsersNewForm에서 값 가져오기

UsersProvider에서 새로 입력되는 값을 관리하는 statesetter를 만들고, 이를 context value로 내려 UsersNewForm에서 입력값을 받아온다.

// src/features/users/context/UsersProvider.tsx
const [newUserValue, setNewUserValue] = useState<PayloadNewUser>(INIT_NEW_USER_DATA)

UsersForm에서는 onChange 이벤트로 newUserValue를 갱신한다.

email, first_name, last_name 갱신하기

먼저 email, first_name, last_name부터 갱신하는 과정을 설명하겠다.

// src/features/users/components/UsersNewForm.tsx
export default function UsersNewForm() {
  const { setNewUserValue } = useUsersActions()
  const { newUserValue } = useUsersState()

  const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name: newDataName, value } = e.target
    const key = newDataName.replace(/_userForm$/, '') as RequiredEditableUserKey
    const trimmed = value.trim()
    setNewUserValue((prev) => ({ ...prev, [key]: trimmed }))
  }

  return (
    <div className="userForm">
        <div className="userForm__editer">
          <input
            type="text"
            name="first_name_userForm"
            placeholder="first name"
            value={newUserValue.first_name ?? ''}
            onChange={handleChangeInput}
          />
          <input
            type="text"
            name="last_name_userForm"
            placeholder="last name"
            value={newUserValue.last_name ?? ''}
            onChange={handleChangeInput}
          />
          <input
            type="text"
            name="email_userForm"
            placeholder="email"
            value={newUserValue.email ?? ''}
            onChange={handleChangeInput}
          />
        </div>
    </div>
  )
}

UsersNewForm 컴포넌트의 input name에는 _userForm 접미사를 붙여 구별했었다.
이를 정규식으로 제거해 실제 API에 보내야 하는 키(first_name, last_name, email)로 맞추는 로직을 추가했고, 타입은 RequiredEditableUserKey로 좁혀 두었다.
공백을 방지하기 위해 .trim()으로 앞뒤 공백을 제거했다.

avatar 갱신하기

다음은 프로필 사진인 avatar를 갱신하는 과정이다.

export default function UsersNewForm() {
  const [file, setFile] = useState<File | null>(null)
  const { setNewUserValue } = useUsersActions()
  const { newUserValue } = useUsersState()

  const handleChangeImage = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selected = e.target.files?.[0] || null
    setFile(selected)
  }

  const handleRemoveImage = () => {
    if (!file) return
    setFile(null)
    setNewUserValue((prev) => ({ ...prev, avatar: undefined }))
  }

  const previewUrl = useMemo(() => {
    if (!file) return null
    return URL.createObjectURL(file)
  }, [file])

  useEffect(() => {
    if (!previewUrl) return
    setNewUserValue((prev) => ({ ...prev, avatar: previewUrl }))

    return () => URL.revokeObjectURL(previewUrl)
  }, [previewUrl, setNewUserValue])

  return (
    <div className="userForm__profileWrap">
		<div className="userForm__profile">
            <img src={previewUrl || 'https://placehold.co/100x100?text=Hello+World'} alt="" />
          </div>
          {!file ? (
            <label htmlFor="userFormImg" className="button line userForm__profileBtn">
              프로필 추가
            </label>
          ) : (
            <div className="userForm__profileBtns">
              <span className="userForm__profileName">{file.name}</span>

              <label htmlFor="userFormImg" className="button line userForm__profileBtn">
                프로필 변경
              </label>
              <button
                type="button"
                className="line userForm__profileBtn"
                onClick={handleRemoveImage}
              >
                삭제
              </button>
            </div>
          )}

          <input
            id="userFormImg"
            type="file"
            accept="image/*"
            hidden
            onChange={handleChangeImage}
          />
        </div>
  )
}

마찬가지로 setter 함수로 avatar를 업데이트 한다.
위에서 설명한 것처럼, 실제 업로드 없이 미리보기까지만 구현하고 blob URL로 프론트에서만 이미지를 보여주는 구조다.

6. POST 실행 위치에서 값 검증하기

API로 보내기 전 onNewUserForm 함수에서 값 검증과 UI 상태 전환을 처리한다.

newUserValue가 변경될 때마다 useCallback 의존성에 들어가면, 불필요하게 콜백이 자주 재생성될 수 있으므로 useRef로 최신 값을 참조하도록 변경했다. (isCreatingUser도 동일)

// src/features/users/context/UsersProvider.tsx
const [newUserValue, setNewUserValue] = useState<PayloadNewUser>(INIT_NEW_USER_VALUE)
const newUserValueRef = useRef<PayloadNewUser>(newUserValue)
const [isCreatingUser, setIsCreatingUser] = useState<boolean>(false)
const isCreatingUserRef = useRef<boolean>(isCreatingUser)

const onNewUserForm = useCallback(
	async ({ isShowEditor, isPost = false }: OnNewUserForm) => {
      // 추가완료(POST) : isPost
      if (isPost) {
        if (isCreatingUserRef.current) return
        
        const { email, first_name, last_name } = newUserValueRef.current
        if (!email || !first_name || !last_name) {
           alert('이메일, 이름, 성을 모두 입력해주세요.')
           return
        }

        try {
          setIsCreatingUser(true)
          await onCreate(newUserValueRef.current)
        } catch (error) {
          console.error(error)
          alert('유저 생성에 실패했습니다. 다시 시도해주세요.')
        } finally {
          setIsCreatingUser(false)
        }
      }

      if (isShowEditor) {
        setDisplayItemEditor([])
        resetAllUsersData()
      } else {
        setNewUserValue(INIT_NEW_USER_VALUE)
      }

      // toggle
      setIsShowNewUserForm(isShowEditor)
    },
    [onCreate, resetAllUsersData],
)

필수 입력값인 email, first_name, last_name이 비어 있다면 로직을 중단한다.
isCreatingUser state로 중복 실행을 방지하도록 했다.
최종적으로는 onNewUserForm 안에서 onCreatenewUserValueRef.current를 전달해 POST 요청을 트리거한다.

7. 응답 데이터 확인

응답 값은 대략 다음과 같은 형태다.

  • id는 새로 생성된다.
  • mock API이므로 실제 DB에 저장되지는 않는다.
  • createdAtreqres.in이 자동으로 넣어주는 ISO 형식의 생성 시각이다.

새로 데이터를 추가할 때마다 createdAt 키가 생기므로, 이 필드를 포함한 타입을 하나 더 정의했다.

// src/types/users.ts
export type ApiResultNewUser = User & { createdAt: string }

ApiResultNewUser 타입을 createUserApi의 반환 타입으로 지정해 응답 구조를 타입 레벨에서 보장한다.

// src/api/users.api.ts
export const createUserApi = async (payload: PayloadNewUser) => {
  const response = await fetch('https://reqres.in/api/users', {
    method: 'POST',
    headers: {
      'x-api-key': 'reqres_34b210b936844955a8b80641c7073e29',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) throw Error('유저 데이터를 추가할 수 없습니다.')
  const result: ApiResultNewUser = await response.json()
  return result
}

전달받은 데이터를 UI에 반영하기

POST 요청 자체는 성공하지만, reqres.in은 mock API라 실제 서버에 데이터가 저장되지는 않는다.

따라서 응답으로 받은 데이터를 users state에 직접 추가해 UI에 반영해야 한다.

// src/hooks/useUsersQuery.ts
const createUser = useCallback(async (payload: PayloadNewUser) => {
    try {
      const result = await createUserApi(payload)
      const { id, ...rest } = result
      const numericId = Number(id)
      const newUser: User = {
        id: Number.isNaN(numericId) ? Date.now() : numericId,
        avatar: rest.avatar ?? '',
        email: rest.email,
        first_name: rest.first_name,
        last_name: rest.last_name,
      }
      setUsers((prev) => [newUser, ...prev])
    } catch (err) {
      console.error(err)
      if (err instanceof Error) setError(err.message)
    }
}, [])

응답에서 받은 id가 문자열이기 때문에, 숫자 타입으로 변환해서 User 타입에 맞게 다시 구성한다.
Number.isNaN인 경우에는 Date.now()를 사용해 fallback ID를 생성했다.

setUsers에서는 이전에 있던 값(prev)과 새로 추가된 값(newUser)을 합쳐 새로운 배열을 만들어 반환한다.
newUser..prev보다 앞에 두어, 가장 최근에 추가된 유저가 리스트의 최상단에 보이도록 처리했다.

출력이 정상적으로 되는 것을 확인했으므로, 여기까지가 “유저 데이터를 새로 추가하는 기능”의 완성 단계라고 볼 수 있다.


GET 초기 작업은 아래 Git Branch 링크에서 확인할 수 있으며,
현재는 Main Branch에서 코드 리펙토링으로 많이 수정되었다.
feature/fetchPost Branch 바로가기

앞으로의 계획

추가하기 기능은 완료했으니, 다음 단계는 수정하기(PATCH) 작업이다.
수정 기능은 전체 데이터 수정하기와 개별 데이터 수정하기로 나눠져 있으며, 다음 게시물에서 PATCH 로직을 구현해 볼 예정이다.

profile
퍼블리셔에서 프론트앤드로 전향하기

0개의 댓글