[팝콘]개발기록 - 네트워크 레이어 없이 로그인 API연동 해보기

sunghun kim·2025년 3월 17일

[팝콘-프로젝트]

목록 보기
5/6

민우님이 네트워크 레이어를 공부하고 조만간 PR올린다고 하여서
바뀌기전의 코드를 기록해놓고 변화를 비교해보려고 한다!

지금의 네트워크 레이어는..

지금의 네트워크 레이어는 완전 없는 상태는 아니고, 초반에 페어프로그래밍으로 간략하게 작성한 상태이다.

https://github.com/GDSC-Popcorn/Popcorn-iOS/pull/6

당시에 어려워했던 기억이 있어서 NetworkManager는 빼고 작성하였다.
아마 민우님이 이번 PR에 올려주실거같다.

스크린샷 2025-01-22 15.26.57.png

현재는 4개의 파일이 작성되어있다.

NetworkError: 열거형

HttpMethod: 열거형

Requeset: 클래스

Requestable: 프로토콜

하나하나 조금 자세히 보고 가자.

NetworkError, ServerError

  • 코드보기
    import Foundation
    
    enum NetworkError: LocalizedError {
        case unknown
        case components
        case urlRequest
        case server(ServerError)
        case emptyData
        case parsing
        case decoding(Error)
    
        var errorDescription: String? {
            switch self {
            case .unknown:
                return "Unknown error"
            case .components:
                return "Invalid URL components"
            case .urlRequest:
                return "Invalid URL request"
            case .server(let serverError):
                return "Server Error: \(serverError)"
            case .emptyData:
                return "Empty data"
            case .parsing:
                return "Failed to parse data"
            case .decoding(let error):
                return "Error While Decoding: \(error)"
            }
        }
    }
    
    enum ServerError: Int {
        case unknown
        case badRequest = 400
        case unauthorized = 401
        case forbidden = 403
        case notFound = 404
    }

NetworkError는 네트워크 요청 중에 발생할 수 있는 다양한 에러를 표현하는 열거형이고, LocalizedError 프로토콜을 채택하고 있다.

  • 선언된 케이스!
    • unknown:
      • 알 수 없는 에러가 발생했을 때 사용된다.
      • 디버깅을 위해 기본적으로 모든 에러를 예상하지 못했을 때 사용할 수 있다.
    • components:
      • URLComponents를 생성하거나 구성할 때 문제가 발생한 경우를 나타낸다.
      • URLComponents는 URL에 쿼리파라미터를 추가하는데 주로 사용된다.
      • 예: 잘못된 형식의 URL을 조합했을 때
    • urlRequest:
      • URLRequest를 생성하는 과정에서 문제가 발생했을 때 사용된다.
      • 예: HTTP 메서드, 헤더, 파라미터 설정에서 오류가 있을 때
    • server(ServerError):
      • 서버에서 반환된 HTTP 상태 코드와 관련된 에러를 나타낸다.
      • 서버 응답이 잘못된 경우와 관련이 있다.
      • 추가로 정의된 ServerError 열거형을 포함하여 서버 에러를 세부적으로 처리한다.
    • emptyData:
      • 서버의 응답이 비어 있을 때 사용된다.
      • 예: 네트워크 요청은 성공했지만 데이터가 반환되지 않은 경우
    • parsing:
      • 데이터를 파싱하는 과정에서 문제가 발생한 경우를 나타낸다.
      • 예: JSON 데이터를 딕셔너리나 특정 데이터 구조로 변환하는 과정에서 발생하는 문제
    • decoding(Error):
      • 데이터를 디코딩(Decodable)하는 과정에서 발생한 에러를 포함한다.
  • var errorDescription Description은 기술, 기재 등의 뜻으로 errorDescription은 ****각 케이스에 대해서 해당하는 에러메시지를 반환하는 계산프로퍼티이다. 간단한 사용방법으로는 다음과 같다.
    enum NetworkError: LocalizedError {
        case unknown
        case badRequest
        case serverError
        
        var errorDescription: String? {
            switch self {
            case .unknown:
                return "An unknown error occurred."
            case .badRequest:
                return "Bad request. Please check your input."
            case .serverError:
                return "A server error occurred. Please try again later."
            }
        }
    }
    
    let error = NetworkError.badRequest
    print(error.errorDescription ?? "No description") 
    // 출력: "Bad request. Please check your input."
  • ServerError ServerError는 HTTP 상태 코드와 서버 에러를 명확히 정의하기 위해 사용된 열거형이다.
    • Int을 사용하여 HTTP 상태 코드를 직접 매핑할 수 있다.

      선언된 케이스

    • unknown:

      • 상태 코드가 명시적으로 정의되지 않은 경우
    • badRequest (400):

      • 클라이언트가 잘못된 요청을 보낸 경우
    • unauthorized (401):

      • 인증되지 않은 요청
      • 예: 유효하지 않은 토큰으로 요청을 보낸 경우
    • forbidden (403):

      • 요청이 금지된 리소스에 접근하려는 경우
    • notFound (404):

      • 요청한 리소스가 서버에서 찾을 수 없는 경우

