기다림을 설계하기: 낙관적 UI와 노동 착시

기다림을 설계하기: 낙관적 UI와 노동 착시

채팅 앱에서 메시지를 보낸다. 전송 버튼을 누르면 0.4초쯤 스피너가 돌다가 말풍선이 올라온다. 그 0.4초 동안 화면은 멈춘 것처럼 보이고, 급하면 버튼을 한 번 더 누른다. 그런데 같은 앱의 다른 버전은 버튼을 누르는 순간 말풍선이 곧바로 올라오고, 옆에 작은 시계 아이콘이 잠깐 떴다 사라진다. 서버 응답은 두 버전 다 0.4초 걸린다. 네트워크도 서버도 똑같다. 그런데 두 번째가 압도적으로 빠르게 느껴진다.

여기서 바뀐 건 실제 속도가 아니라 지각된 성능(perceived performance)이다. 지연(latency)은 없앨 수 없을 때가 많다. 네트워크 왕복, 서버 연산, 외부 API 호출은 우리가 아무리 쥐어짜도 남는 시간이 있다. 그렇다면 남는 건 그 시간을 사용자가 어떻게 느끼게 할지를 설계하는 일이다. 스켈레톤은 그 설계의 입문일 뿐이고, 진짜 승부는 기다림의 심리학에서 난다. 이 글은 빈 화면 설계를 다룬 앞 글과 짝을 이룬다. 그 글이 채울 게 없을 때의 화면이었다면, 이 글은 아직 안 채워졌을 때의 화면이다.

지각의 세 임계선

기다림의 심리학에는 오래된 지도가 하나 있다. 응답 시간의 세 임계선이다. 제이콥 닐슨(Jakob Nielsen)이 정리해 유명해졌지만, 원출처는 1968년 로버트 밀러(Robert Miller)의 연구다.Miller, R. B. (1968), “Response time in man-computer conversational transactions.” Card et al.(1991)이 이 값을 다시 확인했고, 닐슨은 자신의 책 Usability Engineering(1993)에서 UX 맥락으로 대중화했다. 세 값은 30년 넘게 거의 그대로 통용된다. 세 값은 0.1초, 1초, 10초이고, 각 구간에서 UI가 해야 할 일이 서로 정반대다.

0.1초는 즉각 반응의 경계다. 반응이 이 안에 떨어지면 사용자는 자기 행동이 시스템을 직접 움직였다고 느낀다. 버튼을 눌렀을 때 눌린 모양으로 바뀌는 것, 드래그하는 카드가 손가락을 따라오는 것이 여기 속한다. 이 구간에서 UI가 할 일은 단순하다. 그냥 결과를 즉시 보여주면 된다. 스피너도 로딩 메시지도 필요 없다. 오히려 이 구간에 스피너를 띄우면, 없어도 될 지연을 사용자에게 굳이 광고하는 꼴이 된다.

1초는 흐름 유지의 경계다. 사용자는 지연을 인지하지만 생각의 흐름은 끊기지 않는다. 탭을 눌렀더니 0.8초 뒤에 화면이 넘어가는 정도. 여기서 스피너를 띄우는 건 애매하다. 0.4초짜리 스피너는 나타나자마자 사라져서 깜빡임으로만 남고, 화면을 오히려 어수선하게 만든다. 그래서 이 구간의 진짜 기술은 스피너를 예쁘게 그리는 게 아니라 기다림을 아예 안 보이게 접는 것이고, 그게 다음 장에서 볼 낙관적 UI다.

10초는 주의 이탈의 경계다. 이 선을 넘기면 사용자의 주의가 화면을 떠난다. 다른 탭을 열거나 폰을 집어 든다. 파일 업로드나 영상 렌더링처럼 원래 오래 걸리는 작업이 여기 해당한다. 이 구간에서는 확정 진행 바(determinate progress)로 남은 정도를 알려주고, 가능하면 그동안 다른 일을 할 수 있게 화면을 풀어줘야 한다. 사용자를 10초 동안 빈 화면 앞에 묶어두면 그 자리를 뜬다.

세 임계선이 주는 교훈은, 같은 로딩이라도 구간마다 처방이 반대라는 것이다. 0.1초 구간은 아무것도 하지 말라 하고, 1초 구간은 기다림을 숨기라 하고, 10초 구간은 오히려 진행을 상세히 보여주라 한다. 그러니 모든 로딩에 스피너를 붙이라는 한 줄짜리 규칙은 세 구간 중 두 곳에서 틀린 답이다.

기다림을 숨기는 쪽: 낙관적 UI

1초 구간에서 가장 강력한 처방은 그 1초를 아예 0으로 접는 것이다. 서버 응답을 기다리지 않고 성공을 미리 가정해 화면에 즉시 반영한 뒤, 혹시 실패하면 되돌린다. 이걸 낙관적 UI(optimistic UI)라고 부른다.

