← Swift & Foundation 로드맵 21 / 33

21 · URL과 URLComponents

문자열 이어붙이기가 URL을 왜 조용히 오염시키는지부터, +가 공백으로 둔갑하는 이유, path·absoluteString의 쓰임 차이, iOS 17의 파싱 엄격화와 이중 인코딩 버그, URLResourceValues로 파일 메타데이터를 다루는 법까지.
진행률
0 / 0 완료

URL은 그냥 문자열이 아니라 scheme://authority/path?query#fragment 형태로 고정된 문법을 갖는 구조체다. 이 챕터는 그 문법을 무시하고 문자열을 그냥 이어붙였을 때 정확히 어디서 무엇이 조용히 오염되는지, URLComponents가 왜 그 문제를 구조적으로 막아주는지를 본다. + 기호의 이중적 해석, path/absoluteString을 잘못 골랐을 때의 함정, iOS 17이 바꾼 파싱 엄격도, 파일 메타데이터를 다루는 URLResourceValues까지 다섯 문항을 다룬다. 표와 실행 결과는 전부 이 환경(Swift 6.2.1 / macOS 26 SDK)에서 직접 실행해 확인한 값이다.

Q1. 문자열 이어붙이기로 URL을 만들면 왜 깨지는가? — RFC 3986의 reserved/unreserved

🔑 30초 답변

URL은 컴포넌트 사이를 & # ? / 같은 예약(reserved) 문자로 구분하는 고정 문법을 따른다. 쿼리 값 안에 이 구분자와 같은 문자, 또는 애초에 문법에 없는 문자(공백, 비ASCII)가 그대로 섞이면, 문자열을 이어붙여 만든 URL은 그 문자를 "값의 일부"가 아니라 "새로운 구분자"로 오독해버린다. 더 위험한 건 URL(string:) 자체가 에러 없이 성공해버린다는 점이다 — 검증되지 않은 채로 파라미터가 잘리거나 몰래 하나 더 생겨도 겉으론 멀쩡해 보인다. URLComponents는 각 컴포넌트를 개별 필드로 다루며 필요한 문자만 percent-encode하므로 이 문제 자체가 생기지 않는다.

원리

