Swift 프로토콜과 확장 | Protocol, Extension

이 글의 핵심

프로토콜은 타입이 갖춰야 할 요구 사항을 선언하는 계약이고, 확장은 기존 타입이나 프로토콜에 구현을 덧붙이는 도구입니다. 요구 사항 선언, 기본 구현과 디스패치 규칙, 연관 타입, any·some·제네릭의 차이, 테스트 경계를 프로토콜로 나누는 방법을 예제로 정리합니다.

#05 | Swift 시리즈 목차 | 이전: #04 클래스·구조체 · 다음: #06 제네릭

들어가며: 네트워크 경계를 프로토콜로 자르기

코드 곳곳에서 URLSession.shared를 직접 호출하면, 그 코드를 테스트할 때마다 실제 네트워크가 필요해집니다. 테스트가 느려지고, 서버 상태에 따라 실패하고, 에러 응답 같은 경우는 재현하기도 어렵습니다. 프로토콜로 “요청을 보내고 데이터와 응답을 돌려준다”는 계약만 정의해 두면, 실제 앱에서는 URLSession을, 테스트에서는 고정된 데이터를 돌려주는 가짜 구현을 끼울 수 있습니다.

import Foundation

protocol HTTPClient {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

extension URLSession: HTTPClient {
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        try await data(for: request, delegate: nil)
    }
}

struct UserDTO: Codable {
    let id: Int
    let name: String
}

struct RemoteUserService {
    let baseURL: URL
    let client: any HTTPClient

    func fetchUser(id: Int) async throws -> UserDTO {
        let request = URLRequest(url: baseURL.appendingPathComponent("users/\(id)"))
        let (data, _) = try await client.data(for: request)
        return try JSONDecoder().decode(UserDTO.self, from: data)
    }
}

struct MockHTTPClient: HTTPClient {
    let payload: Data
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        let response = HTTPURLResponse(url: request.url!, statusCode: 200,
                                       httpVersion: nil, headerFields: nil)!
        return (payload, response)
    }
}

URLSession의 async 메서드는 data(for:delegate:)로, delegate 매개변수에 기본값이 있어 호출할 때는 data(for:)처럼 보입니다. 하지만 프로토콜 준수를 판단할 때는 기본 인자가 고려되지 않으므로, 확장에서 요구 사항과 같은 시그니처의 메서드를 명시적으로 만들어 전달해야 합니다. 이 메서드 안의 data(for: request, delegate: nil) 호출은 인자 레이블이 다르므로 자기 자신을 재귀 호출하지 않습니다.

이 예에서 프로토콜은 단순한 기능 목록이 아니라 “네트워크 계층과 나머지 코드 사이의 경계”입니다. 이 글에서는 이런 경계를 만드는 데 필요한 문법, 즉 요구 사항 선언, 확장과 기본 구현, 연관 타입, any와 some을 차례로 살펴봅니다. 클래스와 구조체에서 값과 참조를 다뤘다면, 이번 글은 “이 타입이 무엇을 할 수 있는가”를 추상화하는 단계입니다.


요구 사항 선언

프로토콜은 프로퍼티, 메서드, 이니셜라이저, 타입 메서드를 요구 사항으로 선언할 수 있습니다.

protocol Drawable {
    var color: String { get set }
    var identifier: String { get }
    func draw()
}

protocol Resettable {
    mutating func reset()
}

protocol DefaultConstructible {
    init()
    static func makeDefault() -> Self
}

프로퍼티 요구 사항의 { get }과 { get set }은 최소한 제공해야 하는 접근을 뜻합니다. { get }으로 선언한 요구 사항은 읽기 전용 계산 프로퍼티로도, var 저장 프로퍼티로도 만족할 수 있습니다. 반면 { get set }은 let 상수나 읽기 전용 계산 프로퍼티로는 만족할 수 없습니다.

구조체나 열거형이 메서드 안에서 자기 프로퍼티를 바꾸려면 mutating이 필요하므로, 그런 구현을 허용하려면 프로토콜의 요구 사항에도 mutating을 붙여야 합니다. 클래스는 참조 타입이라 메서드에 mutating을 쓰지 않으며, mutating 요구 사항을 일반 메서드로 그대로 만족할 수 있습니다.