좋아요 버튼이 교과서적인 예다. 하트를 누르면 서버 확인을 기다리지 않고 그 자리에서 하트가 채워지고 숫자가 1 올라간다. 실제 서버 반영은 뒤에서 조용히 일어난다. 실패하면 하트를 원래대로 되돌린다. 메시지 전송도 같은 원리다. 말풍선을 먼저 올려두고, 전송이 실패하면 그 말풍선에 빨간 느낌표를 붙인다. 도입부에서 본 빠르게 느껴지던 그 채팅 앱이 바로 이 방식이다.

코드로 보면 핵심은 세 줄이다. 먼저 화면을 바꾸고, 뒤에서 요청을 보내고, 실패하면 되돌린다.

async function toggleLike(postId: string) {
  // 1. 서버 응답을 기다리지 않고 화면부터 바꾼다
  const previous = store.getLike(postId);
  store.setLike(postId, {
    liked: !previous.liked,
    count: previous.count + (previous.liked ? -1 : 1),
  });

  try {
    // 2. 실제 요청은 뒤에서 보낸다
    await api.setLike(postId, !previous.liked);
  } catch (err) {
    // 3. 실패하면 아까 값으로 되돌린다
    store.setLike(postId, previous);
    toast("좋아요를 반영하지 못했어. 다시 시도해줘.");
  }
}

코드를 직접 짤 일은 없으니, 줄마다 카톡으로 메시지 보내는 장면에 얹어 모양만 읽어보자. 맨 윗줄 async function toggleLike(postId: string)는 좋아요를 켜고 끄는 동작 하나를 상자에 담아 이름을 붙인 것이다. 앞에 붙은 async는 이 안에서 서버를 다녀오는, 시간이 걸리는 일을 기다릴 수 있다는 표시이고, 괄호 안 postId는 어느 글의 좋아요인지 가리키는 이름표다.

그다음이 화면부터 바꾸는 1번 블록이다. const previous = store.getLike(postId)는 지금 상태, 그러니까 하트가 눌렸는지와 숫자가 몇인지를 미리 한 벌 복사해 두는 줄이다. 되돌릴 때 쓸 스냅샷을 챙겨두는 것으로, 카톡으로 치면 보내기 직전의 화면을 기억해 두는 셈이다. 이어지는 store.setLike(...)가 서버에 묻지도 않고 화면을 즉시 바꾼다. 안쪽의 !previous.liked에서 느낌표 !는 값을 반대로 뒤집으라는 기호다. 안 눌려 있었으면 눌린 걸로, 눌려 있었으면 안 눌린 걸로 뒤집는다. 숫자 쪽 previous.liked ? -1 : 1에서 물음표와 콜론 ? :은 만약 이랬으면 이 값, 아니면 저 값을 고르라는 뜻이다. 이미 좋아요였으면 1을 빼고, 아니었으면 1을 더한다. 두 줄을 합치면 결과는 하나다. 하트를 그 자리에서 채우고 숫자를 즉시 올린다. 보내기를 누르는 순간 말풍선이 툭 떠오르는 그 장면이다.

2번은 try { await api.setLike(...) }다. 그제서야 실제 요청이 뒤에서 서버로 나간다. await는 서버가 답을 줄 때까지 기다린다는 표시이고, 이 줄을 감싼 try는 여기서 뭔가 잘못될 수 있으니 지켜본다는 신호다. 화면은 이미 바뀐 뒤라, 이 통신은 뒤에서 조용히 도는 카톡의 실제 전송 단계에 해당한다. 마지막 3번 catch (err) { ... }는 그 통신이 실패했을 때만 들어오는 블록이다. 안에서 store.setLike(postId, previous)가 아까 복사해 둔 값으로 화면을 되돌리고, toast(...)가 화면 아래 잠깐 뜨는 알림 한 줄을 띄운다. 카톡 말풍선 옆에 빨간 느낌표가 붙으며 전송 실패로 되돌아가는 바로 그 순간이다.

문제는 3번, 롤백의 UX가 생각보다 어렵다는 데 있다. 사용자 눈에는 이미 성공한 것처럼 보였는데 뒤늦게 취소되면, 그 한 번의 되돌림이 신뢰를 크게 깎는다. 좋아요는 되돌려도 타격이 작다. 하지만 송금이 완료된 것처럼 떴다가 롤백되면 사용자는 앱 전체를 의심하기 시작한다. 게다가 여러 낙관적 업데이트가 겹쳐 날아가는 중에 앞선 하나가 실패하면, 되돌릴 값이 이미 다음 액션에 덮인 뒤라 복구 자체가 꼬인다. 그래서 낙관적으로 갈지는 두 축으로 판단한다. 성공률이 압도적으로 높은가, 그리고 실패를 되돌렸을 때 사용자 손실이 작은가. 좋아요, 팔로우, 정렬 토글은 두 조건을 다 만족한다. 결제, 전송 확정, 삭제는 아니다. 이 판단을 건너뛰고 모든 액션을 낙관적으로 처리하면, 언젠가 가장 중요한 순간에 사용자를 배신한다.

