본문 바로가기
iOS/트러블슈팅

친절한 에러 UX 만들기

by hong7 2026. 4. 12.

두게더 iOS 프로젝트를 개발하면서, 사용자가 에러를 마주하는 순간에 어떤 안내를 받는지가 서비스 경험에 꽤 큰 영향을 준다는 걸 느꼈어요.

 

앱을 사용하다 보면 에러 화면을 마주하는 순간이 종종 있어요. 그런데 불친절한 에러 화면은 왜 문제가 생겼는지 알기 어렵고, 그다음에 내가 무엇을 해야 하는지도 알려주지 않아서 더 답답하게 느껴질 때가 있었어요.

 

저는 두게더에서는 그런 답답함을 조금이라도 줄이고 싶었어요. 사용자가 느끼기에 지금 어떤 에러가 발생한 건지, 그리고 다음에는 어떤 행동을 하면 되는지를 더 친절하게 안내하는 UX를 만들고 싶었어요. 단순히 실패를 보여주는 데서 끝나는 게 아니라, 실패 이후의 흐름까지 설계하고 싶었어요.

 

서비스를 만들다 보면 성공 흐름은 비교적 선명해요. 그룹에 참여하고, 투두를 저장하고, 로그인을 마치고, 인증을 등록하면 되죠. 그런데 실제 서비스에서는 항상 성공만 일어나지 않아요. 네트워크가 불안정할 수도 있고, 로그인 정보가 만료될 수도 있고, 이미 참여한 그룹이나 정원이 가득 찬 그룹에 다시 들어오려는 경우도 생겨요.

 

문제는 이 실패들이 사용자에게는 전부 그냥 “안 된다”로 느껴질 수 있다는 점이었어요.

 

두게더도 한동안은 이 경계가 아주 명확하진 않았어요. 어떤 에러는 공통 에러 화면으로 보였고, 어떤 에러는 별도 팝업으로 빠졌고, 어떤 상황은 아예 서버까지 갔다가 나서야 제약을 알게 되는 식이었죠. 기능은 동작하고 있었지만, 실패 이후의 경험은 조금 더 다듬을 필요가 있었어요.

 

그래서 이번 글에서는 두게더 iOS에서 에러 처리 경험을 어떻게 개선해 나갔는지, 그리고 그 과정에서 어떤 기준을 세우게 됐는지 정리해보려고 해요.

문제의 발견

처음에는 에러도 결국 “실패했으니 안내해주면 되는 것 아닌가?”라고 생각하기 쉬워요.

하지만 실제 서비스에서는 에러가 모두 같은 성격이 아니었어요.

 

예를 들어 네트워크 연결이 일시적으로 실패한 경우와, 이미 참여한 그룹에 다시 참여하려는 경우는 전혀 다른 상황이에요. 전자는 다시 시도하면 해결될 수 있지만, 후자는 재시도를 반복해도 해결되지 않아요.

 

두게더의 네트워크 공통 처리 구조를 보면, 요청 실패 시 먼저 에러를 공통 레벨에서 판단하고 있었어요.

private func checkCommonError(_ error: Error) -> Bool {
    // 먼저 Error를 우리가 정의한 NetworkError 타입으로 변환해요.
    // 여기서 변환되지 않는 에러는 세부 분기 없이 공통 에러로 처리해요.
    guard let error = error as? NetworkError,
          case let .dogetherError(code, _) = error else { return true }

    // 아래 코드들은 "사용자가 바로 이해해야 하는 비즈니스 에러"예요.
    // 예: 로그인 만료, 이미 참여한 그룹, 정원 초과 등
    // 이런 경우는 공통 에러 화면이 아니라 별도 팝업으로 분기해요.
    return !(code == .ATF0002 || code == .ATF0003 ||
             code == .CGF0002 || code == .CGF0003 ||
             code == .CGF0004 || code == .CGF0005)
}

여기서 드러난 건, 이미 서버 응답 코드 기준으로도 에러의 성격이 꽤 다르다는 점이었어요.

  • 어떤 에러는 공통 실패로 봐야 했어요.
  • 어떤 에러는 로그인 만료처럼 계정 상태와 연결돼 있었어요.
  • 어떤 에러는 그룹 참여 불가처럼 비즈니스 제약이었어요.

그런데 이걸 사용자가 보기에는 모두 같은 실패처럼 보이면 문제가 생겨요.

  • 재시도하면 되는 상황과 아닌 상황을 구분하기 어려워요.
  • 실패 원인을 알 수 없어서 같은 행동을 반복하게 돼요.
  • 사용자는 앱이 단순히 “불친절하다”고 느끼기 쉬워요.

