어플리케이션, 앱 (Application)/안드로이드 (Android)

안드로이드 jetpack compose 공부 정리 9강 (라이브러리와 API )

sobal 2025. 6. 28. 16:49

1. JSON (JavaScript Object Notation) 이해하기

기본 개념

JSON은 JavaScript Object Notation의 약자로, 현대 웹 및 모바일 애플리케이션에서 가장 널리 사용되는 경량 데이터 교환 형식이다. JSON은 사람이 읽고 쓰기 쉬우며, 동시에 기계가 파싱하고 생성하기에도 용이하다.

JSON은 언어에 독립적인 텍스트 형식이지만, C 계열 언어(C, C++, C#, Java, JavaScript, Kotlin 등) 프로그래머들에게 익숙한 구조를 사용한다. 

JSON의 핵심 구조

JSON은 두 가지 기본 구조를 바탕으로 구성된다:

객체(Objects): 순서가 없는 이름/값 쌍의 집합으로, 중괄호 {}로 둘러싸여있다. 이는 다른 프로그래밍 언어의 객체, 레코드, 구조체, 딕셔너리, 해시 테이블과 유사한 개념이라고 할 수 있다.

배열(Arrays): 순서가 있는 값들의 목록으로, 대괄호 []로 둘러싸여있다. 이는 대부분의 언어에서 배열, 벡터, 리스트, 시퀀스와 대응된다.

JSON 데이터 타입

JSON에서 사용할 수 있는 값의 종류는 다음과 같다:

  • 문자열(String): 큰따옴표로 둘러싸인 문자들의 연속
  • 숫자(Number): 정수 또는 부동소수점 숫자
  • 불린(Boolean): true 또는 false
  • null: 빈 값을 나타냄
  • 객체(Object): 중괄호로 둘러싸인 키-값 쌍들
  • 배열(Array): 대괄호로 둘러싸인 값들의 목록

예제

간단한 사용자 정보 JSON:

{
    "id": 1,
    "name": "김철수",
    "age": 28,
    "isStudent": false,
    "email": "kimcs@example.com"
}

중첩된 객체를 포함한 복잡한 JSON:

{
    "user": {
        "id": 1,
        "name": "김철수",
        "profile": {
            "address": {
                "street": "강남대로 123",
                "city": "서울",
                "zipcode": "06234"
            },
            "phone": "010-1234-5678"
        },
        "hobbies": ["독서", "영화감상", "코딩"]
    },
    "lastLogin": "2024-01-15T09:30:00Z"
}

Android에서 JSON 활용

Android 개발에서 JSON은 주로 다음과 같은 상황에서 사용된다:

  • REST API 통신: 서버와 클라이언트 간 데이터 교환
  • 로컬 데이터 저장: SharedPreferences나 파일 시스템에 구조화된 데이터 저장
  • 설정 파일: 앱의 설정 정보를 JSON 형태로 관리

2. API (Application Programming Interface) 이해

API의 본질

API는 서로 다른 소프트웨어 애플리케이션들이 상호작용할 수 있도록 하는 계약서 또는 규칙(규정)이다. API는 애플리케이션이 다른 시스템의 기능이나 데이터에 접근하는 방법을 정의하고 이를 통해 개발자는 복잡한 내부 구현을 알 필요 없이 외부 서비스를 활용할 수 있다.

API의 종류와 특징

웹 API (REST API): HTTP/HTTPS 프로토콜을 사용하여 인터넷을 통해 접근하는 API다. 대부분의 현대 웹 서비스가 이 방식을 채택하고 있고 JSON 형태로 데이터를 주고받는다.

라이브러리 API: 프로그래밍 언어나 프레임워크에서 제공하는 함수, 클래스, 메서드들의 집합이다. 예를 들어, Android의 Jetpack Compose API가 여기에 해당한다.

운영체제 API: 운영체제의 기능에 접근하기 위한 인터페이스로, 파일 시스템 접근, 하드웨어 제어 등의 작업을 수행할 수 있게 해준다.

HTTP 메서드의 이해

REST API에서 사용되는 주요 HTTP 메서드들과 그 의미는 다음과 같다:

  • GET: 데이터 조회 (읽기 전용)
  • POST: 새로운 데이터 생성
  • PUT: 기존 데이터 전체 수정
  • PATCH: 기존 데이터 부분 수정
  • DELETE: 데이터 삭제

API 응답 상태 코드

API 호출의 결과를 나타내는 주요 HTTP 상태 코드들:

  • 200 OK: 요청 성공
  • 201 Created: 리소스 생성 성공
  • 400 Bad Request: 잘못된 요청
  • 401 Unauthorized: 인증 실패
  • 404 Not Found: 리소스를 찾을 수 없음
  • 500 Internal Server Error: 서버 내부 오류

실제 API 사용 예제

JSONPlaceholder라는 무료 테스트 API를 예로 들어보겠다:

API 엔드포인트: https://jsonplaceholder.typicode.com/users/1

요청: GET 메서드로 사용자 ID 1의 정보 조회

응답 예시:

{
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
        "street": "Kulas Light",
        "suite": "Apt. 556",
        "city": "Gwenborough",
        "zipcode": "92998-3874"
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org"
}

 


3. Gradle과 의존성 관리

Gradle의 역할

Gradle은 Android 프로젝트의 빌드 시스템으로, 소스 코드를 컴파일하고 패키징하여 실행 가능한 APK 파일을 생성하는 전체 과정을 관리한다.

주요 Gradle 파일들

프로젝트 레벨 build.gradle: 프로젝트 전체에 적용되는 설정을 정의한다.

// 프로젝트 레벨 build.gradle (Project)
buildscript {
    ext {
        compose_version = '1.5.8'
        kotlin_version = '1.9.22'
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

앱 레벨 build.gradle: 특정 모듈(일반적으로 app 모듈)에 적용되는 설정을 정의한다.

// 앱 레벨 build.gradle (Module: app)
android {
    namespace 'com.example.myapp'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.myapp"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    
    kotlinOptions {
        jvmTarget = '1.8'
    }
    
    buildFeatures {
        compose true
    }
    
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
}

의존성(Dependencies) 관리

의존성은 프로젝트가 정상적으로 작동하기 위해 필요한 외부 라이브러리나 모듈을 말한다. Android 프로젝트에서는 dependencies 블록에서 이를 관리한다.

의존성 구성(Configuration) 타입

implementation: 컴파일 시점과 런타임에 모두 사용되고 다른 모듈에는 노출되지 않는 의존성이다.

api: implementation과 비슷하지만, 다른 모듈에도 노출되는 의존성이다.

compileOnly: 컴파일 시점에만 필요하고 런타임에는 포함되지 않는 의존성이다.

testImplementation: 테스트 코드에서만 사용되는 의존성이다.

주요 Android 의존성 예제

dependencies {
    // Android 핵심 라이브러리
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
    
    // Jetpack Compose
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
    implementation 'androidx.compose.material3:material3:1.1.2'
    implementation 'androidx.activity:activity-compose:1.8.2'
    
    // 네트워킹
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    
    // 이미지 로딩
    implementation 'io.coil-kt:coil-compose:2.5.0'
    
    // 코루틴
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
    
    // 테스팅
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
}

4. Retrofit - 강력한 HTTP 클라이언트

Retrofit의 개념과 장점

Retrofit은 Square에서 개발한 타입 안전한 HTTP 클라이언트 라이브러리로, Android와 Java 애플리케이션에서 REST API 통신을 간단하고 효율적으로 처리할 수 있게 해준다. 안드로이드 개발에서 뺄래야 절대 뺄 수 없는 개념이라 할 수 있다.

Retrofit의 주요 특징

타입 안전성: 컴파일 타임에 API 호출의 정확성을 검증할 수 있다.

자동 직렬화/역직렬화: JSON 응답을 Kotlin 객체로, 그리고 그 반대로 자동 변환한다.

어노테이션 기반: 간단한 어노테이션으로 API 엔드포인트와 HTTP 메서드를 정의할 수 있다.

코루틴 지원: Kotlin 코루틴과 완벽하게 통합되어 비동기 처리가 용이하다.

Retrofit 설정과 사용법

1단계: 의존성 추가

dependencies {
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}

2단계: 데이터 클래스 정의

// API 응답을 받을 데이터 클래스
data class User(
    val id: Int,
    val name: String,
    val email: String,
    val phone: String
)

// 사용자 목록을 위한 래퍼 클래스 (필요한 경우)
data class UserResponse(
    val users: List<User>,
    val total: Int,
    val page: Int
)

3단계: API 인터페이스 정의

import retrofit2.http.*

interface ApiService {
    // 모든 사용자 조회
    @GET("users")
    suspend fun getAllUsers(): List<User>
    
    // 특정 사용자 조회
    @GET("users/{id}")
    suspend fun getUserById(@Path("id") userId: Int): User
    
    // 사용자 생성
    @POST("users")
    suspend fun createUser(@Body user: User): User
    
    // 사용자 정보 수정
    @PUT("users/{id}")
    suspend fun updateUser(@Path("id") userId: Int, @Body user: User): User
    
    // 사용자 삭제
    @DELETE("users/{id}")
    suspend fun deleteUser(@Path("id") userId: Int): Unit
    
    // 쿼리 파라미터 사용 예제
    @GET("users")
    suspend fun getUsersByPage(
        @Query("page") page: Int,
        @Query("limit") limit: Int
    ): UserResponse
}

4단계: Retrofit 인스턴스 생성

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitClient {
    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
    
    private val retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    
    val apiService: ApiService by lazy {
        retrofit.create(ApiService::class.java)
    }
}

5단계: 실제 API 호출

class UserRepository {
    private val apiService = RetrofitClient.apiService
    
    suspend fun getUsers(): Result<List<User>> {
        return try {
            val users = apiService.getAllUsers()
            Result.success(users)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun getUserById(id: Int): Result<User> {
        return try {
            val user = apiService.getUserById(id)
            Result.success(user)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

5. Kotlin 코루틴 - 비동기 프로그래밍의 혁신

코루틴의 핵심 개념

코루틴(Coroutines)은 Kotlin에서 제공하는 비동기 프로그래밍 도구로, 복잡한 스레드 관리 없이도 비동기 작업을 순차적인 코드처럼 작성할 수 있게 해준다.

코루틴의 주요 특징

경량성: 수천 개의 코루틴을 생성해도 성능에 큰 영향을 주지 않는다.

구조화된 동시성: 코루틴의 생명주기를 체계적으로 관리할 수 있다.

취소 지원: 진행 중인 작업을 안전하게 취소할 수 있다.

suspend 함수

suspend 키워드는 함수가 일시 중단될 수 있음을 나타낸다. 이러한 함수는 다른 suspend 함수나 코루틴 스코프 내에서만 호출할 수 있다.

// suspend 함수의 기본 형태
suspend fun fetchUserData(userId: Int): User {
    // 네트워크 호출 시뮬레이션
    delay(1000) // 1초 대기 (논블로킹)
    return User(userId, "사용자$userId", "user$userId@example.com", "010-1234-5678")
}

// 여러 suspend 함수를 조합하는 예제
suspend fun loadUserProfile(userId: Int): UserProfile {
    val user = fetchUserData(userId)
    val posts = fetchUserPosts(userId)
    val followers = fetchUserFollowers(userId)
    
    return UserProfile(user, posts, followers)
}

코루틴 빌더

launch: 결과를 반환하지 않는 코루틴을 시작한다. "fire and forget" 방식의 작업에 적합하다.

viewModelScope.launch {
    try {
        val users = userRepository.getUsers()
        _uiState.value = UiState.Success(users)
    } catch (e: Exception) {
        _uiState.value = UiState.Error(e.message ?: "알 수 없는 오류")
    }
}

async: 결과를 반환하는 코루틴을 시작하며, Deferred 객체를 반환한다. 병렬 처리에 유용하다.

suspend fun loadMultipleData(): CombinedData {
    val userDeferred = async { fetchUserData(1) }
    val postsDeferred = async { fetchUserPosts(1) }
    val commentsDeferred = async { fetchUserComments(1) }
    
    // 모든 작업이 완료될 때까지 대기
    val user = userDeferred.await()
    val posts = postsDeferred.await()
    val comments = commentsDeferred.await()
    
    return CombinedData(user, posts, comments)
}

코루틴 스코프

GlobalScope: 애플리케이션 전체 생명주기와 연결된 스코프이다. 일반적으로 사용을 피해야 한다.

viewModelScope: Android ViewModel과 연결된 스코프로, ViewModel이 소멸되면 자동으로 취소된다.

lifecycleScope: Android 생명주기 컴포넌트와 연결된 스코프다.

class UserViewModel : ViewModel() {
    private val userRepository = UserRepository()
    private val _users = MutableLiveData<List<User>>()
    val users: LiveData<List<User>> = _users
    
    fun loadUsers() {
        viewModelScope.launch {
            try {
                val result = userRepository.getUsers()
                result.onSuccess { userList ->
                    _users.value = userList
                }.onFailure { error ->
                    // 에러 처리
                    Log.e("UserViewModel", "사용자 로딩 실패", error)
                }
            } catch (e: Exception) {
                Log.e("UserViewModel", "예상치 못한 오류", e)
            }
        }
    }
}

6. 예외 처리 - try, catch, finally

예외 처리의 중요성

네트워크 통신이나 파일 접근 등의 작업에서는 예상치 못한 오류가 발생할 수 있다. 이러한 상황을 적절히 처리하지 않으면 앱이 충돌할 수 있기 때문에 예외 처리는 안정적인 앱 개발의 핵심이라고 할 수 있다.

try-catch-finally 구조

// 기본적인 예외 처리 구조
suspend fun safeApiCall(): Result<User> {
    return try {
        val user = apiService.getUser()
        Result.success(user)
    } catch (e: IOException) {
        // 네트워크 연결 오류
        Log.e("ApiCall", "네트워크 오류: ${e.message}")
        Result.failure(e)
    } catch (e: HttpException) {
        // HTTP 오류 (404, 500 등)
        Log.e("ApiCall", "HTTP 오류: ${e.code()}")
        Result.failure(e)
    } catch (e: Exception) {
        // 기타 예상치 못한 오류
        Log.e("ApiCall", "알 수 없는 오류: ${e.message}")
        Result.failure(e)
    } finally {
        // 성공/실패 여부와 관계없이 실행되는 코드
        Log.d("ApiCall", "API 호출 완료")
    }
}

예외 처리 패턴

class NetworkManager {
    suspend fun <T> safeApiCall(
        apiCall: suspend () -> T
    ): NetworkResult<T> {
        return try {
            val result = apiCall()
            NetworkResult.Success(result)
        } catch (e: IOException) {
            NetworkResult.Error("인터넷 연결을 확인해주세요")
        } catch (e: HttpException) {
            when (e.code()) {
                400 -> NetworkResult.Error("잘못된 요청입니다")
                401 -> NetworkResult.Error("인증이 필요합니다")
                404 -> NetworkResult.Error("요청하신 정보를 찾을 수 없습니다")
                500 -> NetworkResult.Error("서버에 문제가 발생했습니다")
                else -> NetworkResult.Error("오류가 발생했습니다 (${e.code()})")
            }
        } catch (e: Exception) {
            NetworkResult.Error("알 수 없는 오류가 발생했습니다")
        }
    }
}

sealed class NetworkResult<T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error<T>(val message: String) : NetworkResult<T>()
}

7. Jetpack Compose UI 컴포넌트

CircularProgressIndicator

CircularProgressIndicator는 로딩 상태를 시각적으로 표현하는 컴포넌트다.

@Composable
fun LoadingScreen(
    isLoading: Boolean,
    modifier: Modifier = Modifier
) {
    if (isLoading) {
        Box(
            modifier = modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                CircularProgressIndicator(
                    modifier = Modifier.size(50.dp),
                    strokeWidth = 4.dp
                )
                Spacer(modifier = Modifier.height(16.dp))
                Text(
                    text = "데이터를 불러오는 중...",
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

LazyVerticalGrid

LazyVerticalGrid는 항목들을 격자 형태로 효율적으로 표시하는 컴포넌트다.

@Composable
fun UserGrid(
    users: List<User>,
    onUserClick: (User) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 160.dp), // 적응형 컬럼
        contentPadding = PaddingValues(16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = modifier
    ) {
        items(users) { user ->
            UserCard(
                user = user,
                onClick = { onUserClick(user) }
            )
        }
    }
}

@Composable
fun UserCard(
    user: User,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .clickable { onClick() },
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = user.name,
                style = MaterialTheme.typography.titleMedium,
                textAlign = TextAlign.Center
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = user.email,
                style = MaterialTheme.typography.bodySmall,
                textAlign = TextAlign.Center,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

8. 원격 이미지 표시 with Coil

Coil 라이브러리 설정

Coil은 Kotlin으로 작성된 Android용 이미지 로딩 라이브러리다.

// build.gradle (Module: app)
dependencies {
    implementation 'io.coil-kt:coil-compose:2.5.0'
}

AsyncImage 사용법

import coil.compose.AsyncImage
import coil.request.ImageRequest

@Composable
fun ProfileImage(
    imageUrl: String,
    userName: String,
    modifier: Modifier = Modifier
) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .crossfade(true) // 부드러운 전환 효과
            .build(),
        placeholder = painterResource(R.drawable.placeholder_user), // 로딩 중 표시할 이미지
        error = painterResource(R.drawable.error_image), // 오류 시 표시할 이미지
        contentDescription = "$userName 프로필 사진",
        contentScale = ContentScale.Crop,
        modifier = modifier
            .size(80.dp)
            .clip(CircleShape) // 원형으로 자르기
    )
}

고급 이미지 로딩 처리

@Composable
fun AdvancedAsyncImage(
    imageUrl: String,
    contentDescription: String,
    modifier: Modifier = Modifier
) {
    var isLoading by remember { mutableStateOf(true) }
    var isError by remember { mutableStateOf(false) }
    
    Box(modifier = modifier) {
        AsyncImage(
            model = ImageRequest.Builder(LocalContext.current)
                .data(imageUrl)
                .listener(
                    onStart = { isLoading = true },
                    onSuccess = { _, _ -> 
                        isLoading = false
                        isError = false
                    },
                    onError = { _, _ -> 
                        isLoading = false
                        isError = true
                    }
                )
                .build(),
            contentDescription = contentDescription,
            modifier = Modifier.fillMaxSize()
        )
        
        // 로딩 인디케이터
        if (isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.align(Alignment.Center)
            )
        }
        
        // 에러 상태 표시
        if (isError) {
            Column(
                modifier = Modifier.align(Alignment.Center),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Icon(
                    imageVector = Icons.Default.Error,
                    contentDescription = "오류",
                    tint = MaterialTheme.colorScheme.error
                )
                Text(
                    text = "이미지를 불러올 수 없습니다",
                    style = MaterialTheme.typography.bodySmall
                )
            }
        }
    }
}

9. 인터넷 권한 설정

AndroidManifest.xml 설정

인터넷을 사용하는 앱을 개발할 때는 반드시 인터넷 권한을 선언해야 한다.

<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapp">

    <!-- 인터넷 권한 추가 -->
    <uses-permission android:name="android.permission.INTERNET" />
    
    <!-- 네트워크 상태 확인 권한 (선택사항) -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.MyApp"
        android:usesCleartextTraffic="true"> <!-- HTTP 통신 허용 -->
        
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

네트워크 보안 구성

Android 9 (API 레벨 28) 이상에서는 HTTPS가 기본값이므로, HTTP 통신이 필요한 경우 추가 설정이 필요하다.

<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">api.example.com</domain>
    </domain-config>
</network-security-config>

10. 실제 프로젝트 구현 예제

완전한 사용자 목록 앱 구현

지금까지 학습한 내용을 종합하여 실제 동작하는 앱을 만들어보자.

1단계: 프로젝트 구조 설계

app/
├── src/main/java/com/example/userapp/
│   ├── data/
│   │   ├── model/
│   │   │   └── User.kt
│   │   ├── remote/
│   │   │   └── ApiService.kt
│   │   └── repository/
│   │       └── UserRepository.kt
│   ├── ui/
│   │   ├── components/
│   │   │   ├── UserCard.kt
│   │   │   └── LoadingScreen.kt
│   │   ├── screen/
│   │   │   └── UserListScreen.kt
│   │   └── viewmodel/
│   │       └── UserViewModel.kt
│   └── MainActivity.kt

2단계: 데이터 모델 정의

// data/model/User.kt
data class User(
    val id: Int,
    val name: String,
    val username: String,
    val email: String,
    val phone: String,
    val website: String,
    val address: Address
)

data class Address(
    val street: String,
    val suite: String,
    val city: String,
    val zipcode: String,
    val geo: Geo
)

data class Geo(
    val lat: String,
    val lng: String
)

3단계: API 서비스 구현

// data/remote/ApiService.kt
import retrofit2.http.GET
import retrofit2.http.Path

interface ApiService {
    @GET("users")
    suspend fun getUsers(): List
    
    @GET("users/{id}")
    suspend fun getUserById(@Path("id") id: Int): User
    
    companion object {
        const val BASE_URL = "https://jsonplaceholder.typicode.com/"
    }
}

4단계: 리포지토리 구현

// data/repository/UserRepository.kt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class UserRepository {
    private val apiService: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(ApiService.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
    
    suspend fun getUsers(): Result<List<User>> {
        return withContext(Dispatchers.IO) {
            try {
                val users = apiService.getUsers()
                Result.success(users)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
    
    suspend fun getUserById(id: Int): Result<User> {
        return withContext(Dispatchers.IO) {
            try {
                val user = apiService.getUserById(id)
                Result.success(user)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
}

5단계: ViewModel 구현

// ui/viewmodel/UserViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class UserViewModel : ViewModel() {
    private val repository = UserRepository()
    
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    init {
        loadUsers()
    }
    
    fun loadUsers() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, error = null)
            
            repository.getUsers()
                .onSuccess { users ->
                    _uiState.value = _uiState.value.copy(
                        users = users,
                        isLoading = false
                    )
                }
                .onFailure { error ->
                    _uiState.value = _uiState.value.copy(
                        error = error.message ?: "알 수 없는 오류가 발생했습니다",
                        isLoading = false
                    )
                }
        }
    }
    
    fun refreshUsers() {
        loadUsers()
    }
}

data class UserUiState(
    val users: List<User> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

6단계: UI 컴포넌트 구현

// ui/components/UserCard.kt
@Composable
fun UserCard(
    user: User,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .clickable { onClick() },
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.Top
            ) {
                Column(modifier = Modifier.weight(1f)) {
                    Text(
                        text = user.name,
                        style = MaterialTheme.typography.headlineSmall,
                        fontWeight = FontWeight.Bold
                    )
                    Text(
                        text = "@${user.username}",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.primary
                    )
                }
                
                Icon(
                    imageVector = Icons.AutoMirrored.Filled.ArrowForward,
                    contentDescription = "더보기",
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Default.Email,
                    contentDescription = "이메일",
                    modifier = Modifier.size(16.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    text = user.email,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            
            Spacer(modifier = Modifier.height(4.dp))
            
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Default.Phone,
                    contentDescription = "전화번호",
                    modifier = Modifier.size(16.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text(
                    text = user.phone,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}
// ui/components/LoadingScreen.kt
@Composable
fun LoadingScreen(
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            CircularProgressIndicator(
                modifier = Modifier.size(50.dp),
                strokeWidth = 4.dp
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = "사용자 정보를 불러오는 중...",
                style = MaterialTheme.typography.bodyLarge
            )
        }
    }
}

@Composable
fun ErrorScreen(
    error: String,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Icon(
                imageVector = Icons.Default.Error,
                contentDescription = "오류",
                modifier = Modifier.size(64.dp),
                tint = MaterialTheme.colorScheme.error
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = error,
                style = MaterialTheme.typography.bodyLarge,
                textAlign = TextAlign.Center
            )
            Spacer(modifier = Modifier.height(16.dp))
            Button(onClick = onRetry) {
                Text("다시 시도")
            }
        }
    }
}

7단계: 메인 화면 구현

// ui/screen/UserListScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserListScreen(
    viewModel: UserViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("사용자 목록") },
                actions = {
                    IconButton(
                        onClick = { viewModel.refreshUsers() }
                    ) {
                        Icon(
                            imageVector = Icons.Default.Refresh,
                            contentDescription = "새로고침"
                        )
                    }
                }
            )
        }
    ) { paddingValues ->
        when {
            uiState.isLoading -> {
                LoadingScreen(
                    modifier = Modifier.padding(paddingValues)
                )
            }
            
            uiState.error != null -> {
                ErrorScreen(
                    error = uiState.error,
                    onRetry = { viewModel.refreshUsers() },
                    modifier = Modifier.padding(paddingValues)
                )
            }
            
            else -> {
                LazyColumn(
                    modifier = Modifier.padding(paddingValues),
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    items(uiState.users) { user ->
                        UserCard(
                            user = user,
                            onClick = {
                                // 사용자 상세 화면으로 이동
                                // 예: navController.navigate("user_detail/${user.id}")
                            }
                        )
                    }
                }
            }
        }
    }
}

8단계: MainActivity 구현

// MainActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.userapp.ui.screen.UserListScreen
import com.example.userapp.ui.theme.UserAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            UserAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    UserListScreen()
                }
            }
        }
    }
}

11. 성능 최적화 및 베스트 프랙티스

메모리 관리

// 이미지 캐싱 최적화
@Composable
fun OptimizedAsyncImage(
    imageUrl: String,
    contentDescription: String,
    modifier: Modifier = Modifier
) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .memoryCacheKey(imageUrl) // 메모리 캐시 키 설정
            .diskCacheKey(imageUrl)   // 디스크 캐시 키 설정
            .size(Size.ORIGINAL)      // 원본 크기 유지
            .crossfade(true)
            .build(),
        contentDescription = contentDescription,
        modifier = modifier
    )
}