한 가지 더. 롤백을 조용히 하면 안 된다. 실패를 눈에 띄게 보여주고 재시도 경로까지 줘야 한다. 안 그러면 사용자는 좋아요가 눌린 줄 알고 화면을 떠난다.

디자이너: “이 좋아요 버튼, 서버 확인 기다리지 말고 바로 채우자.” 개발자: “실패하면 어떻게 해?” 디자이너: “조용히 되돌리지 말고, 되돌린 걸 눈에 보이게 해줘. 앞 글에서 로딩이랑 빈 상태 뭉치지 말라던 거랑 같은 얘기야.”

상태를 뭉치지 말라는 원칙은 여기서도 그대로다. 성공, 실패, 되돌림은 서로 다른 상태이고 화면이 이를 구분해줘야 한다.

기다림을 드러내는 쪽: 노동 착시

낙관적 UI가 기다림을 안 보이게 접는 전략이라면, 정반대 방향의 전략도 있다. 기다림을 일부러 드러내는 것이다.

사람은 시스템이 일하는 것처럼 보이면 그 결과를 더 신뢰하고 더 만족한다. 이걸 노동 착시(labor illusion)라고 부른다. 뷰엘과 노턴이 온라인 여행과 데이팅 서비스를 모사한 다섯 개의 실험에서, 시스템이 일하는 과정을 드러내면(운영 투명성, operational transparency) 사용자가 그 결과를 더 가치 있게 평가한다는 걸 보였다.Buell, R. W. & Norton, M. I. (2011), “The Labor Illusion: How Operational Transparency Increases Perceived Value,” Management Science 57(9), 1564–1579. 놀라운 대목은, 똑같은 결과인데도 즉시 나오는 것보다 잠깐 기다리며 “1,200개 항공권을 검색하는 중"을 보여준 쪽을 오히려 더 선호하기도 했다는 점이다.

왜 그럴까. 사람은 노력의 흔적을 보면 결과를 신뢰하고, 일종의 보답 심리가 작동한다. 항공권 검색이 0.05초 만에 끝나 결과가 툭 튀어나오면 오히려 이게 제대로 다 뒤진 게 맞나 하는 의심이 든다. 반대로 항공사 이름들이 주르륵 지나가며 검색되는 장면을 보면, 결과가 같아도 더 꼼꼼하게 찾아준 느낌을 받는다.

여기서 역설이 나온다. 실제로는 0.2초면 끝날 검색을 2초로 늘리고 “항공사 검색 중 → 가격 비교 중 → 최저가 정렬 중” 같은 단계를 일부러 보여주는 게, 즉시 결과를 던지는 정직함보다 만족도가 높을 수 있다. 기다림이 늘 없앨 대상인 건 아니라는 뜻이다.

다만 이 카드는 남용하면 독이 된다. 진행 단계가 거짓 티가 나는 순간 역효과가 난다. 매번 똑같은 순서로 똑같은 시간만큼 도는 가짜 단계는 사용자가 금방 눈치챈다. 정공법은 실제로 하는 일을 보여주는 것이다. 검색한 소스를 진짜로 나열하고, 실제 진행에 맞춰 단계를 넘긴다. 그리고 노동 착시는 결과가 기다릴 값이 있을 때만 통한다. 메시지 전송 같은 가볍고 잦은 상호작용에 “전송 준비 중… 암호화 중…“을 붙이면 신뢰가 아니라 짜증만 쌓인다.

그래서 두 전략의 갈림은 결국 하나의 질문으로 정리된다. 사용자가 이 결과의 무게를 느끼는가. 좋아요나 팔로우처럼 가볍고 반복되는 상호작용은 기다림을 숨기고(낙관적 UI), 검색이나 분석, 생성처럼 무겁고 드문 한 방은 기다림을 드러낸다(노동 착시). 같은 로딩을 두고 정반대로 설계하는 이유가 여기 있다.

진행을 보여주기로 했다면

기다림을 드러내기로 했으면, 어떻게 드러낼지가 다음 문제다. 진행 표시에도 심리학이 있고, 여기서 흔한 실수가 나온다.

첫째, 진행은 뒤로 가면 안 된다. 90%까지 찼다가 70%로 떨어지는 진행 바는 강한 불만을 부른다. 해리슨 등이 진행 바를 다시 뜯어본 2007년 연구를 보면, 사람은 진행이 멈추거나 더뎌지는 걸 특히 후반부에서 가장 견디지 못했다.Harrison, C., Amento, B., Kuznetsov, S., Bell, R. (2007), “Rethinking the Progress Bar,” Proceedings of UIST 2007. 반대로 초반의 더딤은 상대적으로 잘 참았다.