RFC 3986 §2.2/§2.3은 문자를 두 그룹으로 나눈다. reservedgen-delims(: / ? # [ ] @)와 sub-delims(! $ & ' ( ) * + , ; =)로, URI 구조 자체를 나누는 데 쓰이는 문자다. unreservedALPHA / DIGIT / - . _ ~로, 어떤 위치에 있어도 안전한 문자다. 그 밖의 모든 바이트는 % + 16진수 두 자리로 percent-encode해야 한다(§2.1). URL 전체는 scheme ":" hier-part [ "?" query ] [ "#" fragment ]로 쪼개지고, 이 쪼개짐 자체가 & # ? 같은 문자를 만나는 순간 일어난다 — 그 문자가 "이 값 안의 진짜 데이터"인지 "다음 컴포넌트로 넘어가라는 신호"인지는 URL 파서 입장에서 구분할 방법이 없다.

내부 동작

문자열 이어붙이기가 위험한 진짜 이유는 "URL이 안 만들어진다"가 아니라 "만들어지긴 하는데 다르게 파싱된다"는 점이다. 검색어에 &가 섞인 현실적인 값으로 확인해보자.

"던킨&도너츠"를 이어붙이면 쿼리 파라미터가 몰래 하나 더 생긴다
let value = "던킨&도너츠"
let naive = "https://api.example.com/search?q=\(value)"
let url = URL(string: naive)!                 // 성공한다 — nil이 아니다
let parsed = URLComponents(url: url, resolvingAgainstBaseURL: false)
print(url.absoluteString)
// https://api.example.com/search?q=%EB%8D%98%ED%82%A8&%EB%8F%84%EB%84%88%EC%B8%A0
print(parsed?.queryItems?.map { "\($0.name)=\($0.value ?? "")" } ?? [])
// ["q=던킨", "도너츠="]  <- q 값이 "던킨"에서 끊기고, "도너츠"가 새 파라미터 이름으로 주입됐다

한글이 자동으로 percent-encode되어 URL(string:) 자체는 성공한다(이 자동 인코딩의 정체는 Q4에서 다룬다). 문제는 그 다음이다 — &는 인코딩되지 않은 채 그대로 남아 있었기 때문에, 재파싱 시점에 진짜 구분자로 해석돼 q의 값을 끊고 도너츠라는 존재하지 않던 파라미터를 만들어낸다. 반면 같은 값을 URLComponents.queryItems에 그대로 넣으면 &%26으로 인코딩되어 파라미터 하나로 안전하게 유지된다.

문자예시 값실제로 무엇이 깨지는가
&던킨&도너츠값이 끊기고, & 뒤가 새 쿼리 파라미터로 주입된다(파라미터 인젝션). 위에서 직접 재현.
=검색어=맛집URLComponents는 첫 =만 name/value 구분자로 쓰므로 이 값 하나만 있을 땐 우연히 안 깨진다. 하지만 값 안에 =가 있다는 사실 자체를 다른 파서(서버, 프록시, 로그 수집기)가 다르게 나눌 수 있어 이식성이 없다.
?공지사항?진짜쿼리 안에서는 문법상 안전(query = *( pchar / "/" / "?" )). 하지만 아직 쿼리가 시작 안 된 경로에 이 값을 이어붙이면 그 지점에서 쿼리가 조기에 시작돼버린다.
#1등급#인증마크# 이후(인증마크)가 프래그먼트로 통째로 떨어져 나가 서버는 애초에 받지도 못한다.
/2024/할인전경로에 이어붙이면 값 하나가 아니라 경로 세그먼트 두 개로 쪼개진다. 쿼리 값 안에서는 ?와 마찬가지로 문법상 안전하다.
공백맛집 추천RFC 3986 문법 어디에도(reserved도 unreserved도) 속하지 않는 원천적으로 무효한 문자다.
+1+1구조적으로는 안 깨진다(sub-delims라 유효). 다만 서버가 폼 인코딩 관례로 공백과 혼동할 수 있다 — Q2에서 자세히.
비ASCII검색어=맛집RFC 3986은 ASCII만 정의한다. 원칙적으로 무효지만, 지금 SDK의 URL(string:)은 관대하게 자동 인코딩해준다(Q4).
URL 해부 — scheme :// authority path ? query https :// api.example.com /search ?q=던킨&도너츠 scheme authority path query — & 가 값 안에 그대로 있다 문자열 이어붙이기 → 재파싱 q = 던킨 도너츠 = (주입됨!) 의도한 값 하나가 파라미터 2개로 쪼개졌다 URLComponents.queryItems q = %EB%8D%98%ED%82%A8%26%EB%8F%84... & 가 %26으로 인코딩되어 파라미터 1개 유지 같은 입력, 다른 조립 방식 → 다른 결과 핵심: URL(string:)이 nil을 안 줘도 안전하다는 뜻이 아니다 두 경로 모두 URL 생성 자체는 "성공"한다 — 차이는 재파싱했을 때 드러난다.
같은 검색어를 이어붙이면 파라미터가 몰래 하나 더 생기지만, URLComponents로 조립하면 &가 인코딩되어 파라미터 하나로 안전하게 유지된다.

실험 · 도구

위 코드를 파일로 저장해 swift 파일명.swift로 실행하면 즉시 확인된다. swift -e '<code>'로 한 줄씩 테스트해도 된다. 의심 가는 문자가 있으면 URLComponents(url:resolvingAgainstBaseURL:)로 재파싱한 뒤 queryItems 개수를 세보는 습관이 가장 빠른 검증법이다 — 개수가 예상과 다르면 그 자리에서 바로 문제가 드러난다.

프로젝트 적용

쿼리 파라미터는 절대 문자열 보간(string interpolation)으로 조립하지 않는다. URLQueryItem 배열을 만들고 URLComponents에 맡긴다.

쿼리 조립은 항상 URLQueryItem으로
func searchURL(keyword: String, sort: String) -> URL? {
    var comps = URLComponents(string: "https://api.example.com/search")!
    comps.queryItems = [
        URLQueryItem(name: "q", value: keyword),
        URLQueryItem(name: "sort", value: sort)
    ]
    return comps.url
}

// ❌ 나쁜 예 — 검색어에 &, =, 한글이 섞이면 그대로 오염된다
func searchURLNaive(keyword: String) -> URL? {
    URL(string: "https://api.example.com/search?q=\(keyword)")
}

딥링크·리다이렉트처럼 이미 완성된 URL 문자열을 받아 파라미터를 추가·수정해야 할 때도 문자열 replacingOccurrences로 손대지 말고 URLComponents(string:)로 파싱한 뒤 queryItems 배열을 직접 조작한다.

기존 URL에 파라미터 추가 — 파싱 후 조작, 문자열 조작 아님
func appendingTracking(to url: URL, campaign: String) -> URL? {
    guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
    var items = comps.queryItems ?? []
    items.append(URLQueryItem(name: "utm_campaign", value: campaign))
    comps.queryItems = items
    return comps.url
}
⚠️ 흔한 오해

"URL(string:)nil을 안 돌려주면 그 URL은 안전하다"는 틀렸다. 위 실험이 보여주듯 이어붙인 문자열도 대부분 성공적으로 URL 객체가 되지만, 재파싱하면 의도와 다른 파라미터 구성이 드러난다. "에러가 안 나면 맞게 만든 것"이라는 습관적 가정이 URL 조립에서는 특히 위험하다.

🧒 쉽게 이해하기

우편 봉투에는 수신인·주소·우편번호를 적는 칸이 정해져 있다. 칸을 무시하고 손으로 글씨를 죽 이어 쓰면, 주소란에 쓴 글자가 우편번호 칸까지 넘쳐써져서 엉뚱한 동네로 배달될 수 있다. URLComponents를 쓰는 건 정해진 칸에 맞춰 또박또박 나눠 쓰는 것과 같다. 비유가 깨지는 곳: 사람은 봉투 글씨가 삐뚤빼뚤하면 눈치라도 채지만, 컴퓨터는 이어붙인 URL도 "그럴듯하게 완성된 것"으로 받아들여버려서 사람이 알아채기 훨씬 어렵다.

꼬리 질문

URLComponents도 아무렇게나 쓰면 안전한가? percentEncodedQuery에 직접 문자열을 넣어도 되는가?
아니다. queryItems는 API가 각 이름/값을 대신 percent-encode해주기 때문에 안전하지만, percentEncodedQuery에 직접 문자열을 대입하는 건 "이미 인코딩까지 끝난 raw 쿼리 문자열"을 넘긴다는 뜻이라 인코딩 책임이 전부 호출자에게 넘어간다. 여기에 인코딩 안 된 &를 실수로 넣으면 Q1의 문제가 그대로 재현된다.
쉽게 말하면 queryItems는 "내용만 주면 봉투 칸에 알아서 나눠 써주는" 서비스이고, percentEncodedQuery는 "이미 다 쓴 봉투를 그대로 붙이는" 것이다. 후자는 내가 미리 제대로 안 써놓으면 그대로 사고가 난다.
path 부분과 query 부분이 서로 다른 "안전 문자 집합"을 쓰는 이유는?
컴포넌트마다 그 안에서 구분자로 쓰이는 문자가 다르기 때문이다. 예를 들어 /는 경로 세그먼트를 나누는 구분자라 경로 안에서는 위험하지만, 쿼리 안에서는 아무 의미 없는 평범한 문자다(§3.4 문법에 /가 이미 포함). 그래서 CharacterSet.urlPathAllowed.urlQueryAllowed는 서로 다른 집합이고, URLComponents는 컴포넌트별로 맞는 집합을 내부적으로 골라 쓴다.
쉽게 말하면 주소 칸에서는 쉼표가 괜찮아도 전화번호 칸에서는 쉼표가 이상해 보이는 것처럼, 같은 문자라도 어느 칸에 들어가느냐에 따라 안전한지 아닌지가 달라진다.
이미 %26처럼 인코딩된 값을 다시 URLComponents나 URL(string:)에 넣으면 또 인코딩되는가?
경로에 따라 다르다. queryItems에 raw 문자열(%26 그 자체)을 값으로 넣으면 % 기호까지 다시 인코딩해 %2526이 되는 이중 인코딩이 발생할 수 있다. URL(string:)도 무효 문자를 자동 보정하는 과정에서 비슷한 이중 인코딩이 실제로 보고된 버그가 있다 — 이건 Q4에서 이 SDK로 직접 재현한다.
쉽게 말하면 이미 포장된 선물을 또 포장지로 감싸면 상자가 이상하게 두꺼워지는 것과 같다. "이 문자열이 이미 인코딩됐는지 아닌지"를 URL 조립 코드가 자동으로 알 방법은 없다.

Q2. `URLComponents.queryItems`를 쓰는데도 `+` 기호가 어긋나는 이유와 해결책은?

🔑 30초 답변

RFC 3986 관점에서 +sub-delims에 속하는 평범한 유효 문자라 URLComponents는 이를 인코딩하지 않고 그대로 둔다. 그런데 웹의 오래된 관례인 application/x-www-form-urlencoded에서는 +가 "공백의 동의어"로 약속돼 있다. 서버가 그 관례를 기대하면 리터럴 +를 공백으로 잘못 해독해버린다. 해결책은 값을 +까지 포함해 직접 percent-encode한 뒤 percentEncodedQuery에 넣는 것이다.

원리

두 표준이 서로 다른 문제를 풀다가 충돌한다. RFC 3986은 URI 문법 표준으로 "이 문자가 URI 구조를 깨뜨리는가"만 신경 쓴다. +는 구조를 안 깨뜨리므로 유효하다. 반면 application/x-www-form-urlencoded는 HTML 폼 제출을 위한 별도의 오래된 관례로, "공백을 매 파라미터마다 %20으로 쓰면 URL이 너무 길고 지저분하다"는 실용적 이유에서 +를 공백의 축약형으로 약속했다. URLComponents는 전자만 따르므로 이 둘의 충돌은 URLComponents의 버그가 아니라 애초에 서로 다른 두 스펙이 같은 문자를 다르게 정의한 결과다.

내부 동작

직접 확인해보면 queryItems+를 정말 안 건드리는지, 그리고 값 하나만 인코딩할 때 흔히 쓰는 CharacterSet이 왜 위험한지가 드러난다.

queryItems는 =는 인코딩하지만 +는 그대로 둔다 — 실측
var comps = URLComponents(string: "https://api.example.com/search")!
comps.queryItems = [URLQueryItem(name: "q", value: "1+1=2")]
print(comps.percentEncodedQuery!)
// q=1+1%3D2   <- "=" 는 %3D로 인코딩됐지만 "+" 는 그대로 남았다

// urlQueryAllowed로 "값 하나"를 인코딩하면 &, = 를 놓친다(위험)
let dangerous = "a&b=c".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(dangerous)   // a&b=c  <- 하나도 안 바뀜, 여전히 파라미터 인젝션에 노출

마지막 줄이 특히 함정이다. CharacterSet.urlQueryAllowed는 "쿼리 문자열 전체"에서 안전한 문자 집합이라 &=도 포함하고 있다(둘 다 쿼리 문법의 정상적인 부분이니까). 그런데 이 집합을 "쿼리 값 하나"에 그대로 쓰면 그 값 안에 우연히 들어간 &/=까지 안전하다고 오판해 인코딩을 건너뛴다. 값 단위 인코딩에는 더 좁은 커스텀 집합이 필요하다.

값 단위 인코딩 — +, &, = 를 명시적으로 제외한 CharacterSet
var valueAllowed = CharacterSet.urlQueryAllowed
valueAllowed.remove(charactersIn: "+&=")   // 값 안에 있으면 안 되는 구분자 후보를 직접 제거

let encoded = "1+1=2".addingPercentEncoding(withAllowedCharacters: valueAllowed)!
print(encoded)   // 1%2B1%3D2  <- + 와 = 모두 인코딩됨

var comps2 = URLComponents(string: "https://api.example.com/search")!
comps2.percentEncodedQuery = "q=\(encoded)"
print(comps2.url!.absoluteString)
// https://api.example.com/search?q=1%2B1%3D2
같은 "+" 하나, 두 가지 해석 q=1+1%3D2 RFC 3986 (URLComponents) "+" 는 sub-delims — 그대로 문자 + x-www-form-urlencoded (서버) "+" 는 공백의 동의어 서버가 후자를 기대하면 "1+1" 을 "1 1"(공백)로 읽어버린다
URLComponents는 RFC 3986만 따라 +를 그대로 두지만, 폼 인코딩 관례를 기대하는 서버는 같은 +를 공백으로 해독해 값이 어긋난다.

실험 · 도구

의심 가는 API가 있으면 swift -e"a+b".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)를 직접 찍어 +가 살아있는지 바로 확인할 수 있다. 서버가 폼 인코딩을 기대하는지는 요청 헤더의 Content-Type: application/x-www-form-urlencoded 유무나 API 문서로 판단해야 한다 — 클라이언트 코드만 봐서는 알 수 없다.