struct Counter: Resettable {
    var value = 0
    mutating func reset() { value = 0 }
}

final class Session: Resettable {
    var token: String? = "abc"
    func reset() { token = nil }   // 클래스는 mutating 없이 준수
}

init() 요구 사항을 클래스가 준수할 때는 required init()으로 선언해야 합니다. 하위 클래스도 같은 이니셜라이저를 제공해야 프로토콜 준수가 유지되기 때문입니다. final class라면 하위 클래스가 없으므로 required를 생략할 수 있습니다. Self는 “이 프로토콜을 준수하는 실제 타입”을 가리키므로 팩토리 메서드의 반환 타입에 자주 쓰입니다.


준수와 확장

타입 선언에 프로토콜 목록을 적어 준수할 수도 있고, 확장에서 준수를 추가할 수도 있습니다. 한 타입이 여러 프로토콜을 동시에 준수할 수 있으며, 요구 사항을 하나라도 빠뜨리면 컴파일 에러가 납니다.

protocol Movable {
    func move(to x: Int, y: Int)
}

struct Sprite: Drawable {
    var color: String
    var identifier: String { "sprite-\(color)" }
    func draw() { print("draw \(identifier)") }
}

extension Sprite: Movable {
    func move(to x: Int, y: Int) { print("move to (\(x), \(y))") }
}

확장으로 준수를 분리하면 “이 부분은 Movable을 위한 코드”라는 경계가 코드에 드러나서 큰 타입을 읽기 쉬워집니다. 소스를 수정할 수 없는 타입, 예를 들어 표준 라이브러리나 다른 모듈의 타입에도 확장으로 준수를 추가할 수 있습니다. 앞의 URLSession: HTTPClient가 그 예입니다. 다만 확장에서는 저장 프로퍼티를 추가할 수 없으므로, 새 상태가 필요한 요구 사항은 확장만으로 만족할 수 없습니다.

where 절을 붙이면 조건부로 준수하거나 조건부로 메서드를 추가할 수 있습니다. 표준 라이브러리의 Array는 요소가 Equatable일 때만 Equatable을 준수하는데, 이것이 조건부 준수입니다.

extension Array where Element: Hashable {
    func uniqued() -> [Element] {
        var seen = Set<Element>()
        return filter { seen.insert($0).inserted }
    }
}

[3, 1, 3, 2, 1].uniqued()   // [3, 1, 2]

프로토콜 상속, 합성, 클래스 전용 프로토콜

프로토콜은 다른 프로토콜을 상속해 요구 사항을 이어받을 수 있습니다. 표준 라이브러리의 Hashable은 Equatable을 상속하므로, Hashable을 준수하는 타입은 ==도 제공해야 합니다.

protocol Named {
    var name: String { get }
}

protocol Playable: Named {
    func play()
}

struct Track: Playable {
    var name: String
    func play() { print("Playing \(name)") }
}

상속 체인이 길어질수록 준수하는 타입이 채워야 할 요구 사항이 늘어납니다. 일부 준수 타입에만 필요한 기능은 별도 프로토콜로 떼어 두는 편이 낫습니다.

상속 관계를 만들지 않고 “여러 프로토콜을 동시에 만족하는 타입”을 표현할 때는 &로 합성합니다. Codable이 대표적인 예로, Decodable과 Encodable을 상속한 새 프로토콜이 아니라 typealias Codable = Decodable & Encodable로 정의된 합성입니다.

func render(_ item: some Drawable & Movable) {
    item.draw()
    item.move(to: 0, y: 0)
}

delegate처럼 weak 참조로 보관해야 하는 경우에는 프로토콜을 AnyObject로 제한합니다. weak는 참조 타입에만 쓸 수 있으므로, 구조체가 준수할 수 있는 프로토콜 타입의 변수에는 weak를 붙일 수 없습니다.

protocol ImageCacheDelegate: AnyObject {
    func cache(_ cache: ImageCache, didUpdateKey key: String)
}

final class ImageCache {
    weak var delegate: (any ImageCacheDelegate)?

    func store(_ data: Data, for key: String) {
        // ...
        delegate?.cache(self, didUpdateKey: key)
    }
}

