Jetpack Compose - Something to do with REST

2024. 3. 5. 20:19IT/Android

728x90

<미리 보기>

 

 

<소스 코드>

https://github.com/SeongHyunJeon/android-kotlin-practice/tree/b1f5abf569e0b5f4970c448f0ec1e2da9649c40c/MarsPhoto

 

 

<정리>

REST(REpresentational State Transfer) - 클라이언트와 서버가 데이터를 주고받는 웹 서비스를 구축하기 위한 아키텍처 스타일로, 클라이언트는 HTTP 프로토콜을 통해 서버의 자원에 접근하고 조작할 수 있다.

 

RESTful - REST 아키텍처 스타일을 구현한 실질적인 웹 서비스로, 웹 서비스 요청은 URI와 HTTP 프로토콜을 사용하여 서버에 전송한다.

 

URI(Uniform Resource Identifier) - 리소스를 식별하는 문자열로, 리소스의 상세한 위치나 접근 방법을 암시하지 않는다.

URL(Uniform Resource Locator) - 리소스가 존재하는 위치와 리소스의 접근 방법을 지정하는 URI의 하위 집합을 의미한다.

엔드 포인트(EndPoint) - 웹 서비스 리소스에 접근하기 위한 URL의 한 부분을 의미한다.

https://example.com/product URI, URL
https://example.com URI
/product EndPoint

 

웹 서비스 요청(HTTP 작업)

GET : 서버로부터 데이터를 가져온다.

POST : 서버에 새로운 데이터를 생성한다.

PUT : 서버에 있는 기존 데이터를 수정한다.

DELETE : 서버에 있는 데이터를 삭제한다.

 

웹 서비스 응답

XML(eXtensible Makeup Language) 또는 JSON(JavaScript Object Notation)과 같은 일반적인 데이터 형식으로 지정되는데, 앱은 JSON을 사용하여 REST API와 통신하게 된다.

 

Retrofit 라이브러리 - RESTful과의 통신을 위해 사용되는 라이브러리로, 웹 서비스의 기본 URI와 변환기 팩토리가 있어야 해당 웹 서비스의 API를 빌드할 수 있다. 외부 라이브러리이므로 종속 항목 추가를 통해 사용이 가능하다.

*웹 서비스 API : 리소스 등을 목적으로 서버와 통신하기 위해 사용자가 정의하는 함수들로 Retorift을 사용하여 생성할 수 있다.


ScalarsConverter를 이용한 Retrofit 생성.

//Base URI
private const val BASE_URL =
    "https://android-kotlin-fun-mars-server.appspot.com"

//Retrofit with ScalarsConverter
private val retrofit = Retrofit.Builder()
    .addConverterFactory(ScalarsConverterFactory.create())
    .baseUrl(BASE_URL)
    .build()

*ScalarsConverter : Retrofit이 웹 서비스로부터 가져온 JSON을 String으로 변환해 준다.


웹 서비스 API를 인터페이스로 정의.

interface MarsApiService {
    @GET("photos") //Retrofit에 이 메서드가 GET요청이고 EndPoint가 photos임을 알린다.
    suspend fun getPhotos(): String
}

Retrofit을 이용한 웹 서비스 API 생성.

object MarsApi {
    val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }
}

*웹 서비스 API처럼 단 하나의 인스턴스만으로 충분하고 비용이 많이 요구된다면 싱글톤 패턴으로 구현.

*지연 초기화로 선언된 객체는 실제로 사용이 요구될 때까지 생성되지 않아 불필요한 리소스 사용을 방지할 수 있다.


viewModelScope - ViewModel을 대상으로 정의된 코루틴 범위로, 해당 코루틴은 ViewModel이 소멸하면 취소된다.

private fun getMarsPhotos() {
    viewModelScope.launch {}
}

AndroidManifest.xml - Android App이 시스템 기능에 접근하기 위한 권한을 요청하는 파일.

//인터넷에 대한 접근 권한을 요청하는 코드로, <application> 태그 앞에 선언된다.
<uses-permission android:name="android.permission.INTERNET" />

봉인된 인터페이스(sealed interface) - 봉인된 인터페이스는 다른 인터페이스가 상속받을 수 없고 내부에 선언된 클래스만이 해당 인터페이스를 구현할 수 있으므로 타입 안정성을 높일 수 있다.

sealed interface MarsUiState {
    data class Success(val photos: String): MarsUiState
    object Error: MarsUiState
    object Loading: MarsUiState
} //오직 세 클래스만이 MarsUiState 타입을 갖을 수 있다.

