Foundation의 URL과 URLComponents는 겉보기에 비슷하지만 목적과 동작이 다릅니다. 이 둘을 혼동해서 발생하는 버그를 여러 번 목격한 뒤 정리한 노트입니다.
기본적인 차이
URL은 완성된, 유효한 URL을 표현합니다. RFC 3986에 정의된 형식을 따라야 하고, 유효하지 않으면 초기화 자체가 실패합니다.
URLComponents는 URL의 각 구성 요소를 개별적으로 다루기 위한 도구입니다. scheme, host, path, query 등을 각각 수정할 수 있고, 최종적으로 URL로 변환해서 사용합니다.
단순 규칙: 이미 완성된 URL 문자열을 다룰 때는 URL, URL을 조립하거나 부분을 수정할 때는 URLComponents.
쿼리 파라미터 다루기
가장 자주 하는 실수 중 하나가 쿼리 파라미터 처리입니다.
// 잘못된 방식
let base = "https://api.example.com/search"
let query = "q=Hello World&lang=ko"
let urlString = "\(base)?\(query)"
let url = URL(string: urlString) // nil
“Hello World”의 공백 때문에 URL이 유효하지 않아 nil이 반환됩니다. 파라미터를 문자열 조합으로 만들면 인코딩을 매번 신경 써야 합니다.
// 올바른 방식
var components = URLComponents(string: "https://api.example.com/search")!
components.queryItems = [
URLQueryItem(name: "q", value: "Hello World"),
URLQueryItem(name: "lang", value: "ko")
]
let url = components.url // 자동으로 인코딩됨
URLComponents는 queryItems에 넣은 값을 자동으로 percent-encoding 처리합니다. 공백은 + 또는 %20으로, 한글은 UTF-8 인코딩 후 percent-encoding으로 변환됩니다.
Percent-encoding의 미묘함
URLQueryItem이 자동 인코딩한다고 했지만, 특정 문자는 처리 방식이 예상과 다를 수 있습니다.
예를 들어 + 문자는 URL 쿼리에서 공백으로 해석됩니다. 실제 +를 값으로 보내고 싶다면 %2B로 인코딩해야 합니다. URLQueryItem은 이 부분을 처리하지 않을 수 있습니다.
let item = URLQueryItem(name: "value", value: "1+1=2")
// 결과 URL의 쿼리: value=1+1=2
// 서버에서 파싱하면 "1 1=2"가 됨
이 경우 값을 미리 명시적으로 인코딩해야 합니다.
let raw = "1+1=2"
let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? raw
// 하지만 .urlQueryValueAllowed는 +를 허용된 문자로 포함
가장 안전한 방법은 필요한 문자만 명시적으로 인코딩하는 것입니다.
extension String {
func strictURLEncoded() -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "+&=?#")
return self.addingPercentEncoding(withAllowedCharacters: allowed) ?? self
}
}
경로에 파라미터 삽입
RESTful API에서 /users/:id 같은 경로 파라미터를 다룰 때 주의가 필요합니다.
let userId = "user-abc/def" // 슬래시 포함
let path = "/users/\(userId)"
// 결과: /users/user-abc/def (경로가 예상보다 깊어짐)
경로 세그먼트 안의 특수 문자도 인코딩해야 합니다.
let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId
let path = "/users/\(encoded)"
// 결과: /users/user-abc%2Fdef
.urlPathAllowed는 슬래시를 허용된 문자로 포함하므로, 경로 안의 슬래시를 이스케이프하려면 별도의 문자 세트가 필요합니다.
URL에서 파라미터 추출
URL에서 특정 쿼리 파라미터를 읽어야 할 때도 URLComponents가 유용합니다.
let url = URL(string: "https://example.com/page?id=123&name=Hello%20World")!
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let id = components.queryItems?.first(where: { $0.name == "id" })?.value
// "123"
let name = components.queryItems?.first(where: { $0.name == "name" })?.value
// "Hello World" (자동으로 디코딩됨)
수동으로 URL.query 문자열을 파싱하지 마세요. 인코딩 처리를 매번 다시 구현하게 됩니다.
URL 조작이 필요한 상황
기존 URL에서 특정 부분만 바꿔야 하는 경우도 URLComponents가 답입니다.
// 페이지네이션: 기존 URL의 page 파라미터만 바꾸기
func nextPageURL(from url: URL, page: Int) -> URL? {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
var items = components.queryItems ?? []
items.removeAll { $0.name == "page" }
items.append(URLQueryItem(name: "page", value: String(page)))
components.queryItems = items
return components.url
}
이 패턴은 API 클라이언트에서 자주 나타납니다.
Fragment (해시) 다루기
URL의 #section 부분(fragment)도 URLComponents.fragment로 다룰 수 있습니다.
var components = URLComponents(string: "https://docs.example.com/guide")!
components.fragment = "section-3"
let url = components.url
// "https://docs.example.com/guide#section-3"
Fragment는 서버로 전송되지 않지만 클라이언트 라우팅에는 중요합니다. SPA 라우팅이나 문서 앵커 이동에 사용됩니다.
Base URL과 상대 경로
URL(string:relativeTo:)로 상대 경로를 baseURL에 결합할 수 있습니다.
let base = URL(string: "https://api.example.com/v1/")!
let endpoint = URL(string: "users/123", relativeTo: base)!
// endpoint: "https://api.example.com/v1/users/123"
주의: baseURL의 마지막 슬래시 유무에 따라 결과가 다릅니다.
let base1 = URL(string: "https://api.example.com/v1")! // 슬래시 없음
let url1 = URL(string: "users", relativeTo: base1)!
// "https://api.example.com/users" (v1이 사라짐)
let base2 = URL(string: "https://api.example.com/v1/")! // 슬래시 있음
let url2 = URL(string: "users", relativeTo: base2)!
// "https://api.example.com/v1/users"
이 동작은 HTML의 <a href> 상대 링크 규칙과 동일합니다. API 클라이언트에서 baseURL 상수를 정의할 때 뒤에 슬래시를 붙이는 관례가 이 때문입니다.
URLComponents.string과 URLComponents.url의 차이
둘 다 문자열이나 URL을 반환하지만 미묘한 차이가 있습니다.
components.url은 유효한 URL을 반환합니다. 특정 필드가 유효하지 않으면 nil을 반환할 수 있습니다.
components.string은 필드를 조합한 문자열을 반환합니다. URL 유효성 검증을 하지 않을 수 있습니다.
실전에서는 components.url을 사용하고 nil 체크를 하는 편이 안전합니다.
디버깅 팁
URL 관련 버그를 디버깅할 때 도움이 되는 방법:
URLComponents로 파싱해서 확인. 예상한 URL이 실제로 어떤 컴포넌트로 분해되는지 확인.
let url = URL(string: suspiciousURLString)!
let comp = URLComponents(url: url, resolvingAgainstBaseURL: false)!
print("scheme: \(comp.scheme ?? "nil")")
print("host: \(comp.host ?? "nil")")
print("path: \(comp.path)")
print("query: \(comp.query ?? "nil")")
print("queryItems: \(comp.queryItems ?? [])")
curl로 실제 요청 확인. Swift 코드로 생성한 URL을 curl로 요청해보면 서버가 실제로 어떤 요청을 받는지 확인할 수 있습니다.
Charles Proxy나 Proxyman. 실제 네트워크 트래픽을 캡처해서 URL이 어떻게 전송되는지 확인. 특히 리다이렉트나 헤더 문제 디버깅에 유용.
결론
단순화하면 이렇게 기억할 수 있습니다.
URL을 조립하거나 수정할 때는 항상 URLComponents를 사용. 문자열 조합은 인코딩 버그의 원천입니다.
URL에서 정보를 추출할 때도 URLComponents를 사용. 수동 파싱은 예외 케이스를 놓칩니다.
완성된 URL을 다룰 때만 URL 타입을 직접 사용. 네트워크 요청, 파일 접근, 저장 등.
이 구분만 지켜도 URL 관련 버그의 상당수를 예방할 수 있습니다.