네트워크 요청 최적화

// 요청 타임아웃 설정
object RetrofitClient {
    private val okHttpClient = OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = if (BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY
            } else {
                HttpLoggingInterceptor.Level.NONE
            }
        })
        .build()
    
    private val retrofit = Retrofit.Builder()
        .baseUrl(ApiService.BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    
    val apiService: ApiService = retrofit.create(ApiService::class.java)
}

상태 관리 최적화

// remember를 활용한 상태 최적화
@Composable
fun OptimizedUserList(users: List<User>) {
    // 비싼 계산을 remember로 캐싱
    val sortedUsers = remember(users) {
        users.sortedBy { it.name }
    }
    
    LazyColumn {
        items(
            items = sortedUsers,
            key = { user -> user.id } // 키를 지정하여 재구성 최적화
        ) { user ->
            UserCard(user = user, onClick = {})
        }
    }
}

12. 디버깅 및 테스팅

로깅 시스템

// 구조화된 로깅
object Logger {
    private const val TAG = "UserApp"
    
    fun d(message: String, tag: String = TAG) {
        if (BuildConfig.DEBUG) {
            Log.d(tag, message)
        }
    }
    
    fun e(message: String, throwable: Throwable? = null, tag: String = TAG) {
        Log.e(tag, message, throwable)
    }
    
