반응형

iOS 개발자가 많이 하는 실수 - 네트워크 에러 및 HTTP 상태 코드(4xx/5xx)를 무시하는 실수

 

OS 네트워크 코드를 처음 작성할 때 가장 많이 하는 실수 중 하나는:

  • error만 대충 체크하거나
  • 아예 체크도 안 하고
  • HTTP 상태 코드(4xx, 5xx)를 완전히 무시하는 것

입니다.

 

1. 문제 패턴: 에러/상태코드 무시하고 data만 사용하는 코드

대표적인 코드:

URLSession.shared.dataTask(with: url) { data, response, error in
    guard let data = data else { return }

    let user = try? JSONDecoder().decode(User.self, from: data)
    DispatchQueue.main.async {
        self.updateUI(with: user)
    }
}.resume()

여기서 흔한 문제점:

  1. error를 전혀 보지 않는다.
  2. response가 HTTP 200인지, 404인지, 500인지 확인하지 않는다.
  3. 서버가 에러 HTML, 에러 JSON을 내려줘도 그대로 decode를 시도한다.

결과:

  • 서버측 에러가 나도 앱은 “정상 응답”인 것처럼 행동
  • 사용자에게 “알 수 없는 오류” UI만 표시하거나
  • 조용히 실패해서 아무것도 안 보이는 상태가 된다.

2. URLSession 콜백에서 확인해야 할 3가지

URLSession의 기본 콜백 시그니처:

(data: Data?, response: URLResponse?, error: Error?)

실무에서는 이 세 가지를 모두 확인해야 한다.

  1. transport 레벨 에러 (error)
    • 네트워크 연결 실패, 타임아웃, DNS 문제 등
  2. HTTP 레벨 상태 코드 ((response as? HTTPURLResponse)?.statusCode)
    • 200, 204, 400, 401, 404, 500 등
  3. 응답 데이터 (data)
    • JSON, 빈 바디, 에러 응답 등

3. 올바른 기본 처리 흐름 예시

URLSession.shared.dataTask(with: url) { data, response, error in
    // 1) Transport 에러 체크 (네트워크 자체의 문제)
    if let error = error {
        print("Network error:", error)
        // 사용자에게 네트워크 오류 안내
        return
    }

    // 2) HTTP 상태 코드 체크
    guard let httpResponse = response as? HTTPURLResponse else {
        print("Invalid response")
        return
    }

    guard (200..<300).contains(httpResponse.statusCode) else {
        print("Server error status:", httpResponse.statusCode)
        // 상태코드별 에러 처리 (401, 403, 500 등)
        return
    }

    // 3) 실제 데이터 체크
    guard let data = data else {
        print("Empty data")
        return
    }

    // 4) 데이터 파싱/디코드
    do {
        let user = try JSONDecoder().decode(User.self, from: data)
        DispatchQueue.main.async {
            self.updateUI(with: user)
        }
    } catch {
        print("Decode error:", error)
    }
}.resume()

이 패턴만 잘 지켜도 네트워크 관련 버그의 상당 부분을 줄일 수 있습니다.


4. Transport 에러 vs HTTP 에러의 차이

4-1. Transport 에러 (error)

  • DNS 실패
  • 연결 타임아웃
  • 인터넷 연결 끊김
  • TLS/SSL 문제 등

이 경우는 네트워크 자체가 실패한 것이므로

사용자에게 “네트워크 오류”를 보여주는 것이 적절합니다.


4-2. HTTP 에러 (4xx, 5xx 등)

  • 4xx: 클라이언트 요청이 잘못되었거나 권한 문제
    • 400 Bad Request
    • 401 Unauthorized
    • 403 Forbidden
    • 404 Not Found 등
  • 5xx: 서버 내부 문제
    • 500 Internal Server Error
    • 502 Bad Gateway
    • 503 Service Unavailable 등

이 경우:

  • 네트워크는 정상적으로 연결되었지만,
  • 서버가 요청을 처리하지 못한 것이므로
    • 로그인 문제, 권한 문제, 서버 점검 등의 UI를 보여주는 것이 맞습니다.

