Search

URL를 깔끔하게 쓰는 법이 없을까?

 들어가며

프로젝트에서 서버 API를 잘 사용하는 와중에 문제가 발생했습니다. baseURL로 들어가있는 코드에 /api/v1 까지 포함이 되어있는데 이후에 /api/v2 를 가진 API가 생긴겁니다.
문제 상황
// 원래 URL ⓵ https://www.baseURL.com/api/v1/rooms/:roomId/messages-sent // 바뀐 URL ⓶ https://www.baseURL.com/api/v2/rooms/:roomId/messages-sent
Plain Text
복사
v1, v2 는 Versioning과 관련된 부분이라서 이후에 v3, v4, … , v100 이 될 수도 있습니다. 따라서 하드코딩으로 URL을 넣어둔다고 해서 해결될 일이 아닙니다.
후에 다른 버전들이 생겨나도 코드의 많은 부분을 수정하지 않으면서 새로운 version을 추가할 수 있으면 좋겠다는 생각이 들었습니다. 따라서 URL 코드 생성 방식을 변경하기로 결정했습니다.

 APIEnvironment 수정

먼저 baseURL을 수정해야겠다는 생각이 들었습니다. 현재 들어있는 /api/v1 을 지우는 걸 먼저 진행했습니다.
enum URLLiteral { static let developmentUrl: String = "http://developmentURL.com" static let productionUrl: String = "https://productionURL.com" }
Swift
복사
APIEnvironment는 URLLiteral의 URL을 바로 받아서 사용하고 있었기 때문에 version이 추가되어 있지 않습니다. 따라서 version을 붙여줄 필요가 있었습니다.
enum APIEnvironment { // 그냥 쓰면 버전이 안붙어서 나옵니다.. static let baseUrl: String = UrlLiteral.productionUrl }
Swift
복사
baseURL을 호출하면 version까지 추가되어서 baseURL을 받아볼 수 있게 만들어야 합니다. 따라서 version을 case로 넣어두고 매개변수로 들어오는 case에 맞춰서 baseURL을 return해주는 함수를 APIEnvironment에 만들었습니다.
enum APIEnvironment { case v1, v2 static func baseURL(_ version: Self = v1) -> String { return URLLiteral.productionUrl + "/api/\(version)" } }
Swift
복사
대부분의 URL이 v1 버전이었기 때문에 아무것도 지정하지 않으면 v1 버전이 들어가도록 코드를 짰습니다.
프로젝트에서는 getURL에 baseURL을 매개변수로 넣는 방식을 사용했기 때문에 위에서 만든 baseURL 함수를 매개변수로 넣어주면 됩니다.
v1 인 baseURL 사용
func createRequest() -> NetworkRequest { return NetworkRequest(url: getURL(baseURL: APIEnvironment.baseURL()), reqBody: requestBody, reqTimeout: requestTimeOut, httpMethod: httpMethod ) }
Swift
복사
v2 인 baseURL 사용
func createRequest() -> NetworkRequest { return NetworkRequest(url: getURL(baseURL: APIEnvironment.baseURL(.v2)), reqBody: requestBody, reqTimeout: requestTimeOut, httpMethod: httpMethod ) }
Swift
복사