HttpMethod

  • 코드보기
    enum HttpMethod: String {
        case get = "GET"
        case post = "POST"
        case put = "PUT"
        case delete = "DELETE"
    }

enum HttpMethod: String은 열거형이 String 타입의 원시값(raw value)을 가진다는 것을 의미한다.

CRUDHTTP Method설명
ReadGET데이터를 요청하기 위한 메서드로, URL 쿼리 파라미터를 통해 서버로 전달됨
CreatePOST데이터를 서버에 제출하기 위한 메서드로, 요청 본문(body)에 데이터 포함됨
UpdatePUT서버에 데이터를 수정 또는 생성하기 위한 메서드
DeleteDELETE서버에서 데이터를 삭제하기 위한 메서드

사용 예시는 다음과 같다.

let method = HttpMethod.get
print(method.rawValue) // "GET"

if let method = HttpMethod(rawValue: "POST") {
    print(method) // post
}

Request

  • 코드보기
    import Foundation
    
    final class Request: Requestable {
        var baseURL: String
        var httpMethod: HttpMethod
        var path: String
        var queryItems: [URLQueryItem]
        var headers: [String: String]
        var bodyParameters: Encodable
    
        init(
            baseURL: String = "",
            httpMethod: HttpMethod,
            path: String,
            queryItems: [URLQueryItem] = [],
            headers: [String: String] = [:],
            bodyParameters: Encodable
        ) {
            self.baseURL = baseURL
            self.httpMethod = httpMethod
            self.path = path
            self.queryItems = queryItems
            self.headers = headers
            self.bodyParameters = bodyParameters
        }
    
        func makeURLRequest() -> URLRequest? {
            guard let url = makeURL() else { return nil }
    
            var urlRequest = URLRequest(url: url)
            urlRequest.allHTTPHeaderFields = headers
            urlRequest.httpMethod = httpMethod.rawValue
    
            return urlRequest
        }
    }
  • 프로퍼티
    • baseURL: API 요청의 기본 URL(예: https://www.popcorm.com)
    • httpMethod: HTTP 메서드(GET, POST, PUT, DELETE 등), HttpMethod 열거형으로 정의
    • path: baseURL 뒤에 추가되는 경로(예: /login)
    • queryItems: URL에 추가되는 쿼리 파라미터(예: ?username=김성훈)
    • headers: HTTP 요청 헤더(예: Content-Type: application/json)
    • bodyParameters: 요청 본문에 포함되는 데이터(POST/PUT 요청에서 사용)
  • makeURLRequest() 메서드
    • URL 생성
      • makeURL() 메서드를 호출하여 URL을 만듦
      • makeURL() 메서드는 baseURL, path와 queryItems를 설정함
        Requestable 프로토콜에 정의되어있음!
      • URL이 생성되지 않으면 nil 반환
    • URLRequest 생성:
      • URL을 사용해 URLRequest 객체를 생성
      • 헤더와 HTTP 메서드를 설정
    • URLRequest 반환:
      • 생성한 URLRequest 객체를 반환
  • 왜 bodyParameters는 설정하지 않나요?
    • makeURL함수에서는 baseURL, path, queryItems를 가지고 url을 생성을 합니다.
      그리고 makeURLRequest() 메서드에서는 헤더와 httpMethod를 설정을 합니다.

    • 그런데 왜 bodyParameters 는 설정하지 않나요?
      이 답변은 GET에서는 body가 있으면 안되기 때문입니다.
      그래서 따로 body를 설정하는 로직을 추가하거나
      아래와같이 분기설정을 하면 됩니다.!

      func makeURLRequest() -> URLRequest? {
          guard let url = makeURL() else { return nil }
      
          var urlRequest = URLRequest(url: url)
          urlRequest.allHTTPHeaderFields = headers
          urlRequest.httpMethod = httpMethod.rawValue
      
          // bodyParameters 설정
          if httpMethod == .post || httpMethod == .put || httpMethod == .patch {
              if let bodyData = try? JSONEncoder().encode(bodyParameters) {
                  urlRequest.httpBody = bodyData
              }
          }
      
          return urlRequest
      }

Requestable

  • 코드보기
    import Foundation
    
    protocol Requestable {
        var baseURL: String { get }
        var httpMethod: HttpMethod { get }
        var path: String { get }
        var queryItems: [URLQueryItem] { get }
        var headers: [String: String] { get }
        var bodyParameters: Encodable { get }
    
        func makeURLRequest() -> URLRequest?
    }
    
    extension Requestable {
        func makeURL() -> URL? {
            guard var components = URLComponents(string: baseURL) else { return nil }
            components.path = path
            components.queryItems = queryItems
    
            return components.url
        }
    }

위에서 봤던 Request를 만들기 위해 필요한 프로퍼티와 메서드를 정의하는 기본틀 입니다.

네트워크 레이어 없이 하드코딩을 한다면?

네트워크 레이어가 완성되지 않아서 로그인 API연동을 직접 해보았다.

사실 API 연동이 처음이라 네트워크 레이어 생각도 않고 막무가내로 해보았다.

흐름

스크린샷 2025-01-22 17.17.31.png

UML 다이어그램을 오랜만에 그려봤다.

화살표도 막 사용했고, 클래스와 구조체도 구분안해놨다.. 정말 흐름만 파악하자

코드

전체 코드는 보기 불편하니 PR을 참고하는게 좋을거같다.

https://github.com/GDSC-Popcorn/Popcorn-iOS/pull/83

여기서 볼 내용은 다음과 같다.
1. LoginViewControllerLoginManager한테 login 시키는 코드,
2. LoginManagerlogin하는 하드코딩 코드

  1. LoginViewController

    @objc private func loginButtonTapped() {
        guard let username = loginView.idTextField.text, !username.isEmpty,
              let password = loginView.pwTextField.text, !password.isEmpty else {
            updateErrorLabel(message: "아이디 또는 비밀번호를 입력해주세요.")
            return
        }
    
        LoginManager.shared.login(username: username, password: password) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let token):
                    self?.handleLoginSuccess(token)
                case .failure:
                    self?.updateErrorLabel(message: "아이디 또는 비밀번호를 확인해주세요.")
                }
            }
        }
    }
    • 아이디와 비밀번호 유효성 검사
      guard let username = loginView.idTextField.text, !username.isEmpty,
            let password = loginView.pwTextField.text, !password.isEmpty else {
          updateErrorLabel(message: "아이디 또는 비밀번호를 입력해주세요.")
          return
      }
      • loginView.idTextField.textloginView.pwTextField.text를 사용하여
        아이디와 비밀번호 필드의 텍스트를 가져온다.
      • !username.isEmpty!password.isEmpty를 통해
        두 입력 필드가 비어 있는지 검사한다.
      • 만약 하나라도 비어 있으면, updateErrorLabel(message:)을 호출하여
        사용자에게 에러 메시지를 표시하고 함수 실행을 중단(return)한다.
    • 로그인 요청
      LoginManager.shared.login(username: username, password: password) { [weak self] result in
      • LoginManager.shared: 싱글톤 패턴으로 구현된 LoginManager를 통해
        로그인 요청을 처리한다.
      • usernamepassword: 서버로 전달할 사용자 입력 값(파라미터)
      • 클로저(completion handler)를 사용하여 서버 응답에 따라 다른 작업을 수행한다.
      • [weak self]: 클로저 내부에서 self를 약하게 참조하여 강한 순환 참조를 방지한다.
    • 서버 응답 처리
      DispatchQueue.main.async {
          switch result {
          case .success(let token):
              self?.handleLoginSuccess(token)
          case .failure:
              self?.updateErrorLabel(message: "아이디 또는 비밀번호를 확인해주세요.")
          }
      }
      • DispatchQueue.main.async: 서버 응답은 백그라운드 스레드에서 처리되고,
        UI 업데이트는 메인 스레드에서 실행한다.
      • switch result: LoginManager로부터 반환된 결과(Result<Token, Error>)를 처리
        • .success(let token): 로그인이 성공하면 서버에서 받은 토큰을 사용해 handleLoginSuccess(token)을 호출하여 추가 작업을 처리한다.
          handleLoginSuccess(token)은 토큰을 저장하고 메인화면으로 넘어가는 로직이다.
        • .failure: 로그인이 실패하면 에러 메시지를 표시한다.
  2. LoginManager

    • 참고 - LoginResponse구조체
      //
      //  LoginResponse.swift
      //  Popcorn-iOS
      //
      //  Created by 김성훈 on 1/12/25.
      //
      
      import Foundation
      
      struct LoginResponse: Decodable {
          let resultCode: Int
          let status: String
          let data: LoginResponseData
      }
      
      enum LoginResponseData: Decodable {
          case token(Token)
          case errorMessage(String)
      
          init(from decoder: Decoder) throws {
              let container = try decoder.singleValueContainer()
              if let token = try? container.decode(Token.self) {
                  self = .token(token)
              } else if let errorMessage = try? container.decode(String.self) {
                  self = .errorMessage(errorMessage)
              } else {
                  throw DecodingError.typeMismatch(
                      LoginResponseData.self,
                      DecodingError.Context(
                          codingPath: decoder.codingPath,
                          debugDescription: "Expected Token or String in data field"
                      )
                  )
              }
          }
      }
      
    import Foundation
    
    final class LoginManager {
        static let shared = LoginManager()
        private init() {}
    
        func login(username: String, password: String, completion: @escaping (Result<Token, Error>) -> Void) {
            let url = URL(string: "https://popcorm.store/login")!
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    
            let requestBody: [String: String] = [
                "username": username,
                "password": password
            ]
    
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: [])
            } catch {
                completion(.failure(error))
                return
            }
    
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    print("로그인 요청 실패: \(error.localizedDescription)")
                    completion(.failure(error))
                    return
                }
    
                guard let httpResponse = response as? HTTPURLResponse, let data = data else {
                    print("로그인 응답 없음 또는 잘못된 응답")
                    completion(.failure(NSError(domain: "InvalidResponse", code: -1, userInfo: nil)))
                    return
                }
    
                do {
                    let decoder = JSONDecoder()
                    let loginResponse = try decoder.decode(LoginResponse.self, from: data)
    
                    if httpResponse.statusCode == 200 {
                        if case let .token(token) = loginResponse.data {
                            completion(.success(token))
                        } else {
                            print("로그인 성공 응답에서 토큰 정보가 올바르지 않음")
                            completion(.failure(NSError(domain: "InvalidTokenData",
                                                        code: -1,
                                                        userInfo: [NSLocalizedDescriptionKey: "Unexpected token data"])))
                        }
                    } else {
                        if case let .errorMessage(errorMessage) = loginResponse.data {
                            print("로그인 실패. 상태 코드: \(httpResponse.statusCode), 오류 메시지: \(errorMessage)")
                            completion(.failure(NSError(domain: "LoginFailed",
                                                        code: httpResponse.statusCode,
                                                        userInfo: [NSLocalizedDescriptionKey: errorMessage])))
                        } else {
                            print("로그인 실패. 상태 코드: \(httpResponse.statusCode), 오류 메시지를 디코딩할 수 없음")
                            completion(.failure(NSError(domain: "UnknownError",
                                                        code: httpResponse.statusCode,
                                                        userInfo: nil)))
                        }
                    }
                } catch {
                    print("로그인 데이터 처리 실패: \(error.localizedDescription)")
                    completion(.failure(error))
                }
            }
            task.resume()
        }
    }
    • 클래스 정의
      final class LoginManager {
          static let shared = LoginManager()
          private init() {}
      • 싱글톤 패턴으로 앱 전체에서 동일한 인스턴스가 생성될 수 있게 해줍니다.
    • 로그인 메서드 정의
      func login(username: String, password: String, completion: @escaping (Result<Token, Error>) -> Void)
      • username, password: 사용자가 입력한 아이디와 비밀번호
      • completion: 비동기 작업 결과를 전달하기 위한 클로저이다.
        성공하면 Token을 반환하고, 실패하면 Error를 반환한다.
      • @escaping은 클로저가 함수의 실행 컨텍스트를 벗어나도 실행될 수 있음을 나타내는 키워드이다.
        함수의 파라미터로 전달된 클로저는 함수의 실행이 끝날 때까지 실행이 보장된다.
        하지만 함수가 종료된 이후에도 클로저가 실행될 가능성이 있다면 @escaping 키워드를 사용해야 한다.
    • URL생성, HTTP요청 설정
      let url = URL(string: "https://popcorm.store/login")!
      var request = URLRequest(url: url)
      request.httpMethod = "POST"
      request.setValue("application/json", forHTTPHeaderField: "Content-Type")
      • url: API의 엔드포인트 URL이다. (하드코딩이 제일 잘 보이는 줄이다.)
      • request.httpMethod: HTTP 메서드를 POST로 설정
      • request.setValue: 요청 헤더에 Content-Type을 설정하여 요청 본문이 JSON 형식임을 명시한다.
    • 요청 본문 설정
      let requestBody: [String: String] = [
          "username": username,
          "password": password
      ]
      
      do {
          request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: [])
      } catch {
          completion(.failure(error))
          return
      }
      • requestBody: 서버에 전달할 데이터이다. 딕셔너리 형태이다.
      • JSONSerialization: 딕셔너리를 JSON 데이터로 변환
      • 변환 실패 시 completion(.failure(error))로 에러를 반환하고 작업을 중단한다.
    • 네트워크 요청 실행
      let task = URLSession.shared.dataTask(with: request) { data, response, error in
      • URLSession.shared.dataTask: 비동기적으로 네트워크 요청을 실행한다.
      • data: 서버에서 응답받은 데이터
      • response: HTTP 응답 객체
      • error: 네트워크 요청 중 발생한 에러
    • 에러처리
      if let error = error {
          print("로그인 요청 실패: \(error.localizedDescription)")
          completion(.failure(error))
          return
      }
      • 네트워크 요청 중 에러가 발생하면 이를 출력하고 completion(.failure(error))으로 처리 결과를 전달한다.
    • 응답 데이터 확인
      guard let httpResponse = response as? HTTPURLResponse, let data = data else {
          print("로그인 응답 없음 또는 잘못된 응답")
          completion(.failure(NSError(domain: "InvalidResponse", code: -1, userInfo: nil)))
          return
      }
      • httpResponse: 서버의 HTTP 응답이 올바르게 수신되었는지 확인한다.
      • data: 서버로부터 받은 데이터가 있는지 확인한다.
      • 둘 중 하나라도 없으면 오류를 반환한다.
    • 응답 데이터 디코딩
      do {
          let decoder = JSONDecoder()
          let loginResponse = try decoder.decode(LoginResponse.self, from: data)
      • JSONDecoder: JSON 데이터를 Swift 객체로 변환하는 데 사용된다.
      • decode(LoginResponse.self, from: data): 응답 데이터를 LoginResponse 객체로 디코딩한다.
    • 로그인 성공 처리
      if httpResponse.statusCode == 200 {
          if case let .token(token) = loginResponse.data {
              completion(.success(token))
          } else {
              print("로그인 성공 응답에서 토큰 정보가 올바르지 않음")
              completion(.failure(NSError(domain: "InvalidTokenData", ...)))
          }
      }
      • HTTP 상태 코드가 200인 경우 loginResponse.dataToken인지 확인하고 성공 시 completion(.success(token))을 호출한다.
    • 로그인 실패 처리
      if case let .errorMessage(errorMessage) = loginResponse.data {
          print("로그인 실패. 상태 코드: \(httpResponse.statusCode), 오류 메시지: \(errorMessage)")
          completion(.failure(NSError(domain: "LoginFailed", ...)))
      } else {
          print("로그인 실패. 상태 코드: \(httpResponse.statusCode), 오류 메시지를 디코딩할 수 없음")
          completion(.failure(NSError(domain: "UnknownError", ...)))
      }
      • 실패 시 상태 코드와 오류 메시지를 확인한다.
    • 디코딩 에러 처리
      } catch {
          print("로그인 데이터 처리 실패: \(error.localizedDescription)")
          completion(.failure(error))
      }
      • 디코딩 중 에러가 발생한 경우 이를 출력하고 completion(.failure(error))으로 에러를 반환한다.
profile
기죽지않기

0개의 댓글