결국 필요한 건 에러를 하나로 처리하는 구조가 아니라, 에러의 성격에 따라 다른 후속 행동을 안내하는 UX였어요.

공통 에러는 “다시 시도”에 집중했어요

먼저 정리한 건, 모든 실패를 세세하게 설명하려고 하기보다 공통 에러가 필요한 범위를 분명히 하는 것이었어요.

 

네트워크 불안정, 서버 문제, 예측하기 어려운 실패처럼 사용자가 직접 원인을 해석하기 어려운 경우에는, 개별 화면 안에서 애매하게 처리하는 것보다 앱 차원의 공통 에러 화면으로 보여주는 편이 더 낫다고 판단했어요.

 

두게더에서는 이런 경우 NetworkManager에서 공통 에러 화면을 띄우도록 구성했어요.

func request<T: Decodable>(_ endpoint: NetworkEndpoint) async throws -> T {
    do {
        let response: ServerResponse<T> = try await NetworkService.shared.request(endpoint)
        ...
        return data
    } catch {
        // 지금 발생한 에러가 "공통 에러"인지 먼저 판단해요.
        if checkCommonError(error) {
            // 공통 에러 화면에서 "다시 시도"를 누르면
            // 방금 실패한 동일 요청을 다시 실행해요.
            let data: T = try await handleCommonError(endpoint)
            return data
        } else {
            // 공통 에러가 아니라면 세부 분기(팝업, 로그아웃 유도 등)로 넘겨요.
            throw handleDetailError(error)
        }
    }
}

여기서 중요했던 건 단순히 “에러 화면을 띄운다”가 아니었어요.

 

공통 에러는 사용자에게 선택지를 많이 주기보다, 지금 할 수 있는 가장 안전한 행동 하나를 명확히 주는 게 더 중요했어요. 그래서 두게더의 공통 에러 화면은 복잡한 설명 대신, 다시 시도에 집중한 구조로 만들었어요.

private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let retryButton = DogetherButton("다시 시도")

override func configureView() {
    // 제목은 "서비스 전체가 잠시 불안정한 상태"라는 느낌을 주도록 짧게 구성했어요.
    titleLabel.text = "서비스 이용이 원활하지 않아요"

    // 사용자가 당장 이해해야 할 핵심 메시지만 남겼어요.
    // 공통 에러에서는 상세 원인보다 "잠시 후 다시 시도"가 더 중요했어요.
    descriptionLabel.text = "잠시 후 다시 접속해주세요."
}

이 방식의 장점은 분명했어요.

  • 화면마다 다른 방식으로 실패를 보여주지 않아도 돼요.
  • 사용자는 최소한 “지금은 일시적인 실패일 수 있다”는 맥락을 이해할 수 있어요.
  • 재시도 흐름도 공통으로 보장할 수 있어요.

특히 재시도 버튼을 눌렀을 때 단순히 화면만 닫는 게 아니라, 이전 요청을 다시 실행하도록 연결한 점이 중요했어요.

func retryAction() {
    coordinator?.dismissErrorView() { [weak self] in
        guard let self else { return }

        // 이전에 쌓아둔 completion들을 꺼내면서
        // 실패했던 요청을 다시 실행해요.
        // 즉, "에러 화면 닫기"가 아니라 "같은 요청 재시도"에 가까운 동작이에요.
        while let completion = completions.popLast() {
            completion()
        }
    }
}

사용자 입장에서는 “에러가 났다”보다 “이어서 다시 시도할 수 있다”가 더 자연스러운 흐름이니까요.

하지만 모든 실패를 재시도로 풀 수는 없었어요

공통 에러 화면만으로는 해결되지 않는 문제도 분명히 있었어요.

 

대표적인 게 그룹 참여였어요. 두게더에서는 초대코드를 입력해 그룹에 참여하는 흐름이 있는데, 이때 실패 원인은 여러 가지일 수 있었어요.

  • 이미 참여한 그룹일 수 있어요.
  • 그룹 정원이 가득 찼을 수 있어요.
  • 종료되었거나 유효하지 않은 그룹일 수 있어요.

이 상황에서 공통 에러 화면으로 “다시 시도”만 보여주면 사용자 입장에서는 오히려 더 답답해져요. 실패 원인은 분명한데, 해결되지 않는 행동만 반복하게 되니까요.

 

그래서 그룹 참여 화면에서는 서버 응답 코드를 기준으로 사용자 메시지를 분기했어요.