delegate를 weak로 두는 이유는 순환 참조 때문입니다. 보통 뷰 컨트롤러가 캐시를 강하게 소유하고 캐시가 delegate로 뷰 컨트롤러를 가리키는데, 이 참조까지 강하면 둘 다 해제되지 않습니다.

일부 요구 사항을 선택 사항으로 만드는 optional 키워드는 @objc 프로토콜에서만 쓸 수 있습니다. UITableViewDelegate 같은 Objective-C 기반 API에서 흔히 보지만, 순수 Swift 설계에서는 빈 기본 구현을 제공하거나 프로토콜을 작게 나누는 방법이 일반적입니다.

@objc protocol LegacyDelegate: AnyObject {
    @objc optional func shouldSkip() -> Bool
    func didFinish()
}

프로토콜 확장과 기본 구현

프로토콜 확장에 메서드 구현을 두면, 준수하는 모든 타입이 그 구현을 기본으로 갖게 됩니다. 이것이 프로토콜 지향 프로그래밍(POP)의 핵심 도구입니다. 상위 클래스에 공통 코드를 두는 대신 프로토콜 확장에 두므로, 상속할 수 없는 구조체와 열거형도 같은 방식으로 공통 기능을 얻습니다.

protocol Shape {
    func area() -> Double
    func perimeter() -> Double
}

extension Shape {
    func describe() -> String {
        "넓이: \(area()), 둘레: \(perimeter())"
    }
}

struct Circle: Shape {
    let radius: Double
    func area() -> Double { .pi * radius * radius }
    func perimeter() -> Double { 2 * .pi * radius }
}

struct Rectangle: Shape {
    let width: Double, height: Double
    func area() -> Double { width * height }
    func perimeter() -> Double { 2 * (width + height) }
}

요구 사항인가, 확장 전용인가: 디스패치 규칙

여기에 Swift 프로토콜에서 가장 자주 혼동되는 규칙이 있습니다. 확장에 구현된 메서드가 프로토콜 본문에 요구 사항으로 선언되어 있는지에 따라 호출 방식이 달라집니다.

protocol Greeter {
    func greet()                 // 요구 사항
}

extension Greeter {
    func greet() { print("기본 인사") }
    func farewell() { print("기본 작별") }   // 요구 사항 아님
}

struct Korean: Greeter {
    func greet() { print("안녕하세요") }
    func farewell() { print("안녕히 가세요") }
}

let concrete = Korean()
concrete.greet()       // 안녕하세요
concrete.farewell()    // 안녕히 가세요

let existential: any Greeter = Korean()
existential.greet()    // 안녕하세요
existential.farewell() // 기본 작별

greet()는 요구 사항이므로 프로토콜 타입을 통해 호출해도 위트니스 테이블을 거쳐 실제 타입의 구현이 호출됩니다. 반면 farewell()은 확장에만 있으므로 컴파일러가 정적 타입을 보고 호출할 함수를 정합니다. 정적 타입이 any Greeter이면 Korean에 같은 이름의 메서드가 있어도 확장의 구현이 호출됩니다. 제네릭 함수 func f<T: Greeter>(_ x: T) 안에서 x.farewell()을 불러도 마찬가지로 기본 구현이 호출됩니다.

준수 타입이 바꿔 끼울 수 있어야 하는 동작이라면 반드시 프로토콜 본문에 요구 사항으로 올려야 합니다. 확장 전용 메서드는 “모든 준수 타입에 공통이고 바꿀 일이 없는 편의 기능”에만 두는 것이 안전합니다.

확장을 남용하지 않기

기본 구현을 확장에 몰아넣으면 중복은 줄지만, 위의 디스패치 차이 때문에 “구현했는데 호출이 안 된다”는 버그가 생기기 쉽고, where 절이 붙은 확장이 여러 겹이면 어떤 구현이 선택되는지 추적하는 비용도 커집니다. 저는 세 가지 기준으로 이를 줄입니다. 첫째, 프로토콜 본문에 올릴 요구 사항과 확장에만 둘 편의 메서드를 의도적으로 구분합니다. 둘째, “여러 곳에서 쓰인다”는 이유만으로 확장을 늘리지 않고, 그 기능이 프로토콜의 책임에 속하는지부터 봅니다. 셋째, 선택적 메서드를 흉내 내려고 빈 기본 구현을 추가하기 전에 프로토콜을 작게 나눌 수 없는지 검토합니다.


