프로그래밍 언어/TypeScript

어디까지 useState로 관리해야 할까? & TS로 API 응답 다루기

원코 2026. 5. 7. 01:20

React에서 state로 관리해야 하는 값과 아닌 값

이번 SOPT 과제를 진행하면서 로그인, 회원가입, 마이페이지, 회원 조회 기능을 구현했다.

구현하면서 가장 많이 고민했던 부분은 “어떤 값을 state로 관리해야 하지?”였다.

처음에는 컴포넌트 안에서 쓰이는 값이면 전부 useState로 만들어야 할 것 같았다. 그런데 코드를 작성하다 보니, 모든 값을 state로 관리하는 것이 오히려 불필요한 렌더링과 복잡도를 만든다는 걸 알게 되었다.

이번에는 특히 LoginPage에서 버튼 활성화 여부를 만들면서 이 차이를 제대로 느꼈다.


처음 생각

로그인 페이지에는 아이디 입력값과 비밀번호 입력값이 있다.

const [userID, setUserID] = useState('');
const [userPW, setUserPW] = useState('');

그리고 로그인 버튼은 아이디와 비밀번호가 모두 입력되어 있을 때만 활성화되어야 했다.

처음에는 이런 식으로 생각할 수 있다.

const [isButtonActive, setIsButtonActive] = useState(false);

그리고 아이디나 비밀번호가 바뀔 때마다 setIsButtonActive를 해주는 방식이다.

하지만 이렇게 하면 문제가 있다.

isButtonActive는 사용자가 직접 바꾸는 값이 아니다.
사용자가 바꾸는 값은 userID, userPW이고, isButtonActive는 그 두 값을 바탕으로 계산되는 값이다.

그래서 굳이 state로 둘 필요가 없었다.


state로 관리해야 하는 값

이번에 정리한 기준은 이렇다.

const [userID, setUserID] = useState('');
const [userPW, setUserPW] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);

이런 값들은 state로 관리하는 것이 맞다.

  • 사용자가 직접 입력해서 바뀌는 값
  • 서버 요청 상태처럼 시간에 따라 바뀌는 값
  • 화면에 직접 영향을 주는 값

예를 들어 userID, userPW는 사용자가 input에 입력하는 값이다.
isLoggingIn은 로그인 요청 중인지 아닌지를 나타내는 값이고, 버튼 문구나 활성화 여부에 영향을 준다.

이런 값들은 React가 변경을 감지하고 화면을 다시 그려야 하므로 state가 맞다.


state로 관리하지 않아도 되는 값

반면 로그인 버튼 활성화 여부는 이렇게 만들 수 있다.

const isButtonActive = userID.trim() !== '' && userPW.trim() !== '' && !isLoggingIn;

이 값은 별도의 state가 아니다.
그냥 렌더링될 때마다 현재 state를 기준으로 계산되는 변수다.

처음에는 “이것도 화면에 영향을 주는데 state여야 하는 거 아닌가?”라고 생각했다.

하지만 중요한 건 이 값이 독립적으로 변하는 값인지 여부였다.

isButtonActive는 독립적으로 변하지 않는다.
항상 userID, userPW, isLoggingIn에 의해 결정된다.

즉, 다른 state로 계산 가능한 값이다.

그래서 state로 만들면 오히려 같은 정보를 두 군데에서 관리하게 된다.

const [isButtonActive, setIsButtonActive] = useState(false);

이렇게 만들면 userID, userPW가 바뀔 때마다 isButtonActive도 같이 맞춰줘야 한다.
동기화해야 하는 값이 하나 더 생기는 셈이다.

반대로 변수로 두면 항상 최신 state를 기준으로 계산되기 때문에 더 단순하다.


마이페이지 수정 버튼에서도 같은 고민

마이페이지에서도 비슷한 상황이 있었다.

회원 정보를 수정할 때 버튼은 다음 조건을 모두 만족해야 활성화되어야 했다.

  • 이름, 이메일, 나이가 비어있지 않아야 함
  • 이름은 10자 미만이어야 함
  • 이메일 형식이 맞아야 함
  • 나이는 1~150 사이 숫자여야 함
  • 기존 정보와 현재 입력값이 달라야 함
  • 수정 요청 중이 아니어야 함

처음 보면 버튼 활성화 여부도 state로 관리하고 싶어진다.

하지만 이것도 결국 다른 값들로 계산할 수 있다.

const isUpdateButtonActive =
  userInfo !== null &&
  formValues.name.trim() !== '' &&
  formValues.email.trim() !== '' &&
  formValues.age.trim() !== '' &&
  !validationErrorMessage &&
  !isSameUserInfo &&
  !isUpdating;

여기서 formValues, userInfo, isUpdating은 state다.
하지만 isUpdateButtonActive는 state가 아니라 계산된 값이다.

이렇게 하니까 “버튼 활성화 상태를 따로 업데이트하는 코드”가 필요 없어졌다.
입력값이 바뀌면 컴포넌트가 다시 렌더링되고, 그때 자동으로 다시 계산된다.


원본 데이터와 수정 중인 데이터 분리하기

마이페이지에서 또 하나 고민했던 부분은 서버에서 받아온 회원 정보와 사용자가 수정 중인 값을 어떻게 관리할지였다.

처음에는 서버에서 받은 userInfo를 그대로 input value에 넣었다.

<TextField value={userInfo.name} />

하지만 이렇게 하면 수정이 어렵다.
input은 사용자가 입력할 때 값이 바뀌어야 하는데, userInfo는 서버에서 받아온 원본 데이터이기 때문이다.

그래서 원본과 수정 중인 값을 분리했다.

const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null);

const [formValues, setFormValues] = useState({
  name: '',
  email: '',
  age: '',
});

