금액 및 인원 수 입력 UX 개선하기: 입력 필드에서 0이 제거되지 않는 불편함 해소

soleil_lucy·2025년 6월 17일
3

글을 작성하게 된 이유

Receipto는 모임에서 결제 내역을 쉽게 정리하고 공유할 수 있게 도와주는 웹 애플리케이션입니다. 기본적인 기능을 구현한 후, 여러 지인들에게 사용해보라고 부탁했습니다.

Receipto의 기본 기능:

  • 결제 내용, 금액, 인원 수, 날짜, 제목을 입력하여 결제 내역을 정리합니다.
  • 입력한 정보를 바탕으로 총 결제 금액과 각자가 부담해야 할 1/N 금액을 계산합니다.
  • 계산된 결과를 모임 참여자와 쉽게 공유할 수 있도록 도와줍니다. 복사하기 버튼을 통해 메신저에 붙여넣기할 수 있습니다.

사용자로부터 금액이나 인원 수를 입력할 때, 0이 자동으로 지워지지 않아 불편하다는 공통적인 피드백을 받았습니다. 이 문제를 어떻게 해결했는지 그 과정을 조사하고 글로 남기고자 합니다.

UX(User eXperience) 문제점 분석

현재 배포된 Receipto에서 금액과 인원 수 입력 시 나타나는 문제를 분석해봤습니다.

Receipto의 입력 UI/UX

금액을 입력하는 UI/UX

금액 입력 시, 기본 값으로 0이 설정되어 있어 새로운 금액을 입력할 때마다 0을 지워야 합니다.

금액을 입력하는 UI/UX gif 파일

인원 수를 입력하는 UI/UX

인원 수 또한 금액과 비슷하게 0이 기본값 입니다. 입력할 때마다 0을 지워야 합니다.

Receipto의 입력 로직 코드

금액 입력 이벤트 코드 분석

아래 코드에서는 history.amount의 초기값이 0으로 설정되어 있습니다. 따라서, 사용자가 금액을 입력할 때, 0이 자동으로 삭제 되지 않게 됩니다.

원인이 되는 코드:

const [history, setHistory] = useState<{ content: string; amount: number }>({
    content: '',
    amount: 0,
  });
  
 ...
 
 <Input
  id="amount"
  type="number"
  placeholder="0"
  value={history.amount}
  onChange={(e) =>
    setHistory((prev) => ({
      ...prev,
      amount: Number(e.target.value),
    }))
  }
/>

전체 코드:

// ...
function InputPaymentHistory({ dispatch }: InputPaymentHistoryProps) {
  const [history, setHistory] = useState<{ content: string; amount: number }>({
    content: '',
    amount: 0,
  });

  return (
    <Card>
      <CardHeader>
        ...
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="space-y-4">
          ...
          <Label htmlFor="amount">금액</Label>
          <Input
            id="amount"
            type="number"
            placeholder="0"
            value={history.amount}
            onChange={(e) =>
              setHistory((prev) => ({
                ...prev,
                amount: Number(e.target.value),
              }))
            }
          />
        </div>
      </CardContent>
      <CardFooter>
        ...
      </CardFooter>
    </Card>
  );
}

export default InputPaymentHistory;

인원 수 입력 이벤트 코드 분석

마찬가지로 peopleCount의 초기값이 0으로 설정되어 있어 비슷한 문제가 발생합니다.

문제의 원인이 되는 코드:

const [peopleCount, setPeopleCount] = useState(0);

...

<Input
  id="peopleCount"
  type="number"
  placeholder="예: 2"
  min="1"
  value={peopleCount}
  onChange={(e) => {
    const newPeopleCount = Number(e.target.value);
    dispatch({
      type: 'CHANGED_PEOPLE_COUNT',
      payment: { peopleCount: newPeopleCount },
    });
    setPeopleCount(newPeopleCount);
  }}
/>

전체 코드:

// ...
function InputPeopleCount({ dispatch }: InputPeopleCountProps) {
  const [peopleCount, setPeopleCount] = useState(0);

  return (
    <Card>
      <CardHeader>
				...
      </CardHeader>
      <CardContent>
        <div className="space-y-2">
          <Label htmlFor="peopleCount">참여 인원</Label>
          <Input
            id="peopleCount"
            type="number"
            placeholder="예: 2"
            min="1"
            value={peopleCount}
            onChange={(e) => {
              const newPeopleCount = Number(e.target.value);
              dispatch({
                type: 'CHANGED_PEOPLE_COUNT',
                payment: { peopleCount: newPeopleCount },
              });
              setPeopleCount(newPeopleCount);
            }}
          />
        </div>
      </CardContent>
    </Card>
  );
}

export default InputPeopleCount;

UX 불편함의 원인: inputdml 기본값을 0으로 설정

금액과 인원 수를 입력받는 필드는 초기값이 0으로 설정되어 있습니다. 브라우저에서는 이를 유효한 값으로 처리하지만, 사용자는 0이 불필요한 값이라고 느낄 수 있습니다. 사용자들은 입력할 때 0이 자동으로 사라지길 기대하며, 비어 있는 입력란이나 안내 문구가 오히려 직관적일 수 있습니다.

다른 회사에서는 이러한 문제를 어떻게 해결하고 있는지 살펴봤습니다. 이를 통해 Receipto에서는 어떻게 개선할지 고민해보았습니다.

K사의 정산하기 예시

K사는 금액입력(원)이라는 placeholder를 사용해 금액 입력을 안내합니다. 기본값이 설정되어 있지 않으며, 사용자가 원하는 금액을 쉽게 입력할 수 있습니다.

K사의 정산하기 스크린샷

T사의 정산하기 예시

T사의 경우, placeholder나 기본값 없이 빈 입력란을 제공합니다. 사용자가 바로 입력을 시작할 수 있어 불필요한 0을 삭제하는 번거로움이 없습니다.

T사의 정산하기 스크린샷

이러한 사례들을 참고하여, Receipto에서도 기본값인 0이 뜨지 않도록 하여 사용자가 0을 지우는 불편함을 없애고, placeholder를 통해 어떤 값을 입력할지 안내하여 개선하고자 합니다.

UX 개선을 위한 구현 방법

처음에는 단순히 input의 기본값을 빈 문자열(``)로 설정하면 문제를 해결할 수 있을 것이라 생각했습니다.

const [history, setHistory] = useState<{ 
		amount: string; 
		content: string; 
}>({ amount: '', content: '' });

const [peopleCount, setPeopleCount] = useState<number | ''>('');

컴포넌트의 상태 기본값이 0으로 설정된 탓에 사용자가 금액이나 인원 수를 입력할 때 0이 자동으로 지워지지 않는 문제를 유니온 타입으로 해결하는 것이 최선인지 의문이 들었습니다.

다른 개발자들이 보통 어떻게 이 문제를 해결하는지 궁금했습니다.

이를 위해 AI 플랫폼인 ChatGPT, Claude, Perplexity에 질문한 결과, 대부분의 개발자는 상태의 type을 string으로 정의하고, 필요할 때 number로 변환하는 방식을 사용하고 있었습니다.

const [history, setHistory] = useState<{ 
	amount: string; 
	content: string; 
}>({ amount: '', content: '' });
const [peopleCount, setPeopleCount] = useState('');

이 방식은 초기 상태를 빈 문자열로 설정하여 사용자가 0을 지우는 불편함을 없애고, 상태를 저장할 때만 number로 변환하여 처리할 수 있습니다.

사실 처음에는 답변을 잘못 이해해서 input의 타입을 string으로 바꿔야 한다고 오해했습니다. 이에 대한 의문이 있어 whatwg의 input tag 명세서form tag 명세서를 읽고, React 프로젝트에서 흔히 사용하는 React Hook Form의 예제를 찾아보았습니다. 저와 비슷한 문제를 다룬 github issue 글도 검토했습니다. 이 과정에서 AI 플랫폼의 답변이 input 타입이 아니라 React 컴포넌트의 상태 타입을 string으로 정의 하라는 것임을 깨달았습니다.