JSON 데이터 응답 구조 - JSON 배열, 키와 값 쌍으로 이뤄진 각각의 JSON 객체, 키와 값은 콜론(:)으로 구분됨, 값은 숫자나 문자열뿐만 아니라 다양할 수 있다.


직렬화(Serialization) - App에서 사용하는 데이터를 네트워크에서 사용할 수 있는 데이터 형식으로 변환하는 프로세스.

<->역직렬화(Deserialization) - 네트워크에서 사용하는 데이터를 App에서 사용할 수 있는 데이터 형식으로 변환하는 프로세스.

 

kotlinx.serialization - Retrofit과 호환되는 직렬화와 역직렬화 둘 다 가능한 서드파티 라이브러리 변환기로, 플러그인과 종속 항목 추가를 통해 사용 가능하다. JSON과 Kotlin 객체 간의 변환을 지원하기 때문에 만약 JSON 데이터를 파싱 하여 Kotlin 객체로 변환하고자 한다면, 파싱 된 결과를 저장할 Kotlin 데이터 클래스가 필요하다.

 

kotlinx.serialization를 이용한 Retrofit 생성.

private val retrofit = Retrofit.Builder()
    .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
    .baseUrl(BASE_URL)
    .build()

 

파싱 된 결과를 저장할 Kotlin 데이터 클래스 정의.

@Serializable
data class MarsPhoto(
    val id: String,
    @SerialName(value = "img_src")
    val imgSrc: String
)

*@Serializable : 해당 클래스가 직렬화, 역직렬화가 가능하다는 걸 나타내는 어노테이션.

*@SerialName : JSON 데이터를 파싱 할 때 이름이 일치하는 키를 찾아 데이터 객체를 적절한 값으로 채우는데, 키 이름과 다른 변수명을 사용하고자 할 때 쓸 수 있는 어노테이션.


데이터 레이어 - 데이터 소스와 상호작용하는 비즈니스 로직들을 담당하고, 단방향 데이터 흐름 패턴을 통해 UI 레이어에 데이터를 노출한다. 데이터 레이어는 하나 이상의 저장소로 구성되는데, 권장사항에 따르면 데이터 소스 유형별로 저장소가 있는 게 좋다.

 

저장소

앱의 나머지 부분에 데이터 노출 : 데이터를 제공하는 함수 정의.

데이터 변경을 한 곳으로 일원화 : 데이터 변경을 위한 연산을 한 곳에서 정의.

여러 데이터 소스간에 충돌 해결 : 다양한 데이터 소스가 동일한 데이터를 변경하지 않도록 방지.

앱의 나머지 부분에서 데이터 소스 추상화 : 구체적인 구현은 캡슐화, 실질적 사용은 인터페이스를 참고.

비즈니스 로직 : UI와 간접적으로 관련된 함수 정의.

*데이터 소스 : 웹 서비스 API처럼 실제 데이터에 접근하여 어떻게 데이터를 얻을지, 어디서 가져올지를 정의하는 곳.

*저장소 작명 규칙 : 데이터 유형(MarsPhotos) + 저장소(Repository) = MarsPhotosRepository

 

저장소를 인터페이스로 정의하고 클래스가 이를 확장하는데, 파라미터로 웹 서비스 API를 받아 구현을 완성한다.

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
): MarsPhotosRepository {
    override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

종속 항목(Dependency) - 클래스를 정의할 때 다른 클래스의 인스턴스를 사용하는 경우에서 인용되는 클래스를 의미한다. 클래스 내부에서 필요한 클래스의 인스턴스를 생성하기 위해 생성자를 호출하는 경우와 필요한 인스턴스를 파라미터로 전달받는 방법이 있는데, 클래스 내부에서 인스턴스를 생성하는 방법은 만약 해당 클래스의 정의가 변경되면 이를 생성하기 위한 코드도 같이 변경되어야 하므로 유연성이 떨어진다. 따라서 일반적으로 종속 항목은 런타임에 클래스 외부에서 인스턴스를 생성하여 파라미터로 전달하는 게 좋고, 이를 종속 항목 삽입(DI) 혹은 컨트롤 반전이라 부른다.

 

종속 항목 삽입 장점

코드 재사용성 지원 : 특정 객체에 종속되지 않으므로 유언성이 높다.

리팩터링 편의성 향상 : 코드의 한 섹션을 리팩터링해도 다른 섹션에 영향을 미치지 않는다.

테스트 지원 : 테스트 중에 객체를 전달할 수 있어 네트워크 요청 같이 비용이 들 수 있는 테스트를 모의로 확인이 가능하다.

 

컨테이너(Container) - 앱에 필요한 종속 항목이 포함된 객체로, 이는 전체 애플리케이션에 걸쳐 사용되므로 모든 활동에서 사용할 수 있는 일반적인 위치에 배치되어야 한다. 

 

컨테이너를 인터페이스로 정의하고 클래스가 이를 확장하는데, 레트로핏을 생성하여 웹 서비스 API를 생성하고 이어서 저장소를 생성한다.

interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
}

