Receipto는 모임에서 결제 내역을 쉽게 정리하고 공유할 수 있게 도와주는 웹 애플리케이션입니다. 기본적인 기능을 구현한 후, 여러 지인들에게 사용해보라고 부탁했습니다.
복사하기
버튼을 통해 메신저에 붙여넣기할 수 있습니다.사용자로부터 금액이나 인원 수를 입력할 때, 0이 자동으로 지워지지 않아 불편하다는 공통적인 피드백을 받았습니다. 이 문제를 어떻게 해결했는지 그 과정을 조사하고 글로 남기고자 합니다.
현재 배포된 Receipto에서 금액과 인원 수 입력 시 나타나는 문제를 분석해봤습니다.
금액 입력 시, 기본 값으로 0이 설정되어 있어 새로운 금액을 입력할 때마다 0을 지워야 합니다.
인원 수 또한 금액과 비슷하게 0이 기본값 입니다. 입력할 때마다 0을 지워야 합니다.
아래 코드에서는 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;
금액과 인원 수를 입력받는 필드는 초기값이 0으로 설정되어 있습니다. 브라우저에서는 이를 유효한 값으로 처리하지만, 사용자는 0이 불필요한 값이라고 느낄 수 있습니다. 사용자들은 입력할 때 0이 자동으로 사라지길 기대하며, 비어 있는 입력란이나 안내 문구가 오히려 직관적일 수 있습니다.
다른 회사에서는 이러한 문제를 어떻게 해결하고 있는지 살펴봤습니다. 이를 통해 Receipto에서는 어떻게 개선할지 고민해보았습니다.
K사는 금액입력(원)이라는 placeholder를 사용해 금액 입력을 안내합니다. 기본값이 설정되어 있지 않으며, 사용자가 원하는 금액을 쉽게 입력할 수 있습니다.
T사의 경우, placeholder나 기본값 없이 빈 입력란을 제공합니다. 사용자가 바로 입력을 시작할 수 있어 불필요한 0을 삭제하는 번거로움이 없습니다.
이러한 사례들을 참고하여, Receipto에서도 기본값인 0이 뜨지 않도록 하여 사용자가 0을 지우는 불편함을 없애고, placeholder를 통해 어떤 값을 입력할지 안내하여 개선하고자 합니다.
처음에는 단순히 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를 개선하기로 결정했습니다. 이제 이 구현 방법을 실제 코드에 적용해보겠습니다.
// 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('');
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);
}}
/>
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({
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({
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) },
});
금액 및 인원 수 입력 필드에 이제 placeholder가 표시되며, 사용자는 불필요하게 0을 지우는 액션을 하지 않아도 됩니다.
Receipto에서 기본값으로 0이 나타나지 않도록 하여 사용자가 매번 0을 지우는 불편함을 해소하려 했습니다. 대신, placeholder를 사용하여 입력해야 할 값을 명확히 안내하였습니다.
글에서 썻듯 1차적으로 완성한 프로젝트를 지인들에게 테스트를 부탁하여 피드백을 받았습니다. 공통적으로 받은 피드백을 통해 사용자 경험이 저하된 문제를 분석하고, 이를 해결하기 위한 방안을 모색 및 구현하는 과정을 경험했습니다. 이 과정에서 input 타입
이 number
일지라도 컴포넌트의 상태
는 string
으로 처리하며, 입력된 값을 저장
할 때는 number로 형 변환
하면 된다는 사실을 배웠습니다.
또한, UX를 개선하면서 사용자에게 유효한 값만 입력하도록 하기 위해 유효성 검사 로직의 필요성을 느꼈습니다.
다음 단계에서는 유효성 검사를 쉽게 구현할 수 있도록, form 관리를 돕는 React Hook Form 라이브러리와 schema 기반의 유효성 검사를 지원하는 Zod 라이브러리를 활용할 계획입니다. 이를 통해 사용자가 올바른 값을 입력하도록 하고, 애플리케이션이 정확한 데이터를 받도록 검증을 강화하고자 합니다.