연관 타입

연관 타입(associatedtype)은 프로토콜 안에서 쓰는 타입 매개변수입니다. 구체 타입은 준수할 때 정해집니다. Sequence의 Element, Collection의 Index가 대표적입니다.

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

struct IntStack: Container {
    var items: [Int] = []
    var count: Int { items.count }
    subscript(i: Int) -> Int { items[i] }   // Item == Int로 추론
}

func printAll<C: Container>(_ c: C) where C.Item: CustomStringConvertible {
    for i in 0..<c.count { print(c[i].description) }
}

연관 타입은 대부분 구현의 시그니처에서 추론되므로 typealias Item = Int를 쓰지 않아도 됩니다. Swift 5.6까지는 연관 타입이나 Self 요구 사항이 있는 프로토콜을 let c: Container처럼 변수 타입으로 쓸 수 없었고, 제네릭 제약으로만 쓸 수 있었습니다. Swift 5.7(SE-0309)부터는 any Container처럼 존재 타입으로도 쓸 수 있고, 주 연관 타입(SE-0346)을 선언한 프로토콜은 any Collection<Int>, some Sequence<String>처럼 연관 타입을 지정할 수도 있습니다.


any, some, 제네릭

프로토콜을 타입으로 쓰는 방법은 세 가지이고, 각각 의미와 비용이 다릅니다.

any P는 존재 타입(existential)으로, “P를 준수하는 어떤 타입의 값이든 담는 상자”입니다. 서로 다른 구체 타입을 한 배열에 담을 때 필요합니다.

let shapes: [any Shape] = [Circle(radius: 1), Rectangle(width: 2, height: 3)]
for s in shapes { print(s.describe()) }

이 상자에는 비용이 있습니다. 값이 작으면(포인터 3개 크기 이내) 상자 안에 바로 저장되지만 더 크면 힙에 따로 할당되고, 메서드 호출은 위트니스 테이블을 거치는 동적 호출이 되며, 컴파일러가 구체 타입에 맞춰 코드를 특수화하거나 인라인하기 어렵습니다. 대부분의 코드에서는 무시해도 될 수준이지만, 수많은 요소를 도는 계산 루프라면 차이가 날 수 있으므로 Instruments로 확인한 뒤 판단합니다. any 키워드는 Swift 5.6(SE-0335)에서 도입되었고, 생략해도 지금은 동작하지만 ExistentialAny 기능을 켜면 필수가 됩니다. 존재 타입이라는 사실을 드러내기 위해 붙여 쓰는 것을 권합니다.

some P를 반환 타입에 쓰면 불투명 타입(opaque type)이 됩니다. 함수 안에서는 하나의 구체 타입으로 고정되지만, 호출하는 쪽에는 “P를 준수하는 어떤 타입”으로만 보입니다.

func makeShape() -> some Shape {
    Circle(radius: 1)
}

구체 타입이 컴파일 시점에 정해져 있으므로 존재 타입의 상자 비용이 없고, 구현을 바꿔도 반환 타입이 API에 드러나지 않습니다. 대신 조건에 따라 Circle과 Rectangle을 섞어 반환하면 컴파일 에러가 납니다. 반환 타입이 하나로 고정되지 않으면 any Shape를 쓰거나 열거형으로 감싸야 합니다. SwiftUI의 var body: some View가 이 기능의 대표적인 사용처로, 수정자를 이어 붙이며 만들어지는 길고 복잡한 구체 타입을 숨깁니다.

제네릭 제약 func f<T: P>(_ x: T)는 호출 지점마다 T가 하나의 구체 타입으로 정해지므로, 컴파일러가 특수화해 정적 호출로 바꿀 수 있습니다. Swift 5.7부터는 매개변수 위치에 some P를 쓰는 것이 이 제네릭의 축약 표기입니다. 즉 func f(_ x: some P)는 func f<T: P>(_ x: T)와 같습니다.

선택 기준을 정리하면, 하나의 구체 타입을 다루는 함수라면 제네릭(또는 매개변수 위치의 some)이 기본이고, 반환 타입을 숨기고 싶다면 some, 서로 다른 구체 타입을 한 컬렉션이나 프로퍼티에 담아야 할 때 any를 씁니다.