 APIEnvironment baseURL의 문제점

현재 만들어둔 baseURL을 사용하려다가 문제점을 발견했습니다. 바로 baseURL을 매개변수로 받는 getURL함수 내부에 v1, v2 이 섞여서 들어가야 하는 경우가 있다는 것입니다.
두 가지가 다 존재하는 경우에는 baseURL을 한 가지의 baseURL로만 넣을 수 없습니다. 결국 getURL 내부에서 baseURL을 지정해줘야 하는 최악의 상황이 발생합니다. baseURL은 baseURL대로 들어오지만 case 별로 다시 baseURL을 써줘야 하는 거죠.
func getURL(baseURL: String) -> String { switch self { case .dispatchLetter(let roomId, _, _, _): return "\(APIEnvironment.baseURL())/rooms/\(roomId)/messages-separate" case .fetchSendLetter(let roomId): return "\(APIEnvironment.baseURL(.v2))/rooms/\(roomId)/messages-sent" case .fetchReceiveLetter(let roomId): return "\(APIEnvironment.baseURL(.v2))/rooms/\(roomId)/messages-received" case .patchReadMessage(let roomId, _): return "\(APIEnvironment.baseURL())/rooms/\(roomId)/messages/status" } }
Swift
복사
최악의 상황
최악의 상황을 모면하기 위해서 원래 있던 URLLiteral을 extension해서 그 내부에다가 Screen에 맞는 path들을 정리하기로 했습니다.
enum 타입으로 Screen 별로 정리할 수 있게 만들었습니다. Path는 항상 String이기 때문에 String 타입의 rawValue 를 받을 수 있도록 String를 conform 했습니다. 그리고 subscript를 사용해서 case에 맞는 URL을 return 받을 수 있도록 코드를 짰습니다.
extension URLLiteral { // MARK: - main path enum Main: String { case fetchCommonMission = "/missions/common/" case fetchManittoList = "/rooms/" static subscript(_ `case`: Self, version: APIEnvironment = .v1) -> String { return APIEnvironment.baseURL(version) + `case`.rawValue } } }
Swift
복사

subscript가 뭔가요?

코드를 쓰다보니 모든 Screen에서 subscript를 중복해서 써야 한다는 생각이 들었습니다.
“subscript는 자동으로 만들어지고 나는 case만 썼으면 좋겠다!”
라는 생각이 들어서 case만 쓸 수 있는 방법을 찾아봤습니다.

 subscript 중복 피하기

subscript를 어디다가 써줘야 내가 사용하고 싶은 열거형에서 subscript 코드를 중복으로 써주지 않아도 쓸 수 있을까요? 저는 그 답을 RawRepresentable 프로토콜에서 찾으려고 했습니다.
우리가 사용하고 있는 열거형 타입이 String, Integer, Floating-point를 raw type으로 가지게 된다면 Swift 컴파일러는 자동으로 RawRepresentable 프로토콜을 준수하도록 합니다.
RawRepresentable 프로토콜 내부는 사용할 RawValue type을 지정해주는 부분, 해당 rawValue 값을 가진 부분, init(rawValue:) 코드를 작성해주는 부분으로 이루어져있습니다.
public protocol RawRepresentable<RawValue> { associatedtype RawValue init?(rawValue: Self.RawValue) var rawValue: Self.RawValue { get } }
Swift
복사
만약 우리가 Int 타입을 rawValue로 사용하고자 한다면 이런식으로 코드를 쓰겠죠. 이때 RawValue 가 Int 타입으로 설정되고, rawValue는 1, 2, 3, 4로 설정될 겁니다.(암시적으로 할당)
enum Counter: Int { case one = 1, two, three, four, five }
Swift
복사
Apple Document example
만약 rawValue에 따라서 enum case를 print하고 싶다면 우리는 이렇게 코드를 짤 거 같네요. 결과값은 init?(rawValue:) 내부에 써있는 코드에 의해서 return되는 걸겁니다.
for i in 3...6 { print(Counter(rawValue: i)) } // Prints "Optional(Counter.three)" // Prints "Optional(Counter.four)" // Prints "Optional(Counter.five)" // Prints "nil"
Swift
복사
Apple Document example
enumerations 타입이 RawRepresentable 프로토콜을 conform하기 때문에 우리가 위의 변수와 메소드를 사용할 수 있는겁니다.
제가 만드려는 Screen 별 enumeration 타입은 String 타입을 매번 conform할 것으로 예상이 됩니다. 왜냐하면 path를 String rawValue로 가져야 하기 때문입니다. 그렇기 때문에 RawRepresentable 프로토콜 내부에 subscript를 작성해주면 “중복없이 사용할 수 있지 않을까?” 라는 생각이 들었습니다.
RawRepresentable 프로토콜을 extension해서 내부에 제가 만든 subscript 코드를 작성했습니다.
extension RawRepresentable { static subscript(_ `case`: Self, version: APIEnvironment = .v1) -> String { return APIEnvironment.baseURL(version) + "\(`case`.rawValue)" } }
Swift
복사
아까 작성한 Main은 subscript 없는 코드로 작성해주었습니다.
extension URLLiteral { // MARK: - main path enum Main: String { case fetchCommonMission = "/missions/common/" case fetchManittoList = "/rooms/" } }
Swift
복사
그렇다면 subscirpt 에서 return 하려는 URL를 제대로 사용할 수 있을까요? print문을 사용해서 콘솔창에 프린트 해봤습니다.
print("테스트!!", URLLiteral.Main[.fetchManittoList])
Swift
복사
콘솔창에 제대로 프린트 됩니다!