또한, 웹 접근성 측면에서도 금액과 인원 수는 숫자로 입력 받아야 한다고 표시해야 스크린 리더기를 사용하는 사용자에게 혼란울 주지 않는다고 생각했습니다.

결론적으로, 컴포넌트의 상태 타입을 string으로 정의하고, 저장 시에만 number로 변환하는 방법으로 UX를 개선하기로 결정했습니다. 이제 이 구현 방법을 실제 코드에 적용해보겠습니다.

구현 및 결과

컴포넌트의 상태 타입을 number 에서 string으로 변경

// Before
const [history, setHistory] = useState<{ content: string; amount: number }>({
    content: '',
    amount: 0,
  });
 
const [peoplCount, setPeopleCount] = useState(0);

// After
const [history, setHistory] = useState<{ content: string; amount: string }>({
    content: '',
    amount: '',
  });
  
const [peoplCount, setPeopleCount] = useState('');

input 태그의 change 이벤트 핸들러 에러 해결

금액 입력 필드 에러

setHistory 함수의 파리미터로 amount를 number로 변환하면서 발생한 에러입니다.

오류 코드:

<Input
  id="amount"
  type="number"
  placeholder="0"
  value={history.amount}
  onChange={(e) =>
    setHistory((prev) => ({
      ...prev,
      amount: Number(e.target.value),
    }))
  }
/>

// Error Message:
Argument of type '(prev: { content: string; amount: string; }) => { amount: number; content: string; }' is not assignable to parameter of type 'SetStateAction<{ content: string; amount: string; }>'.
  Type '(prev: { content: string; amount: string; }) => { amount: number; content: string; }' is not assignable to type '(prevState: { content: string; amount: string; }) => { content: string; amount: string; }'.
    Call signature return types '{ amount: number; content: string; }' and '{ content: string; amount: string; }' are incompatible.
      The types of 'amount' are incompatible between these types.
        Type 'number' is not assignable to type 'string'.ts(2345)

수정 코드:

amount 값을 변환하지 않고 그대로 설정합니다.

<Input
  id="amount"
  type="number"
  placeholder="0"
  value={history.amount}
  onChange={(e) =>
    setHistory((prev) => ({
      ...prev,
      amount: e.target.value,
    }))
  }
/>

인원 수 입력 필드 에러

setPeopleCount 함수에 number 타입의 값을 넘겨줘서 발생한 에러입니다.

오류 코드:

<Input
  id="peopleCount"
  type="number"
  placeholder="예: 2"
  min="1"
  value={peoplCount}
  onChange={(e) => {
    const newPeopleCount = Number(e.target.value);
    dispatch({
      type: 'CHANGED_PEOPLE_COUNT',
      payment: { peopleCount: newPeopleCount },
    });
    setPeopleCount(newPeopleCount);
  }}
/>

// Error Message:
Argument of type 'number' is not assignable to parameter of type 'SetStateAction<string>'.ts(2345)

수정 코드:

setPeopleCount 함수에 string 타입의 값을 넘겨줍니다.

<Input
  id="peopleCount"
  type="number"
  placeholder="예: 2"
  min="1"
  value={peoplCount}
  onChange={(e) => {
    const newPeopleCount = e.target.value;
    dispatch({
      type: 'CHANGED_PEOPLE_COUNT',
      payment: { peopleCount: newPeopleCount },
    });
    setPeopleCount(newPeopleCount);
  }}
/>

dispatch 함수의 에러 해결