타입 지우기

any가 연관 타입을 다룰 수 없던 시절에는, 제네릭 타입을 감싸 연관 타입만 남기는 타입 지우기(type erasure) 래퍼를 직접 만들었습니다. 표준 라이브러리의 AnySequence, AnyIterator, Combine의 AnyPublisher가 이런 래퍼입니다.

struct AnyIntIterator: IteratorProtocol {
    private let nextImpl: () -> Int?
    init<I: IteratorProtocol>(_ base: I) where I.Element == Int {
        var base = base
        nextImpl = { base.next() }
    }
    mutating func next() -> Int? { nextImpl() }
}

var it = AnyIntIterator([1, 2, 3].makeIterator())
it.next()   // 1

구체 반복자 타입은 클로저 안에 갇히고 바깥에는 Int를 내놓는 반복자라는 사실만 남습니다. Swift 5.7 이후에는 any IteratorProtocol<Int>로 같은 목적을 이룰 수 있는 경우가 많아졌으므로, 새 코드에서는 직접 래퍼를 만들기 전에 some, any, 제네릭으로 충분한지 먼저 확인합니다. AnyPublisher는 여전히 Combine에서 퍼블리셔의 복잡한 구체 타입을 API 경계에서 숨기는 표준적인 방법입니다.


표준 라이브러리 프로토콜과 자동 합성

Equatable은 ==, Hashable은 Set과 Dictionary의 키로 쓰기 위한 hash(into:), Comparable은 <를 요구합니다. 구조체의 모든 저장 프로퍼티가 Equatable(또는 Hashable)이면 컴파일러가 해당 구현을 자동으로 합성하고, 열거형도 연관 값이 모두 준수하면 합성됩니다. Comparable은 열거형에서만 합성되고(케이스 선언 순서가 기준), 구조체는 <를 직접 구현해야 합니다. Codable도 마찬가지로 모든 저장 프로퍼티가 Codable이면 합성되며, 연관 값을 가진 열거형의 Codable 합성은 Swift 5.5부터 지원됩니다.

struct Point: Hashable {
    var x: Int, y: Int
}

var visited: Set<Point> = [Point(x: 0, y: 0)]

let data = try JSONEncoder().encode(UserDTO(id: 1, name: "Ann"))
let user = try JSONDecoder().decode(UserDTO.self, from: data)

클래스는 Equatable과 Hashable이 자동 합성되지 않습니다. 상속 계층에서 무엇을 기준으로 같다고 볼지 컴파일러가 정할 수 없기 때문에 직접 구현해야 합니다.

자동 합성이 항상 원하는 의미는 아닙니다. 합성된 ==는 모든 저장 프로퍼티를 비교하는데, 목록의 행처럼 ID가 같으면 같은 항목으로 봐야 하는 경우도 있습니다. 이때는 직접 구현하되, ==에서 같다고 판단한 두 값은 반드시 같은 해시를 내야 한다는 규칙을 지켜야 합니다. ==는 ID만 비교하면서 해시는 합성에 맡겨 모든 프로퍼티를 섞으면, 같은 항목이 Set에 두 번 들어가는 버그가 생깁니다.

struct Row: Hashable {
    let id: UUID
    var title: String

    static func == (lhs: Row, rhs: Row) -> Bool { lhs.id == rhs.id }
    func hash(into hasher: inout Hasher) { hasher.combine(id) }
}

Codable DTO는 서버 스키마와 직접 맞닿아 있으므로 도메인 모델과 분리해 두는 편이 안전합니다. JSON에 필드가 추가되는 것은 기본 디코딩이 무시하므로 문제가 없지만, 필드가 빠지거나 이름이 바뀌면 옵셔널이 아닌 프로퍼티의 디코딩이 실패합니다. 키 이름이 다르면 CodingKeys로, 날짜 형식은 JSONDecoder.dateDecodingStrategy로 맞춥니다.


프로토콜로 경계 설계하기

앞의 HTTP 클라이언트처럼 프로토콜은 테스트할 때 바꿔 끼워야 하는 경계, 즉 네트워크, 저장소, 현재 시각, UUID 생성 같은 곳에서 가장 큰 효과를 냅니다. 저장소 계층을 프로토콜로 두면 뷰 모델은 구현이 원격 API인지 로컬 캐시인지 모른 채로 동작하고, 테스트에서는 메모리 안의 딕셔너리를 쓰는 구현을 끼울 수 있습니다.