    fun networkRequest(url: String, method: String) {
        d("Network Request: $method $url", "Network")
    }
    
    fun networkResponse(url: String, code: Int, time: Long) {
        d("Network Response: $url - $code (${time}ms)", "Network")
    }
}

단위 테스트

// UserRepositoryTest.kt
@Test
fun `getUsers should return success when api call succeeds`() = runTest {
    // Given
    val mockUsers = listOf(
        User(1, "John", "john", "john@example.com", "123", "john.com", 
             Address("", "", "", "", Geo("", "")))
    )
    coEvery { apiService.getUsers() } returns mockUsers
    
    // When
    val result = repository.getUsers()
    
    // Then
    assertTrue(result.isSuccess)
    assertEquals(mockUsers, result.getOrNull())
}

 

 

주요 학습 내용

  • JSON과 API의 이해: 데이터 교환 형식과 서버 통신의 기초
  • Retrofit 활용: 타입 안전한 HTTP 클라이언트로 효율적인 네트워크 통신
  • Kotlin 코루틴: 비동기 프로그래밍의 혁신적 접근 방법
  • Jetpack Compose UI: 선언적 UI 프레임워크의 실제 활용
  • 이미지 로딩: Coil을 사용한 원격 이미지 처리
  • 예외 처리: 안정적인 앱을 위한 오류 관리

추가적으로 배워야할 내용

  • 상태 관리 라이브러리 (Hilt, Room) 학습
  • 네비게이션 컴포넌트 활용
  • 고급 Compose 애니메이션
  • 앱 아키텍처 패턴 (MVVM, Clean Architecture)