Receipto에서는 입력 컴포넌트(InputPaymentHistory, InputPeopleCount)의 지역 상태(history.amount, peopleCount)와는 별개로, 나중에 사용자에게 모임의 총 결제 내역을 정리해서 보내기 위한 상태를 useReducer로 관리합니다. 이 상태는 입력 컴포넌트의 상위 컴포넌트(App 컴포넌트)에서 관리되며, 타입은 다음과 같습니다:

interface PaymentHistory {
  id: string;
  content: string;
  amount: number;
}

interface Receipt {
  title: string;
  date: Date | undefined;
  histories: PaymentHistory[];
  peopleCount: number;
}

function reducer(state: Receipt, action: ReceiptAction): Receipt {
  ...
}

const [receipt, dispatch] = useReducer(reducer, {
    title: '',
    date: undefined,
    histories: [],
    peopleCount: 0,
  });

InputPaymentHistory와 InputPeopleCount 컴포넌트에서 props로 전달받은 dispatch 함수를 사용 중인데, 해당 함수에서 에러가 발생하고 있어 이를 해결하겠습니다.

dispatch 함수의 파라미터로 입력 받은 금액을 넘길 때 number 타입으로 변환

오류 코드:

dispatch({
  type: 'ADD_PAYMENT_HISTORY',
  receipt: {
    history: { id: Date.now().toString(), content, amount },
  },
});

// Error Message:
Type 'string' is not assignable to type 'number'.ts(2322)
payment.ts(4, 3): The expected type comes from property 'amount' which is declared here on type 'PaymentHistory'

수정 코드:

dispatch({
  type: 'ADD_PAYMENT_HISTORY',
  receipt: {
    history: {
      id: Date.now().toString(),
      content,
      amount: Number(amount),
    },
  },
});

dispatch 함수의 파라미터로 입력 받은 인원 수를 넘길 때 number 타입으로 변환

오류 코드:

dispatch({
  type: 'CHANGED_PEOPLE_COUNT',
  receipt: { peopleCount: newPeopleCount },
});

// Error Message:
Type 'string' is not assignable to type 'number'.ts(2322)

수정 코드:

dispatch({
  type: 'CHANGED_PEOPLE_COUNT',
  receipt: { peopleCount: Number(newPeopleCount) },
});

UX 개선 구현 결과

금액 및 인원 수 입력 필드에 이제 placeholder가 표시되며, 사용자는 불필요하게 0을 지우는 액션을 하지 않아도 됩니다.

금액을 입력하는 UI/UX

UX 개선 후 금액을 입력하는 UIUX gif 파일

인원 수를 입력하는 UI/UX

UX 개선 후 인원 수를 입력하는 UIUX gif 파일

회고

Receipto에서 기본값으로 0이 나타나지 않도록 하여 사용자가 매번 0을 지우는 불편함을 해소하려 했습니다. 대신, placeholder를 사용하여 입력해야 할 값을 명확히 안내하였습니다.

글에서 썻듯 1차적으로 완성한 프로젝트를 지인들에게 테스트를 부탁하여 피드백을 받았습니다. 공통적으로 받은 피드백을 통해 사용자 경험이 저하된 문제를 분석하고, 이를 해결하기 위한 방안을 모색 및 구현하는 과정을 경험했습니다. 이 과정에서 input 타입number일지라도 컴포넌트의 상태string으로 처리하며, 입력된 값을 저장할 때는 number로 형 변환하면 된다는 사실을 배웠습니다.

또한, UX를 개선하면서 사용자에게 유효한 값만 입력하도록 하기 위해 유효성 검사 로직의 필요성을 느꼈습니다.

다음 단계에서는 유효성 검사를 쉽게 구현할 수 있도록, form 관리를 돕는 React Hook Form 라이브러리와 schema 기반의 유효성 검사를 지원하는 Zod 라이브러리를 활용할 계획입니다. 이를 통해 사용자가 올바른 값을 입력하도록 하고, 애플리케이션이 정확한 데이터를 받도록 검증을 강화하고자 합니다.

참고 자료

profile
여행과 책을 좋아하는 개발자입니다.

0개의 댓글