스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Instrumets로 행 분석하기
사용자 인터페이스 요소는 실시간 반응을 포함한 실제 세계의 상호 작용을 흉내내는 경우가 많습니다. 사용자 상호 작용에서 눈에 띄는 지연 현상인 '행'이 있는 앱은 환상을 깨고 답답함을 줄 수 있죠. Instruments를 사용하여 모든 Apple 플랫폼에서 앱의 행을 분석하고 이해하며 수정하는 방법을 보여 드립니다. Instruments trace 문서를 효율적으로 탐색하고, 트레이스 데이터를 해석하고, 추가적인 프로파일링 데이터를 녹화하여 특정 행을 이해하는 방법을 살펴보세요. Instruments를 사용하는 것이 익숙하지 않다면, 'Instruments 시작하기'를 먼저 시청하시기 바랍니다. 앱에서 행을 발견할 수 있도록 도와줄 수 있는 다른 툴에 관해 알고 싶다면, 'Xcode와 온디바이스 감지로 행 추적하기'를 시청하세요.
챕터
- 1:56 - What is a hang?
- 3:51 - What is instant?
- 4:39 - Event handling and rendering loop
- 8:25 - Keep main thread work below 100ms
- 9:15 - Busy main thread hang
- 14:26 - Too long or too often?
- 21:46 - LazyVGrid still hangs on iPad
- 24:31 - Fix: Use task modifier to load thumbnail asynchronously
- 25:52 - Asynchronous hangs
- 32:38 - Fix: Get off of the main actor
- 35:57 - Blocked Main Thread Hang
- 39:19 - Fix: Make shared property async
- 40:35 - Blocked Thread does not imply unresponsive app
리소스
관련 비디오
WWDC23
WWDC22
WWDC21
Tech Talks
WWDC20
WWDC19
WWDC16
-
다운로드
♪ ♪
'Instruments로 행 분석하기' 세션입니다 제 이름은 Joachim이고 Instruments 팀의 엔지니어죠 오늘은 '행'을 자세히 알아볼게요 먼저 행의 개요를 설명할 건데 그러기 위해서는 인간의 지각을 이야기해야 하죠 그리고 잠시 이벤트 처리와 렌더링 루프라는 행을 일으키는 원인의 기초 지식을 잠시 이야기할게요 이러한 이론적 지식을 바탕으로 Instruments로 넘어가서 행의 3가지 예를 알아보죠 바쁜 상태의 메인 스레드 행과 비동기적인 행 차단된 메인 스레드 행이 있어요 각 종류의 행을 구분하는 방법과 분석할 때 살펴봐야 하는 요소들과 문서에 다른 인스트루먼트를 추가하는 시점에 관해 다루도록 하죠 시작하기 전에 잠시 시간을 내서 Instruments에 익숙해지는 게 도움이 될 거예요 Instruments로 앱을 프로파일링한 적이 있으면 바로 시작해도 되죠 그게 아니라면 2019년 세션인 'Instruments 시작하기'를 보세요
행은 보통 3단계로 처리하죠 행을 식별하고 행의 원인을 분석하고 해결하는 거예요 해결됐는지 확인도 하고요 오늘은 이미 행을 찾았다고 가정하고 분석하는 것에 집중할 예정이며 일부 해결책도 논의할게요 행을 식별에 관해 더 알고 싶으면 이 세션을 참고하세요 WWDC22의 'Xcode와 기기 내 감지로 행 식별하기'죠 행을 식별하는 모든 도구를 다루며 Instruments와 iOS Developer 설정에서 활성화할 수 있는 기기 내 행 감지와 Xcode Organizer를 포함해요 오늘은 Instruments를 사용하여 이미 찾은 행을 분석할게요 행을 더 잘 이해하려면 인간의 지각에 관해 이야기하고 불을 켜야 해요
전구와 전선이 필요하죠 훨씬 낫네요 전등이 본래 그래야 하듯 전선을 꽂자 불이 켜졌어요 다시 전선을 뽑으면 바로 꺼지죠 근데 지연 시간이 있다면 어땠을까요? 다시 꽂을게요 이번에 켜지는데 잠깐 시간이 걸렸죠 더 이상한 건 다시 뽑았을 때 같은 일이 벌어지는 거예요 전선을 꽂았을 때 불이 켜질 때까지 지연 시간은 500ms에 불과했죠 하지만 상자 안에서 벌어지는 일이 벌써 궁금해져요 전구가 바로 점멸하지 않는 게 이상하게 느껴지죠 근데 다른 상황에서는 500ms의 지연이 괜찮을 수도 있어요 어떤 종류의 지연이 용인되는지는 상황에 따라 다르죠 이런 대화를 엿들었다고 가정하죠 '거북이는 어떻게 소통해?' '거북폰' 여기서 질문과 대답 사이에 1초의 지연이 있었죠 하지만 자연스러웠어요 이건 자연스럽지 않죠
왜 그럴까요? 거북이와 유니콘의 대화는 요청-반응 형태의 상호작용이지만 전등의 전선을 꽂는 건 실제 물체를 바로 조작하는 거죠 실제 물체는 바로 반응해요 실제 물건을 시뮬레이션할 때도 즉각 반응해야 하죠 그러지 않으면 환상이 깨져요
제가 이게 진짜 전등이라고 주장했을 때 전선을 꽂고 바로 불이 켜지면 반박하지 않았죠 근데 눈에 띄는 지연이 보이자 여러분의 뇌가 이렇게 생각합니다 '잠깐, 작동 방식이 이상해' 그럼 얼마나 빨라야 즉각적일까요? 지연 시간이 얼마나 짧아야 눈치챌 수 없을까요?
지연 시간이 없는 걸 기준으로 하죠
100ms는 어떨까요?
저는 불을 켤 때 살짝 지연이 느껴졌지만 불을 끌 때는 몰랐고 자세히 관찰해야 알았죠 여러분의 경험을 다를 수 있어요 100ms가 일종의 임곗값이죠 훨씬 짧은 지연 시간은 알아보기 어려워요 250ms를 시도해 보죠
250ms는 즉각적인 느낌이 아니에요 느리진 않지만 지연을 확실히 느낄 수 있죠
이러한 지각의 임곗값이 행의 보고에도 영향을 줘요 버튼을 탭하는 것처럼 독립된 상호작용에서 지연 시간이 100ms 미만이면 즉각적으로 느껴지죠 이보다 더 짧아야 하는 특수한 때도 있지만 목표로 하기에 좋아요 이보다 긴 것은 상황에 따라 달라지죠 250ms까지는 괜찮을 수 있어요 이보다 길어지면 적어도 무의식적으로는 지각할 수 있죠 연속적인 기준이지만 250ms보다 길어지면 즉각적인 느낌이 들지 않아요 그래서 대부분의 툴이 기본적으로 250ms부터 행으로 보고하는데 이런 건 쉽게 무시할 수 있어 '마이크로 행'이라고 부르죠 맥락에 따라 괜찮을 수도 있지만 안 그런 경우도 많아요 500ms를 넘어가면 정식 행으로 간주하죠 이를 바탕으로 다음의 기준을 사용할 수 있어요 즉각적인 걸 원하면 지연 시간이 100ms 이하여야 하죠 요청-반응 형태의 상호작용이면 500ms 동안 추가 피드백이 없어도 괜찮아요 하지만 상호작용에 두 가지 모두 있는 경우가 많죠 예를 들어 볼게요
이 세션을 준비하는 걸 도와준 모든 동료에게 이메일 작성을 마쳐서 보낼 준비가 됐죠 마우스를 전송 버튼으로 가져가서 클릭했는데 잠시 후 이메일 창이 사라지면서 전송됐다는 걸 나타내요 여기에서 2개의 일이 벌어졌는데요 먼저 버튼의 색이 바뀌었고 500ms의 지연 시간 후에 이메일 창이 사라졌어요 하지만 버튼 색이 바뀐 걸 요청 접수로 이해해서 지연 시간이 괜찮다고 느꼈죠 버튼은 실제 물건으로 간주하여 실시간으로 반응하기를 기대해요
그래서 인터페이스의 실제 UI 요소는 즉시 업데이트하는 걸 목표로 하죠 UI 요소가 즉각적으로 반응할 수 있도록 UI가 아닌 작업을 메인 스레드에서 빼야 해요 왜 그런지 보기 위해 이벤트 처리와 렌더링 루프를 자세히 살펴봄으로써 Apple 플랫폼에서 이벤트를 처리하는 방법과 사용자 입력이 화면 업데이트로 이어지는 과정을 알아보죠
어느 시점에 누군가 기기와 상호작용할 거예요 그 행위는 우리가 제어할 수 없죠 대개는 마우스나 터치스크린 등의 하드웨어가 사용돼요 상호작용을 감지하면 이벤트를 생성하여 운영 체제에 전송하죠 운영 체제에서 어떤 프로세스가 이벤트를 처리할지 알아내서 해당 프로세스를 여러분의 앱으로 전달해요 앱에서는 앱의 메인 스레드에서 이벤트를 처리해야 하죠 이 작업에 대부분의 UI 코드가 실행돼요 UI를 어떻게 업데이트할지 결정한 뒤 UI 업데이트를 렌더링 서버로 전송하며 개별 UI 레이어를 합성하고 다음 프레임을 렌더링하는 별도의 프로세스예요 끝으로 디스플레이 드라이버가 렌더링 서버가 준비한 비트맵을 화면의 픽셀에 맞게 업데이트하죠 이 과정에 대해 더 알고 싶다면 '앱 반응성 개선하기'라는 문서에서 이 내용을 다뤄요 지금은 대략적인 개요만으로 이해할 수 있죠 만약 다른 이벤트가 들어오면 대개는 병렬로 처리할 수 있어요 하지만 단일 이벤트가 파이프라인을 지나는 걸 보면 시퀀스의 모든 단계를 살펴봐야 하죠 메인 스레드에 가기 전 마지막 프로세싱 단계와 이후의 렌더링과 디스플레이 업데이트 단계는 소요 시간을 예측하는 게 쉬운 편이에요 만약 상호작용이 심각하게 지연된다면 메인 스레드가 너무 오래 걸렸거나 이벤트가 들어왔을 때 메인 스레드에서 다른 작업을 수행하고 있어서 작업이 끝날 때까지 기다린 뒤 이벤트를 처리하는 경우죠 UI 요소의 모든 업데이트는 메인 스레드에서 시간이 걸리는데 이런 업데이트는 100ms 내에 처리되어야 진짜로 느껴지므로 메인 스레드의 모든 작업이 100ms를 넘으면 안 돼요 더 빠르면 더 좋죠 메인 스레드의 장기 작업은 히치도 유발할 수 있는데 히치를 피하기 위해 낮은 임곗값을 적용하죠 히치에 관한 자세한 사항은 Tech Talk 'UI 애니메이션 히치와 렌더 루프'와 '앱 반응성 개선하기' 문서를 참고하세요 오늘은 행에 집중할게요 제 동료가 Backyard Birds 앱에서 새로운 기능을 개발하다가 행을 발견했죠 Instruments로 앱을 프로파일링해 볼게요
앱의 Xcode 프로젝트가 여기 있죠 이제 Instruments에서 앱을 프로파일링하기 위해 Product 메뉴를 클릭하고 Profile을 선택하면 Xcode에서 앱을 빌드하여 기기에 설치하지만 실행하지는 않아요
또한, Xcode가 Instruments를 열어서 Xcode에서 설정한 것과 같은 앱, 기기를 목표로 설정하죠 Instruments의 템플릿 선택 창에서 Time Profiler를 선택할 수 있는데 뭘 찾아야 할지 모를 때 앱이 하는 일을 이해하고 싶은 경우 좋은 시작점이 되죠 그러면 Time Profiler 템플릿에서 새 Instruments 문서를 만들어요 다른 문서와 함께 이 문서는 Time Profiler 인스트루먼트와 Hangs 인스투르먼트를 포함하는데 둘 다 분석에 유용하죠 툴바 왼쪽 위 Record 버튼을 눌러 녹화를 시작할게요 Instruments가 설정 앱을 실행하고 데이터를 포착하기 시작하죠
여기 Backyard Birds 앱이 있어요 첫 번째 정원을 탭해서 상세 뷰로 이동하죠 Choose Background 버튼을 탭하고 기다리면 아래에서 시트가 올라와서 선택할 수 있는 배경 그림들이 나타나야 해요 지금 해 볼게요 버튼을 눌렀지만 뭔가 막힌 듯하군요 시트가 나타날 때까지 꽤 오래 걸리죠 심각한 행이에요
Instruments가 모든 걸 녹화하고 있었죠 이제 툴바의 Stop 버튼을 클릭하여 녹화를 멈출게요 Instruments도 행을 감지했죠 행의 지속 시간을 재고 심각도에 따라 해당 구간을 표시해요 이 경우에는 Instruments에서 심각한 행이 발생했다고 나타나죠 앱을 사용 때 경험했던 것과 들어맞아요 Instruments가 반응이 없는 메인 스레드를 감지했고 해당 구간을 잠재적인 행으로 표시하죠 이 경우에는 실제로 행이 발생했어요 반응이 없는 메인 스레드는 두 가지 경우가 있죠 가장 간단한 사례는 메인 스레드가 다른 작업을 하느라 바빴던 거예요 이 경우에는 메인 스레드에 다양한 CPU 활동이 나타나죠 다른 사례는 메인 스레드가 차단된 거예요 메인 스레드가 다른 곳에서 완료해야 하는 작업을 기다리고 있는 거죠 스레드가 차단되면 메인 스레드의 CPU 활동이 없거나 미미할 거예요 어떤 사례인지에 따라 다음 절차를 통해 현재 상황을 알아내야 하죠 다시 Instruments에서 Main Thread를 찾아야 합니다 문서의 마지막 트랙에 목표 프로세스의 트랙이 나오죠 왼쪽의 작은 공개 인디케이터를 보면 서브 트랙이 있다는 걸 알려 줘요 클릭하면 프로세스의 스레드 별로 별도의 트랙이 나타나죠 여기서 Main Thread 트랙을 선택해요 상세 정보 공간이 업데이트되어 Profile 뷰가 나타나고 모든 함수의 호출 트리를 보여 주는데 녹화 시간 동안 메인 스레드에서 실행됐던 것들이죠
우리는 행이 발생한 시간에만 관심이 있으므로 행 구간을 오른쪽 클릭하여 컨텍스트 메뉴를 나타나게 해요 여기서 Set Inspection Range를 선택할 수도 있지만 옵션 키를 함께 눌러서 Set Inspection Range and Zoom을 선택하도록 하죠
이는 구간의 범위로 확대하고 상세 뷰에 나타난 데이터를 선택한 시간대로 필터링할게요
CPU 사용량이 전체 행 주기 동안 100%는 아니지만 꽤 높은 수준인 60%에서 90%의 CPU 사용량이 나타나고 있죠 바쁜 상태의 메인 스레드가 분명해요 어떤 CPU 작업인지 알아보죠
이제 호출 트리의 개별 노드를 자세히 살펴볼 수 있지만 오른쪽에 잘 요약돼 있죠 Heaviest Stack Trace 뷰예요 Heaviest Stack Trace 뷰의 프레임을 클릭하면 호출 트리 뷰가 업데이트되어 노드를 보여 주죠 이를 보면 메서드 호출이 호출 트리 깊숙한 곳에 있어요
Heaviest Stack Trace는 소스 코드에서 유래하지 않은 이후의 함수 호출을 기본적으로 숨겨서 어느 부분의 소스 코드가 관련됐는지 쉽게 볼 수 있죠 호출 트리 뷰에서도 비슷한 필터를 적용할 수 있는데 하단 바의 Call Tree 버튼을 클릭하여 Hide System Libraries 체크박스를 활성화하세요 이러면 시스템 라이브러리의 모든 함수를 필터링하여 우리 코드에 쉽게 집중할 수 있죠 호출 트리 뷰를 보면 백트레이스 대부분이 BackgroundThumbnailView.body .getter 호출이에요 그럼 바디 게터를 빠르게 바꿔야겠죠? 아니에요 메인 스레드가 바쁜 상태인 건 알죠 CPU가 많은 작업을 하고 있어요 CPU가 많은 시간을 보내는 메서드도 찾았죠 하지만 2개의 사례가 있어요 이 메서드에서 CPU 시간이 많이 소요되는 게 메서드가 오랫동안 실행되기 때문일 수도 있죠 하지만 호출 횟수가 많아서 이렇게 나타날 수도 있어요 사례에 따라 메인 스레드 작업을 줄이는 방법이 다르죠
전형적인 호출 스택은 이런 구조예요 메인 함수에서 호출이 들어오면 UI 프레임워크와 다른 것들을 호출하죠 그리고 어느 시점에 여러분의 코드를 호출해요 이 함수를 한 번만 호출하는데 여기 Turtle 함수처럼 호출 시간이 오래 걸리면 이 함수가 뭘 호출하는지 봐야 하죠 작업을 많이 할지도 모릅니다 그럼 작업을 줄여야겠죠 하지만 다른 경우는 우리가 조사하는 메서드가 여기 있는 Unicorn처럼 많이 호출될 수도 있어요 그렇다면 그 작업이 반복되겠죠 이런 일이 일어나는 건 Unicorn 함수를 호출하는 호출자가 루프 같은 곳에서 호출하기 때문이에요 이런 경우에는 Unicorn 함수의 기능을 최적화하는 대신 적게 호출하는 방법을 조사하는 게 더 나을 수도 있어요
그 말은 우리가 다음으로 봐야 하는 방향이 사례에 따라 달라진다는 거죠 Turtle 함수처럼 오랫동안 실행되는 함수라면 함수의 적용과 호출 대상을 살펴봐야 해요 더 아래쪽을 살펴봐야 하죠 만약 Unicorn 함수처럼 여러 번 호출된다면 무엇이 이 함수를 호출하는지 살펴보고 적게 호출하는 방법을 찾는 게 낫죠 더 위쪽을 살펴봐야 해요 근데 Time Profiler는 어떤 경우인지 알려 줄 수 없죠 Unicorn과 Turtle에 대한 호출이 연속으로 이어졌다고 가정할게요 Time Profiler가 일정 간격으로 CPU에서 실행 중인 걸 보고 데이터를 수집하죠 샘플 별로 어떤 함수가 CPU에서 실행 중인지 확인해요 이 예시에서는 Turtle과 Unicorn이 4번씩 나타나죠 그렇지만 이건 아주 빠른 Turtle과 오래 걸리는 Unicorn일 수도 있고 다른 조합일 수도 있어요 모든 시나리오가 Time Profiler에 같은 데이터를 만들어내죠
특정 함수의 실행 시간을 재려면 os_signposts를 사용하세요 2019 세션 'Instruments 시작하기'에서 사용 방법을 다뤘어요 특수한 인스트루먼트와 다양한 기술로 현재 상황을 정확히 알 수 있죠 그중 하나는 SwiftUI View Body 인스트루먼트예요 SwiftUI Body 인스트루먼트를 추가하기 위해 툴바의 오른쪽 위에 있는 더하기 버튼을 클릭할게요 Instruments 라이브러리를 보여 주죠 Instruments 앱이 제공하는 모든 인스트루먼트의 목록이에요 정말 많죠 커스텀 인스트루먼트도 작성할 수 있어요
필터에 'SwiftUI'를 입력하면 인스트루먼트 2개가 나타나죠 View Body 인스트루먼트를 선택한 뒤 문서 창에 드래그하여 추가할게요 이 인스트루먼트는 마지막 녹화 때 문서에 없었기 때문에 표시할 데이터가 없어요 그래도 문제없죠 다시 녹화하면 돼요 시간을 아끼기 위해 이미 녹화했죠 SwiftUI View Body 인스트루먼트를 문서에 넣고 녹화했더니 View Body 트랙에도 데이터가 나타나요 SwiftUI View Body 트랙에도 구간이 많죠 너무 밀집해 있어서 Ctrl+플러스로 높이를 올릴게요 SwiftUI View Body 트랙이 적용된 라이브러리별로 구간들을 묶죠 각 구간은 하나의 View Body 실행이에요 다시 행으로 확대해 들어가죠
두 번째 줄에 주황색 구간이 많은데 BackgroundThumbnailView 레이블이 붙어 있죠 몇 개의 바디를 실행했는지 각각 얼마나 걸렸는지 정확하게 알 수 있어요 주황색은 특정 바디 실행 시간이 SwiftUI 목표 시간보다 조금 오래 걸렸다는 걸 나타내죠 하지만 더 큰 문제점은 구간의 개수예요 상세 정보 뷰에 모든 바디 구간이 요약돼 있죠 Backyard Birds 옆에 있는 공개 인디케이터를 클릭하면 Backyard Birds의 뷰 타입이 나타나요 BackgroundThumbnailView의 바디가 70번이나 실행됐는데 평균 실행 시간이 약 50ms이며 총소요 시간이 3초가 넘죠 이게 행의 소요 시간을 거의 설명해요 6개의 이미지만 보여 주면 되는데 70회는 과한 것 같아요 바디를 더 적은 횟수로 호출해야 하는 경우여서 바디 게터의 호출자를 조사하여 자주 호출하는 이유와 횟수를 줄일 방법을 알아내야 하죠 관련 코드로 쉽게 이동하기 위해 Main Thread 트랙을 선택하고 호출 트리의 BackgroundThumbnail View.body.getter를 오른 클릭하여 관련 메뉴를 열고 'Reveal in Xcode'를 선택할게요
그럼 Xcode에 바디 적용 부분이 나오죠 이 뷰가 어떻게 사용됐는지 보려면 타입을 오른쪽 클릭하고 'Find'에서 'Find Selected Symbol in Workspace'를 선택할게요 Find 내비게이터의 첫 번째 결과가 우리가 찾는 정보죠
BackgroundThumbnailView가 GridRow 안의 ForEach에 쓰이는데 다른 Grid의 ForEach 안에 또 들어 있어요 Grid는 생성되면 전체 내용을 처리하기 때문에 배경 섬네일 전체를 처리하지만 실제로 필요한 건 처음 몇 개뿐이죠 하지만 대안이 있죠 LazyVGrid예요 화면을 채울 정도의 뷰만 처리하죠 SwiftUI의 많은 뷰에는 지연 타입이 있어서 필요한 만큼의 뷰만 처리하므로 쉬운 방법으로 일을 적게 할 수 있어요 하지만 즉시 처리 기능이 더 적은 메모리로 같은 양의 콘텐츠를 렌더링하죠 대부분 즉시 기능을 사용하되 지연 기능은 많은 작업을 초반에 처리해서 성능 이슈가 생길 때 사용하세요
WWDC 2020 세션 'SwiftUI의 스택, 그리드, 아웃라인'을 보면 지연 기능을 소개하고 더 자세히 설명하죠 개선된 코드를 프로파일링할게요 녹화를 시작해서 Choose Background 버튼을 눌러 행을 재현해 볼게요 이제 훨씬 낫군요 살짝 지연이 있었지만 이전만큼 심하진 않았어요 Instruments에도 같은 결과가 나왔죠 우리가 녹화한 행이 400ms 이하였어요 마이크로 행이죠 View Body 트랙을 봐도 BackgroundThumbnail 바디 실행이 8회밖에 되지 않아 예상한 결과와 맞아떨어져요 이 정도면 괜찮을지도 모르죠 마이크로 행이 눈에 잘 띄지 않아요 다른 기기에도 잘 작동하는지 확인하기 위해 iPad에서 Backyard Birds를 프로파일링하죠
iPad에서 Backyard Birds를 실행하고 있어요 이미 상세 보기 화면이죠 Choose Background 버튼을 탭하는데 시트가 나타날 때까지 시간이 오래 걸리는군요 시트가 나타나자 이유를 알 수 있죠 화면이 크고 공간이 많아서 섬네일이 훨씬 많아요 Instruments도 이 행을 기록했죠
행 구간으로 검사 범위를 집중했더니 BackgroundThumbnailView 바디가 더 많이 보이는군요 이해가 되죠 이제는 전체 화면에 더 많은 섬네일이 들어가므로 40개 정도를 렌더링해야 해요 같은 코드가 iPhone에서는 괜찮게 작동됐는데 단지 화면이 크다는 이유로 iPad에서는 느렸죠 마이크로 행을 수정해야 하는 이유 중 하나예요 개발 때 테스트에서 발견한 마이크로 행이 상황이 다른 사용자에게는 심각한 행일 수 있죠 지금은 화면을 채우는 뷰만 렌더링할 수 있도록 수정해서 호출 수를 줄여 최적화하는 방법은 사용할 수 없어요 각각의 실행 속도를 빠르게 하는 방법을 알아보죠 BackgroundThumbnailView 단일 구간으로 검사 범위 설정 후 Main Thread 트랙으로 돌아갈게요 Instruments가 뷰 바디 게터를 Heaviest Back Trace에 보여 주며 BackyardBackground.thumbnail 프로퍼티 게터를 호출하는 걸 보여 주죠 뷰에 표시할 섬네일 이미지를 제공하는 모델 오브젝트예요 UIImageimageByPreparingThumbnail OfSize를 섬네일 게터가 호출하죠 섬네일을 실시간으로 처리하는 모양이군요 이 작업은 시간이 걸리죠 이 경우는 150ms이군요 이런 작업은 백그라운드에서 해야 하며 메인 스레드를 바쁘게 하면 안 되죠 어떤 변화를 줄 수 있는지 이해하기 위해 섬네일 게터를 호출하는 맥락을 살펴보고 싶어요 BackgroundThumbnailView.body .getter 프레임을 Heaviest Stack Trace 뷰에서 오른쪽 클릭하고 'Open in Source Viewer'를 선택할게요 그럼 호출 트리 뷰 대신 소스 뷰가 나타나서 바디 게터가 적용된 걸 보여 주고 적용된 줄에 주석을 달고 Time Profiler 샘플을 통해 코드 소요 시간을 보여 주죠 바디의 적용은 간단해요 새로운 배경이 반환한 섬네일을 포함한 Image 뷰를 만들죠 근데 섬네일의 호출이 오래 걸리는군요 코드를 다르게 작성할 방법이 있죠 Xcode로 가서 오른쪽 위 메뉴 버튼을 클릭하고 'Open file in Xcode'를 선택하세요
전처럼 Xcode에 소스 코드가 나와 수정하면 되죠 이제 배경에 있는 섬네일을 로딩하고 로딩되는 동안 진행 인디케이터를 나타낼게요 먼저 로딩된 섬네일을 잡고 있을 상태 변수가 필요해요
그리고 바디 안에 이미지를 이미 로딩했다면 Image 뷰에서 사용하는 거죠 로딩하지 않았다면 진행 상황 뷰를 보여 줘요
이제 실제 섬네일을 로딩하면 되죠 뷰가 나타나면 로딩을 시작하려고 해요 그게 .task 모디파이어의 역할이죠
나타날 때 SwiftUI가 태스크를 시작하여 섬네일 게터를 호출하고 결괏값을 image에 할당하여 뷰를 업데이트할 거예요 한번 해 보죠 Instruments가 녹화하는 중에 Choose Background 버튼을 탭하면 시트가 바로 나타나요 좋아요 진행 상황 인디케이터를 봤고 몇 초 후에 섬네일이 나타났죠 성공했습니다, 좋아요
잠깐만요, Instruments가 아직 2초에 가까운 행을 보여 줘요 지금은 행이 조금 뒤에 나타나고 있죠 Backyard Birds 앱을 통해 언제 나타나는지 보여 드릴게요 벌써 상세 정보 뷰에 있어요 잠시 후 Choose Background 버튼을 탭하고 바로 Done 버튼을 탭하여 시트 생성을 취소할게요 Choose Background와 Done 버튼을 누를게요 여러 번 탭했지만 이미지 로딩 중에는 저의 탭이 무시됐죠 이게 바로 Instruments가 알려 준 행이에요 시트가 나타난 후에 발생하죠
조금 다른 종류의 행이에요 메인 스레드가 바쁜 상태인 것과 차단된 것의 차이는 이미 얘기했죠 행을 보는 다른 시각이 있어요 행이 일어나는 원인과 행이 발생하는 시점이죠 이를 각각 동기 및 비동기 행이라고 불러요
여기서는 메인 스레드가 작업 중이죠 만약 이벤트가 들어왔을 때 이벤트 처리에 오랜 시간이 걸리면 그게 바로 행이에요 그 문제를 수정해서 이벤트를 빨리 처리한다고 가정하죠 하지만 메인 스레드에서 일부 작업을 연기했거나 다른 메인 스레드를 작업 중일 때 이벤트가 발생하면 이전 작업이 끝날 때까지 기다렸다가 이벤트를 처리할 수 있어요 그럼 여전히 행이 발생하죠 이벤트 처리 코드가 각 작업을 빨리 끝내도 나타나요 우리 플랫폼에서 행을 감지하는 방식은 메인 스레드의 모든 작업 항목을 살펴보고 너무 길지 않은지 확인하는 거죠 만약 그렇다면 잠재적 행으로 표시해요 이때 사용자 입력과 관계없이 표시하는데 사용자 입력이 언제 발생할지 모르기 때문에 실제 행으로 나타날 수 있기 때문이죠 따라서 행을 감지할 때 비동기, 지연 사례도 감지해요 잠재적인 지연을 측정한 거고 실제로 나타난 지연은 아니죠
비동기적 행을 비동기적이라고 부르는 이유는 메인 큐의 dispatch_async 작업이 원인이거나 Swift 동시성 태스크가 비동기로 메인 행위자에 실행되기 때문인데 메인 스레드에 작업을 발생시키는 모든 게 원인이 될 수 있어요 처음에 봤던 행은 동기적 행이죠 버튼을 탭했는데, 탭 동작이 오래 걸리는 작업을 발생시켜 결과가 늦게 나타났어요
가장 최근에 본 행은 비동기적 또는 지연 행이죠 Done 버튼을 탭하는 건 그 자체로 힘든 작업이 아닌데 메인 스레드에 여전히 작업이 있어서 탭을 처리할 수 없었어요 앱을 사용하는 사람들이 눈치채지 못할 수도 있고 이 시점에 앱과 상호작용을 안 할 수도 있지만 그럴 가능성에 대비하여 수정해야 해요 그걸 지금부터 하죠 다시 Instruments로 돌아와서 비동기 행의 선택 범위를 설정하고 확대했어요 View Body 트랙의 요약 뷰에서 Instruments에 따르면 현재 75회의 호출이 BackgroundThumbnailView의 바디 게터로 들어갔죠 섬네일 바디 게터 대부분이 2회 실행됐기 때문이에요 SwiftUI가 그리드를 채우기 위해 40개 뷰를 생성했죠 근데 실제로 표시된 건 35개에 불과하고 35개의 뷰도 이미지를 로딩하기 시작하며 이미지 로딩 후 뷰를 업데이트하고 바디를 다시 호출하여 바디 게터 실행이 총 75회가 되는 거예요
총 75개의 바디 게터가 1ms도 걸리지 않았어요 이제 바디 게터가 빠르죠 그 부분은 수정했어요 그래도 행이 있죠 다시 Main Thread 트랙의 Heaviest Stack Trace 뷰를 보면 아직도 섬네일 게터가 메인 스레드에서 시간을 오래 끌고 있어요 이번에는 BackgroundThumbnailView .body.getter의 클로저에서 호출하고 있으며 바디 게터가 직접 호출하지 않죠 더블 클릭하면 소스 뷰어가 바로 열려요 .task 모디파이어 클로저에 있는 내용이므로 백그라운드에서 실행되길 기대했던 코드죠 이때 코드가 실행돼야 하지만 메인 스레드에 실행되면 안 돼요 이런 경우처럼 Swift 동시성 태스크가 기대했던 대로 실행되지 않을 때 유용하게 쓸 수 있는 인스트루먼트가 있죠 Swift 동시성 태스크 인스트루먼트예요 Swift 동시성 태스크를 추가한 뒤에 같은 행동을 이미 녹화했죠 Swift 태스크 인스트루먼트는 문서에 요약 트랙을 추가하지만 이 경우에 더 흥미로운 건 각 스레드 트랙으로 보내는 데이터예요 여기 Main Thread 트랙에 Swift 태스크 인스트루먼트의 새로운 그래프가 있죠 단일 트랙에서 다수의 그래프를 보여 줘요 Thread 트랙 헤더에 있는 아래 화살표를 클릭하면 어떤 그래프를 보여 줄지 설정할 수 있죠 Time Profiler의 CPU Usage 그래프처럼 다른 그래프를 선택할 수도 있고 커맨드 키를 누른 채로 클릭하여 여러 개를 선택할 수도 있죠 이제 Instruments가 이 스레드의 CPU 사용량과 Swift 태스크 그래프를 함께 보여 줘요 행 구간을 확대할게요 Swift Tasks 줄에 명확히 나타나 있는데 메인 스레드에 여러 개의 태스크가 실행됐죠 그중 하나에 검사 범위를 설정하고 Profile 뷰에서 Heaviest Stack Trace를 선택하면 이 태스크가 섬네일 처리 작업을 래핑하고 있는 게 확인돼요
이 작업은 우리가 원했던 대로 래핑 되어 있죠 하지만 메인 스레드에서 실행된 건 예상하지 못했던 거예요 어떤 일인지 설명할게요 먼저, 바디 게터가 SwiftUI의 View 프로토콜로부터 @MainActor 주석을 상속받아요 View 프로토콜에서 바디가 @MainActor로 주석이 달려 있어 적용할 때 바디 게터도 암시적으로 @MainActor로 주석이 달리죠 두 번째로 .task 모디파이어의 클로저가 주변 맥락의 행위자 독립을 상속받아요 따라서 바디 게터가 MainActor로 독립되며 .task 클로저도 마찬가지죠 이 클로저 안에서 실행되는 모든 코드는 기본적으로 주 행위자에서 실행되며 섬네일 게터가 동기적이기 때문에 메인 스레드에서 동기적으로 실행돼요
Swift 동시성 태스크는 기본적으로 주변 맥락의 행위자 독립을 상속받죠 SwiftUI의 .task 모디파이어도 같은 행동을 해요 주 행위자에서 분리되는 방법이 두 가지 있죠 주 행위자에 바인딩 되지 않은 함수를 비동기적으로 호출하여 태스크가 주 행위자에서 분리되게 하는 거예요 이게 가능하지 않은 경우가 있죠 그때는 행위자 주변 맥락에서 명시적으로 태스크를 분리하는 Task.detached를 사용할 수 있지만 강압적인 방법이고 별도의 태스크를 생성하는 건 기존 작업을 유보하는 것보다 비용이 많이 들어요 SwiftUI는 관련 뷰가 사라질 때 .task 모디파이어로 생성된 태스크를 자동으로 취소하지만 취소가 Task.detached와 같은 새로운 비정형 태스크로 전파되지 않아요 자세한 내용은 WWDC 2022의 'Swift 동시성 시각화와 최적화'와 '앱 반응성 개선하기' 문서를 참고하세요 이 경우 이미 비동기적인 맥락 안에 있으므로 섬네일 함수를 비독립, 비동기로 만드는 것이 쉬워서 1번 방법을 선택할 거예요 여기 섬네일 로딩 코드가 있죠 문제는 .task가 바디 게터의 주요 행위자 독립을 상속받으므로 주 행위자에서 실행되고 섬네일 게터가 동기적이므로 주 행위자에 남아 있는다는 거예요 해결 방법은 쉽죠 섬네일 게터의 정의로 가서 게터를 비동기로 만들고 다시 뷰 구조체로 돌아가서
이제 게터가 비동기적이므로 앞에 await를 추가해야 해요
이렇게 하면 섬네일 게터가 메인 스레드가 아닌 Swift 동시성의 동시 스레드에서 실행되게 하죠 시도해 볼게요 다시 상세 보기 뷰로 가서 Choose Background를 탭하세요 이야, 정말 빠르네요 행이 없었을 뿐만 아니라 전반적인 로딩이 빨라진 것 같았어요 진행 상황 뷰를 거의 못 봤죠 Instruments도 행이 없었다는 걸 확인해 줘요 여기 CPU 사용량이 조금 높네요 확대해 볼게요 섬네일을 로딩하는 부분이죠 메인 스레드를 확인해 보면 메인 스레드의 모든 태스크의 구간이 아주 짧아요 다른 스레드 트랙으로 가면 Swift의 태스크가 다른 스레드에서 연속이 아닌 병렬로 실행되고 있어 멀티코어 CPU를 제대로 활용하죠 이를 통해 모든 섬네일을 수백 ms에 처리해요 원래는 1.5초가 걸렸죠 그리고 이 시간 동안 메인 스레드가 반응성을 확보하여 이 문제를 완벽히 해결했어요
이제 문제를 조사한 뒤 메인 스레드가 바쁜 상태여서 반응성이 없었던 문제를 수정했는데 메인 스레드가 CPU를 많이 사용하여 행이 나타났죠 또한 행이 동기적으로 발생하는 건 사용자 상호작용의 일부로 나타날 수 있음을 알았고 비동기적으로 발생하는 건 메인 스레드에 예정됐던 작업이 새로운 이벤트가 처리되는 걸 늦춘다는 걸 알았으며 Instruments가 두 경우 모두 감지할 수 있어요 행을 수정하는 방법은 작업을 적게 하거나 적게 할 수 없는 일은 백그라운드에서 처리한 뒤 UI를 업데이트할 때만 메인 스레드로 돌아오는 거죠 근데 아직 살펴보지 않은 사례가 하나 있어요 차단된 메인 스레드죠 이 경우는 메인 스레드가 CPU를 아주 적게 사용해요 다른 차원도 차단된 메인 스레드에 같은 방식으로 적용되지만 이런 사례를 분석하려면 다른 인스트루먼트가 필요하죠 예를 살펴볼게요
다른 행의 .trace 파일이 있죠 이미 행을 확대했어요 정말 길군요, 몇 초간 지속되네요 Main Thread 트랙의 CPU Usage 그래프를 보면 초반에 CPU를 사용하지만 나중에는 사용량이 없죠 차단된 메인 스레드가 명백한 경우예요 Time Profiler가 데이터를 수집하는 방법은 CPU에서 실행되는 걸 샘플링하는 거죠
확대해 보면 CPU Usage 그래프가 개별 샘플까지 보여 줘요 각 마커는 Time Profiler가 가져온 샘플이죠 오른쪽에 샘플이 더 많지만 그 이후에는 없어요 근데 샘플이 없는 시간대를 선택하면 Time Profiler가 무슨 일인지 알려 줄 수 없죠 이 시간에는 데이터를 녹화하지 않았으니까요 그래서 다른 도구가 필요합니다 Thread States 인스트루먼트죠 이전의 다른 인스트루먼트처럼 Instruments 라이브러리에서 추가할 수 있어요 저는 Thread State Trace 인스트루먼트를 추가하여 같은 행을 녹화했죠 이 인스트루먼트에 대한 새로운 트랙이 있죠 Swift 동시성 인스트루먼트처럼 우리가 관심 가질 만한 데이터는 Thread 트랙에 있어요 메인 스레드에 6초가 넘는 차단 구간이 있는데 행 길이의 대부분을 설명하죠 중간을 클릭하면 Instruments의 타임 커서가 움직여 상세 정보 구역의 Narrative 뷰를 업데이트하여 차단된 상태의 자료를 보여 줘요 Narrative 뷰가 스레드의 이야기를 알려 주고 뭘 언제 왜 했는지 알려 주죠
선택된 시간 동안 스레드가 6.64초간 차단됐다고 나와요 시스콜인 mach_msg2_trap을 호출하여 차단됐죠 오른쪽에는 다시 Backtrace 뷰가 있어요 Heaviest Back Trace와 같은 종합 정보는 아니죠 시스콜인 mach_msg2_trap의 정확한 백트레이스로 스레드를 차단한 원인이었죠 함수 호출이 아래쪽에 리프 노드로 표시돼 있고 호출 스택은 위에 표시되어 있어요 호출 스택에 따르면 시스콜이 일어난 이유는 MLModel 할당 때문인데 이 일이 발생한 이유는 ColorizingService 타입의 오브젝트 할당 때문이었고 이는 ColorizingService의 싱글톤 프로퍼티인 shared의 일부로 호출됐죠 이는 바디 게터의 클로저에서 호출했어요 해당 클로저를 더블 클릭하면 다시 Source Viewer로 넘어가서 호출 시점의 코드를 찾을 수 있죠 이 행은 해가 없어 보이죠? 자세히 살펴볼게요
ColorizingService의 shared 프로퍼티에 접근하여 로컬 변수에 저장하고 있죠 근데 문제는 shared 프로퍼티가 처음 접근 시 shared ColorizingService 인스턴스를 만들고 그로 인해 모델을 로딩하는 전체 과정을 시작하여 스레드를 차단해요 그럼 이렇게 말하고 싶겠죠 '전부 await 이후의 비동기 부분으로 옮겨요' 하지만 생각과는 달리 문제를 해결하지 못해요 await 키워드는 이후 코드의 비동기 함수 호출에만 적용되기 때문이죠 이 예시에서는 colorize 함수가 비동기지만 shared 프로퍼티는 그렇지 않아요 정적인 let 프로퍼티여서 처음 접근했을 때 지연하여 초기화되며 모두 동기적으로 진행되죠 await 키워드가 그걸 바꾸지 않아서 동기적인 호출은 여전히 메인 스레드에서 발생하죠 이전 사례와 같은 방법으로 수정하려면 shared 프로퍼티도 비동기로 만들어서 주 행위자에서 분리해야 해요 스레드 대신 진전이 있는 곳에서 작업을 기다리는 경우라면 일반적으로는 괜찮을 수 있죠 스레드 차단의 일반적인 이유는 락과 세마포어예요 염두에 둘 모범 사례와 Swift 동시성에서 락과 세마포어를 사용할 때 피해야 할 것들은 WWDC 21의 'Swift 동시성 뒷이야기'를 시청하세요
끝내기 전에 차단된 메인 스레드와 관련된 사례를 하나 더 얘기하고 싶어요 조금 전에 봤던 .trace 파일이죠 오른쪽에는 차단된 메인 스레드와 관련하여 우리가 방금 조사했던 행인데 왼쪽에 메인 스레드가 몇 초간 차단된 다른 사례가 있지만 Instruments가 잠재적 행으로 표시하지 않았어요 사용자 입력이 없어 메인 스레드가 잠든 상태죠 운영 체제의 관점에서는 차단된 것처럼 보이지만 할 일이 없을 때 실행을 멈추고 자원을 아끼는 거예요 입력값이 들어오자마자 깨어나서 처리하죠 차단된 스레드가 반응성 문제인지 결정할 때는 Thread States 인스트루먼트 대신 Hangs 인스트루먼트를 보세요 그럼 차단된 메인 스레드에 반응이 없는 걸 암시하지 않죠 이와 유사하게 높은 CPU 사용량이 메인 스레드의 반응이 없는 걸 암시하지 않아요 하지만 메인 스레드가 반응이 없으면 차단됐거나 바빴다는 의미죠 우리의 행 감지는 이런 세부 사항을 모두 고려하여 메인 스레드가 실제로 반응하지 않은 경우만 표시하고 잠재적인 행으로 나타내요 이 세션에서 하나만 기억한다면 이걸 기억하세요 메인 스레드에서 어떤 작업을 하든 100ms 이하로 작업해야 메인 스레드를 이벤트 처리에 쓸 수 있어요 짧을수록 더 좋죠 행을 자세히 분석하려면 Instruments가 최고의 친구예요 바쁜 상태와 차단된 메인 스레드의 차이점을 기억하고 메인 스레드의 비동기 작업으로 발생할 수 있는 행도 기억하세요 행을 수정하려면 작업을 줄이거나 백그라운드로 옮겨야 해요 때로는 둘 다 해야 하죠 작업을 적게 한다는 건 작업에 맞는 API를 사용하는 거죠 일반적으로 시간을 먼저 재고 최적화하기 전에 행이 있는지 확인하세요 모범 사례도 있지만 동시적이고 비동기적인 코드는 디버깅이 훨씬 어렵죠 알고 보면 빠른 것들과 알고 보면 느린 것들을 보고 놀라는 일이 많을 거예요 행을 찾고 분석하고 수정하면서 즐거운 시간 보내세요 시청해 주셔서 감사해요 ♪ ♪
-
-
19:38 - BackgroundThumbnailView
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground var body: some View { Image(uiImage: background.thumbnail) } }
-
19:58 - BackgroundSelectionView with Grid
var body: some View { ScrollView { Grid { ForEach(backgroundsGrid) { row in GridRow { ForEach(row.items) { background in BackgroundThumbnailView(background: background) .onTapGesture { selectedBackground = background } } } } } } }
-
20:03 - BackgroundSelectionView with Grid (simplified)
var body: some View { ScrollView { Grid { ForEach(backgroundsGrid) { row in GridRow { ForEach(row.items) { background in BackgroundThumbnailView(background: background) } } } } } }
-
20:26 - LazyVGrid variant
var body: some View { ScrollView { LazyVGrid(columns: [.init(.adaptive(minimum: BackgroundThumbnailView.thumbnailSize.width))]) { ForEach(BackyardBackground.allBackgrounds) { background in BackgroundThumbnailView(background: background) } } } }
-
24:05 - BackgroundThumbnailView
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground var body: some View { Image(uiImage: background.thumbnail) } }
-
24:59 - BackgroundThumbnailView with progress (but without loading)
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) } } }
-
25:26 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
29:59 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
31:41 - BackgroundThumbnailView with async loading on main thread (simplified)
struct BackgroundThumbnailView: View { // [...] var body: some View { // [...] ProgressView() .task { image = background.thumbnail } // [...] } }
-
33:40 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
33:59 - synchronous thumbnail property
public var thumbnail: UIImage { get { // compute and cache thumbnail } }
-
34:03 - asynchronous thumbnail property
public var thumbnail: UIImage { get async { // compute and cache thumbnail } }
-
34:08 - BackgroundThumbnailView with async loading in background
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = await background.thumbnail } } } }
-
38:52 - shared property causes blocked main thread
var body: some View { mainContent .task(id: imageMode) { defer { loading = false } do { var image = await background.thumbnail if imageMode == .colorized { let colorizer = ColorizingService.shared image = try await colorizer.colorize(image) } self.image = image } catch { self.error = error } } }
-
39:00 - shared property causes blocked main thread (simplified)
struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] let colorizer = ColorizingService.shared result = try await colorizer.colorize(image) } } }
-
39:10 - shared property causes blocked main thread + ColorizingService (simplified)
class ColorizingService { static let shared = ColorizingService() // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] let colorizer = ColorizingService.shared result = try await colorizer.colorize(image) } } }
-
39:25 - shared synchronous property after await keyword still causes blocked main thread
class ColorizingService { static let shared = ColorizingService() // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] result = try await ColorizingService.shared.colorize(image) } } }
-
class ColorizingService { static let shared = ColorizingService() func colorize(_ grayscaleImage: CGImage) async throws -> CGImage // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] result = try await ColorizingService.shared.colorize(image) } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.