catch let error as NetworkError {
    // 네트워크 에러 중에서도 서버가 내려준 비즈니스 에러 코드를 확인해요.
    if case let .dogetherError(code, _) = error {

        // 서버 코드 -> 사용자에게 보여줄 팝업 타입으로 변환해요.
        // 여기서 중요한 건 "에러 코드 자체"가 아니라
        // 사용자가 이해할 수 있는 문장과 행동으로 바꾸는 과정이에요.
        guard let alertType: AlertTypes =
                code == .CGF0002 ? .alreadyParticipated :
                code == .CGF0003 ? .fullGroup :
                code == .CGF0004 || code == .CGF0005 ? .unableToParticipate :
                nil else { return }

        coordinator?.showPopup(type: .alert, alertType: alertType) { [weak self] _ in
            guard let self else { return }

            // 확인 후에는 현재 가입 화면을 닫고 이전 흐름으로 되돌려요.
            // 사용자가 같은 화면 안에서 계속 같은 시도를 반복하지 않도록 한 처리예요.
            coordinator?.popViewController()
        }
    }
}

이렇게 바꾸고 나니 같은 실패라도 사용자에게 전달되는 의미가 완전히 달라졌어요.

“다시 시도해보세요”가 아니라,

  • 이미 참여한 그룹이에요
  • 그룹 인원이 가득 찼어요
  • 참여할 수 없는 그룹이에요

처럼 지금 상황을 더 직접적으로 이해할 수 있게 됐어요.

기술적으로는 서버 코드를 매핑한 것뿐이지만, UX 측면에서는 “실패를 설명하는 방식”이 달라진 거예요.

에러 코드를 사용자 언어로 번역하려고 했어요

이 과정에서 중요했던 건 서버 에러 코드를 그대로 노출하지 않는 거였어요.

 

개발자 입장에서는 ATF-0003, CGF-0003 같은 코드가 익숙할 수 있지만, 사용자에게 이런 정보는 문제 해결에 거의 도움이 되지 않아요. 오히려 불안감만 줄 수 있죠.

 

그래서 두게더에서는 에러 코드를 직접 보여주는 대신, 각 상황을 사용자 행동 기준으로 다시 정리했어요.

var title: String {
    switch self {
    case .needLogout:
        // 단순히 "인증 에러"라고 하지 않고
        // 사용자가 바로 이해할 수 있는 문장으로 바꿔요.
        return "로그인 정보가 만료됐어요"

    case .needRevoke:
        // Apple 로그인 특수 케이스도
        // 사용자가 실제로 해야 할 행동 중심으로 표현해요.
        return "로그인 연결을 해제해주세요"

    case .alreadyParticipated:
        // 그룹 참여 실패도 서버 코드가 아니라 상황 자체를 알려줘요.
        return "이미 참여한 그룹이에요"

    case .fullGroup:
        return "그룹 인원이 가득 찼어요"

    case .unableToParticipate:
        return "참여할 수 없는 그룹이에요"
    ...
    }
}

여기서 기준은 단순했어요.

“이 에러가 무엇인가?”보다

“사용자는 지금 무엇을 이해해야 하는가?”를 먼저 보자는 거였어요.

 

예를 들어 로그인 만료는 단순 오류가 아니라 다시 로그인해야 하는 상태였어요. Apple 로그인 리보크도 마찬가지였고요. 이 경우에는 실패 사실보다, 다음 행동을 더 명확히 안내하는 게 중요했어요.

 

이렇게 정리하고 나니 에러 처리가 기술적인 예외 처리에서 조금 더 사용자 흐름 설계에 가까운 작업처럼 느껴졌어요.

더 좋은 에러 UX는 사후 처리만으로 완성되지 않았어요

에러 팝업과 공통 에러 화면을 정리하고 나서도 한 가지가 더 남아 있었어요.

 

아무리 실패를 잘 설명해도, 애초에 막을 수 있는 잘못된 시도를 계속 서버까지 보내고 있다면 UX는 여전히 비효율적이라는 점이었어요.

그래서 두게더에서는 일부 흐름에서 사전 차단도 함께 보완했어요.

 

대표적으로 그룹 참여 화면에서는 초대코드가 8자리가 되기 전까지 가입 버튼을 비활성화했어요.

let code = String((codeTextField.text ?? "").prefix(codeMaxLength))
codeTextField.text = code

// 사용자가 입력한 초대코드를 ViewModel 쪽 상태에 반영해요.
delegate?.updateCodeAction(code: code)

// 초대코드가 8자리가 되기 전까지는 가입 버튼을 비활성화해요.
// 즉, 명백히 잘못된 입력은 서버까지 보내지 않도록 막아요.
delegate?.updateButtonStatusAction(
    status: code.count < codeMaxLength ? .disabled : .enabled
)

