완벽한 사용자 경험: 중복 제출 방지와 낙관적 업데이트
게시글이 두 번 등록되는 것을 막고, '좋아요'를 누르는 즉시 화면에 반영했다가 실패 시 되돌리는 고급 UI 기법을 배웁니다.
사용자는 성격이 급합니다. 결제 버튼을 눌렀는데 반응이 느리면 두 번, 세 번 버튼을 마구 누릅니다. 그 결과 결제가 세 번 중복되는 대참사가 발생하기도 하죠. 반대로 인스타그램의 '좋아요' 버튼은 누르자마자 서버의 응답을 기다리지 않고 즉시 빨간 하트로 변해 쾌적한 느낌을 줍니다. 이번 에피소드에서는 최상의 사용자 경험을 위한 두 가지 고급 기술, 중복 제출 방지와 낙관적 업데이트(Optimistic Update)를 배웁니다.
대참사를 막는 기본: 중복 제출(Duplicate Submit) 방지
데이터베이스에 새로운 글을 등록하거나, 결제를 요청할 때는 절대 같은 요청이 여러 번 연속으로 들어가서는 안 됩니다. 네트워크 지연으로 서버의 응답이 1~2초 늦어질 때, 불안해진 사용자가 버튼을 여러 번 누르는 일은 너무나도 흔하게 일어납니다.
이런 상황을 중복 제출(Duplicate Submit)이라고 부르며, 프론트엔드에서 이를 반드시 막아주어야 합니다. 해결책은 의외로 간단합니다. 상태(State)를 사용하여 "현재 작업이 진행 중인가?"를 체크하는 것입니다.
안전한 버튼 클릭 처리 루틴
- 1버튼을 클릭하면 로딩 상태(isSubmitting)를 true로 바꿉니다.
- 2isSubmitting이 true인 동안에는 버튼을 비활성화(disabled)시켜 클릭을 막습니다.
- 3서버 요청이 끝난 후(성공이든 실패든) isSubmitting을 다시 false로 되돌려줍니다.
React 폼 중복 제출 방지 구현
import { useState } from 'react';
export default function SubmitForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
// 이미 제출 중이라면 함수 실행을 막습니다.
if (isSubmitting) return;
// 제출 시작: 로딩 상태를 켭니다.
setIsSubmitting(true);
try {
await fetch('/api/posts', { method: 'POST' });
alert('성공적으로 등록되었습니다!');
} catch (error) {
alert('등록에 실패했습니다.');
} finally {
// 성공/실패 여부와 관계없이 로딩 상태를 끕니다.
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* 로딩 중일 때는 버튼 클릭을 막고 텍스트를 바꿉니다 */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '등록하는 중...' : '게시글 등록'}
</button>
</form>
);
}마법 같은 속도감: 낙관적 업데이트 (Optimistic Update)
중복 제출 방지가 '안전'을 위한 것이라면, 낙관적 업데이트는 '경험'을 위한 것입니다.
보통 서버에 좋아요를 요청하면 [클릭 -> 서버 요청 -> 성공 응답 수신 -> 화면에 하트 표시]의 과정을 거칩니다. 아무리 빨라도 0.1초~0.5초의 딜레이가 발생하여 약간 버벅거리는 느낌을 줍니다.
하지만 낙관적 업데이트는 "서버 통신은 무조건 성공할 거야"라고 낙관적으로 가정합니다. [클릭 -> 즉시 화면에 하트 표시 -> 뒤에서 서버 요청] 순서로 동작합니다. 사용자는 버튼을 누르자마자 0초 만에 시각적 피드백을 받으므로 앱이 엄청나게 빠르다고 느끼게 됩니다.
낙관적 업데이트를 적용할까 말까?
| 상황 | 선택 | 이유 |
|---|---|---|
| '좋아요'나 '북마크' 클릭 | 적용함 | 실패하더라도 큰 타격이 없으며, 즉각적인 상호작용이 만족감을 줍니다. |
| 장바구니 수량 변경 | 적용함 | +, - 버튼을 연타할 때 반응이 빨라야 쇼핑 경험이 쾌적해집니다. |
| 결제 승인 버튼 클릭 | 적용 안 함 | 절대 낙관적으로 처리하면 안 됩니다! 반드시 서버의 최종 승인 결과를 기다려야 합니다. |
실패했을 때의 롤백(Rollback)
낙관적 업데이트의 핵심은 "만약 통신이 실패하면 어떻게 할 것인가?"에 있습니다. 하트를 빨갛게 칠해놨는데 뒤늦게 서버에서 에러가 났다면? 조용히 하트를 다시 빈 하트로 되돌리고(Rollback), 사용자에게 짧게 안내해야 합니다.
낙관적 업데이트의 흐름 구현
typescript```typescript
const handleLike = async () => {
// 1. 현재 상태 기억
const previousState = isLiked;
// 2. 낙관적으로 화면 즉시 업데이트
setIsLiked(!isLiked);
try {
// 3. 백그라운드 서버 통신
const res = await fetch('/api/like', { method: 'POST' });
if (!res.ok) throw new Error('서버 에러');
} catch (error) {
// 4. 에러 시 상태 원상 복구 (롤백)
setIsLiked(previousState);
alert('좋아요 처리에 실패했습니다.');
}
};
```- 1현재 상태를 임시로 저장해둡니다. (실패 시 되돌리기 위해)
- 2서버 응답을 기다리지 않고 화면의 상태(isLiked)를 즉시 변경합니다.
- 3백그라운드에서 서버로 요청을 보냅니다.
- 4에러가 발생하면 저장해둔 원래 상태로 복구하고 에러 메시지를 띄웁니다.
예외 처리는 디테일의 차이입니다
로딩 스피너를 넣고, 에러 화면을 만들고, 중복 클릭을 막고, 낙관적 업데이트를 적용하는 것. 이 모든 과정은 기능이 동작하게 만드는 데엔 필수적이지 않을 수도 있습니다.
하지만 사용자가 "이 앱은 정말 잘 만들어졌고, 믿음직스럽다"라고 느끼게 만드는 것은 바로 이런 실패 상태와 예외를 다루는 디테일에서 나옵니다. 방어적인 코드로 빈틈없는 애플리케이션을 완성해 보세요.
개념 퀴즈
퀴즈 답을 맞춰야 학습 완료가 됩니다.낙관적 업데이트(Optimistic Update) 기법에 대한 설명으로 가장 적절한 것은 무엇인가요?
3줄 요약
- 1버튼 클릭 시 로딩 상태(disabled)를 활용하여 데이터베이스 중복 제출 대참사를 막아야 합니다.
- 2낙관적 업데이트는 사용자에게 즉각적인 시각적 피드백을 주어 체감 속도를 극대화합니다.
- 3낙관적 업데이트를 적용할 때는 통신 실패 시 조용히 상태를 원상 복구(Rollback)하는 로직이 반드시 필요합니다.