Form 컴포넌트 제작기

Seungrok Yoon (Lethe)·2023년 8월 29일
1

비동기 통신 과정에서 발생하는 에러를 처리하는 방법에 관한 질문으로 이 글은 시작합니다

1. 질문의 배경

비동기 통신 과정에서 발생하는 에러들은 사용자가 알 수 있는 방식으로 처리해주어야 사용성에 문제가 발생하지 않습니다.
그래서 여러 서비스에서는 각종 에러상황을 토스트 메시지나 모달 알림창을 통해 사용자에게 보여줍니다.

그렇다면 에러를 UI에 보여주기 이전 단계인 에러를 캐치하고 처리하는 코드의 바람직한 위치는 과연 어디일까요?

저는 이 부분에 대해 고민했습니다.

2. 그래서 이 글에서는

로그인 페이지와 폼 컴포넌트를 리팩토링하면서 제 나름의 결론을 찾아가는 과정을 소개드리면서 조언을 구하고자 합니다.

3. 첫 구현

로그인 폼 컴포넌트 내부에서 try-catch 로 에러처리

SignForm 컴포넌트 내부에서 signinRequest를 try-catch문을 통해서 의 로그인 요청에 대한 에러처리를 하고 있습니다.
에러가 발생하면 alert로 처리하고 있어요.

//SignForm.tsx


function SignForm() {
  const [email, setEmail] = useState<EmailState>({
    email: '',
    error: false,
    errorMessage: '',
  })
  const [password, setPassword] = useState<PasswordState>({
    password: '',
    error: false,
    errorMessage: '',
  })
  const [error, setError] = useState()

  const isSubmittable = !email.error && !password.error

  const postSigninRequest = async () => {
    const res = await postSignin({
      email: email.email,
      password: password.password,
    })
    if (!res.error) {
      alert('로그인 성공')
      signinUser(res.body)
    } else {
      setError(`로그인 실패: ${res.body}`)
      alert(`로그인 실패: ${res.body}`)
    }
    return res
  }

  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    const regExp = new RegExp('[@]')
    const validated = regExp.test(e.target.value)
    validated
      ? setEmail((prev) => ({
          ...prev,
          email: e.target.value,
          error: false,
          errorMessage: '',
        }))
      : setEmail((prev) => ({
          ...prev,
          email: e.target.value,
          error: true,
          errorMessage: 'Email should have at least 1 @',
        }))
  }

  const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
    const regExp = new RegExp('.{8,}')
    const validated = regExp.test(e.target.value)
    validated
      ? setPassword((prev) => ({
          ...prev,
          error: false,
          errorMessage: '',
          password: e.target.value,
        }))
      : setPassword((prev) => ({
          ...prev,
          password: e.target.value,
          error: true,
          errorMessage: 'Password should have at least 8 characters',
        }))
  }
  return (
    <section>
      Sign In Page
      <form>
        <label htmlFor="email" title="email 입력란">
          <input
            id="email"
            name="user_email"
            type="email"
            data-testid="email-input"
            placeholder="이메일을 입력해주세요"
            value={email.email}
            onChange={handleEmailChange}
            required
            pattern="@{1}"
          />
          <div className="validation-note">{email.errorMessage}</div>
        </label>
        <label htmlFor="password" title="password 입력란">
          <input
            id="password"
            name="user_password"
            type="password"
            data-testid="password-input"
            placeholder="비밀번호를 입력해주세요"
            value={password.password}
            onChange={handlePasswordChange}
            required
          />
          <div className="validation-note">{password.errorMessage}</div>
        </label>
        <button
          type="button"
          data-testid="signin-button"
          disabled={!isSubmittable}
          onClick={postSigninRequest}
        >
          Sign In
        </button>
        <div>
          <Link to={'/signup'}>go to Sign Up</Link>
        </div>

        <p>{error}</p> // <= 로그인 요청시 에러메시지 출력
      </form>
    </section>
  )
}

export default SignForm

4. 문제점

잘 돌아가는 코드였습니다. 그런데 마음에 들지 않았습니다.

그 이유는 첫번째로 SignForm컴포넌트가 재사용하기가 어려워졌다는 것입니다.
컴포넌트 내부에 비동기 함수가 떡 하니 선언되어 있으니, API가 바뀌거나 하면 꼼짝없이 SignForm 코드를 수정해야 할 것이 눈에 훤했습니다.

그리고 에러 상태도 폼이 가지고 있다는 것이 이상했습니다. 분명히 로그인 요청은 폼에서 보낸 요청이니 폼에서 관련 에러를 처리해야 할 것 같았는데 에러 상태까지 가지고 있다는 것이 어색했습니다.