 URL Argument에서의 문제점 발생

Main 화면에서는 URL에 별다른 값을 집어넣지 않습니다. String rawValue로 지정한 값을 사용해서 path를 지정하면 그만입니다.
하지만 다른 화면에서는 Argument를 집어넣어야 하는 상황이 발생했습니다.
func getURL(baseURL: String) -> String { switch self { case .fetchWithFriend(let roomId): return "\(baseURL)/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(let roomId): return "\(baseURL)/rooms/\(roomId)" } }
Swift
복사
현재 Main에서 진행한 방식과 동일한 방식으로 써주게 되면 에러 메세지를 받을 수 있습니다.
Enum with raw type cannot have cases with arguments. 열거형이 원시 타입(raw type)을 가진 경우에는 case가 arguments와 함께 사용할 수 없습니다.
argument가 있는 경우에는 raw type을 가질 수 없는거죠. 그러면 어떻게 쓰면 될까요?
enum DetailWait { case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) var path: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사
이렇게 작성해서 사용할 수 있습니다. 하지만 subscript를 다시 써줘야 하는 문제가 발생합니다. 아까 RawRepresentable에 subscript 코드를 써뒀지만 현재 enum은 raw type을 conform하지 않기 때문에 subscript 코드를 사용할 수 없는것이죠.
저는 subscript를 전과 동일하게 사용하고 싶었기 때문에 RawRepresentable 프로토콜을 conform했습니다.
raw type이 없는 상태에서 RawRepresentable 프로토콜을 conform 해줬기 때문에 전과 다르게 설정해줘야 하는 부분들이 있습니다. RawRepresentable 프로토콜이 가진 3가지를 설정해줘야 됩니다.
RawRepresentable Required
1.
typealias RawValue
2.
var rawValue
3.
init?(rawValue:)
이 부분을 설정해줘야만 rawValue가 있는 거처럼 사용할 수 있습니다.
1.
typealias RawValue = String
우리가 사용하는 Path는 String 값을 가지기 때문에 RawValue를 String으로 설정해주었습니다.
enum DetailWait: RawRepresentable { typealias RawValue = String case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) var path: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사
RawValue를 설정하고나면 rawValue의 타입이 정해지기 때문에 rawValue와 init?(rawValue:) 코드를 작성할 수 있게 됩니다.
2.
var rawValue: String
rawValue에는 우리가 return하고 싶은 Path 코드를 적어주면 될 거 같네요. 전에 path로 써준 코드를 rawValue로 옮겨주면 좋을 것 같아요.
enum DetailWait: RawRepresentable { typealias RawValue = String case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) var rawValue: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사
3.
init?(rawValue: String)
이 부분은 전에 봤던 거 처럼 DetailWait(rawValue: .fetchWithFriend)로 사용했을 때의 코드를 적어주면 됩니다. 저는 Path 코드를 subscript로만 사용할 생각이기 때문에 init?(rawValue:)로 접근했을 때 nil를 return하려고 합니다.
enum DetailWait: RawRepresentable { typealias RawValue = String case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) init?(rawValue: String) { return nil } var rawValue: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사
RawValueinit?(rawValue: String)은 모든 부분에서 동일하게 나타날 거 같네요. 그렇다면 subscript처럼 RawRepresentable 내부로 넣어주겠습니다.
extension RawRepresentable { typealias RawValue = String init?(rawValue: String) { return nil } static subscript(_ `case`: Self, version: APIEnvironment = .v1) -> String { return APIEnvironment.baseURL(version) + "\(`case`.rawValue)" } }
Swift
복사
이제 Argument가 들어오는 case도 subscript를 쓰지 않고도 사용할 수 있게 되었습니다.
enum DetailWait: RawRepresentable { case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) var rawValue: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사

 RawRepresentable 문제 발생

방금 Argument가 있는 상황에서도 subscript 를 사용하기 위해서 RawRepresentable 내부에 RawValue와 init?() 코드를 작성했습니다.
저렇게 사용하면 raw type을 conform하는 다른 enumeration에도 영향을 주지 않을까 하는 생각이 들었습니다. 그리고 실제로 영향을 주었습니다. Int raw type을 conform하고 있는 enum에서 에러 메시지를 띄웁니다.
다른 값들이 더이상 RawRepresentable를 사용할 수 없다는 겁니다.
subscript를 위해서 RawRepresentable를 사용하려다가 다른 Enum 타입들에 문제를 일으키게 된겁니다. 따라서 RawRepresentable를 준수하면서 RawValue로 String를 가지는 protocol을 만들었습니다.
protocol URLRepresentable: RawRepresentable where RawValue == String { } extension URLRepresentable { init?(rawValue: String) { return nil } }
Swift
복사
enum 타입에 URLRepresentable 를 conform하게 만들면 RawRepresentable를 conform한 것과 동일한 기능을 사용할 수 있고 RawRepresentable를 conform하고 있는 다른 타입들에게 영향을 끼치지 않습니다.
enum DetailWait: URLRepresentable { case fetchWithFriend(roomId: String) case fetchWaitingRoomInfo(roomId: String) var rawValue: String { switch self { case .fetchWithFriend(roomId: let roomId): return "/rooms/\(roomId)/participants" case .fetchWaitingRoomInfo(roomId: let roomId): return "/rooms/\(roomId)" } } }
Swift
복사
문제 없이 subscript를 사용해서 URL을 받을 수 있습니다.
func getURL(baseURL: String) -> String { switch self { case .fetchWithFriend(let roomId): return URLLiteral.DetailWait[.fetchWithFriend(roomId: roomId)] case .fetchWaitingRoomInfo(let roomId): return URLLiteral.DetailWait[.fetchWaitingRoomInfo(roomId: roomId)] } }
Swift
복사

 Enum case 중복 피하기

위의 방식으로 사용해도 문제없이 URL을 사용할 수 있습니다. 하지만 갑자기 그런 생각이 들더라구요.
getURL 메소드를 가지고 있는 EndPoint 도 열거형 타입이고, URLLiteral.<ScreenName> 에서 쓰는 case들을 동일하게 들고 있다면 URLLiteral 안에 열거형을 하나 더 만들지 말고 EndPoint 에서 바로 URLRepresentable을 conform하면 되지 않을까?
URLLiteral에 Screen 별로 선언해주는 방식은 Path를 return 해줘야 하기 때문에 Path를 받아서 사용하는 EndPoint 열거형 타입과 동일한 case를 가지게 됩니다.
// URLLiteral.Main extension URLLiteral { enum Main: String { case fetchCommonMission = "/missions/common/" case fetchManittoList = "/rooms/" } } // MainEndPoint enum MainEndPoint: EndPointable { case fetchCommonMission case fetchManittoList }
Swift
복사
URLLiteral.<ScreenName> 을 만들지 않아도 EndPoint 열거형에서 충분히 Path 코드를 사용할 수 있을 거 같다는 생각이 들어서 재정비를 시작했습니다.
[재정비 진행]
1.
URLRepresentable 프로토콜을 수정했습니다.
RawRepresentable 프로토콜을 사용한 이유는 subscript를 사용하기 위함이었습니다. 하지만 제가 만들어둔 URLRepresentable에 subscript를 넣어준다면 RawRepresentable은 필요하지 않을겁니다. 덧붙여서 URL Path를 rawValue라는 이름으로 계속 사용하는게 적절하지 않다는 생각 + init?() 코드를 매번 nil을 return 하게 만드는 것이 좋지 않다 라는 생각으로 RawRepresentable을 제거하게 되었습니다.
protocol URLRepresentable { var path: String { get } } extension URLRepresentable { subscript(_ `case`: Self, version: APIEnvironment = .v1) -> String { return APIEnvironment.baseURL(version) + "\(`case`.path)" } }
Swift
복사
rawValue 대신 path 라는 이름을 가진 변수를 항상 가질 수 있도록 했고, subscript를 URLRepresentable 내부로 옮겼습니다.
2.
URLLiteral 내부 Enum 타입을 지우고 EndPoint로 합쳤습니다.
전에 URLLiteral 내부에서 써주던 Path를 EndPoint 내부로 합쳤습니다. EndPoint가 URLRepresentable을 conform하게 되면 URLLiteral에 써주던 거처럼 사용할 수 있습니다.
enum MainEndPoint: URLRepresentable { case fetchCommonMission case fetchManittoList var path: String { switch self { case .fetchCommonMission: return "/missions/common/" case .fetchManittoList: return "/rooms/" } } }
Swift
복사
이전과는 다르게 case를 한 번 더 쓸 필요도 없고 rawValue라고 써있는 것보다 path라는 변수가 어떤 값을 return하는 건지 이해하기 쉬워졌습니다.
3.
subscript 를 사용해서 URL를 받아봅시다.
현재 enum 타입 내부에 있는 subscript를 사용해야 하기에 self[] 형식으로 작성해서 사용합니다.
func getURL(baseURL: String) -> String { switch self { case .fetchCommonMission: return self[.fetchCommonMission] case .fetchManittoList: return self[.fetchManittoList] } }
Swift
복사

 마치며

Path를 보기 쉽게 정리하면서도 version 관련 문제를 쉽게 해결해보고 싶었는데 잘 반영이 된 거 같아서 만족스럽습니다. 다른 분들이 보기에 매우매우매우매우 부족한 코드일지도 모릅니다..
저도 개인적으로 아쉬운 부분들이 아직 코드에 존재합니다.
바로 argument 부분입니다. 사용하지 않는 argument는 underscore(_)로 나타냅니다. 하지만 Self case를 넣을 때 사용하지 않는 값까지 넣어주어야 해서 underscore로 나타낼 값도 적어주어야 합니다.
func getURL(baseURL: String) -> String { switch self { case .putRoomInfo(let roomId, let roomInfo): return self[.putRoomInfo(roomId: roomId, roomInfo: roomInfo) } }
Swift
복사
roomInfo는 path에서 사용하지 않는 데이터지만 .putRoomInfo 를 사용하려면 해당 값을 넣어줘야 하는거죠. 쓰는 값만 넣어주고 싶은데 이 부분을 어떻게 수정을 해줘야 할 지 앞으로도 고민을 더 해보려 합니다.
[23.03.18 (토)]
EndPoint 코드를 작성하다보니 /api/rooms 같은 version이 없는 case를 발견했습니다. 이 부분을 해결하기 위해서 none 케이스를 추가했습니다.
enum APIEnvironment: String { case v1 = "/v1" case v2 = "/v2" case none = "" static func baseURL(_ version: Self = v1) -> String { return URLLiteral.productionUrl + "/api\(version.rawValue)" } }
Swift
복사
새로운 case도 쉽게 처리 완료했습니다.
var url: String { switch self { case .mixRandomManitto(let roomId): return self[.mixRandomManitto(roomId: roomId), .none] case .openManitto: return self[.openManitto, .none] } }
Swift
복사
getURL도 url 변수로 수정했습니다. 어쩌다가 그렇게 됐는지 알고 싶으시다면 프로젝트에서 작성한 PR에서 확인하실 수 있습니다.
416
pull

 참고자료