프로젝트 적용

쿼리 값 인코딩은 항상 값 단위 헬퍼로 통일하고, + & =를 명시적으로 제외한 CharacterSet을 쓴다. .urlQueryAllowed를 값 하나에 그대로 쓰지 않는다.

재사용 가능한 값 단위 인코딩 헬퍼
extension String {
    /// 쿼리 "값" 하나를 안전하게 percent-encode한다 (전체 쿼리 문자열용 CharacterSet과는 다르다)
    var percentEncodedQueryValue: String {
        var allowed = CharacterSet.urlQueryAllowed
        allowed.remove(charactersIn: "+&=")
        return addingPercentEncoding(withAllowedCharacters: allowed) ?? self
    }
}
print("1+1=2".percentEncodedQueryValue)   // 1%2B1%3D2

반대 방향도 있다 — 서버가 폼 인코딩 관례로 공백을 +로 받길 기대하는 POST body(application/x-www-form-urlencoded)를 직접 만들 때는, URLComponents가 만들어주는 %20을 오히려 +로 치환해줘야 서버가 기대하는 형식이 된다.

x-www-form-urlencoded body를 직접 만들 때는 %20을 +로
// ❌ percentEncodedQueryValue 라는 프로퍼티는 존재하지 않는다.
//    x-www-form-urlencoded 는 허용 문자 집합을 직접 만들어야 한다.
private let formAllowed: CharacterSet = {
    var cs = CharacterSet.alphanumerics
    cs.insert(charactersIn: "-._* ")   // 공백은 일단 통과시킨 뒤 '+'로 치환한다
    return cs
}()

