민우님이 네트워크 레이어를 공부하고 조만간 PR올린다고 하여서
바뀌기전의 코드를 기록해놓고 변화를 비교해보려고 한다!
지금의 네트워크 레이어는 완전 없는 상태는 아니고, 초반에 페어프로그래밍으로 간략하게 작성한 상태이다.
당시에 어려워했던 기억이 있어서 NetworkManager는 빼고 작성하였다.
아마 민우님이 이번 PR에 올려주실거같다.

현재는 4개의 파일이 작성되어있다.
NetworkError: 열거형
HttpMethod: 열거형
Requeset: 클래스
Requestable: 프로토콜
하나하나 조금 자세히 보고 가자.
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에 쿼리파라미터를 추가하는데 주로 사용된다.urlRequest:URLRequest를 생성하는 과정에서 문제가 발생했을 때 사용된다.server(ServerError):ServerError 열거형을 포함하여 서버 에러를 세부적으로 처리한다.emptyData:parsing:decoding(Error):Decodable)하는 과정에서 발생한 에러를 포함한다.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는 HTTP 상태 코드와 서버 에러를 명확히 정의하기 위해 사용된 열거형이다.Int을 사용하여 HTTP 상태 코드를 직접 매핑할 수 있다.
unknown:
badRequest (400):
unauthorized (401):
forbidden (403):
notFound (404):
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}enum HttpMethod: String은 열거형이 String 타입의 원시값(raw value)을 가진다는 것을 의미한다.
| CRUD | HTTP Method | 설명 |
|---|---|---|
| Read | GET | 데이터를 요청하기 위한 메서드로, URL 쿼리 파라미터를 통해 서버로 전달됨 |
| Create | POST | 데이터를 서버에 제출하기 위한 메서드로, 요청 본문(body)에 데이터 포함됨 |
| Update | PUT | 서버에 데이터를 수정 또는 생성하기 위한 메서드 |
| Delete | DELETE | 서버에서 데이터를 삭제하기 위한 메서드 |
사용 예시는 다음과 같다.
let method = HttpMethod.get
print(method.rawValue) // "GET"
if let method = HttpMethod(rawValue: "POST") {
print(method) // post
}
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() 메서드makeURL() 메서드를 호출하여 URL을 만듦makeURL() 메서드는 baseURL, path와 queryItems를 설정함Requestable 프로토콜에 정의되어있음!nil 반환URLRequest 객체를 생성URLRequest 객체를 반환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
}
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 연동이 처음이라 네트워크 레이어 생각도 않고 막무가내로 해보았다.

UML 다이어그램을 오랜만에 그려봤다.
화살표도 막 사용했고, 클래스와 구조체도 구분안해놨다.. 정말 흐름만 파악하자
전체 코드는 보기 불편하니 PR을 참고하는게 좋을거같다.
여기서 볼 내용은 다음과 같다.
1. LoginViewController가 LoginManager한테 login 시키는 코드,
2. LoginManager가 login하는 하드코딩 코드
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.text와 loginView.pwTextField.text를 사용하여!username.isEmpty와 !password.isEmpty를 통해updateErrorLabel(message:)을 호출하여return)한다.LoginManager.shared.login(username: username, password: password) { [weak self] result inLoginManager.shared: 싱글톤 패턴으로 구현된 LoginManager를 통해username과 password: 서버로 전달할 사용자 입력 값(파라미터)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: 서버 응답은 백그라운드 스레드에서 처리되고,switch result: LoginManager로부터 반환된 결과(Result<Token, Error>)를 처리.success(let token): 로그인이 성공하면 서버에서 받은 토큰을 사용해 handleLoginSuccess(token)을 호출하여 추가 작업을 처리한다.handleLoginSuccess(token)은 토큰을 저장하고 메인화면으로 넘어가는 로직이다..failure: 로그인이 실패하면 에러 메시지를 표시한다.LoginManager
//
// 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)completion: 비동기 작업 결과를 전달하기 위한 클로저이다.Token을 반환하고, 실패하면 Error를 반환한다.@escaping은 클로저가 함수의 실행 컨텍스트를 벗어나도 실행될 수 있음을 나타내는 키워드이다.@escaping 키워드를 사용해야 한다.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 inURLSession.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", ...)))
}
}loginResponse.data가 Token인지 확인하고 성공 시 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))으로 에러를 반환한다.