그리고 로그인과 더불어 회원가입도 이 폼 컴포넌를 활용하여 기능을 구현하고 싶은데, 그렇게되면 컴포넌트 내 API함수들이 점점 많아지게 될 것입니다.
결국 이 컴포넌트는 요구사항이 바뀔 때마다 내부가 수정되어야 하는 재사용성이 없는 컴포넌트인 것입니다.

5. [나름의 해결책] 데이터를 표현하는 컴포넌트데이터를 호출하는 컴포넌트를 분리

폼의 역할에 대해 생각해 보았습니다.

  • 입력값에 대한 검증
  • 입력한 값들을 가지고 서버에 요청

이 두 역할이었습니다. 로그인 요청을 보내고 엑세스 토큰을 받고, 이를 로컬스토리지에 업데이트하는 기능은 폼의 기능과는 거리가 멀어보였습니다.

그래서 폼 컴포넌트는 순수하게 입력된 데이터만 관리하고, 상위 컴포넌트(페이지)에서 API호출 함수를 정의하고, 관련 에러처리 상태를 지니도록 리팩토링을 진행했습니다.

리팩토링 결과 로그인 페이지에서 로그인 요청 비동기 함수를 정의하였고, 에러 상태를 관리하고있습니다.
SignForm 함수는 페이지의 로그인 요청 비동기 함수를 버튼에 전달해주고, 에러메시지를 prop으로 받아 폼에 출력해줍니다.

효과

SignForm에서는 이제 요청만 보내지, 어떤 요청인지는 모르게 되었습니다.

//instance.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'

const BASE_API_URL = 'https://www.pre-onboarding-selection-task.shop'
const TOKEN_KEY_STR = 'access_token'
const withBearer = (tokenStr: string) => `Bearer ${tokenStr}`
export const UNKNOWN_ERROR = { code: 512, message: 'Unknown Error' }

const axiosInstance = axios.create({
  baseURL: BASE_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
})

axios.interceptors.request.use(function (config) {
  const token = localStorage.getItem(TOKEN_KEY_STR)
  if (token !== null) config.headers.Authorization = withBearer(token)
  return config
})

export const api = {
  get: <T, D>(url: string, config?: AxiosRequestConfig) =>
    axiosInstance.get<T, AxiosResponse<T, D>, D>(url, config),
  post: <T, D>(url: string, data: D, config?: AxiosRequestConfig) =>
    axiosInstance.post<T, AxiosResponse<T, D>, D>(url, data, config),
}
//auth.ts
export const signinRequest = async ({ email, password }: AuthBodyType) => {
  return await api.post<SigninResponse, AuthBodyType>(AUTH_API_URL.signin, {
    email,
    password,
  })
}
//SignInPage.tsx

const SignInPage = () => {
  const { checkUserAuth, signinUser } = useAuthState()
  const [error, setError] = useState({
    error: false,
    message: '',
  })

  const requestSignin = (data: AuthBodyType) => {
    signinRequest(data)
      .then((res) => {
        signinUser(res.data.access_token)
      })
      .catch((err) => {
        if (isAxiosError<SigninResponse>(error)) {
          setError({
            error: true,
            message: error.message,
          })
          return
        }
        setError({
          error: true,
          message: UNKNOWN_ERROR.message,
        })
        console.error(err)
      })
  }

  return (
    <>
      {checkUserAuth() ? (
        <Navigate to="/todo" replace />
      ) : (
        <ErrorBoundary>
          <section>
            Sign In Page
            <SignForm.Form>
              <SignForm.Email testId="email-input" />
              <SignForm.Password testId="password-input" />
              <SignForm.ButtonGroup
                testId="signin-button"
                onSubmit={requestSignin}
              />
              <SignForm.Error message={error.message} />
            </SignForm.Form>
          </section>
        </ErrorBoundary>
      )}
    </>
  )
}

export default SignInPage

6. 나름의 결론을 내렸지만 아직도 확신이 들지 않아요

그래서 저는 비동기 함수를 사용하는 컴포넌트 내부에서 함수 정의와 에러처리를 함께 진행하지 않고, 그 상위 컴포넌트 어딘가에서 비동기 함수를 정의하고, 관련 에러상태를 관리하는 것이 적절하다 결론을 내렸습니다. 제가 도달한 방법은 페이지가 많아지면 관리해야 하는 에러 상태가 덩달아 늘어난다는 점에서 아직 단점이 있는 것 같습니다만, 여기서 저는 생각이 막혀버렸습니다.

그래서 실무에서는 서버와의 통신시에 발생하는 에러를 어떻게 관리하는지 궁금해졌습니다.

긴 글 읽어주셔서 감사합니다!

7. 2차 리팩토링을 통한 Form 컴포넌트 개선

(진행중입니다.)

profile
안녕하세요 개발자 윤승록입니다. 내 성장을 가시적으로 기록하기 위해 블로그를 운영중입니다.

0개의 댓글