func formURLEncodedBody(_ params: [String: String]) -> Data {
    let pairs = params.map { key, value -> String in
        let k = key.addingPercentEncoding(withAllowedCharacters: formAllowed)!
            .replacingOccurrences(of: " ", with: "+")
        let v = value.addingPercentEncoding(withAllowedCharacters: formAllowed)!
            .replacingOccurrences(of: " ", with: "+")
        return "\(k)=\(v)"
    }
    return pairs.joined(separator: "&").data(using: .utf8)!
}

// 실행 결과 (Swift 6.2.1 실측)
let body = formURLEncodedBody(["검색어": "맛집 추천", "q": "a+b&c"])
print(String(data: body, encoding: .utf8)!)
// %EA%B2%80%EC%83%89%EC%96%B4=%EB%A7%9B%EC%A7%91+%EC%B6%94%EC%B2%9C&q=a%2Bb%26c
//                                              ↑ 공백은 +        ↑ 리터럴 +는 %2B, &는 %26
⚠️ 흔한 오해

"queryItems를 쓰면 +도 알아서 안전하게 처리해준다"는 틀렸다. URLComponents+구조적으로 안전하다고 정확히 판단한 것뿐이지, 그 값이 서버에서 의미적으로 어떻게 해석될지까지는 알 수 없다. ".urlQueryAllowed로 인코딩하면 쿼리 값이 항상 안전하다"도 틀렸다 — 그건 "쿼리 문자열 전체"용 집합이라 값 하나에 쓰면 &/=를 놓친다.

🧒 쉽게 이해하기

두 동네에서 같은 손동작(+표시)을 다르게 알아듣는다고 하자. 한 동네에서는 그냥 "더하기"라는 뜻 그대로 받아들이고, 다른 동네에서는 그 손동작을 "빈칸"이라는 신호로 통역해서 알아듣는다. 내가 진짜 "더하기"를 말하고 싶은데 상대가 두 번째 동네 사람이면, 내 말은 조용히 "빈칸"으로 잘못 전달된다. 비유가 깨지는 곳: 실제 대화에서는 상대가 못 알아들으면 되물어보지만, 서버는 그냥 조용히 잘못 해석한 채로 다음 처리를 계속 진행해버린다 — 에러도, 되물음도 없다.

꼬리 질문

왜 URLComponents가 애초에 +를 %2B로 인코딩해버리지 않는가? 그게 더 안전하지 않은가?
URLComponents는 RFC 3986만 따르는 범용 URI 라이브러리라, HTML 폼 제출이라는 특정 애플리케이션 관례까지 알 필요가 없다. RFC 3986 기준으로 +는 완전히 유효한 문자이므로 인코딩하면 오히려 "필요 없는 인코딩을 한다"는 원칙(가능한 한 최소한만 인코딩)에 어긋난다. 이 구분(구조적 유효성과 애플리케이션 의미론)을 아는 건 API가 아니라 호출자의 책임이다.
쉽게 말하면 우체국은 봉투 규격만 지키면 통과시켜준다. 봉투 안 편지 내용이 받는 사람에게 어떤 의미로 읽힐지는 우체국이 신경 쓸 일이 아니다.
공백을 보내고 싶을 때 %20과 + 중 뭘 써야 하는가?
쿼리 문자열 자체는 %20이 RFC 3986 표준이고 어디서나 안전하다. +가 공백으로 해석되는 건 application/x-www-form-urlencoded 컨텍스트(폼 제출, 특정 API)에 한정된 관례이므로, 그 서버가 명시적으로 +를 기대한다고 문서에 나와 있지 않다면 %20을 쓰는 게 항상 더 안전하다.
쉽게 말하면 모두가 알아듣는 표준어(%20)를 쓰는 게 특정 동네에서만 통하는 사투리(+)를 쓰는 것보다 안전하다.
이 문제가 URLComponents만의 문제인가, 다른 언어의 URL 라이브러리도 똑같은가?
RFC 3986과 x-www-form-urlencoded의 충돌은 스펙 차원의 문제라 언어에 상관없이 구조적으로 동일하게 존재한다. 다만 라이브러리마다 기본 동작이 갈린다 — 일부 언어의 쿼리 문자열 헬퍼는 애초에 "폼 인코딩을 가정"하고 +를 자동으로 공백 처리해주기도 한다. Swift의 URLComponents는 그런 가정을 하지 않고 RFC 3986만 따르므로, 폼 인코딩이 필요하면 위 프로젝트 적용 ②처럼 직접 변환해야 한다.
쉽게 말하면 어느 나라 말이든 "더하기 기호를 빈칸으로 읽을지 말지"는 그 나라(언어/라이브러리)마다 정해둔 규칙이 다를 수 있다는 뜻이다.

Q3. `path` / `relativePath` / `absoluteString`은 각각 언제 쓰는가?

🔑 30초 답변

path는 percent-decoding까지 끝난 순수 경로 문자열이라 FileManager 같은 파일시스템 API에 바로 넘길 수 있다. absoluteString은 스킴을 포함한 전체 URL을 percent-encoding이 유지된 채로 보여주는 문자열이라 로깅·서버 전송·사용자 표시에 쓴다. relativePath는 baseURL이 있는 상대 URL에서 "상대 부분만" 떼어낸 값이다. 경험칙은 단순하다 — 파일 작업엔 path, 그 밖엔 absoluteString, 그리고 absoluteString을 손으로 다시 잘라 쓰는 건 절대 하지 않는다.

원리

이 세 프로퍼티는 "인코딩 여부"와 "베이스 URL 기준 여부"라는 두 축으로 갈린다. path/relativePath는 percent-decode된 값이고 absoluteString은 인코딩이 유지된 값이다. path는 항상 URL 전체를 절대 경로 기준으로 해석한 결과를 주는 반면, relativePathrelativeTo: 베이스가 있을 때 그 상대 문자열 부분만 돌려준다. iOS 16부터는 url.path(percentEncoded:)로 디코딩 여부를 명시적으로 고를 수도 있다.

내부 동작

파일 URL과 상대 URL 각각으로 실제 값을 찍어보면 차이가 분명해진다.

file URL — path는 디코딩된 경로, absoluteString은 file:// 스킴 + 인코딩 유지 — 실측
let fileURL = URL(fileURLWithPath: "/Users/kim/문서 폴더/report.pdf")
print(fileURL.path)
// /Users/kim/문서 폴더/report.pdf   <- 그대로 쓸 수 있는 파일시스템 경로
print(fileURL.absoluteString)
// file:///Users/kim/%E1%84%86%E1%85%AE%E1%86%AB%E1%84%89%E1%85%A5%20%E1%84%91%E1%85%A9%E1%86%AF%E1%84%83%E1%85%A5/report.pdf

