preonboarding-internship / qna-12th

3 stars 0 forks source link

비동기 통신 과정에서 발생하는 에러를 처리하는 방법 #4

Closed SeungrokYoon closed 1 year ago

SeungrokYoon commented 1 year ago

비동기 통신 과정에서 발생하는 에러를 처리하는 방법에 관한 질문

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. 나름의 결론을 내렸지만 아직도 확신이 들지 않아요

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

서버와의 통신시에 발생하는 에러는 어떻게 관리하는 것이 권장할 만한 방법일지 멘토님의 조언이 듣고 싶습니다.

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

yeonuk-hwang commented 1 year ago

안녕하세요 승록님, 일단 먼저 구현 과정에서 많은 고민을 하고, 문제점을 도출하고, 해결책을 여러 근거를 기반으로 시도해보는 과정 자체가 너무 좋네요 👍, 다른 동기분들도 함께 고민했던 과정들 보고 각자 자신이라면 어떻게 해결했을지, 그리고 어떤 방법이 더 좋을지 생각해보고 얘기해보는 것도 좋을 것 같습니다 💪

먼저 말씀해주신 코드를 기반으로 봤을 때 제가 답변드릴 수 있는 부분은 크게 두 가지로 나눌 수 있습니다.

첫번째, 비동기 통신 과정에서 발생하는 에러를 어디서 처리해야 하는가?

이 부분은 두 곳에서 처리가 가능하다고 생각합니다. 첫번째는 API 호출 코드(지금 작성하신 코드에서는 axios interceptor or signinRequest로 생각하시면 될 것 같네요), 두번째는 컴포넌트입니다. 둘 중 어디서 처리할지에 대한 기준은 내가 해당 에러를 UI로 리액션을 줘야한다고 하면 컴포넌트에서 에러를 받아서, UI로 리액션을 주는게 맞고, 만약 전 코드에서 공통적으로 처리해야되는 에러 동작이 있다면(401에러가 발생하면 로그인 경고창을 출력 후, 로그인 페이지로 이동 등) 그 부분은 UI에 관련된 내용은 아니기에 API 호출을 하는 부분에서 처리하는 것도 좋아보입니다. 그리고 이 두 가지 처리방법은 서로 베타적이지 않습니다. API 호출 부분에서 공통으로 적용되는 에러 처리를 적용 후, 그대로 에러를 전달해서 컴포넌트에서 UI로 리액션을 별도로 주게 할 수도 있습니다.

두번째, 컴포넌트에서 UI를 처리할 때에는 컴포넌트의 관심사를 어떻게 분리해야 하는가?

질문 주신 부분은 UI에 대한 에러처리를 컴포넌트에서 하고 싶은데, 컴포넌트를 어느 수준까지 분리해야 하는가? 주된 내용으로 보입니다. 이 부분은 사실 컴포넌트의 관심사 분리로 생각해 볼 수 있을 것 같네요 컴포넌트의 관심사는 1. 커스텀 훅 2. 컴포넌트 분리 두 가지 방법을 분리를 할 수 있습니다. 둘 중 어떤 방법을 사용할지는 주변 상황을 고려해서 판단해야 합니다.

현재 주어진 상황에서는 Form 부분을 공용 UI로 처리하고, 달라지는 부분만 Signup, Singin 별도의 컴포넌트로 분리하신 방향이 좋아보입니다. 그리고 보여주신 코드에서는 API 호출, 리다이렉트, Section 태그에 출력되는 글자에 대한 관심사가 SignIn 컴포넌트로 옮겨졌기에 자연스럽게 API 호출에 대한 에러처리도 자연스럽게 SignIn에서 에러처리를 해주는게 맞습니다.

사실 지금 중점적으로 다루신 부분은 에러처리에 대한 관점보다는 컴포넌트간 관심사의 분리에 대해서 고민하신 것이라고 봐도 될 것 같네요! 그 여러 관심사중에 하나가 에러처리로 볼 수 있을 것 같고요! 페이지가 많아지면 관리해야 하는 에러상태가 늘어난다는 문제 또한 각 로직에 대한 분리를 잘 수행한다면 복잡성을 크게 줄인 상태로 관리할 수 있을 것으로 보입니다.

결과적으로 지금 관심사의 분리를 통해서 재활용성을 올리고, 유지보수가 용이한 형태로 잘 리팩토링 하신 것으로 보입니다. 이걸 좀 더 깔끔하게 순차적으로 진행하는 방법은 금요일, 다음주 화요일 수업동안 충분한 근거와 설명을 통해서 알려드릴 예정이니 참고하셔도 좋을 것 같네요 :)

해결안된 추가적인 궁금점 있으면 자유롭게 이어서 코멘트 남겨주세요~!

SeungrokYoon commented 1 year ago

답변 감사합니다. 정말 많은 도움이 되었습니다!!!

비동기 통신 과정에서 발생하는 에러를 API 호출하는 부분, 또는 UI컴포넌트 중 한 곳에서만 처리해야만 하는 것은 아님을 새로이 깨달았습니다. UI와 관련 없는 에러 처리 동작은 API 호출 부분에서 따로 하고, 에러를 UI컴포넌트에 전달하여 별도의 UI로 표시하는 방법을 도입해 봐야겠어요😎

기능 구현에 초점을 두어 개발을 정신없이 하다보면, 어느새 컴포넌트 하나에 많은 관심사가 몰려 있게 되는 경우가 잦았습니다. 이번에도 생각 나는 대로 Form 부분을 리팩토링한 것 같아 리팩토링을 체계적으로 잘 하는 방법에 대해 추가로 여쭤보고 싶었던 참이었습니다. 금요일, 다음주 화요일 세션을 통해 순차적으로 리팩토링을 진행하는 법에 대한 깨달음을 얻어갈 수 있겠네요. 정말 기대가 됩니다 😉.

질문 관련하여 궁금한 점이 해결되었어요. 추가적으로 궁금한 점이 생긴다면 고민 과정을 상세히 남겨 다음 이슈로 여쭤보도록 하겠습니다. 감사합니다!