스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Swift의 디자인 프로토콜 인터페이스
Swift 5.7로 프로토콜을 사용하여 고급 추상화를 디자인하는 방법을 알아보세요. 실존 타입을 사용하는 방법을 소개하고, 불투명 반환 타입으로 인터페이스와 구현을 분리하는 방법을 알아보며, 구체적인 타입 간의 관계를 식별하고 보장하는 데 도움이 될 수 있는 동일 타입 요구사항을 소개합니다. 이 세션을 최대한 활용하려면 WWDC22의 ‘Embrace Swift generics(Swift 제네릭 활용)'을 시청하시기 바랍니다.
리소스
관련 비디오
WWDC23
WWDC22
-
다운로드
♪ ♪
안녕하세요, 저는 Swift 컴파일러 팀의 Slava입니다 Swift 디자인 프로토콜 인터페이스에 오신 것을 환영해요 저는 'Swift 제네릭 받아들이기' 강연 내용에 이어서 구체 타입 추상화 및 프로토콜로 타입 관계를 모델링하는 몇 가지 고급 기술을 보여드리고자 합니다 이 강연에서는 기존의 Swift 언어 기능과 Swift 5.7에 도입된 새로운 일부 기능을 모두 다룰 예정이에요 강연은 크게 3가지 주제로 구성되는데 먼저 '결과 타입 소거' 기능의 작동 방식에 대한 설명을 통해 연관 타입을 가진 프로토콜이 실존하는 'Any' 타입과 상호 작용하는 방법을 보여 드리고 다음으로 불분명한 결과 타입을 사용하여 인터페이스를 구현에서 분리함으로써 캡슐화를 개선하는 방법을 살펴본 다음 마지막으로는 프로토콜의 동일 타입 요구 사항이 어떻게 여러 개의 서로 다른 구체 타입 집합 간의 관계를 모델링하는지 살펴보죠
그럼 먼저 연관 타입을 가진 프로토콜이 어떻게 실존 타입과 상호 작용하는지 알아보죠 여기 프로토콜 한 쌍과 4가지 구체 타입이 있는 데이터 모델이 있는데요 동물은 닭과 소 두 종류이고 식품은 달걀과 우유 두 종류입니다 닭은 달걀을 낳고 소는 우유를 만들어 내죠 식품의 생산을 추상화하기 위해 Animal 프로토콜에 produce() 메소드를 추가하겠습니다 'Swift 제네릭 받아들이기' 강연 내용을 기억하시겠지만 소, 닭에 대한 produce() 메소드의 다양한 반환 타입을 추상화하는 최고의 방법은 연관 타입을 사용하는 거였죠 연관 타입을 사용하여 구체 타입의 동물을 선언하고 produce() 메소드를 호출하면 구체 동물 타입에 따라 특정 타입의 식품이 반환되는데 이러한 관계는 다이어그램으로 나타낼 수도 있습니다 프로토콜 'Self' 타입은 'Animal' 프로토콜을 따르는 실제 구체 타입을 나타내며 Self' 타입에는 'Food'를 따르는 연관 'Commodity' 타입이 있죠 구체적인 Chicken 및 Cow 타입 간의 관계와 동물 프로토콜에 대한 연관 타입 다이어그램도 살펴볼게요
Chicken 타입은 CommodityType이 'Egg'인 Animal 프로토콜을 따르고 Cow 타입은 'Milk' CommodityType 의 Animal 프로토콜을 따릅니다 동물로 가득 찬 농장이 있다고 가정해보면 Farm의 'animals' 저장 속성은 'any Animal'의 이종 배열이죠 Swift 제네릭 받아들이기'에서 어떻게 'any Animal' 타입이 어떠한 특정 동물 타입이라도 동적으로 저장할 수 있는 박스로 표현되는지 알아봤는데요 이렇게 다양한 구체 타입에 대해 동일한 표현을 사용하는 전략을 타입 소거라고 합니다
generateCommodities 메소드는 동물들의 배열을 매핑하면서 각 동물에 대해 produces 메소드를 호출하는데 간단해 보이는 메소드지만 우리는 타입 소거가 실제 동물 타입에 대한 정적 타입 관계를 제거함을 알고 있으므로 이러한 타입을 검사하는 이유를 더 깊이 이해할 필요가 있죠
map() 클로저의 'animal' 매개변수는 'any Animal' 타입이며 'produce()'의 반환 타입은 연관 타입입니다 실존 타입에 대해 연관 타입을 반환하는 메소드를 호출하면 컴파일러는 타입 소거를 사용하여 호출의 결과 타입을 결정하죠 타입 소거는 이러한 연관 타입을 동등한 제약 조건을 가진 일치하는 실존 타입으로 대체하는데요 우리는 구체적인 Animal 타입과 연관 CommodityType 간의 관계를 'any Animal'과 'any Food'로 대체하여 제거했습니다 'any Food' 타입은 연관 Commodity Type의 상한 타입이라고 하는데 'any Animal'에 대해 produce() 메소드가 호출됐기 때문에 반환 값은 타입 소거되어 'any Food' 타입의 값이 반환됩니다 바로 우리가 원하는 타입이죠
Swift 5.7의 새로운 기능인 '연관 타입 소거'의 작동 방식을 자세히 살펴보겠습니다 프로토콜 메소드의 결과 타입에 나타나는 화살표 오른쪽의 연관 타입은 '생산 위치'에 있다고 하는데 메소드를 호출하면 해당 타입의 값이 생산되기 때문이죠 'any Animal'에 대해 이 메소드를 호출하는 경우 컴파일 시점에 구체적인 결과 타입은 알 수 없지만 상한 타입의 하위 타입이라는 건 알 수 있습니다 예시를 보면 런타임에 Cow 객체를 가진 'any Animal'에 대해 generate()를 호출하고 있는데 이 경우 Cow의 generate() 메소드는 Milk를 반환하며 Milk는 Animal 프로토콜과 관련된 CommodityType의 상한인 'any Food' 내에 저장될 수 있습니다 Animal 프로토콜을 따르는 모든 구체 타입에 대해 항상 안전하죠
반면 메소드나 생성자의 매개변수 목록에 연관 타입이 나타나면 어떻게 되는지 생각해 보겠습니다 여기에서 Animal 프로토콜의 eat() 메소드는 소비 위치에 연관 타입인 FeedType을 갖고 있어요 메소드를 호출하려면 이 타입의 값을 전달해야 하는 거죠 그런데 변환이 반대 방향으로 진행되기 때문에 타입 소거를 수행할 수 없으며 구체 타입을 알 수 없기 때문에 연관 타입의 상한 실존 타입은 실제 구체 타입으로 안전하게 변환되지 않습니다 예를 들어보죠 이번에도 Cow를 저장하고 있는 'any Animal'이 있고 Cow의 'eat' 메소드가 Hay를 취한다고 가정할 경우 Animal 프로토콜 연관 'FeedType' 상한은 'any AnimalFeed'이지만 그러나 임의의 'any AnimalFeed'가 주어질 경우 'Hay' 구체 타입을 저장한다는 것을 정적으로 보장할 방법은 없습니다 타입 소거는 소비 위치에서 연관 타입을 사용하는 것을 허용하지 않아요 그 대신 불분명한 'some' 타입을 취하는 함수에 입력함으로써 실존하는 'any' 타입을 언박싱해야 하죠 사실 연관 타입의 이러한 타입 소거 동작은 Swift 5.6에서 볼 수 있는 기존의 기능과 유사합니다 참조 타입을 복제하기 위한 프로토콜이 있다고 해보죠 이 프로토콜은 Self를 반환하는 단일 clone() 메소드를 정의하는데 'any Cloneable' 타입의 값에 대해 clone()을 호출하면 결과 타입 'Self'가 상한까지 타입 소거됩니다 Self 타입의 상한선은 항상 프로토콜 자체이므로 'any Cloneable' 타입의 새로운 값이 반환되죠 요약하면 'any'를 사용하여 값 타입이 프로토콜을 따르는 구체 타입을 저장하는 실존 타입임을 선언할 수 있다는 겁니다 이 기능은 연관 타입을 가진 프로토콜에서도 사용 가능해요 생산 위치에서 연관 타입을 가진 프로토콜 메소드를 호출할 경우 연관 타입은 연관 타입 제약 조건을 가진 또 다른 실존 타입인 상한 타입까지 타입 소거되죠 구체 타입을 추상화하는 것은 함수 입력뿐만이 아니라 출력에도 유용한데요 구체 타입은 구현했을 때만 볼 수 있죠 그럼 구체적인 결과 타입을 추상화하여 구현 세부 정보에서 코드 조각의 필수 인터페이스를 분리함으로써 정적 타입 할당을 더욱 모듈화하고 변화에 강하게 만드는 방법을 보겠습니다 동물에게 먹이를 줄 수 있도록 Animal 프로토콜을 일반화해보죠 동물은 배가 고파질 테고 그러면 먹이를 먹어야 하니 Animal 프로토콜에 isHungry 속성을 추가해 보겠습니다 Farm의 feedAnimals()는 배고픈 하위 집합 동물에게 먹이를 주는데 배고픈 동물의 하위 집합에 대한 연산을 hungryAnimals 속성으로 나누었어요 이 hungryAnimals()의 초기 구현은 filter() 메소드를 사용하여 isHungry 속성이 참인 동물의 하위 집합을 선택합니다 'any Animal' 배열에 대해 filter()를 호출하면 'any Animal'의 새로운 배열이 반환되죠 보시면 feedAnimals는 hungryAnimals를 한 번 반복한 다음 즉시 이 임시 배열을 버린다는 걸 알 수 있는데요 농장에 배고픈 동물이 많은 경우 비효율적인 방법이죠 이러한 임시 할당을 피할 수 있는 방법 중 하나는 표준 라이브러리의 지연 계산 컬렉션 기능을 사용하는 건데요 지연 계산 컬렉션은 'filter'에 대한 호출을 'lazy.filter'로 대체하면 얻을 수 있습니다 지연 계산 컬렉션은 'filter'에 대한 일반 호출을 통해 반환된 배열과 동일한 요소를 갖지만 임시 할당을 피합니다 하지만 이 경우 'hungryAnimals' 속성의 타입은 'any Amimal' 배열의 'LazyFilterSequence'라는 복잡한 구체 타입으로 선언되어야 하는데 이는 불필요한 구현 세부 사항을 노출하죠 클라이언트인 feedAnimals()는 'hungryAnimals' 구현 시 'lazy.filter'를 썼는지는 알 필요가 없고 반복할 수 있는 컬렉션을 얻을 수 있다는 것만 알면 됩니다 불분명한 결과 타입을 사용하면 복잡한 구체 타입을 컬렉션의 추상 인터페이스 뒤로 숨길 수 있죠 그럼 'hungryAnimals'를 호출하는 클라이언트는 컬렉션 프로토콜을 따르는 어떤 구체 타입을 얻고 있다는 것만 알고 특정 구체 타입의 컬렉션은 모르게 됩니다
하지만 보시듯이 이러한 방법은 클라이언트로부터 정적 타입 정보를 너무 많이 숨겨 버리죠 hungryAnimals가 컬렉션을 따르는 구체 타입을 출력한다고 선언하지만 그 컬렉션의 요소 타입에 대한 정보는 전혀 없습니다 요소 타입이 'any Animal'이라는 지식이 없다면 요소 타입을 전달하는 작업만 할 수 있을 뿐이고 Animal 프로토콜의 어떤 메소드도 호출할 수 없죠 그럼 불분명한 결과 타입인 'some Collection'을 자세히 보죠 '제한된 불분명한 결과 타입'을 사용하면 구현 세부 정보를 감추고 인터페이스 정보를 노출시키는 정도를 균형 있게 조절할 수 있죠 '제한된 불분명한 결과 타입'이란 Swift 5.7의 새로운 기능으로 프로토콜 이름 뒤에 있는 꺾쇠 괄호 안에 타입 인수를 넣는 형식으로 작성하며 Collection 프로토콜의 인수는 Element 타입 하나만 사용합니다 이렇게 'hungryAnimals'가 제한된 불분명한 결과 타입으로 선언되면 클라이언트는 이게 'any Amimal 배열의 LazyFilterSequence'라는 사실은 알 수 없게 되지만 'any Animal'과 같은 Element 연관 타입을 가진 컬렉션을 따르는 구체 타입이라는 것은 여전히 알 수 있어요 우리가 원하는 인터페이스인 거죠 'feedAnimals()'의 for 반복문 내에서 'animal' 변수는 'any Animal' 타입을 갖게 되므로 Animal 프로토콜 메소드를 각각의 배고픈 동물에 대해 호출할 수 있어요 이 모든 게 동작할 수 있는 이유는 Collection 프로토콜이 Element 연관 타입을 기본 연관 타입으로 선언했기 때문이죠 이렇게 프로토콜 이름 뒤에 괄호로 묶인 하나 이상의 연관 타입을 넣으면, 기본 연관 타입을 가진 고유한 프로토콜을 선언할 수 있습니다 기본 연관 타입으로 가장 적합한 것은 컬렉션의 Element 타입같이 호출자가 일반적으로 제공하는 타입이고 컬렉션의 반복문 타입 같은 구현 세부 정보는 적합하지 않습니다 또한 프로토콜의 기본 연관 타입과 그 프로토콜을 따르는 구체 타입의 일반 매개변수는 일치하는 경우가 많은데요 보시면 'Collection'의 Element 기본 연관 타입이 Array 및 Set의 'Element' 일반 매개변수로 구현되었으며 두 구체 타입은 컬렉션을 따르는 표준 라이브러리에 의해 정의됩니다 'Element의 Collection'은 'some' 키워드를 사용하는 불분명한 결과 타입으로 쓸 수도 있고 'any' 키워드를 사용하는 제한된 실존 타입과 함께 쓸 수 있습니다 Swift 5.7 전에는 특정 제네릭 인수를 가진 실존 타입을 나타내려면 고유한 데이터 타입을 작성해야 했지만 Swift 5.7은 '제한된 실존 타입'이라는 개념을 언어에 적용했습니다
hungryAnimals가 hungryAnimals 수를 지연 계산할지 즉시 계산할지 선택 할 수 있도록 하고 싶은 경우에 Animal의 불분명한 컬렉션을 사용하면 함수가 2가지 기본 타입을 반환한다는 오류가 뜨는데 이를 해결하려면 'any Animal의 any Collection'을 반환하여 이 API가 호출 시 다른 타입을 반환할 수 있음을 알려주면 되죠 기본 연관 타입을 제한하는 기능은 불분명한 타입과 실존 타입에 새로운 수준의 표현력을 제공합니다 이것은 컬렉션 같이 다양한 표준 라이브러리 프로토콜과 사용할 수 있으며 기본 연관 타입을 가진 고유한 프로토콜을 선언할 수도 있죠 불투명 타입 제네릭 코드 작성은 추상 타입 관계에 기반해야 하는데 연관 프로토콜을 사용하여 여러 추상 타입 간에 필요한 타입 관계를 식별하고 보장하는 방법을 살펴보겠습니다 Animal 프로토콜에 동물들이 먹는 사료의 구체적 타입에 대한 새로운 연관 타입과 동물들에게 해당 타입의 사료를 먹도록 지시하는 eat() 메소드를 추가하려고 하는데요 재미를 위해 좀 더 복잡하게 만들어 보겠습니다 동물에게 먹이를 주기 전에 적절한 타입의 작물을 재배하고 수확하여 사료를 생산해야 한다고 말이죠 첫 번째 구체 타입 집합은 이렇습니다 소는 건초를 먹으니 소가 주어지면 먼저 건초를 키워야 하죠 그리고 알팔파를 수확하여 소가 먹을 수 있는 건초로 가공해서 제공해야 합니다 이건 구체 타입의 두 번째 집합이에요 닭의 모이는 스크래치이므로 닭이 입력되면 먼저 기장이라고 하는 곡물을 재배해야 하며 이걸 수확하고 가공하여 스크래치를 만들어서 닭에게 먹입니다 저는 이러한 두 가지 연관 구체 타입 세트를 추상화해서 feedAnimal() 메소드를 한 번만 구현함으로써 소와 닭 그리고 앞으로 데려올 새로운 타입의 동물들에게까지 먹이를 주려고 해요 feedAnimal()은 소비 위치에서 연관 타입 Animal 프로토콜의 eat() 메소드와 함께 작동해야 하므로 feedAnimal() 메소드가 'some Animal'을 매개 변수 타입으로 사용한다고 선언하여 실존 타입을 언박싱하겠습니다 먼저 프로토콜과 연관 타입에 대해 그동안 배운 걸 활용하여 AnimalFeed과 Crop 프로토콜 한 쌍을 정의해보죠 AnimalFeed에는 Crop을 따르는 연관 CropType이 있고 Crop에는 AnimalFeed를 따르는 연관 FeedType이 있습니다 이번에도 각 프로토콜의 타입 매개 변수 다이어그램을 볼 수 있는데 AnimalFeed부터 보도록 하죠 모든 프로토콜은 구체적인 준수 타입을 나타내는 'Self' 타입을 가지며 우리의 프로토콜은 Crop을 따르는 연관 'CropType'을 가집니다 연관 'CropType'은 AnimalFeed를 따르는 중첩 연관된 'FeedType'을 가지며 이 타입은 또 Crop을 따르는 중첩 연관된 'CropType'를 갖죠 사실상 이런 식으로 AnimalFeed와 Crop을 따르는 연관 타입 간에 앞뒤로 번갈아 가며 무한 중첩이 형성됩니다
Crop 프로토콜의 경우도 한 칸만 이동될 뿐 상황은 마찬가지입니다 'Crop'을 따르는 'Self'에서 시작하여 'Self'는 AnimalFeed를 따르는 연관 'FeedType'을 갖고 'FeedType'은 'Crop'을 따르는 중첩 연관된 'CropType'를 갖는 식으로
끝없이 나아가죠 그럼 이러한 프로토콜이 구체 타입 간의 관계를 정확하게 모델링하는지 봅시다 동물에게 먹이를 주기 전에 작물을 재배하여 올바른 타입의 사료로 가공해야 한다는 점을 기억하세요 grow()는 AnimalFeed 프로토콜의 정적 메소드인데 그 말은 AnimalFeed를 따르는 특정 값이 아닌 타입에 대해 직접 호출되어야 한다는 뜻입니다 따라서 AnimalFeed를 따르는 타입의 이름을 넣어야 하는데 우리가 가진 것은 다른 프로토콜인 Animal을 따르는 일부 타입의 특정 값뿐이죠 그런데 우리는 이 값의 타입이 Animal을 따르는 어떤 타입이라는 걸 알고 Animal에는 AnimalFeed를 따르는 연관 FeedType이 있습니다
따라서 이 타입을 grow() 메소드 호출의 기본으로 사용할 수 있죠 AnimalFeed의 grow() 메소드는 AnimalFeed의 중첩 연관 CropType 타입을 갖는 값을 반환합니다 CropType이 Crop을 따른다는 것을 알고 있으므로 여기에 대해 harvest()를 호출할 수 있는데요 그럼 어떤 결과를 얻게 될까요? harvest()는 Crop 프로토콜의 연관 FeedType을 반환하도록 선언되었고 이 경우 호출의 기본 타입은 (someAnimal).FeedType.CropType 이므로 harvest()는 (some Animal).FeedType .CropType.FeedType 타입의 값을 반환하게 될 텐데 안타깝게도 이건 잘못된 타입입니다 eat 메소드가 (someAnimal).FeedType .CropType.FeedType이 아닌 (some Animal).FeedType를 요구했기 때문이죠 프로그램이 제대로 작성되지 않은 겁니다 이 프로토콜의 정의는 동물 사료 타입부터 시작하여 작물을 재배하고 수확할 경우 우리가 동물에게 먹이고자 하는 처음 시작과 동일한 타입의 동물 사료를 되돌려 받을 수 있다는 것을 보장하지 않죠 또 다른 문제점은 프로토콜 정의가 너무 일반적이라서 구체 타입 간에 요구되는 관계를 정확하게 모델링하지 못한다는 겁니다 Hay와 Alfalfa 타입을 통해 그 이유를 확인해보죠 건초를 재배하면 알팔파를 얻고 알팔파를 수확하면 건초를 얻는 과정이 반복되는데 코드를 리팩터링하던 중에 실수로 Alfalfa의 harvest() 메소드 반환 타입이 변경되어 Hay 대신 Scratch가 반환된다고 상상해 보세요 이런 우발적인 변경이 발생해도 구체 타입은 AnimalFeed 및 Crop 프로토콜의 요구 사항을 여전히 충족합니다 애초에 원했던 작물을 재배하고 수확하면 처음 시작한 것과 동일한 타입의 사료가 생산된다는 불변성을 위반하더라도 말이죠 AnimalFeed 프로토콜을 다시 살펴볼게요 여기서 진짜 문제는 어떤 면에서 보면 별도의 연관 타입이 너무 많다는 겁니다 따라서 이러한 연관 타입 중 2개는 사실상 동일한 구체 타입이라는 사실을 명시해 두어야 해요 그러면 잘못 작성된 구체 타입이 프로토콜을 따르는 것을 방지할 수 있죠 또한 feedAnimal() 메소드가 의도한 대로 작동하도록 보장할 수 있습니다 이러한 연관 타입 간의 관계는 where문에 동일 타입 요구 사항을 작성하여 표현할 수 있어요 동일 타입 요구 사항은 중첩 가능성이 있는 2개의 연관 타입이 사실상 동일한 구체 타입이어야 한다는 정적 보장을 나타낸 것으로 동일 타입 요구 사항을 추가하면 AnimalFeed 프로토콜을 따르는 구체 타입에 제한 조건이 부과되죠 동일 타입의 요구 사항에서 'Self.CropType.FeedType'이 'Self'와 동일한 타입임을 선언하는 것인데 다이어그램으로 표현하면 어떻게 될까요? 바로 이런 식으로 시각화할 수 있습니다 AnimalFeed를 따르는 각 구체 타입은 Crop을 따르는 CropType을 갖지만 이 CropType의 FeedType은 AnimalFeed를 따르는 다른 타입일 뿐 아니라 원래의 AnimalFeed와 동일한 구체 타입이죠 중첩된 연관 타입의 무한한 탑 대신에 모든 관계를 연관 타입의 단일 쌍으로 축소해보았습니다 Crop 프로토콜은 어떻게 될까요? 보시면 Crop의 FeedType을 한 쌍의 타입으로 축소했음에도 아직도 연관 타입이 너무 많습니다 우리가 원하는 것은 Crop의 FeedType.Crop Type이 최초에 시작했던 Crop과 같은 타입이라고 하는 거죠
이제 이 두 프로토콜이 동일 타입 요구 사항을 갖추었으니 feedAnimal() 메소드를 다시 살펴볼게요 이번에도 some Animal의 타입으로 시작한 다음 AnimalFeed 프로토콜을 따르는 동물의 FeedType을 얻습니다 또, 작물을 키울 때 some Animal의 FeedType.Crop Type을 얻는데 이번에는 작물을 수확할 때 다른 중첩 연관 타입을 얻는 대신 동물에게 필요한 정확한 사료 타입을 얻게 되고 이제 행복한 동물들은 갓 재배한 올바른 타입의 사료를 즐길 수 있죠 마지막으로 지금까지 살펴본 모든 것을 합친 Animal 프로토콜의 연관 타입 다이어그램을 봅시다
여기 일치하는 타입의 두 가지 세트가 있는데 첫 번째에는 Cow, Hay, Alfalfa가 있고 두 번째에는 Chicken, Scratch, Millet이 있죠 3개의 프로토콜이 3가지 구체 타입의 각 세트 간의 관계를 정확히 모델링하는 방식에 주목하세요 데이터 모델을 이해하면 동일 타입 요구 사항을 사용하여 중첩된 서로 다른 연관 타입 간의 동일성을 정의할 수 있으며 이러한 관계에 기반하여 프로토콜 요구 사항에 대한 여러 개의 호출을 연결하는 제네릭 코드를 작성할 수 있습니다 이 세션에서는 타입 소거가 안전한 경우와 타입 관계가 보장되는 컨텍스트에 있어야 하는 경우를 살펴보고 이어서 불분명한 결과 타입과 실존 타입 모두에 사용할 수 있는 기본 연관 타입을 활용하여 많은 타입 정보를 노출시키거나 구현 세부 정보를 숨기는 정도를 균형있게 유지하는 방법을 다뤘으며 마지막으로 연관 타입 집합을 나타내는 프로토콜 전반에 걸쳐 동일한 타입 요구 사항을 사용하여 구체 타입 집합 간의 타입 관계를 식별하고 보장하는 방법을 알아봤습니다 들어주셔서 감사드리며 유익한 WWDC가 되기를 바랍니다
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.