흥미로운 지점은 absoluteString의 한글 인코딩이다. %E1%84%86...은 완성형 "문"이 아니라 자모가 낱개로 분해된 NFD(정규 분해형) 상태를 percent-encode한 것이다 — macOS 파일시스템이 파일명을 NFD로 정규화해서 저장하는 습성이 그대로 드러난 것으로, 12장(String과 유니코드)에서 다루는 정규 동치 문제가 URL 레벨에서도 그대로 나타난다는 뜻이다. 반대로 상대 URL은 pathrelativePath가 갈라지는 지점을 보여준다.

상대 URL — path는 절대 경로 전체, relativePath는 상대 부분만 — 실측
let base = URL(string: "https://example.com/docs/")!
let rel = URL(string: "guide.html?x=1", relativeTo: base)!

print(rel.path)            // /docs/guide.html   <- base까지 합쳐진 절대 경로
print(rel.relativePath)    // guide.html          <- 문자열 그대로 준 상대 부분만
print(rel.absoluteString)  // https://example.com/docs/guide.html?x=1
print(rel.absoluteURL.path)// /docs/guide.html    <- .path와 동일

if #available(macOS 13, *) {
    print(rel.path(percentEncoded: true))   // /docs/guide.html
    print(rel.path(percentEncoded: false))  // /docs/guide.html (이 예시엔 인코딩 대상 문자가 없어 동일)
}
예시 URLpathrelativePathabsoluteString
file:///Users/kim/문서 폴더/report.pdf디코딩된 전체 경로path와 동일(베이스 없음)file:// + NFD 인코딩된 경로
base=.../docs/, rel=guide.html?x=1/docs/guide.html (베이스 합산)guide.html (상대 부분만)https://example.com/docs/guide.html?x=1

실험 · 도구

위 코드를 swift 파일명.swift로 돌려보되, 한글이 낀 파일명이 있으면 꼭 absoluteString을 함께 찍어봐야 한다. 실제 바이트를 더 정밀하게 보고 싶으면 Array(fileURL.absoluteString.utf8)로 바이트 배열을 찍어 %E1 계열 시퀀스가 몇 개인지 세보면 NFD 자모 분해가 몇 글자로 쪼개졌는지 직접 확인할 수 있다.

프로젝트 적용

FileManager, fopen 같은 POSIX 파일 API에는 반드시 path를 넘긴다. absoluteString을 넘기면 file:// 프리픽스가 붙은 문자열이 그대로 전달돼 파일을 못 찾는다.

파일 API에는 path, absoluteString 아님
func fileExists(at url: URL) -> Bool {
    // ✅ path — 순수 파일시스템 경로
    FileManager.default.fileExists(atPath: url.path)
}

func brokenFileExists(at url: URL) -> Bool {
    // ❌ absoluteString에는 "file://" 스킴이 붙어 있어 그대로 넘기면 항상 false
    FileManager.default.fileExists(atPath: url.absoluteString)
}

서버 전송·로깅·사용자에게 URL을 보여줄 때는 absoluteString을 쓴다. path는 스킴·쿼리·프래그먼트가 통째로 빠지므로 URL 전체를 표현하지 못한다.

로깅·전송에는 absoluteString
func logRequest(_ url: URL) {
    // ✅ 스킴, 쿼리, 프래그먼트까지 다 포함된 전체 URL
    print("요청: \(url.absoluteString)")
}
⚠️ 흔한 오해

"URL이 웹이든 파일이든 문자열이 필요하면 그냥 absoluteString을 쓰면 된다"는 틀렸다. 파일 URL을 FileManager에 넘길 땐 file:// 프리픽스 때문에 항상 실패한다. "pathrelativePath는 그냥 동의어다"도 틀렸다 — 베이스 URL이 있는 상대 URL 맥락에서는 path가 베이스까지 합산된 절대 경로를, relativePath가 상대 부분만을 준다는 차이가 분명히 갈린다.

🧒 쉽게 이해하기

택배 상자에 붙은 "받는 사람 주소"(absoluteString)는 우체국 스티커, 도시명, 번지수까지 다 적힌 완전한 표기다. 반면 path는 그 집 대문을 실제로 열 때 필요한 "현관 비밀번호"만 뽑아낸 것이다. 우체국에 물건을 보낼 땐 완전한 주소가 필요하지만, 문을 열 땐 비밀번호만 있으면 되고 우체국 스티커는 오히려 방해가 된다. 비유가 깨지는 곳: 실제 주소와 비밀번호는 서로 변환할 수 없지만, URL의 pathabsoluteString은 같은 정보를 인코딩 여부만 다르게 표현한 것이라 서로 오갈 수 있다.

꼬리 질문

path(percentEncoded: false)와 path의 차이는 무엇인가?
iOS 16 이전의 path는 항상 디코딩된 값만 줬는데, path(percentEncoded:)는 그 선택을 명시적으로 드러낸 것이다. true를 주면 인코딩이 유지된 경로(구 path와 다르게 %20 같은 시퀀스가 그대로 보임)를, false를 주면 기존 path와 동일한 디코딩된 값을 준다. 코드에서 "이 경로가 디코딩됐는지"가 헷갈릴 때 의도를 명시하는 용도다.
쉽게 말하면 예전엔 항상 "번역된 문장"만 줬는데, 이제는 "번역해줄까 원문 그대로 줄까"를 내가 직접 고를 수 있게 된 것이다.
절대 URL(baseURL 없음)에서 relativePath를 부르면 어떻게 되는가?
베이스가 없으면 "상대 부분"이라는 개념 자체가 성립하지 않으므로 relativePathpath와 같은 값을 돌려준다. 이 챕터의 file URL 예시가 정확히 그 경우였다 — 베이스가 없는 절대 URL이라 pathrelativePath가 같았다.
쉽게 말하면 "여기서부터 얼마나 왔니"를 물었는데 애초에 "여기"(베이스)가 없으면, 그냥 "지금 있는 곳"(전체 경로)을 그대로 답하는 것과 같다.
absoluteString에 있는 %20을 내가 String(replacing:) 같은 걸로 직접 디코딩해도 되는가?
권장하지 않는다. 직접 문자열 치환으로 percent-decoding을 흉내 내면 %25(리터럴 %)나 다바이트 UTF-8 시퀀스, 잘못된 인코딩이 섞인 입력에서 쉽게 깨진다. pathremovingPercentEncoding처럼 검증된 API를 쓰는 게 항상 더 안전하다 — absoluteString을 손으로 다시 파싱하지 말라는 원칙이 여기에도 그대로 적용된다.
쉽게 말하면 암호화된 편지를 스스로 어설프게 풀어보려 하지 말고, 이미 검증된 열쇠(공식 API)로 여는 게 안전하다.

