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)
'어플리케이션, 앱 (Application) > 안드로이드 (Android)' 카테고리의 다른 글
안드로이드 jetpack compose 공부 정리 11강 (위치 서비스) (0) | 2025.06.28 |
---|---|
안드로이드 jetpack compose 공부 정리 10강 (화면 간 탐색) (0) | 2025.06.28 |
안드로이드 jetpack compose 공부 정리 8강 (MVVM) (0) | 2025.06.28 |
안드로이드 jetpack compose 공부 정리 7강 (UI 기능 정리) (0) | 2025.06.28 |
안드로이드 jetpack compose 공부 정리 6강 (상태(State) 이해) (0) | 2025.06.28 |