즉, 명백히 불완전한 입력은 서버까지 보내지 않도록 한 거예요.

 

투두 작성 화면도 비슷했어요. 공백만 입력했을 때는 추가 버튼을 활성화하지 않았고, 최대 개수에 도달하면 입력창 상태와 플레이스홀더를 함께 바꿔 더 이상 시도하지 않도록 만들었어요.

private func updateAddButtonStatus() {
    // "   " 같은 공백 입력은 실제 내용이 없는 것으로 판단해요.
    let trimmed = (currentTodo ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

    // 내용이 있을 때만 버튼을 활성화해서
    // 빈 투두가 추가되는 걸 막아요.
    addButton.isEnabled = !trimmed.isEmpty

    // 버튼 색상도 함께 바꿔서
    // 지금 누를 수 있는 상태인지 시각적으로 전달해요.
    addButton.backgroundColor = trimmed.isEmpty ? .grey600 : .blue300
}
let canAddTodo = (currentTodos ?? []).count < todoMaxCount

// 이미 최대 개수를 채웠다면 입력창 자체를 비활성화해요.
todoTextField.isEnabled = canAddTodo

// 사용자가 왜 더 입력할 수 없는지 플레이스홀더 문구로 설명해요.
todoTextField.attributedPlaceholder = NSAttributedString(
    string: canAddTodo ? "예) 30분 걷기, 책 20페이지 읽기" : "모든 투두를 작성했어요!",
    attributes: ...
)

이 부분을 적용하고 나니 느낀 게 있었어요.

좋은 에러 UX는 에러 화면을 잘 만드는 것만으로 완성되지 않더라고요.

사용자가 불필요한 실패를 덜 겪도록 만드는 것까지 포함해야 했어요.

 

즉,

  • 공통 에러는 공통 에러대로 단순하게 처리하고
  • 비즈니스 제약은 상황별로 다르게 안내하고
  • 사전에 막을 수 있는 입력은 미리 막는 것

이 세 가지가 함께 있어야 실패 경험이 정리되기 시작했어요.

결과

이 개선 이후 가장 크게 달라진 건, 실패 상황에서 사용자가 해야 할 행동이 훨씬 분명해졌다는 점이었어요.

  • 일시적인 실패는 공통 에러 화면에서 다시 시도할 수 있었어요.
  • 로그인 만료나 계정 상태 문제는 재로그인, 연결 해제 같은 후속 행동으로 자연스럽게 이어질 수 있었어요.
  • 그룹 참여 제약은 상황별 팝업으로 원인을 더 명확히 이해할 수 있었어요.
  • 입력 단계에서 막을 수 있는 문제는 버튼 비활성화와 안내 문구로 사전에 줄일 수 있었어요.

무엇보다 좋았던 건, 에러를 “예외 상황”으로만 보지 않게 됐다는 점이었어요. 이제는 새로운 기능을 만들 때도 “실패하면 어떤 안내를 보여줄까?”보다 “이 실패는 공통 처리인지, 비즈니스 제약인지, 아니면 미리 막을 수 있는 문제인지”를 먼저 나눠보게 됐어요.

이 기준이 생긴 것 자체가 꽤 큰 변화였어요.

마치며

에러 처리는 종종 성공 흐름을 다 만든 뒤 마지막에 붙이는 일처럼 느껴질 때가 있어요.

 

하지만 두게더를 개발하면서 느낀 건, 에러 UX야말로 사용자가 서비스의 친절함을 가장 크게 체감하는 순간 중 하나라는 점이었어요.

성공은 원래 기대한 결과지만, 실패는 사용자를 멈추게 만들어요.

 

그래서 실패했을 때 더 중요해지는 건 “문제가 생겼다”는 사실보다, “왜 이런 일이 일어났고 이제 무엇을 해야 하는가”를 이해할 수 있게 돕는 일이었어요.

 

두게더의 에러 UX 개선도 결국 그 방향으로 정리되어 갔어요.

모든 실패를 같은 방식으로 다루지 않고, 공통 실패와 비즈니스 실패를 나누고, 가능하면 사전에 막을 수 있는 시도는 미리 줄이는 쪽으로요.

 

이번 작업을 하면서 에러를 줄이는 것만큼이나, 실패했을 때 사용자가 지금 상황을 이해하고 다음 행동으로 이어질 수 있게 돕는 일이 중요하다는 걸 더 분명히 느꼈어요.

 

사용자는 실패 자체보다, 실패를 이해할 수 없을 때 더 크게 불편함을 느낀다고 생각해요.

이번 에러 UX 개선은 그 당연한 사실을, 실제 서비스 코드 안에서 다시 배운 과정이었어요.