Q4. iOS 17에서 `URL(string:)`의 기본 동작이 어떻게 바뀌었고 무엇이 깨졌는가?

🔑 30초 답변

Apple 공식 문서는 "URL parsing has updated from the obsolete RFC 1738/1808 parsing to the same RFC 3986 parsing as URLComponents"라고 밝힌다. 지금 이 환경(Swift 6.2.1 / macOS 26 SDK)에서 직접 측정한 결과, 기본 URL(string:)은 공백이나 한글처럼 RFC 3986상 무효한 문자를 만나면 실패하는 대신 자동으로 percent-encode해 되도록 유효한 URL을 만들어낸다. init(string:encodingInvalidCharacters:)false를 주면 인코딩 없이 엄격 검증만 해 예전처럼 nil을 돌려준다. 이 자동 인코딩은 이미 올바르게 인코딩된 문자열에 무효 문자가 섞여 있을 때 그 %까지 다시 인코딩해버리는 이중 인코딩 부작용이 있고, 이 SDK에서 직접 재현된다.

원리

과거 URL(string:)은 RFC 1738/1808 기반의 관대한 레거시 파서를 썼고, URLComponents는 처음부터 RFC 3986을 따랐다 — 즉 같은 문자열을 URL(string:)URLComponents(string:)에 넣으면 서로 다른 결과가 나올 수 있는 불일치가 있었다. Apple 문서에 따르면 이제 URLURLComponents와 같은 RFC 3986 파서를 쓰도록 통일됐다. 정확히 어느 OS 버전부터 이 동작이 도입됐는지는 이 macOS 26 환경에서 직접 검증할 방법이 없다 — Apple 문서와 2023년 커뮤니티 기록은 iOS 17 / macOS 14 SDK로 빌드된 앱부터라고 설명하지만, 그 시점 이전 SDK를 이 머신에서 재현할 수 없으므로 여기서는 지금 이 SDK의 실제 동작만 관측한 그대로 보고한다.

내부 동작

의심스러운 입력 다섯 개로 세 가지 초기화 방식을 직접 비교했다.

현재 SDK — 기본 URL(string:) vs encodingInvalidCharacters 명시 — 실측 그대로
let inputs = ["my string", "https://x.com/a b", "https://x.com/한글경로", "a|b", "a^b"]
for s in inputs {
    print(s, "->")
    print("  기본:", URL(string: s)?.absoluteString ?? "nil")
    print("  false:", URL(string: s, encodingInvalidCharacters: false)?.absoluteString ?? "nil")
    print("  true:", URL(string: s, encodingInvalidCharacters: true)?.absoluteString ?? "nil")
}
입력 문자열기본 URL(string:)encodingInvalidCharacters: false
"my string"my%20stringnil
"https://x.com/a b"https://x.com/a%20bnil
"https://x.com/한글경로"https://x.com/%ED%95%9C%EA%B8%80%EA%B2%BD%EB%A1%9Cnil
"a|b"a%7Cbnil
"a^b"a%5Ebnil

기본 동작(encodingInvalidCharacters: true와 동일)은 모든 케이스에서 nil 대신 그럴듯한 URL을 만들어낸다. encodingInvalidCharacters: false는 예외 없이 nil이다 — 인코딩을 아예 안 하고 무효 문자를 그대로 거부하기 때문이다.

더 심각한 건 이중 인코딩이다. Jeff Johnson이 2025년 보고하고 2026년 3월 Michael Tsai가 재확인한 버그를 이 SDK에서 그대로 재현했다.

이미 인코딩된 %3A가 무효 문자(대괄호)와 섞이면 %253A로 이중 인코딩된다 — 실측
let input = "https://example.org?url=https%3A%2F%2Fy.org%3Ffoo[0]%3Dbar"
let u = URL(string: input)!
print(u.absoluteString)
// https://example.org?url=https%253A%252F%252Fy.org%253Ffoo%5B0%5D%253Dbar
// 대괄호는 정상적으로 %5B / %5D 가 됐지만,
// 원래 있던 %3A / %2F 는 %253A / %252F 로 "% 기호까지" 다시 인코딩돼버렸다

입력 문자열에 무효 문자([ ])와 이미 완성된 percent-encoding(%3A 등)이 함께 있으면, 자동 보정 로직이 대괄호는 올바르게 고치면서도 이미 있던 % 기호를 "인코딩해야 할 무효 문자"로 착각해 다시 인코딩해버린다. 기사 작성 시점 기준 신뢰할 만한 완전한 우회책은 보고되지 않았다.

실험 · 도구

의심 가는 URL 문자열이 있으면 swift -e 'print(URL(string: "…")?.absoluteString ?? "nil")'로 즉시 확인할 수 있다. 리다이렉트 URL이나 딥링크 unshortener처럼 "URL을 값으로 담은 URL"을 다루는 코드가 있다면, 그 값에 이미 인코딩된 % 시퀀스가 들어있는지부터 의심해봐야 한다.

프로젝트 적용

사용자 입력이나 외부 시스템에서 받은 문자열로 URL을 만들 때는 encodingInvalidCharacters: false로 엄격 검증한다. 자동 보정에 기대면 무효한 입력이 "그럴듯하게 유효해 보이는" URL로 조용히 바뀌어 버그를 늦게 발견한다.

외부 입력은 엄격 검증으로 조기에 걸러낸다
func strictURL(from raw: String) -> URL? {
    // false — 자동 보정 없이 RFC 3986 위반이면 바로 nil
    URL(string: raw, encodingInvalidCharacters: false)
}

if strictURL(from: userTypedURLString) == nil {
    // 자동으로 고쳐질 걸 기대하지 말고 여기서 사용자에게 즉시 피드백
}