protocol UserRepository {
    func user(id: Int) async throws -> UserDTO
    func save(_ user: UserDTO) async throws
}

struct APIUserRepository: UserRepository {
    let client: any HTTPClient
    let baseURL: URL

    func user(id: Int) async throws -> UserDTO {
        let request = URLRequest(url: baseURL.appendingPathComponent("users/\(id)"))
        let (data, _) = try await client.data(for: request)
        return try JSONDecoder().decode(UserDTO.self, from: data)
    }

    func save(_ user: UserDTO) async throws {
        var request = URLRequest(url: baseURL.appendingPathComponent("users/\(user.id)"))
        request.httpMethod = "PUT"
        request.httpBody = try JSONEncoder().encode(user)
        _ = try await client.data(for: request)
    }
}

actor InMemoryUserRepository: UserRepository {
    private var storage: [Int: UserDTO] = [:]
    func user(id: Int) async throws -> UserDTO {
        guard let u = storage[id] else { throw URLError(.fileDoesNotExist) }
        return u
    }
    func save(_ user: UserDTO) async throws { storage[user.id] = user }
}

테스트용 구현을 actor로 만든 이유는 여러 태스크가 동시에 save를 호출해도 딕셔너리 접근이 직렬화되게 하기 위해서입니다. 요구 사항이 async이므로 actor의 메서드로 그대로 만족할 수 있습니다. Swift 6의 엄격한 동시성 검사에서는 이런 프로토콜 타입 값을 태스크 사이로 넘길 때 Sendable 준수가 필요해지므로, 경계 프로토콜을 설계할 때 protocol UserRepository: Sendable로 선언할지 미리 정해 두면 나중에 @unchecked Sendable로 우회할 일이 줄어듭니다.

POP와 OOP 중 무엇이 옳은가를 두고 논쟁이 많지만, 현실적인 답은 섞어 쓰는 것입니다. SwiftUI의 View는 프로토콜이고 구조체로 화면을 조합하므로 POP가 자연스럽습니다. 반면 UIKit에서는 UIViewController를 상속하는 것이 프레임워크가 요구하는 방식이고, 그 계층 안에서는 상속이 여전히 맞습니다. 프레임워크가 상속을 강제하는 곳은 받아들이고, 그 바깥의 네트워크나 저장소 경계는 프로토콜로 나누는 식입니다. 다만 프로토콜을 지나치게 잘게 쪼개면 하나의 기능을 이해하기 위해 열어 봐야 할 파일이 늘어나므로, 팀이 한 번에 읽을 수 있는 크기로 유지하는 것이 POP에서도 OOP에서도 같은 숙제입니다.


자주 만나는 컴파일 에러

“Protocol ‘P’ can only be used as a generic constraint because it has Self or associated type requirements”는 Swift 5.6 이하에서 연관 타입이 있는 프로토콜을 변수 타입으로 쓰려 할 때 나는 에러입니다. 최신 컴파일러에서는 any P로 쓸 수 있고, 가능하면 제네릭 제약이나 some P로 바꾸는 편이 더 낫습니다.

“Type ‘X’ does not conform to protocol ‘P‘“가 나오면 요구 사항의 시그니처를 하나씩 대조합니다. 인자 레이블, mutating 여부, static 여부, throws/async 여부가 다르면 같은 메서드로 인정되지 않고, 앞의 URLSession 예처럼 기본 인자가 있는 메서드도 요구 사항을 만족하지 못합니다. Xcode의 “Fix” 버튼으로 빠진 요구 사항의 틀을 추가하면 어떤 시그니처가 기대되는지 바로 확인할 수 있습니다.

weak를 붙일 수 없다는 에러는 프로토콜이 AnyObject로 제한되지 않았을 때 나옵니다. Hashable 합성이 실패하면 저장 프로퍼티 중 Hashable이 아닌 것이 있는지 확인하고, Codable 디코딩이 런타임에 실패하면 에러 메시지의 codingPath로 어느 키에서 실패했는지 확인합니다.


참고 자료


같이 보면 좋은 글