class DefaultAppContainer: AppContainer {
    private val baseUrl = "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}

 

 

애플리케이션 클래스 - 안드로이드 애플리케이션의 전역 상태와 생명 주기를 관리하는 싱글턴 패턴의 구현체로, 앱이 종료될 때까지 해당 인스턴스에 전역적으로 접근이 가능하다. 매니페스트 파일에서 확장한 애플리케이션 클래스를 명시적으로 지정해야 적용할 수 있다.

 

애플리케이션 클래스를 확장하여 저장소를 포함하고 있는 컨테이너 객체 생성.

class MarsPhotosApplication: Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}

 

저장소를 생성자 파라미터로 받는 뷰 모델 생성하는데, 안드로이드는 뷰 모델 외부에서 해당 인스턴스를 생성하기 위해 직접 생성자를 호출하여 인자를 전달하는 것을 허용하지 않기 때문에 ViewModelProvider.Factory을 사용하여 제약을 우회했다. UI 컴포저블에서 저장소를 생성자 파라미터로 전달하는 뷰 모델 인스턴스를 생성하기 위해 viewModel(factory = MarsViewModel.Factory) 함수를 실행하면, 아래의 코드의 viewModelFactory{}가 실행된다. 그 결과 뷰 모델 인스턴스를 포함하고 있는 ViewModelProvider.Factory 객체가 생성되는데, viewModel()함수는 이 객체를 사용하여 뷰 모델 인스턴스를 반환한다.

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel() {
    
    //...

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = this[APPLICATION_KEY] as MarsPhotosApplication //애플리케이션 객체 참조.
                val marsPhotosRepository = application.container.marsPhotosRepository //저장소 객체 참조.
                MarsViewModel(marsPhotosRepository = marsPhotosRepository) //저장소를 포함한 뷰 모델 인스턴스 생성.
            }
        }
    }
}

 

종속 항목 삽입을 이용하는 클래스들은 테스트용 종속 항목 객체를 이용하여 각각을 테스트할 수 있는데, 이는 종속 항목이 예상치 못한 문제를 일으키는 것을 방지하여 오류의 범위를 한정시킬 수 있다.

 

네트워크 호출 없는 테스트용 웹 서비스 API 클래스를 정의하고 해당 객체를 파라미터로 받아 저장소를 테스트한다.

object FakeDataSource { //API를 위한 데이터 소스 정의.

    const val idOne = "img1"
    const val idTwo = "img2"
    const val imgOne = "url.1"
    const val imgTwo = "url.2"
    val photosList = listOf(
        MarsPhoto(
            id = idOne,
            imgSrc = imgOne
        ),
        MarsPhoto(
            id = idTwo,
            imgSrc = imgTwo
        )
    )
}

class FakeMarsApiService: MarsApiService { //테스트용 API 정의.
    override suspend fun getPhotos(): List<MarsPhoto> {
        return FakeDataSource.photosList
    }
}
class NetworkMarsPhotosRepository {

    @Test
    fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
        runTest {
            val repository = NetworkMarsPhotosRepository(
                marsApiService = FakeMarsApiService()
            ) //테스트용 API 객체를 이용하여 저장소 객체를 생성.
            assertEquals(FakeDataSource.photosList, repository.getMarsPhotos()) //저장소 메서드 테스트.
        }
}

*runTest : 테스트에서 사용할 수 있는 코루틴 범위 함수.

 

테스트용 저장소 클래스를 정의하고 해당 객체를 파라미터로 받아 뷰 모델을 테스트 하는데, 뷰 모델의 코루틴 함수 ViewModelScope.launch()는 Main디스패처를 사용한다. 즉, UI 스레드를 사용하기 때문에 워크 스테이션 위에서 실행되는 단위 테스트는 테스트 디스패처를 정의하여 명시적으로 디스패처를 변경해야 한다.

object FakeDataSource {

    const val idOne = "img1"
    const val idTwo = "img2"
    const val imgOne = "url.1"
    const val imgTwo = "url.2"
    val photosList = listOf(
        MarsPhoto(
            id = idOne,
            imgSrc = imgOne
        ),
        MarsPhoto(
            id = idTwo,
            imgSrc = imgTwo
        )
    )
}