이미 완성된(인코딩된) URL을 다른 URL의 쿼리 값으로 다시 감쌀 때는 절대 그 문자열을 통째로 URL(string:)에 재통과시키지 말고, URLComponents로 각 필드를 개별 조립해 이중 인코딩 경로 자체를 피한다.

URL을 값으로 담을 땐 URLComponents로 조립 — 문자열 재조합 아님
func wrappingRedirect(target: URL, into base: URL) -> URL? {
    guard var comps = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil }
    var items = comps.queryItems ?? []
    // ✅ queryItems가 target.absoluteString을 안전하게 한 번만 인코딩한다
    items.append(URLQueryItem(name: "url", value: target.absoluteString))
    comps.queryItems = items
    return comps.url
}
⚠️ 흔한 오해

"iOS 17에서 더 관대해졌으니 입력값 검증을 덜 신경 써도 된다"는 정반대다. 자동 인코딩 때문에 예전 같으면 nil이 되어 버그를 조기에 드러냈을 무효한 문자열이 이제는 조용히 "유효해 보이는" URL로 변환된다. "이 변경은 이미 다 고쳐졌다"도 틀렸다 — 이중 인코딩 버그는 2026년 3월 시점에도 여전히 추적 중인 이슈로 재확인됐고, 이 문서 작성 시점에도 같은 SDK에서 재현된다.

🧒 쉽게 이해하기

예전 선생님(iOS 16 이하)은 맞춤법이 틀린 답안지를 보면 바로 "틀렸어"라고 돌려줬다. 새 선생님(지금 SDK)은 맞춤법을 알아서 고쳐서 채점해준다 — 편해 보이지만, 이미 맞게 쓴 부분까지 "이거 틀린 것 같은데?" 하고 잘못 고쳐버리는 경우가 생긴다(이중 인코딩). 비유가 깨지는 곳: 선생님은 고친 부분을 빨간펜으로 표시라도 해주지만, URL(string:)은 무엇을 고쳤는지 아무 표시 없이 조용히 바꿔버린다.

꼬리 질문

encodingInvalidCharacters: false만 쓰면 iOS 17 이전 동작과 완전히 동일해지는가?
대체로 비슷하지만 완전히 같다고 단언할 수는 없다. 파싱 엔진 자체가 RFC 1738/1808에서 RFC 3986으로 바뀐 것이라, 인코딩 여부와 무관하게 아주 미묘한 문법 경계(권한 구분자, 포트 파싱 등)에서 다르게 동작할 가능성이 있다고 커뮤니티에서 보고된다. 이 환경에서 예전 SDK를 직접 재현할 수는 없으므로 "이 정도는 다를 수 있다"는 가능성만 확인된 것이다.
쉽게 말하면 채점 기준(파서)이 통째로 바뀌었으면, 겉보기엔 비슷해 보여도 아주 애매한 경계선 문제에서는 다른 판정이 나올 수 있다.
이중 인코딩 버그를 우회할 방법이 전혀 없는가?
근본적인 완전한 우회책은 보고되지 않았지만, 실무 대응은 가능하다 — 이미 인코딩된 URL 문자열을 다시 URL(string:)에 통과시키는 경로 자체를 피하고, 위 프로젝트 적용 ②처럼 URLComponents로 각 컴포넌트를 개별 조립하면 "이미 인코딩된 전체 문자열을 재해석"하는 상황 자체가 생기지 않는다.
쉽게 말하면 문제가 생기는 상황(이미 포장된 선물을 또 통째로 포장기에 넣는 것) 자체를 피하면, 포장기 고장(버그)을 직접 고치지 않아도 사고를 막을 수 있다.
URL.FormatStyle의 기본 formatted()는 어떤 컴포넌트를 생략하는가?
iOS 16+에서 도입된 URL.FormatStyle의 기본 스타일은 scheme·host·path는 보여주지만 port와 query는 기본적으로 생략한다. 예를 들어 https://www.example.com:8080/path?key=value.formatted()로 찍으면 https://www.example.com/path만 남는다. 쿼리까지 보여주고 싶으면 .query(.always) 같은 체이닝 메서드로 명시해야 한다. 사람에게 보여줄 URL을 요약할 때 유용하지만, 로깅처럼 정확한 전체 URL이 필요한 곳에는 맞지 않는다.
쉽게 말하면 명함에 회사 대표 전화번호만 적고 내선번호(포트)와 오늘의 특별 메모(쿼리)는 굳이 안 적는 것과 비슷하다 — 요약이지 전체 정보가 아니다.

Q5. `URLResourceValues`로 무엇을 읽고 쓰는가? — 파일 크기·생성일·백업 제외

🔑 30초 답변

URLResourceValues는 파일 크기·생성일·수정일·디렉터리 여부·백업 제외 여부 같은 파일시스템 메타데이터를 하나의 값 타입으로 묶어 읽고 쓰는 통로다. 읽기는 url.resourceValues(forKeys:)로 여러 값을 한 번에 배치 조회하고, 쓰기는 var url을 만든 뒤 url.setResourceValues(_:)를 호출해야 한다(URL 값 자체가 캐시를 갱신하며 바뀌므로 var가 필요). 각 값은 옵셔널이라, 파일시스템이 그 값 자체를 지원하지 않으면 에러 없이 nil이 나올 수 있다는 점을 항상 감안해야 한다.

원리

URLResourceValues는 과거 NSURLgetResourceValue(_:forKey:)(단일 키, 타입 소거된 Any 반환)를 여러 키를 한 번에, 강타입으로 다룰 수 있게 감싼 값 타입이다. URLResourceKey 열거형 상수(.fileSizeKey, .creationDateKey 등)로 어떤 값을 조회할지 지정하고, 그 결과를 URLResourceValues의 대응 프로퍼티(fileSize, creationDate)로 읽는다.

내부 동작

임시 파일 하나로 읽기/쓰기 왕복을 직접 확인했다.

쓰기(setResourceValues) 후 읽기(resourceValues) — 실측
var url = tmpDir.appendingPathComponent("test.txt")
try "hello".write(to: url, atomically: true, encoding: .utf8)

var v = URLResourceValues()
v.isExcludedFromBackup = true
try url.setResourceValues(v)   // url이 var여야 하는 이유 — 내부 캐시가 갱신된다