둘째, 같은 시간이 걸려도 초반은 느리고 후반은 빠른, 끝을 향해 가속하는 진행 바가 더 짧게 느껴진다. 해리슨 연구에서 여러 곡선을 비교했을 때 가장 선호된 게 이 가속형이었다. 흔히 하는 오해와 반대라는 점을 짚어둘 만하다. 초반을 빠르게 채우고 후반을 늘리면 끝에서 정체가 생기는데, 사람이 가장 싫어하는 게 바로 그 후반부 정체다. 그러니 실무 처방은 명확하다. 어쩔 수 없이 오래 걸리는 무거운 단계(서버 왕복 같은)는 앞쪽에 몰아두고, 끝은 매끄럽게 가속시켜 마무리한다.

셋째, 확정 진행 바와 불확정 스피너 중 무엇을 쓸지는 하나로 갈린다. 남은 정도를 계산할 수 있으면 확정 진행 바, 없으면 불확정 스피너다. 파일 업로드는 전체 바이트 대비 보낸 바이트를 알 수 있으니 퍼센트가 정직하다. 반대로 서버가 얼마나 걸릴지 모르는 처리에 가짜 퍼센트를 지어내면, 99%에서 영원히 멈추는 그 악명 높은 진행 바가 된다. 그럴 바엔 도는 스피너가 차라리 정직하다. 총량을 아는가, 이 한 가지로 결정하면 된다.

스켈레톤과 스피너와 블랭크

진행 바가 로딩이 얼마나 왔는지를 다뤘다면, 마지막 질문은 로딩 화면을 애초에 어떤 형태로 띄우느냐다. 스켈레톤, 스피너, 블랭크 셋 중 언제 무엇을 쓰는지 갈라두자.

스켈레톤(skeleton)은 콘텐츠가 들어올 자리를 회색 박스로 미리 그려두는 방식이다. 두 가지를 해준다. 하나는 레이아웃 안정이다. 콘텐츠가 도착해도 이미 자리를 잡아둬서 화면이 밀리지 않는다. 이 밀림을 누적 레이아웃 이동(CLS)이라고 부르는데, 스켈레톤이 이걸 막는다. 다른 하나는 예고다. 곧 이런 모양으로 온다는 신호를 미리 준다. 그래서 카드 리스트나 피드처럼 최종 형태가 정해진 화면에 잘 맞는다.

스피너는 최종 형태를 예고할 수 없거나 대기가 짧을 때 쓴다. 저장 버튼을 눌러 서버 승인을 기다리는 단일 액션에는 스켈레톤을 그릴 자리가 없으니 스피너가 맞다.

블랭크, 즉 빈 화면을 잠깐 그대로 두는 선택은 아주 짧은 대기(대략 0.1~0.3초)에 어울린다. 이 구간에 스켈레톤을 띄우면 나타나자마자 사라져서 깜빡임만 남기고, 그 깜빡임이 오히려 화면을 불안하게 만든다.

주의할 건 스켈레톤의 남용이다. 모든 로딩에 기계적으로 스켈레톤을 깔면, 실제 콘텐츠가 뜰 때마다 가짜 콘텐츠가 번쩍이는 화면이 된다. 특히 데이터가 0건일 때가 위험하다. 스켈레톤이 잠깐 떴다가 빈 상태로 바뀌면, 앞 글에서 다룬 로딩과 빈 상태의 혼동이 그대로 재현된다. 스켈레톤은 곧 채워진다는 로딩 신호이지, 채울 게 없다는 빈 상태 신호가 아니다. 이 둘을 한 화면으로 뭉치면 사용자는 결과가 없는 건지 아직 오는 중인지 알 수 없다.

정리

지연을 못 없앨 때 우리가 설계하는 건 시계가 아니라 사용자의 체감이다. 세 임계선은 구간마다 다른 처방을 요구한다. 낙관적 UI는 가벼운 기다림을 숨기고, 노동 착시는 무거운 기다림을 오히려 드러낸다. 진행 표시는 그 드러냄을 어떻게 매끄럽게 할지의 문제이고, 스켈레톤은 그중 한 도구일 뿐이다. 그러니 어떤 로딩 앞에서든 질문은 하나로 모인다. 지금 이 기다림을 숨길 것인가 드러낼 것인가, 그리고 사용자가 이 결과의 무게를 느끼는가.

데이터가 가득 찬 화면은 누구나 빠르게 느끼도록 만든다. 진짜 실력은 아직 아무것도 안 왔을 때, 그 몇 초를 어떻게 설계하느냐에서 드러난다.

이 글, 어떠셨어요?