userInfo는 서버에서 받아온 원본 정보다.
formValues는 사용자가 현재 input에서 수정 중인 값이다.

이렇게 분리하니까 기존 정보와 현재 입력값이 같은지도 비교할 수 있었다.

const isSameUserInfo = isSameFormValues(userInfo, formValues);

그리고 기존 정보와 같으면 수정 버튼을 비활성화했다.

이 과정에서 “서버 데이터”와 “폼 입력 데이터”를 무조건 하나로 관리하려고 하면 오히려 복잡해질 수 있다는 걸 알게 되었다.


API 응답 타입 분리하기

이번 과제에서는 API 응답 형식도 여러 번 사용했다.

성공 응답은 대체로 이런 형태였다.

{
  "success": true,
  "status": 200,
  "message": "유저 조회에 성공했습니다.",
  "code": "USER_200_001",
  "data": {
    "id": 186,
    "loginId": "g1g1",
    "name": "정지원",
    "email": "sopt@sopt.org",
    "age": 25,
    "part": "웹"
  }
}

여기서 success, status, message, code는 공통으로 오고, data만 API마다 달라진다.

처음에는 API마다 응답 타입을 전부 따로 만들 수도 있었다.

interface UserInfoApiResponse {
  success: boolean;
  status: number;
  message: string;
  code: string;
  data: UserInfoResponse;
}

하지만 로그인, 유저 조회, 유저 목록 조회처럼 비슷한 구조가 계속 반복됐다.

그래서 공통 응답 타입을 만들었다.

export interface ApiBaseResponse {
  success: boolean;
  status: number;
  message: string;
  code: string;
}

export interface ApiResponse<T> extends ApiBaseResponse {
  data: T;
}

여기서 Tdata 안에 들어갈 타입이다.

예를 들어 유저 상세 조회 응답은 이렇게 쓸 수 있다.

apiClient.get<ApiResponse<UserInfoResponse>>(`/api/v1/users/${userId}`);

유저 목록 조회 응답은 이렇게 쓴다.

apiClient.get<ApiResponse<UserListResponse>>('/api/v1/users');

즉, 응답의 껍데기는 재사용하고 data 타입만 갈아 끼우는 방식이다.

이번에 제네릭 타입이 왜 필요한지 조금 더 감이 왔다.
처음에는 문법만 보면 어려운데, 실제로 반복되는 API 응답 타입을 줄이는 데 쓰니까 훨씬 이해가 잘 됐다.


API 함수 분리하기

처음에는 페이지 안에서 바로 API를 호출해도 될 것 같았다.

axios.get('/api/v1/users');

하지만 API 호출이 여러 페이지에서 반복되면서 apis 폴더로 분리했다.

export const getUser = async (userId: string) => {
  const { data } = await apiClient.get<ApiResponse<UserInfoResponse>>(
    `${USER_PATH}/${userId}`,
  );

  return data;
};

페이지에서는 이제 이렇게만 사용한다.

const response = await getUser(userId);
setUserInfo(response.data);

이렇게 하니까 페이지 컴포넌트는 “어떤 API를 호출하는지”보다 “받은 데이터로 화면을 어떻게 바꾸는지”에 더 집중할 수 있었다.

API path나 axios 설정이 바뀌어도 페이지 코드를 많이 건드리지 않아도 된다.


에러 응답 처리

실패 응답도 공통 형태가 있었다.

{
  "success": false,
  "status": 404,
  "message": "존재하지 않는 유저입니다.",
  "code": "USER_404_001",
  "meta": {
    "path": "/api/v1/users/1",
    "timestamp": "2025-11-20T08:07:32.204810Z"
  }
}

그래서 에러 응답 타입도 따로 만들었다.

export interface ApiErrorResponse {
  success: false;
  status: number;
  message: string;
  code: string;
  meta: {
    path: string;
    timestamp: string;
  };
}

그리고 axios 에러인지 확인한 뒤 서버 메시지를 꺼냈다.

if (isAxiosError<ApiErrorResponse>(error)) {
  setErrorMessage(error.response?.data.message ?? DEFAULT_ERROR_MESSAGE);
  return;
}

이렇게 하니까 서버에서 내려준 에러 메시지를 화면에 보여줄 수 있었다.
만약 서버 응답이 없으면 기본 에러 메시지를 보여주도록 처리했다.


이번에 배운 점

이번 과제를 하면서 가장 크게 배운 건 state를 무조건 많이 만드는 게 좋은 게 아니라는 점이었다.

사용자가 직접 바꾸는 값 → state
서버 요청 결과처럼 시간에 따라 바뀌는 값 → state
다른 state로 계산 가능한 값 → 변수
렌더링과 상관없이 기억만 하면 되는 값 → ref

특히 isButtonActive, validationErrorMessage, isSameUserInfo 같은 값들은 state가 아니라 계산된 변수로 두는 편이 더 자연스러웠다.

state로 만들면 직접 관리해야 하는 값이 늘어난다.
반대로 계산 가능한 값은 변수로 두면, 기존 state가 바뀔 때마다 자연스럽게 다시 계산된다.

또 API 로직을 페이지에서 분리하면서, 컴포넌트가 너무 많은 책임을 가지지 않게 만드는 것도 중요하다는 걸 느꼈다.

페이지 컴포넌트는 화면과 상태 흐름에 집중하고,
API 함수는 서버와 통신하는 역할만 맡게 하니까 코드가 더 읽기 쉬워졌다.

처음에는 단순히 “동작하게 만들기”에 집중했는데, 구현을 하다 보니 이름 짓기, 폴더 구조, state로 둘 값과 아닌 값, API 타입 분리 같은 것들이 코드의 유지보수에 꽤 큰 영향을 준다는 걸 알게 되었다.