Android App architecture: About the data layer
안드로이드 App architecture: About the data layer를 알아보겠습니다.
출처:
https://developer.android.com/topic/architecture/data-layer
UI layer가 UI와 관련된 state(스테이트)와 UI logic을 다루는 것이라면, data layer는 앱 data와 business logic을 다룹니다.
business logic(비즈니스 로직)은 앱에 가치를 주는 것들입니다. 실제 세계의 business로 이루어졌으며, 앱 data를 어떻게 만들고, 저장하고, 변경하는지를 다룹니다.
이러한 개념의 구분은 data layer를 여러 화면, 앱 부분들의 정보 공유를 할 수 있게 해줍니다. 그리고 business logic을 UI 밖에 둠으로써 unit test(단위 테스트)를 가능하게 합니다.
여기서 추천하는 내용들은 앱을 확장하기 쉽게 만들고 사용성과 품질을 향상시키고 테스트하기 쉽게 만들어줍니다. 그러나 반드시 따라야 하는 것은 아닌 지침이며 당신의 상황에 맞게 적용해야 합니다.
⊙ Data layer architecture; Data layer 구조:
data layer는 repositories(레포지토리)로 이루어졌으며 repositories는 0 ~ 여러 개의 data sources(데이터 소스)를 가집니다. repository class(레포지토리 클래스)는 각기 다른 data type(데이터 타입)을 다룹니다.
예를 들면, MoviesRepository class는 movies와 관련된 data만 다루고, PaymentsRepository class는 payments와 관련된 data만 다룹니다.
Repository classes는 아래의 작업을 수행합니다.
• data를 앱 전체에 전달합니다.
• data의 변화를 관리합니다.
• 여러 data source의 충돌을 관리합니다.
• 앱 전체에서 data source를 볼 수 있게 합니다.
• business logic을 포함합니다.
각 data source class는 오직 하나의 data만 다룹니다. 이 data는 파일, 네트워크에서 받은 것 또는 내부 database(데이터베이스)에서 받은 것을 의미합니다. data source는 앱과 data와 관련된 작업을 연결합니다.
다른 layer에서는 절대로 data source에 접근해서는 안 됩니다. data layer는 항상 repository classes에서 생성되어야 합니다. State holder classes(스테이트 홀더 클래스)나 use case classes(유즈 케이스 클래스)에서 data source를 직접 불러와서는 안됩니다. repository class에서 data layer를 생성하는 것은 다른 layer와 독립적으로 확장 가능하게 만들어줍니다.
data layer에서의 data 전달은 불변해야 합니다. 그래서 외부 class에서 조작할 수 없어야 합니다. 만약 data가 일관성이 없다면 큰 위험부담이 발생합니다. 불변하는 data는 많은 threads에서도 안전합니다.
아래의 dependency injection(의존성 주입)은 좋은 예시입니다. repository는 data source를 constructor(생성자)에 의존성으로 받습니다.
만약 하나의 data source를 사용한다면 repository와 합쳐도 됩니다. 다만, 나중에 확장할 때 잊지 말고 반드시 나눠줘야 합니다.
⊙ Expose APIs; API 전달:
data layer에 있는 classes는 일반적으로 한 번만 실행하는 함수입니다. Create(생성), Read(읽기), Update(최신화) 그리고 Delete(삭제)이 4가지를 CRUD라 부릅니다. CRUD를 사용하거나 data의 변화를 매번 알립니다. data layer는 아래의 경우들을 전달합니다.
• One-shot(한 번만 실행) 기능: data layer는 Kotlin의 suspend(서스펜드) 함수를 사용합니다. 그리고 Java 언어는 call back을 사용하거나 RxJava의 Single, Maybe 또는 Completable 타입을 사용합니다.
• 매번 data의 변화를 알림: data layer는 Kotlin의 flow를 사용합니다. Java 언어는 callback을 사용하거나 Rxjava의 Observable 또는 Flowable 타입을 사용합니다.
⊙ Naming conventions in this guide; 이 지침의 작명 규칙:
이 지침에서는 repository classes는 관리하는 data 뒤에 이름이 붙습니다. 규칙은 아래와 같습니다.
type of data + Repository
예를 들면, NewsRepository, MoviesRepository, 또는 PaymentsRepository.
Data source classes는 data 뒤에 source 뒤에 이름이 붙습니다. 규칙은 아래와 같습니다.
type of data + type of source + DataSource
type of source에는 Remote 또는 Local이 일반적입니다.
예를 들어, NewsRemoteDataSource 또는 NewsLocalDatasource.
좀 더 구체적으로 표현해야 하는 상황이라면 이렇게 사용합니다.
예를 들어, NewsNetworkDataSource 또는 NewsDiskDataSource.
사용하는 기법을 이름으로 정해서는 안됩니다.
예를 둘어, UserSharedPreferencesDataSource.
왜냐하면 data source를 사용하는 repository는 뭘로 data를 저장했는지를 알 필요가 없기 때문입니다.
이러한 규칙을 따르면, 구현하는 방식이 변경되더라도(SharedPreferences에서 DataStore로) layer 이름에는 영향이 없습니다.
새로운 기술로 이전할 때에는 아마도 당신은 interface를 만들고 거기에 data source를 받을 겁니다. 2개를 받게 될 텐데 예전 기술과 현재 기술 이렇게 받을 겁니다. 이때에는 상세하게 구현 기술을 적어도 됩니다. 왜냐하면 repository는 interface를 바라보기 때문입니다. 하지만, 이전이 완료된 상태라면 상세 구현 이름은 지워야 합니다.
⊙ Multiple levels of repositories; 다양한 계층의 repository:
복잡한 business의 요구에 따라 repository는 다른 repository를 참조할 수 있습니다. 왜냐하면 필요한 data가 여러 data source에서 조합해야 하는 경우가 있거나 repository끼리 캡슐화되어야 하기 때문입니다.
예를 들어, 사용자의 인증 data를 다루는 repository가 있다면 UserRepository로 만듭니다. 이 UserRepository는 LoginRepository와 RegistrationRepository를 참조할 수 있습니다.
전통적으로 몇몇 개발자들은 다른 repository를 참조하는 repository를 managers-로 부릅니다. 예를 들면 UserRepository 대신 UserManager로 부릅니다. 만약 당신이 이게 좋다면 이렇게 적어도 됩니다.
⊙ Source of truth; 진실한 소스:
가장 중요한 것은 모든 repository는 single source of truth로 정의된다는 것입니다. source of truth는 항상 data를 일관성 있고, 올바르고, 최신으로 유지합니다. 따라서 repository에서 전송되는 data는 항상 source of truth를 따릅니다.
source of truth는 data source 일 수 있습니다. 예를 들면 database(데이터베이스) 또는 in-memory cache(인 메모리 캐시) 등이 있습니다. Repositories는 사용자 입력이나 single source of truth를 만족하기 위해 여러 다른 data source를 하나로 합치거나 충돌을 해결합니다.
다른 repositories는 다른 sources of truth를 가질 수 있습니다. 예를 들면, LoginRepository class는 source of truth로 cache를 사용할 수 있고, PaymentsRepository class는 network data source를 사용할 수 있습니다.
offline-first(오프라인 먼저) 원칙을 적용하기 위해, database 같은 내부 저장소를 source of truth로 사용하는 것을 추천합니다.
⊙ Threading; 스레딩:
data source를 부르거나 repositories의 작업은 모두 main thread를 보호하는 main-safe 해야 합니다. 이러한 classes들은 오랫동안 작업해도 괜찮은 thread로 옮겨야 합니다. 예를 들면, file(파일)을 읽거나 큰 리스트를 필터 하는 일 등은 main-safe 해야 합니다.
Room, Retrofit, Ktor 같은 API들은 이미 main-safe 기능을 제공해 주고 있습니다.
Kotlin 언어는 coroutine을 추천합니다.
⊙ Lifecycle; 라이프 사이클:
data layer에서 생성된 class는 가능한 오랫동안 garbage collection이 없애기 전까지 memory(메모리)에 남아야 합니다.
만약 class가 cache 같은 in-memory data를 가진다면 당신은 해당 인스턴스를 다시 사용하기를 원할 겁니다. 그렇다면 lifecycle을 고려해야 합니다.
만약 class의 반응이 앱 전체에 중대한 영향을 준다면 class를 application class에 lifecycle을 맞출 수 있습니다. 만약 로그인이나 회원가입처럼 특정한 순간에만 동일한 instance(인스턴스)가 필요하다면 해당 flow(흐름)에 lifecycle을 맞추면 됩니다. 예를 들면, RegistrationRepository가 in-memory data를 RegistrationActivity 또는 회원가입 flow navigation(화면전환) graph(그래프)를 포함할 수 있습니다.
instance의 lifecycle은 어떻게 의존성을 관리할지를 결정하는 중요한 요소입니다. 추천하는 방식은 dependency injection을 사용하는 것입니다. 그래서 dependency container(의존성 컨테이너)가 관리하게 만듭니다.
⊙ Represent business models
여러 data source에서 불러 모은 data model을 data layer에서 전달하고 싶을 수 있습니다. 이상적으로 네트워크나 내부 저장소에서 가져오는 data source는 앱에서 필요한 data만 반환해야 합니다.
예를 들면, 뉴스 API 서버에서 기사의 정보뿐만 아니라 수정된 이력, 사용자의 댓글과 이 밖의 정보들을 준다고 생각해 봅시다.
앱은 이러한 모든 정보가 필요하지 않습니다. 왜냐하면 기사에 대한 정보만 화면에 표시하기 때문입니다. model class를 나누어 repositories가 필요한 정보만 다른 layer에 전달할 수 있게 해주는 것이 좋은 방식입니다. 예를 들면, ArticleApiModel에서 domain과 UI layer로 보낼 Article Model은 다음과 같이 정의할 수 있습니다.
model(모델) classes는 다음과 같은 이점이 있습니다.
• 필요한 정보만 저장하므로 앱의 메모리를 절약합니다.
• 외부에서 오는 data type에 상관없이 내부에서 쓰는 data type으로 변경할 수 있습니다.
• 좋은 분리의 관점을 제공합니다. 예를 들어, Model이 분리되어 있으면 많은 인원이 있는 팀에서 network와 UI layer를 각자 수정해 나갈 수 있습니다.
당신은 model classes의 분리를 앱 여러 구조에서 적용해 볼 수 있습니다. data source class와 ViewModel에서 가능합니다. 그러나 이러한 분리는 추가적인 class와 logic에 대해 문서화가 잘 되어야 하고 테스트를 해야 합니다. 반드시 model을 나누어야 할 때는 data source에서 받은 data와 앱에서 필요한 data가 다를 때, model을 나누어야 합니다.
⊙ Types of data operations; data 작업의 종류:
data layer는 여러 types의 작업을 다룹니다. 어디에 중요하냐에 따라 UI-oriented(UI 중심), app-oriented(앱 중심), business-oriented(비즈니스 중심)으로 나뉩니다.
• UI-oriented operations
UI-oriented operations는 사용자가 보고 있는 화면과 관련 있습니다. 그리고 사용자가 그 화면을 빠져나가면 취소됩니다. 예를 들면, database에서 가져와 화면에 보여주는 작업입니다.
UI-oriented operations는 보통 UI layer와 lifecycle에 의해 발동됩니다. 예를 들면, ViewModel의 lifecycle입니다. 아래에서 다룰 네트워크 호출 부분에서 UI-oriented operation 예시를 볼 수 있습니다.
• App-oriented operations
App-oriented operations는 앱이 열리면서 시작됩니다. 앱이 종료되면 프로세서는 종료되고 이러한 operations도 함께 종료됩니다. 예를 들면 네트워크에서 받은 결과를 caching(캐싱) 하는 작업입니다. 아래에서 다룰 in-memory data caching 부분에서 확인할 수 있습니다.
이러한 operations(작업)는 보통 Application lifecycle을 따르거나 data layer를 따릅니다. 예를 들면, 아래에서 다룰 화면 보다 길게 작동하는 operation 만들기에서 확인할 수 있습니다.
• Business-oriented operations
Business-oriented operations는 취소될 수 없습니다. 프로세서가 종료되어도 살아남습니다. 예를 들면, 사용자가 원하는 사진으로 프로파일을 업로드 완료하는 상황입니다.
business-oriented operations에서 추천하는 것은 WorkManager를 사용하는 것입니다. 아래에서 다룰 WorkManager를 사용하여 예약 작업하기 부분에서 확인할 수 있습니다.
⊙ Expose errors; 오류 전달:
repositories와 sources of data는 성공할 수도 있고 실패할 수도 있습니다. 실패할 때에는 Exception(예외)가 발생합니다. coroutines와 flows에서 Kotlin의 내장된 오류 처리를 사용해야 합니다. suspend 함수에서 발생할 때에는 try/catch로 잘 관리해야 합니다. flows에서는 catch 함수를 사용합니다. 이러한 관점은 UI layer가 data layer를 부를 때 예외를 관리할 수 있게 해줍니다.
data layer는 여러 종류의 오류를 이해하고 다룰 수 있으며, UserNotAuthenticatedException처럼 상황에 맞는 예외를 만들어서 전달할 수 있습니다.
data layer와 상호작용하는 다른 방식은 Result class를 사용하는 것입니다. 이 패턴을 사용하면 오류나 다른 정보들을 result로 처리할 수 있습니다. data layer는 T 대신 Result<T>를 반환하게 됩니다. 이러한 방식은 LiveData처럼 reactive programming APIs에 필요합니다.
⊙ Common tasks; 일반적인 작업:
이전에 예시로 들었던 기사 앱을 바탕으로 특정한 작업 시 어떻게 data layer를 구성하는지 예시를 봅니다.
• Make a network request; 네트워크 작업:
네트워크 요청은 안드로이드 앱에서 일반적인 작업입니다. 기사 앱은 최신의 기사를 보여주기 위해 네트워크에 접속합니다. 그러므로 NewsremoteDataSource로 정의한 data source class는 네트워크 작업을 합니다. 이러한 정보를 앱 전체에 전달하기 위해 NewsRepository를 만들어서 관리합니다.
사용자가 화면을 켜면 항상 최신 기사를 보여주기 때문에 이것은 UI-oriented operation입니다.
- Create the data source; data source 만들기:
data source는 최신의 기사를 반환하는 함수가 필요합니다. ArticleHeadline instance의 목록이 되겠네요. data source는 최신 기사를 네트워크에서 가져올 때 main-safe 여야 합니다. 이를 위해서 CoroutineDispatcher나 Executor가 필요합니다.
fetchLastestNews()라는 one-shot 함수로 만들어서 관리합니다.
NewsApi interface(인터페이스)는 network API client를 숨깁니다. 그래서 Retrofit을 사용하든 HttpURLConnection을 사용하든 상관없게 만듭니다. 따라서 interfaces는 API를 불러오는 기술을 쉽게 변화시킬 수 있게 만들어줍니다.
또한, 이러한 interface 사용은 dependencies를 교체하기 편하고, fake(가짜) data를 test 할 때 사용할 수 있어서 좋습니다.
- Create the repository; repository 만들기:
따로 특별한 logic이 필요한 앱이 아니라서 NewsRepository는 그냥 네트워크 data source를 위한 거쳐가는 proxy(관문) 역할을 합니다. 이러한 추가적인 layer를 더하는 것의 이점은 아래에서 다룰 in-memory caching 부분에서 설명하겠습니다.
repository class가 UI layer에 어떻게 적용되는지 보려면 UI layer guide를 참고해 주세요.
• Implement in-memory data caching; in-memory data caching 사용하기:
기사 앱을 추가적으로 설명하자면, 사용자가 앱을 열었을 때, 이전에 한 번 열었던 사용자에게는 이전 data를 먼저 보여줍니다. 그렇지 않다면, 네트워크에서 최신 기사를 불러옵니다.
새로운 요구사항을 만족하기 위해서는 사용자가 앱을 열 때, in memory에서 최신 기사를 불러옵니다. 따라서 이것은 app-oriented operation입니다.
- Caches; 캐시:
in-memory data caching을 만들어 사용자가 앱을 사용할 때 data를 저장할 수 있습니다. Caches는 어떤 정보를 in memory에 특정 기간 저장하는 것을 말합니다. 이 기사 앱의 경우는 사용자가 앱을 쓰는 동안 저장합니다. Cache는 여러 방법으로 만들 수 있습니다. 단순한 변수이거나 정교한 class 일 수 있습니다. 중요한 것은 여러 thread에서도 읽고/쓰기가 안전해야 한다는 것입니다. 사용법에 따라 repository에 존재하거나 data source classes에 존재할 수 있습니다.
- Cache the result of the network request; 네트워크 결과 캐시로 저장하기:
간단히 말하자면, NewsRepository는 변수로 최신 기사를 저장합니다. 여러 thread에서 읽고/쓰기를 보호하기 위해 Mutex를 사용합니다.
아래의 코드에서 사용된 caches는 최신 기사 정보를 repository의 변수에 저장하고 쓰기 방지를 위해 Mutext를 사용합니다. 만약 네트워크 요청이 성공하면 결과는 lastestNews 변수에 대입됩니다.
- Make an operation live longer than the screen; 화면보다 작업이 더 오래 살아남게 하기:
만약 사용자가 네트워크 요청이 진행 중인데 화면을 옮긴다면 네트워크 요청은 취소되고 결과는 저장되지 않을 겁니다. NewsRepository는 해당 함수의 CoroutineScope로 이 작업을 실행해서는 안 됩니다. 대신 NewsRepository는 해당 logic의 lifecycle에 해당하는 CoroutineScope를 사용해야 합니다. 최신 기사를 불러오는 작업은 app-oriented operation이어야 합니다.
다음의 dependency injection은 좋은 예시입니다. NewsRepository는 자신의 CoroutineScope를 만들기보다 외부에서 CoroutineScope를 받아야 합니다. 왜냐하면 Repositories는 대부분 백그라운드에서 작업을 하기 때문입니다. 당신이 만든 CoroutineScope와 함께 thread pool을 사용하거나 Dispatchers.Default를 사용합니다.
NewsRepository는 externalScope와 함께 app-oriented 작업할 준비가 되었기 때문에 data source로부터 data를 불러오거나 저장하는 일은 externalScope를 이용한 새로운 coroutine을 만들어서 합니다.
async는 external scope를 시작하기 위해 사용되고, await는 네트워크 값을 받아 cache에 저장하는 작업을 새로운 coroutine에 suspend 하기 위해 사용합니다. 만약 사용자가 계속 화면을 유지하면 새로운 기사가 불러와지는 것을 볼 수 있습니다. 만약 사용자가 이동한다면 await는 취소되지만, async는 계속해서 실행됩니다.
• Save and retrieve data from disk; disk에 data를 저장하고 찾기:
당신이 bookmarked 뉴스 정보나 user preferences를 저장하고 싶을 수 있습니다. 이러한 타입의 data는 process가 종료되어도 존재하여야 하고, 네트워크에 연결되지 않아도 접근할 수 있어야 합니다.
process가 종료되어도 data가 유지되려면 다음과 같은 방법으로 disk에 저장해야 합니다.
- 큰 datasets는 queried될 필요가 있고, 오염되지 않아야 하고, 부분적으로 최신화될 필요가 있습니다. 이러한 경우 Room database에 저장합니다. 기사 앱의 경우 뉴스 기사 또는 작성자는 database에 저장하여 관리할 수 있습니다.
- 작은 datasets의 경우 검색 및 저장만 가능하면 됩니다. queries나 부분적 최신화는 필요 없습니다. 이럴 때에는 DataStore를 씁니다. 기사 앱의 경우, 사용자의 preferred data format 또는 다른 display preferences는 DataStore에 저장될 수 있습니다.
- JSON object 같은 데이터 뭉텅이는 file에 저장합니다.
Source of truth에서 설명했듯이 각 data source는 오직 하나의 source와 data type에 대해서만 다룹니다. 예를 들면, News, Authors, NewsAndAuthors 그리고 UserPreferences. Data source를 다루는 classes는 data가 어떻게 저장되는지 알 필요가 없습니다. 예를 들면, database를 사용하든 file을 사용하든 말이죠.
- Room as a data source; Room database를 data source로 사용하기:
각각의 data source는 하나의 source만 다루기 때문에 Room data source는 Data Access Object (DAO)를 받거나 database 자체를 parameter로 받습니다. 예를 들면, NewsLocalDataSource는 NewsDao의 instance를 parameter로 받습니다. 그리고 AuthorsLocalDataSource는 아마도 AuthorsDao를 받습니다.
몇몇의 경우 특별한 로직이 없다면 repository에 바로 DAO를 의존성 추가할 수 있습니다. 왜냐하면 DAO는 interface라서 test 할 때 쉽게 교체가 가능합니다.
- DataStore as a data source; DataStore를 data source로 사용하기:
DataStore는 key-value 타입의 사용자 설정을 보관하기 적절합니다. 예를 들면, time format, notification, preferences, 그리고 사용자가 읽은 기사를 숨길지 보여줄지 여부 등이 있습니다. DataStore은 protocol buffers를 사용하여 typed object를 보관할 수 있습니다.
다른 object와 마찬가지로 DataStore에 저장된 data source는 특정한 type과 앱의 특정한 부분을 가지고 있습니다. DataStore는 최신 화가 될 때마다 flow를 통해 매번 값을 읽어들입니다. 이러한 이유로 관련된 preferences들은 모두 같은 DataStore에 넣어야 합니다.
예를 들어, NotificationsDataStore이 있다고 칩시다. 그리고 이것은 notification에 관련된 preferences만 다룹니다. NewsPreferencesdataStore은 오직 news screen에 관련된 preferences만 다룹니다. 이러한 방법은 최신화를 좀 더 좋은 방법으로 할 수 있게 해줍니다. 왜냐하면 newsScreenPreferencesDataStore.data는 오직 화면과 관련된 정보만 최신화 시키기 때문입니다. 이것은 lifecycle이 짧을 수 있다는 것을 암시합니다. 왜냐하면 news screen이 보일 때에만 작동하기 때문입니다.
- A file as a data source; 파일을 data source로 사용하기:
JSon object 또는 bitmap 같이 큰 objects를 다룰 때에는 thread를 전환하여 File object를 다뤄야 합니다.
• Schedule tasks using WorkManager; WorkManager를 사용한 예약 작업:
기사 앱의 새로운 요구 사항이 있습니다. 최신 기사를 사용자에게 특정한 간격으로 최신화를 시키는 것이죠. 사용자가 충전 중이거나 요금이 청구되지 않는 네트워크에 있을 때면 좋겠네요. 이것은 business-oriented operation입니다. 이것은 사용자가 앱을 사용할 때, 인터넷에 연결되지 않아도 사용자가 최신 기사를 볼 수 있게 해줍니다.
WorkManager는 이러한 예약 작업을 잘 관리할 수 있게 해주는 추천하는 라이브러리입니다. 위에서 설명한 작업을 수행하기 위해 Worker라는 class를 만듭니다. RefreshLatestNewsWorker 같은 이름으로 말이죠. 이 class는 기사를 최신화 시키고 disk에 cache 하기 위해 NewsRepository의 의존성을 가집니다.
이 business logic은 캡슐화되어야 하고 분리된 data source로 다뤄져야 합니다. WorkManager는 모든 요구 조건이 충족할 때 Background thread로 실행되어야 합니다. 이러한 패턴은 환경이 변화되면 빠르게 변경할 수 있게 해줍니다.
예를 들면, 이 기사와 관련된 작업은 NewsRepository에서 불러와져야 합니다. NewsTasksDataSource라는 이름으로 의존성을 주입할 수 있습니다.
이러한 classes는 다루는 data 이름 뒤에 Tasks를 붙입니다. 예를 들면, NewsTasksDataSource 또는 PaymentsTasksDataSource처럼 짓습니다. 모든 Task는 관련된 작업이 같은 class 안에 캡슐화되어야 합니다.
만약 이러한 task가 앱이 시작될 때 실행되어야 한다면, App Startup library의 Initializer를 사용합니다.
⊙ Testing; 테스트:
Dependency injection은 테스트하기 좋은 방법입니다. 또한, 외부 저장소와 통신하는 interface나 class를 위한 좋은 방법입니다. Unit test를 할 때에는 가짜 버전의 dependencies를 넣어 테스트가 가능하게 만들어줍니다.
• Unit tests; 단위 테스트:
data layer에도 일반적인 test 지침을 준수합니다. unit test를 위해서 필요할 땐 진짜 data를 사용하고, file을 읽거나 네트워크 작업하는 등의 외부 작업을 하는 것은 가짜 의존성을 주입합니다.
• Integration tests; 통합 테스트:
Integration tests에서 진짜 data에 접근하는 것은 괜찮습니다. 왜냐하면 실제 device에서 진행하기 때문이죠. 통제된 환경에서 test를 진행하는 것이 추천됩니다.
Databases를 예로 들면, 테스트에서 room이 in-memory database를 만드는 것으로 대체합니다.
Network를 예로 들면, WireMock나 MockWebServer가 당신의 가짜 HTTP와 HTTPS 요청을 확인할 수 있게 해줍니다.
끝.
카테고리: Android
댓글
댓글 쓰기
궁금한 점은 댓글 달아주세요.
Comment if you have any questions.