class FakeNetworkMarsPhotosRepository: MarsPhotosRepository { //테스트용 저장소 정의.
    override suspend fun getMarsPhotos(): List<MarsPhoto> {
        return FakeDataSource.photosList
    }
}
class TestDispatcherRule( //테스트 디스패처 정의.
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
): TestWatcher() {
    override fun starting(description: Description) { //테스트가 실행되기 전에 실행된다.
        Dispatchers.setMain(testDispatcher) //테스트 디스패처로 변경.
    }

    override fun finished(description: Description) { //테스트가 완료된 후에 실행된다.
        Dispatchers.resetMain() //메인 디스패처로 변경.
    }
}

/*
TestWatcher : 테스트의 다양한 실행 단계에서 작업을 실행할 수 있도록 돕는다.
TestDispatcher : 적용할 디스패처의 타입으로, 크게 StandardTestDispatcher와 UnconfinedTestDispatcher가 있다.
StandardTestDispatcher : 코루틴의 실행 제어 가능, 복잡한 테스트에서 사용.
UnconfinedTestDispatcher : 코루틴의 실행 제어 불가, 단순한 테스트에서 사용.
*/
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule() //규칙을 생성하고 어노테이션을 이용하여 적용.

    @Test
    fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
        runTest {
            val marsViewModel = MarsViewModel(
                marsPhotosRepository = FakeNetworkMarsPhotosRepository()
            ) //테스트용 저장소 객체를 이용하여 뷰 모델 생성.
            assertEquals(
                MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
                        "photos retrieved"),
                marsViewModel.marsUiState
            ) //뷰 모델 메서드 테스트.
        }
}

Coli 라이브러리 - URL을 통해 이미지를 다운로드하고 버퍼링, 디코딩, 캐시를 가능케하는 라이브러리로, 이미지 요청을 비동기식으로 실행하고 결과를 렌더링하는 AsyncImage 컴포저블을 통해 이를 출력할 수 있다.

AsyncImage(
    model = ImageRequest.Builder(context = LocalContext.current)
        .data(photo.imgSrc) //로드 할 URL 설정.
        .crossfade(true) //이미지 로드를 부드럽게 처리하는 애니메이션 설정.
        .build(),
    error = painterResource(R.drawable.ic_broken_image), //이미지를 로드하지 못한 경우의 이미지.
    placeholder = painterResource(R.drawable.loading_img), //이미지를 로드하는 동안의 이미지.
    contentDescription = stringResource(R.string.mars_photo),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxWidth()
)

*버퍼링 : 데이터를 실제 사용되기 직전에 미리 메모리에 로드하여 사용자의 대기를 줄이는 과정.

*디코딩 : 이미지를 앱에서 사용할 수 있는 형식으로 변환하는 과정.

*캐시 : 이전에 요청된 데이터를 저장하여 추후에 다시 사용할 수 있도록 하는 과정.


스크롤 설정

@Composable
fun MarsPhotosApp() {
    //상단바가 위로 스크롤하면 사라지고, 아래로 스크롤하면 나타나는 설정을 가진 TopAppBarScrollBehavor 객체를 생성.
    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), //Scaffold 내부와 상단바의 스크롤 동작을 연결.
        topBar = { MarsTopAppBar(scrollBehavior = scrollBehavior) } //상단바 스크롤 동작 설정을 위한 인자 전달.
    ) {
        //...
    }
}

@Composable
fun MarsTopAppBar(scrollBehavior: TopAppBarScrollBehavior, modifier: Modifier = Modifier) {
    CenterAlignedTopAppBar(
        scrollBehavior = scrollBehavior, //상단바 스크롤 동작 설정.
        title = {
            //...
        },
        modifier = modifier
    )
}

지연 그리드

LazyVerticalGrid : 여러 열에 걸쳐 세로로 스크롤. <-> LazyHorizontalGrid

LazyVerticalGrid(
    columns = GridCells.Adaptive(150.dp),
    modifier = modifier.padding(horizontal = 4.dp),
    contentPadding = contentPadding
) {
    items(items = photos, key = { photo -> photo.id }) { photo ->
        MarsPhotoCard(
            photo,
            modifier = Modifier
                .padding(4.dp)
                .fillMaxWidth()
                .aspectRatio(1.5f)
        )
    }
}

*GridCells.Adaptive() : 너비를 지정하는데 사용되고, 그리드는 너비를 계산하여 가능한 한 많은 열을 삽입한다.

*GridCells.Fixed() : 열의 수를 지정하는데 사용되고, 그리드는 열의 수를 기준으로 너비를 계산한다.

*key : 항목 키를 제공하면 컴포저블이 재구성 될 때, 스크롤 상태가 유지된다.

*Modifier.aspectRatio() : 가로, 세로 비율 설정.

728x90