5. 상태 코드별 처리 전략 (예시)

실무에서는 보통 다음처럼 상태코드를 그룹으로 나누어 처리합니다.

switch httpResponse.statusCode {
case 200..<300:
    // 성공

case 400:
    // 잘못된 요청 (클라이언트 버그 가능성)

case 401:
    // 인증 필요 → 로그인 화면으로 유도

case 403:
    // 권한 없음 → 접근 불가 안내

case 404:
    // 리소스 없음 → "삭제되었거나 존재하지 않음" 안내

case 500..<600:
    // 서버 측 문제 → "잠시 후 다시 시도해 주세요" 안내

default:
    // 정의되지 않은 기타 상태
    break
}

이 로직을 서비스 공통 레이어에 모아두면,

각 화면에서는 성공 케이스만 신경 쓰면 됩니다.


6. Result 타입 또는 에러 타입 설계

실무에서는 다음과 같이 네트워크 에러를 하나의 enum으로 정의하고

모든 네트워크 로직이 이 타입을 사용하도록 통일하는 것이 좋습니다.

enum NetworkError: Error {
    case transportError(Error)
    case serverError(statusCode: Int, data: Data?)
    case decodingError(Error)
    case invalidResponse
    case emptyData
}

사용 예:

func requestUser(completion: @escaping (Result<User, NetworkError>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(.transportError(error)))
            return
        }

        guard let httpResponse = response as? HTTPURLResponse else {
            completion(.failure(.invalidResponse))
            return
        }

        guard (200..<300).contains(httpResponse.statusCode) else {
            completion(.failure(.serverError(statusCode: httpResponse.statusCode, data: data)))
            return
        }

        guard let data = data else {
            completion(.failure(.emptyData))
            return
        }

        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(.decodingError(error)))
        }
    }.resume()
}

이렇게 해두면, UI 쪽에서는:

requestUser { result in
    switch result {
    case .success(let user):
        // UI 업데이트
    case .failure(let error):
        // 에러 타입에 따라 다른 메시지 표시
    }
}

처럼 명확하게 처리할 수 있습니다.


7. async/await 환경에서의 에러 처리

Swift Concurrency를 사용할 경우:

func fetchUser() async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }

    guard (200..<300).contains(httpResponse.statusCode) else {
        throw NetworkError.serverError(statusCode: httpResponse.statusCode, data: data)
    }

    do {
        return try JSONDecoder().decode(User.self, from: data)
    } catch {
        throw NetworkError.decodingError(error)
    }
}

사용:

Task {
    do {
        let user = try await fetchUser()
        await MainActor.run {
            self.updateUI(with: user)
        }
    } catch {
        // NetworkError로 캐스팅해서 타입별 처리 가능
        print("Error:", error)
    }
}

8. 실무용 체크리스트

네트워크 코드를 작성할 때 다음을 항상 확인합니다.

  1. error를 체크하는가?
  2. response를 HTTPURLResponse로 캐스팅하여 statusCode를 확인하는가?
  3. 200..<300 범위만 성공으로 취급하는가?
  4. 4xx / 5xx에 대해 적절한 메시지/로직을 구현했는가?
  5. 디코딩 실패(DecodingError)를 구분해 로그를 남기는가?
  6. 공통 네트워크 레이어에서 에러 처리 로직을 재사용 가능한 형태로 빼두었는가?

9. 요약

  • error == nil이라고 해서 요청이 성공한 것은 아니다.
  • HTTP 상태 코드가 2xx인지 반드시 확인해야 한다.
  • 4xx, 5xx를 무시하면 서버 장애, 인증 문제, 권한 문제를 모두 “그냥 nil”로 처리하게 된다.
  • 실무에서는:
    • Transport 에러, HTTP 에러, 디코딩 에러를 구분하고
    • 각각 다른 UI/동작으로 대응하는 것이 중요하다.

핵심 문장:

네트워크 코드에서는 data만 보지 말고, error와 statusCode를 함께 보라.

반응형
Posted by 까칠코더
,