let readBack = try url.resourceValues(forKeys: [
    .fileSizeKey, .isExcludedFromBackupKey, .creationDateKey, .isDirectoryKey
])
print(readBack.fileSize!)               // 5
print(readBack.isExcludedFromBackup!)   // true
print(readBack.creationDate != nil)     // true
print(readBack.isDirectory!)            // false

배치 조회가 중요한 이유는 성능이다 — 키 4개를 각각 따로 조회하면 시스템 콜이 4번 나갈 수 있지만, resourceValues(forKeys:)에 배열로 한 번에 넘기면 파일시스템 메타데이터를 한 번에 모아 가져온다.

프로퍼티용도
.fileSizeKeyfileSize: Int?파일 바이트 크기. 디렉터리면 보통 nil.
.creationDateKeycreationDate: Date?생성일. 볼륨이 생성일 자체를 지원 안 하면 에러 없이 nil.
.contentModificationDateKeycontentModificationDate: Date?마지막 수정일.
.isDirectoryKeyisDirectory: Bool?디렉터리 여부.
.isExcludedFromBackupKeyisExcludedFromBackup: Bool?iCloud/iTunes 백업에서 제외할지. 쓰기 가능(var 필요).

실험 · 도구

swift 파일명.swift로 임시 디렉터리(FileManager.default.temporaryDirectory)에 파일을 만들어 위 코드를 그대로 돌리면 즉시 확인된다. 실제 기기에서 백업 제외가 적용됐는지는 Xcode의 Devices 창이나 xcrun simctl로 컨테이너를 꺼내 확인할 수 있다.

프로젝트 적용

캐시 디렉터리에 저장한 파일은 isExcludedFromBackuptrue로 표시해 사용자의 iCloud 백업 용량을 낭비하지 않는다. 단, Apple은 이 플래그를 사용자 문서에는 쓰지 말라고 명시한다 — 캐시·재생성 가능한 지원 파일 전용이다. 백업/퍼지 정책의 전체 그림(Documents/Caches/tmp가 각각 어떻게 다른지)은 25장(FileManager와 파일 I/O)에서 다룬다.

캐시 파일에만 백업 제외 플래그를 건다
func excludeFromBackup(_ fileURL: URL) throws {
    var url = fileURL
    var values = URLResourceValues()
    values.isExcludedFromBackup = true
    try url.setResourceValues(values)
}
// 호출부: Caches 디렉터리에 새로 쓴 파일에만 적용
try excludeFromBackup(cachesDirectory.appendingPathComponent("thumbnail.jpg"))

다운로드 진행 상황을 폴링하며 파일 크기를 반복 조회해야 한다면, 필요한 키를 한 번에 배치로 묶어 시스템 콜 횟수를 줄인다.

여러 값이 필요하면 한 번에 배치 조회
func fileSnapshot(_ url: URL) throws -> (size: Int, modified: Date?) {
    let v = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
    return (v.fileSize ?? 0, v.contentModificationDate)
}
⚠️ 흔한 오해

"creationDatenil이면 뭔가 에러가 난 것이다"는 틀렸다. 볼륨이 애초에 생성일 개념을 지원하지 않으면 throw 없이 정상적으로 nil이 반환된다. "isExcludedFromBackup을 문서 파일에 걸면 사용자 저장공간을 안전하게 아낄 수 있다"도 틀렸다 — Apple이 사용자 문서에는 쓰지 말라고 명시했고, 일부 파일 조작은 이 플래그를 조용히 false로 되돌리기도 한다.

🧒 쉽게 이해하기

URLResourceValues는 파일에 붙은 "이름표 꾸러미"를 한 번에 읽고 쓰는 것과 비슷하다. 크기표, 생일표, 백업 제외 스티커를 한 장 한 장 따로 확인하러 가지 않고 꾸러미째로 한 번에 가져온다. 비유가 깨지는 곳: 이름표 중 일부(생성일 등)는 애초에 그 파일에 안 붙어 있을 수도 있는데, 이건 이름표를 잃어버린 게 아니라 그 파일이 사는 동네(볼륨)가 애초에 그 이름표 자체를 안 쓰는 것일 뿐이다.

꼬리 질문

setResourceValues를 호출했는데 url이 let이면 왜 컴파일 에러가 나는가?
setResourceValues(_:)URLmutating 메서드다. 파일시스템에 실제로 쓰기 요청을 보내는 동시에, 그 URL 값이 내부적으로 들고 있는 리소스 값 캐시도 함께 갱신하기 때문이다. 값 타입의 mutating 메서드는 var로 선언된 인스턴스에서만 호출할 수 있으므로 let url = …로 받았다면 컴파일 에러가 난다.
쉽게 말하면 파일에 스티커를 새로 붙이는 작업이라, 스티커를 붙일 수 있게 "손에 쥔"(var) 상태여야 한다. 사진(let)으로만 들고 있으면 스티커를 못 붙인다.
fileSize 키를 디렉터리 URL에 조회하면 어떻게 되는가?
보통 nil이 반환된다. fileSize는 일반 파일의 바이트 크기를 위한 값이라 디렉터리에는 의미가 없기 때문이다. 디렉터리 안의 파일들을 모두 합친 크기가 필요하다면 FileManager로 하위 항목을 순회하며 직접 합산해야 한다 — URLResourceValues가 재귀적으로 계산해주지 않는다.
쉽게 말하면 서랍(디렉터리) 자체의 무게를 재려는 게 아니라, 서랍 안 물건 하나하나(파일)의 무게를 재는 저울이라는 뜻이다.
isExcludedFromBackup을 껐다 켰다 반복해도 되는가, 아니면 한 번만 설정해야 하는가?
여러 번 설정해도 안전하다 — 단순히 파일시스템 메타데이터 플래그를 다시 쓰는 것뿐이라 멱등적이다. 다만 Apple 문서가 경고하듯 일부 파일 조작(예: 파일을 다른 곳으로 옮기거나 특정 API로 재작성)이 이 플래그를 조용히 초기화시킬 수 있으므로, 한 번 설정했다고 영구히 유지된다고 가정하지 말고 캐시 파일을 다룰 때마다 다시 확인·설정하는 편이 안전하다.
쉽게 말하면 스티커가 저절로 안 떨어진다는 보장이 없으니, 중요한 파일이라면 가끔 스티커가 잘 붙어 있는지 다시 확인하는 습관이 필요하다.

출처 · 참고자료