Http 통신용 라이브러리
- 라이브러리 없이 http 통신을 구현할 경우 코드가 매우 길어지며 반복적인 코드를 작성해야 한다.
일반적으로 Java로 하면 InputStream을 열어서 while문을 돌면서 1바이트씩 처리해야한다..
- Retrofit2 : Square 사에서 개발. 기존 개발한 OkHttp 라이브러리를 기반으로 사용성을 보강했음.
- JVM 기반 Client 라이브러리. Java 기반의 모든 프로젝트에서 사용 가능.
- https://square.github.io/retrofit/
- Ktor: Jetbrain 에서 배포한 Kotlin 언어 전용 라이브러리.
- Client, Server 모두 구현 가능
- https://ktor.io/
Http 통신에서 주의 할점
- 서버와의 통신은 응답 시간을 예측할 수 없다
- 서버와의 통신은 비동기(Callback) 또는 Non-blocking 으로 수행하는 것이 좋다.
- Non-blocking 을 Background-Thread로 구현하는 경우
- UI 업데이트는 Main Thread에서만 가능하다.
- Callback은 메모리 누수(Memory Leak)를 주의해야 한다.
- 서버 응답 전에 사용자가 화면을 나갈 경우 UI를 참조하는 Callback 실행에서 문제가 발생할 수 있다.
- 그 외 App. Component의 lifecycle을 참조한 예외 처리가 필요한데 이를 개발자의 역량에만 기댈 수는 없다
Retrofit2 사용할거임
Blocking : 작업을 시작한 후 해당 작업이 종료될 때까지 아무런 작업을 수행하지 않음
Non-Blocking : 작업을 시작한 후 다른 작업이 종료될 때까지 기다리지 않고 다른 작업을 계속할 수 있는 것
추천 구조
- ViewModel + LiveData + Coroutine

통신을 메인 스레드에서 하게 되면 통신이 멈출 수도 있음
ViewModel을 사용하면 메인 스레드에서도 LiveData, 백그라운드 스레드에서도 LiveData 수정 가능
val data = MutableLiveData<Int>()
// Main Thread
data.value = 0
// Background Thread
data.postValue(0)
수정이 발생하면 옵저버에 해당 사실을 알려주어야 하는데 반드시 메인 스레드여야함
메인 스레드이면 그냥 value를 수정하면 되고 백그라운드 스레드에 있으면 post를 추가하여 value를 수정할 수 있다.
Coroutine이란?
- 실행 중 일시정지(suspend)/재개(resume) 될 수 있는 프로그램 요소로 보통 함수(subroutines)로 구현된다.
- 여러 Coroutine 함수들을 일시정지/재개 를 반복하면서 실행하면 여러 함수가 동시에 실행되는 것과 같은 효과가 있다.
- 여러 언어가 이러한 개념을 구현하여 제공한다.
- 코틀린 언어에서는 kotlinx.coroutines.* 로 제공한다.
fun test() {
for(i in 1..5)
print(i)
}
test()
함수를 실행(호출)하면 중간에 멈추지 않음 - 실행이 다되면 메모리에서 사라짐
Coroutine 사용 → 함수 중간에 일시정지가 가능
Main Thread - Background Thread
Main Thread ————————————————————————————>종료
Background Thread———————> 종료
중간에 겹치는 구간에는 Main과 Background의 스레드가 동시에 실행되는 것 처럼 보임, 하지만 컴퓨터의 세계에서 동시라는건 존재할 수 없음 CPU가 그냥 Main과 Background를 번갈아가며 하나하나씩 처리하는 것
Coroutine → 이러한 형태를 함수의 형태로 구현을 할 수 있음
Coroutine : 스레드를 만드는 효과가 있음,
Coroutine Scope
- Coroutine은 Coroutine Scope 안에서 실행할 수 있다.
- runBlocking: coroutine scope를 생성하며 해당 coroutine 이 완료될 때 까지 Thread를 blocking 한다. 특정 코드가 완료될 때 까지 해당 Thread는 다음 코드로 진행되지 않는다. -> UI Thread 에서는 크게 사용할 일이 없다.
- CoroutineScope(Dispatchers): 특정 실행 환경(Dispatcher)를 지정해 Coroutine Scope를 생성 (종류는 Platform 마다 약간 다를 수 있음)
- Dispatchers.Main: Main Thread
- Dispatchers.Default: CPU를 많이 사용할 때 사용(정렬, json parsing 등)
- Dispatchers.IO: Disk I/O 또는 네트워크 I/O 에 최적화된 실행 환경
- CoroutineScope.launch() : 지정된 Thread를 Blocking 하지 않는 Coroutine을 생성하며 그 정보를 Job 객체로 반환한다.
import kotlinx.coroutines.*
fun main() {
CoroutineScope(Dispatchers.IO).launch {
for(i in 1..5) {
println("$i")
delay(10L)
}
}
CoroutineScope(Dispatchers.IO).launch {
for(i in 'a'..'e') {
println("$i")
delay(10L)
}
}
}
실행 결과는 1~5 , a~e 까지 섞여서 출력이 될것임
- CoroutineScope.launch(): Job 내부에 또 다른 Job을 생성할 수 있으며 이 Job 들은 Blocking 되지 않고 실행된다.
import kotlinx.coroutines.*
fun main() {
launch(Dispatchers.IO) {
for(i in 1..5){
println("$i")
delay(10L)
}
}
launch(Dispatchers.IO) {
for(i in 'a'..'e'){
println(i)
delay(10L)
}
}
}
Suspend Function
- Coroutine Scope에서 실행할 수 있는 함수. Thread를 Block 하지 않는다.
- CoroutineScope.launch {} 의 코드 블록을 함수로 분리한다면 suspend fun 으로 정의해야 한다.
import kotlinx.coroutines.*
fun main() {
launch(Dispatchers.IO) { printInt() }
launch(Dispatchers.IO) { printChar() }
}
suspend fun printInt(){
for(i in 1..5){
println("$i")
delay(10L)
}
}
suspend fun printChar(){
for(i in 'a'..'e'){
println("$i")
delay(10L)
}
}
CoroutineScope 사용 절차
- CoroutineScope 생성
- launch 함수에게 람다식 전달
- launch 에서 짤 기능을 밖으로 뺄 경우 suspend fun
Android 에서의 Coroutine Scope
- Android 에서는 미리 만들어진 CoroutineScope를 몇 가지 제공함
- GlobalScope: 앱이 실행 중인 동안 유지되는 Coroutine scope. 사용에 주의한다.
- lifecycleScope:
- Activity의 Lifecycle 에 맞춰진 Coroutine scope.
- Activity의 onDestroy에 coroutine job 들이 함께 취소된다.
- 해당 스코프를 사용하는게 좋음 혹시 모를 메모리 누수 방지
- viewModelScope :
- 자신을 사용하는 Activity 또는 Fragment의 lifecycle에 연동된다.
- 해당 Activity에서 Coroutine을 실행하고, 화면을 나갔을 때 자동으로 Coroutine을 종료 시켜줌
실습

목표
- Mockup Server
- 서버로부터 사용자 목록을 받아 RecyclerView로 출력하고
- https://jsonplaceholder.typicode.com/users ← 10명의 사용자 정보를 주는 Fake Server
- 사용자를 한 명 선택할 경우 게시글 중 해당 사용자가 작성한 게시글 목록을 받아 RecyclerView로 출력한다.
- https://jsonplaceholder.typicode.com/posts?userId=1
- ViewModel, LiveData, Coroutine을 사용하여 프로젝트를 구현해본다.
- Retrofit2 를 사용해본다.
프로젝트
- 새 프로젝트 생성
- 이름: Members
- 패키지: com.example.members
- Empty Views Activity로 생성하고 View Binding 적용
의존성 추가
build.gradle.kts(Module:app) 다음 부분 추가 후 sync
android {
buildFeatures.viewBinding = true // 뷰 바인딩
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
// ViewModel & Activity Extension
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
implementation("androidx.activity:activity-ktx:1.8.2")
// coroutine
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation(libs.kotlinx.coroutines)
// Retrofit 2
// implementation("com.squareup.retrofit2:retrofit:2.9.0")
// implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation(libs.squareup.retrofit)
implementation(libs.squareup.retrofit.converter.gson)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
libs.versions.toml
[versions]
coroutine = "1.7.3"
retrofit = "2.9.0"
[libraries]
kotlinx-coroutines = {group="org.jetbrains.kotlinx", name="kotlinx-coroutines-android", version.ref="coroutine"}
squareup-retrofit = {group="com.squareup.retrofit2", name="retrofit", version.ref="retrofit"}
squareup-retrofit-converter-gson = {group="com.squareup.retrofit2", name="converter-gson", version.ref="retrofit"}
권한 추가
- AndroidMaifest.xml에 권한 추가
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <!-- 인터넷 권한 추가 -->
데이터 클래스 정의
- model 패키지 추가 후 클래스 추가
- User, Post
- 서버의 json 구조를 보고 작성한다.
- 모든 데이터를 다 사용할 필요는 없지만 사용할 데이터는 변수 이름 통일 추천
- User id는 게시글 검색에 사용되므로 필수 추가
- User.kt

package com.lunadev.members.model
data class Geo(
val lat:String,
val lng:String
)
data class Address (
val street:String,
val suite:String,
val city:String,
val zipcode:String,
val geo:Geo // Geo를 포함
)
data class Company (
val name:String,
val catchPhrase:String,
val bs:String
)
data class User (
val id:Int,
val name:String,
val username:String,
val email:String,
val phone:String,
val website:String,
val address:Address, // Address를 포함
val company:Company // Company를 포함
)
- Post.kt

package com.lunadev.members.model
data class Post (
val userId:Int,
val id:Int,
val title:String,
val body:String
)
UI 디자인
- res/layout/item_post.xml 파일
- CardView를 이용하여 작성
코드
<?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:layout_marginEnd="8dp" android:layout_marginBottom="4dp" app:cardCornerRadius="8dp"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"> <TextView android:id="@+id/textViewName" android:layout_width="0dp" android:layout_height="wrap_content" android:text="TextView" android:textAppearance="@style/TextAppearance.AppCompat.Large" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/textViewPhone" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewName" /> <TextView android:id="@+id/textViewEmail" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewPhone" /> <TextView android:id="@+id/textViewCompanyName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewEmail" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.cardview.widget.CardView>
- CardView를 이용하여 작성
- res/layout/activity_main.xml 파일
- 코드
- 코드
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewUsers"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Retrofit2
- JPA 등의 ORM과 사용 방법이 비슷하다.
- 각 API 는 interface 로 정의한다.
- Retrofit 객체를 생성(build)한 후 interface 로 정의된 API 기능을 create 하여 사용한다.
- Retrofit 객체는 주로 Singleton으로 생성한다.
- Request에 해당하는 Call 객체를 생성 후 Queue에 넣으면 차례로 전송된다.
- Response는 Callback으로 받을 수 있다.
JPA
- Entity - 실체를 가지는 Class
- Dao - interface
- Database - abstract class → instance를 자동으로 만들어서 사용
Retrofit
- Entity → model : Data class
- Dat → interface 정의
- Database - Retrofit 객체 작성(싱글톤)
차이점 JPA는 Database를 자동으로 만들어지지만 Retrofit은 빌드를 직접해야함
Retrofit2 - Interface 정의하기
- 프로젝트에 api 패키지 안에 작성함
- UserApi interface와 PostApi interface를 추가한다.
package com.lunadev.members.api
import com.lunadev.members.model.User
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
interface UserApi {
@GET("users") // @method(Path)
fun getUsers(): Call<List<User>>
@GET("users/{id}") // @method(Path/PathVariable)
fun getUser(@Path("id") id:Int): Call<User>
}
package com.lunadev.members.api
import com.lunadev.members.model.Post
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface PostApi {
@GET("posts")
fun getPosts(): Call<List<Post>>
@GET("posts/{id}")
fun getPost(@Path("id") id:Int): Call<Post>
@GET("posts")
fun getPostsByUserId(@Query("userId") userId:Int):Call<List<Post>>
}
Retrofit2 - Retrofit 객체 생성
- 하나의 객체만 만들어지도록(싱글톤) 다음과 같이 작성함
package com.lunadev.members.api
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class RetrofitClient {
companion object{
private val client = Retrofit.Builder()
.baseUrl("<https://jsonplaceholder.typicode.com/>")
.addConverterFactory(GsonConverterFactory.create())
.build()
val usersApi:UserApi = client.create(UserApi::class.java)
val postsApi:PostApi = client.create(PostApi::class.java)
}
}
MainActivity용 ViewModel 작성
- 목표 : 여러 화면에서 API를 사용해야 함
package com.lunadev.members
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lunadev.members.api.RetrofitClient
import com.lunadev.members.model.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class MainViewModel: ViewModel() { // ViewModel 상속
val users = MutableLiveData<List<User>>() // 사용자 목록
fun getUser() = viewModelScope.launch(Dispatchers.IO) {// 네트워크 IO
RetrofitClient.usersApi.getUsers().enqueue(object: Callback<List<User>> {
override fun onResponse(call: Call<List<User>>, response: Response<List<User>>) { // 응답 처리
if(response.isSuccessful && response.code() == 200) {
users.postValue(response.body()) // Main Thread가 아니라서 UI에 바로 전달이 안되기 때문에 postValue로 전달
}
}
override fun onFailure(call: Call<List<User>>, t: Throwable) { // 실패 처리
t.printStackTrace()
}
})
}
}
- 안드로이드에서 HTTP 요청은 viewModelScope 안에서 처리하는게 가장 낫다. 메인 스레드에서 HTTP 요청을 처리해도 되지만 응답이 오기 전까지 UI가 멈춰 사용자가 앱이 멈춘것 처럼 느끼고, 만약 응답이 아직 오지 않았는데 사용자가 해당 페이지를 나가서 Activity가 Destory 되었을 때 HTTP의 응답이 오면 이미 요청한 Activity가 사라졌기 때문에 NullPointerException가 떠 오류로 앱이 종료될 수 있다.
- 반면 viewModelScope를 사용하면 ViewModel이 소멸되면 자동으로 취소됨
MainActivity용 Adapter 작성
package com.lunadev.members.widget
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.lunadev.members.R
import com.lunadev.members.model.User
class UserAdapter: RecyclerView.Adapter<UserAdapter.UserViewHolder>() {
private var listener:OnItemSelected? = null
private var data:List<User> = listOf()
fun updateData(data:List<User>) {
this.data = data
notifyItemRangeChanged(0, data.size)
}
fun addListener(listener:OnItemSelected){
this.listener = listener
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(R.layout.item_user, parent, false)
return UserViewHolder(view)
}
override fun getItemCount(): Int = data.size
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(data[position])
}
fun interface OnItemSelected {
fun onItemSelected(user: User)
}
inner class UserViewHolder(v: View): RecyclerView.ViewHolder(v){
private val textViewName: TextView = v.findViewById(R.id.textViewName)
private val textViewEmail:TextView = v.findViewById(R.id.textViewEmail)
private val textViewPhone:TextView = v.findViewById(R.id.textViewPhone)
private val textViewCompanyName:TextView = v.findViewById(R.id.textViewCompanyName)
private lateinit var user:User
init {
v.setOnClickListener {
listener?.onItemSelected(user)
}
}
fun bind(user:User){
this.user = user
textViewName.text = user.name
textViewEmail.text = user.email
textViewPhone.text = user.phone
textViewCompanyName.text = user.company.name
}
}
}
MainActivity
- 서버 데이터 수신 및 출력 확인
package com.lunadev.members
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.lunadev.members.databinding.ActivityMainBinding
import com.lunadev.members.widget.UserAdapter
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val viewModel: MainViewModel by viewModels()
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root)
binding.recyclerViewUsers.layoutManager = LinearLayoutManager(this)
binding.recyclerViewUsers.adapter = adapter
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
viewModel.users.observe(this) {users -> // MainViewModel의 users LiveData를 관찰하고 있음
// 처음에 MainViewModel의 users가 null인데 누군가 getUsers() API를 호출하여 LiveData의 데이터가 변경되면
// RecyclerView의 어댑터에 데이터를 업데이트하도록 함
adapter.updateData(users)
Log.d("MainActivity", "users: $users.size")
}
viewModel.getUser() // MainViewModel의 getUsers() 호출
}
}
- 서버 데이터 수신 및 출력 확인
package com.lunadev.members
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.lunadev.members.databinding.ActivityMainBinding
import com.lunadev.members.widget.UserAdapter
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val viewModel: MainViewModel by viewModels()
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root)
binding.recyclerViewUsers.layoutManager = LinearLayoutManager(this)
binding.recyclerViewUsers.adapter = adapter
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
viewModel.users.observe(this) {users -> // MainViewModel의 users LiveData를 관찰하고 있음
// 처음에 MainViewModel의 users가 null인데 누군가 getUsers() API를 호출하여 LiveData의 데이터가 변경되면
// RecyclerView의 어댑터에 데이터를 업데이트하도록 함
adapter.updateData(users)
Log.d("MainActivity", "users: $users.size")
}
viewModel.getUser() // MainViewModel의 getUsers() 호출
}
}
Http 통신용 라이브러리
- 라이브러리 없이 http 통신을 구현할 경우 코드가 매우 길어지며 반복적인 코드를 작성해야 한다.
일반적으로 Java로 하면 InputStream을 열어서 while문을 돌면서 1바이트씩 처리해야한다..
- Retrofit2 : Square 사에서 개발. 기존 개발한 OkHttp 라이브러리를 기반으로 사용성을 보강했음.
- JVM 기반 Client 라이브러리. Java 기반의 모든 프로젝트에서 사용 가능.
- https://square.github.io/retrofit/
- Ktor: Jetbrain 에서 배포한 Kotlin 언어 전용 라이브러리.
- Client, Server 모두 구현 가능
- https://ktor.io/
Http 통신에서 주의 할점
- 서버와의 통신은 응답 시간을 예측할 수 없다
- 서버와의 통신은 비동기(Callback) 또는 Non-blocking 으로 수행하는 것이 좋다.
- Non-blocking 을 Background-Thread로 구현하는 경우
- UI 업데이트는 Main Thread에서만 가능하다.
- Callback은 메모리 누수(Memory Leak)를 주의해야 한다.
- 서버 응답 전에 사용자가 화면을 나갈 경우 UI를 참조하는 Callback 실행에서 문제가 발생할 수 있다.
- 그 외 App. Component의 lifecycle을 참조한 예외 처리가 필요한데 이를 개발자의 역량에만 기댈 수는 없다
Retrofit2 사용할거임
Blocking : 작업을 시작한 후 해당 작업이 종료될 때까지 아무런 작업을 수행하지 않음
Non-Blocking : 작업을 시작한 후 다른 작업이 종료될 때까지 기다리지 않고 다른 작업을 계속할 수 있는 것
추천 구조
- ViewModel + LiveData + Coroutine

통신을 메인 스레드에서 하게 되면 통신이 멈출 수도 있음
ViewModel을 사용하면 메인 스레드에서도 LiveData, 백그라운드 스레드에서도 LiveData 수정 가능
val data = MutableLiveData<Int>()
// Main Thread
data.value = 0
// Background Thread
data.postValue(0)
수정이 발생하면 옵저버에 해당 사실을 알려주어야 하는데 반드시 메인 스레드여야함
메인 스레드이면 그냥 value를 수정하면 되고 백그라운드 스레드에 있으면 post를 추가하여 value를 수정할 수 있다.
Coroutine이란?
- 실행 중 일시정지(suspend)/재개(resume) 될 수 있는 프로그램 요소로 보통 함수(subroutines)로 구현된다.
- 여러 Coroutine 함수들을 일시정지/재개 를 반복하면서 실행하면 여러 함수가 동시에 실행되는 것과 같은 효과가 있다.
- 여러 언어가 이러한 개념을 구현하여 제공한다.
- 코틀린 언어에서는 kotlinx.coroutines.* 로 제공한다.
fun test() {
for(i in 1..5)
print(i)
}
test()
함수를 실행(호출)하면 중간에 멈추지 않음 - 실행이 다되면 메모리에서 사라짐
Coroutine 사용 → 함수 중간에 일시정지가 가능
Main Thread - Background Thread
Main Thread ————————————————————————————>종료
Background Thread———————> 종료
중간에 겹치는 구간에는 Main과 Background의 스레드가 동시에 실행되는 것 처럼 보임, 하지만 컴퓨터의 세계에서 동시라는건 존재할 수 없음 CPU가 그냥 Main과 Background를 번갈아가며 하나하나씩 처리하는 것
Coroutine → 이러한 형태를 함수의 형태로 구현을 할 수 있음
Coroutine : 스레드를 만드는 효과가 있음,
Coroutine Scope
- Coroutine은 Coroutine Scope 안에서 실행할 수 있다.
- runBlocking: coroutine scope를 생성하며 해당 coroutine 이 완료될 때 까지 Thread를 blocking 한다. 특정 코드가 완료될 때 까지 해당 Thread는 다음 코드로 진행되지 않는다. -> UI Thread 에서는 크게 사용할 일이 없다.
- CoroutineScope(Dispatchers): 특정 실행 환경(Dispatcher)를 지정해 Coroutine Scope를 생성 (종류는 Platform 마다 약간 다를 수 있음)
- Dispatchers.Main: Main Thread
- Dispatchers.Default: CPU를 많이 사용할 때 사용(정렬, json parsing 등)
- Dispatchers.IO: Disk I/O 또는 네트워크 I/O 에 최적화된 실행 환경
- CoroutineScope.launch() : 지정된 Thread를 Blocking 하지 않는 Coroutine을 생성하며 그 정보를 Job 객체로 반환한다.
import kotlinx.coroutines.*
fun main() {
CoroutineScope(Dispatchers.IO).launch {
for(i in 1..5) {
println("$i")
delay(10L)
}
}
CoroutineScope(Dispatchers.IO).launch {
for(i in 'a'..'e') {
println("$i")
delay(10L)
}
}
}
실행 결과는 1~5 , a~e 까지 섞여서 출력이 될것임
- CoroutineScope.launch(): Job 내부에 또 다른 Job을 생성할 수 있으며 이 Job 들은 Blocking 되지 않고 실행된다.
import kotlinx.coroutines.*
fun main() {
launch(Dispatchers.IO) {
for(i in 1..5){
println("$i")
delay(10L)
}
}
launch(Dispatchers.IO) {
for(i in 'a'..'e'){
println(i)
delay(10L)
}
}
}
Suspend Function
- Coroutine Scope에서 실행할 수 있는 함수. Thread를 Block 하지 않는다.
- CoroutineScope.launch {} 의 코드 블록을 함수로 분리한다면 suspend fun 으로 정의해야 한다.
import kotlinx.coroutines.*
fun main() {
launch(Dispatchers.IO) { printInt() }
launch(Dispatchers.IO) { printChar() }
}
suspend fun printInt(){
for(i in 1..5){
println("$i")
delay(10L)
}
}
suspend fun printChar(){
for(i in 'a'..'e'){
println("$i")
delay(10L)
}
}
CoroutineScope 사용 절차
- CoroutineScope 생성
- launch 함수에게 람다식 전달
- launch 에서 짤 기능을 밖으로 뺄 경우 suspend fun
Android 에서의 Coroutine Scope
- Android 에서는 미리 만들어진 CoroutineScope를 몇 가지 제공함
- GlobalScope: 앱이 실행 중인 동안 유지되는 Coroutine scope. 사용에 주의한다.
- lifecycleScope:
- Activity의 Lifecycle 에 맞춰진 Coroutine scope.
- Activity의 onDestroy에 coroutine job 들이 함께 취소된다.
- 해당 스코프를 사용하는게 좋음 혹시 모를 메모리 누수 방지
- viewModelScope :
- 자신을 사용하는 Activity 또는 Fragment의 lifecycle에 연동된다.
- 해당 Activity에서 Coroutine을 실행하고, 화면을 나갔을 때 자동으로 Coroutine을 종료 시켜줌
실습

목표
- Mockup Server
- 서버로부터 사용자 목록을 받아 RecyclerView로 출력하고
- https://jsonplaceholder.typicode.com/users ← 10명의 사용자 정보를 주는 Fake Server
- 사용자를 한 명 선택할 경우 게시글 중 해당 사용자가 작성한 게시글 목록을 받아 RecyclerView로 출력한다.
- https://jsonplaceholder.typicode.com/posts?userId=1
- ViewModel, LiveData, Coroutine을 사용하여 프로젝트를 구현해본다.
- Retrofit2 를 사용해본다.
프로젝트
- 새 프로젝트 생성
- 이름: Members
- 패키지: com.example.members
- Empty Views Activity로 생성하고 View Binding 적용
의존성 추가
build.gradle.kts(Module:app) 다음 부분 추가 후 sync
android {
buildFeatures.viewBinding = true // 뷰 바인딩
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
// ViewModel & Activity Extension
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
implementation("androidx.activity:activity-ktx:1.8.2")
// coroutine
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation(libs.kotlinx.coroutines)
// Retrofit 2
// implementation("com.squareup.retrofit2:retrofit:2.9.0")
// implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation(libs.squareup.retrofit)
implementation(libs.squareup.retrofit.converter.gson)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
libs.versions.toml
[versions]
coroutine = "1.7.3"
retrofit = "2.9.0"
[libraries]
kotlinx-coroutines = {group="org.jetbrains.kotlinx", name="kotlinx-coroutines-android", version.ref="coroutine"}
squareup-retrofit = {group="com.squareup.retrofit2", name="retrofit", version.ref="retrofit"}
squareup-retrofit-converter-gson = {group="com.squareup.retrofit2", name="converter-gson", version.ref="retrofit"}
권한 추가
- AndroidMaifest.xml에 권한 추가
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <!-- 인터넷 권한 추가 -->
데이터 클래스 정의
- model 패키지 추가 후 클래스 추가
- User, Post
- 서버의 json 구조를 보고 작성한다.
- 모든 데이터를 다 사용할 필요는 없지만 사용할 데이터는 변수 이름 통일 추천
- User id는 게시글 검색에 사용되므로 필수 추가
- User.kt

package com.lunadev.members.model
data class Geo(
val lat:String,
val lng:String
)
data class Address (
val street:String,
val suite:String,
val city:String,
val zipcode:String,
val geo:Geo // Geo를 포함
)
data class Company (
val name:String,
val catchPhrase:String,
val bs:String
)
data class User (
val id:Int,
val name:String,
val username:String,
val email:String,
val phone:String,
val website:String,
val address:Address, // Address를 포함
val company:Company // Company를 포함
)
- Post.kt

package com.lunadev.members.model
data class Post (
val userId:Int,
val id:Int,
val title:String,
val body:String
)
UI 디자인
- res/layout/item_post.xml 파일
- CardView를 이용하여 작성
코드
<?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:layout_marginEnd="8dp" android:layout_marginBottom="4dp" app:cardCornerRadius="8dp"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"> <TextView android:id="@+id/textViewName" android:layout_width="0dp" android:layout_height="wrap_content" android:text="TextView" android:textAppearance="@style/TextAppearance.AppCompat.Large" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/textViewPhone" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewName" /> <TextView android:id="@+id/textViewEmail" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewPhone" /> <TextView android:id="@+id/textViewCompanyName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="TextView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewEmail" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.cardview.widget.CardView>
- CardView를 이용하여 작성
- res/layout/activity_main.xml 파일
- 코드
- 코드
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewUsers"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Retrofit2
- JPA 등의 ORM과 사용 방법이 비슷하다.
- 각 API 는 interface 로 정의한다.
- Retrofit 객체를 생성(build)한 후 interface 로 정의된 API 기능을 create 하여 사용한다.
- Retrofit 객체는 주로 Singleton으로 생성한다.
- Request에 해당하는 Call 객체를 생성 후 Queue에 넣으면 차례로 전송된다.
- Response는 Callback으로 받을 수 있다.
JPA
- Entity - 실체를 가지는 Class
- Dao - interface
- Database - abstract class → instance를 자동으로 만들어서 사용
Retrofit
- Entity → model : Data class
- Dat → interface 정의
- Database - Retrofit 객체 작성(싱글톤)
차이점 JPA는 Database를 자동으로 만들어지지만 Retrofit은 빌드를 직접해야함
Retrofit2 - Interface 정의하기
- 프로젝트에 api 패키지 안에 작성함
- UserApi interface와 PostApi interface를 추가한다.
package com.lunadev.members.api
import com.lunadev.members.model.User
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
interface UserApi {
@GET("users") // @method(Path)
fun getUsers(): Call<List<User>>
@GET("users/{id}") // @method(Path/PathVariable)
fun getUser(@Path("id") id:Int): Call<User>
}
package com.lunadev.members.api
import com.lunadev.members.model.Post
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface PostApi {
@GET("posts")
fun getPosts(): Call<List<Post>>
@GET("posts/{id}")
fun getPost(@Path("id") id:Int): Call<Post>
@GET("posts")
fun getPostsByUserId(@Query("userId") userId:Int):Call<List<Post>>
}
Retrofit2 - Retrofit 객체 생성
- 하나의 객체만 만들어지도록(싱글톤) 다음과 같이 작성함
package com.lunadev.members.api
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class RetrofitClient {
companion object{
private val client = Retrofit.Builder()
.baseUrl("<https://jsonplaceholder.typicode.com/>")
.addConverterFactory(GsonConverterFactory.create())
.build()
val usersApi:UserApi = client.create(UserApi::class.java)
val postsApi:PostApi = client.create(PostApi::class.java)
}
}
MainActivity용 ViewModel 작성
- 목표 : 여러 화면에서 API를 사용해야 함
package com.lunadev.members
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lunadev.members.api.RetrofitClient
import com.lunadev.members.model.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class MainViewModel: ViewModel() { // ViewModel 상속
val users = MutableLiveData<List<User>>() // 사용자 목록
fun getUser() = viewModelScope.launch(Dispatchers.IO) {// 네트워크 IO
RetrofitClient.usersApi.getUsers().enqueue(object: Callback<List<User>> {
override fun onResponse(call: Call<List<User>>, response: Response<List<User>>) { // 응답 처리
if(response.isSuccessful && response.code() == 200) {
users.postValue(response.body()) // Main Thread가 아니라서 UI에 바로 전달이 안되기 때문에 postValue로 전달
}
}
override fun onFailure(call: Call<List<User>>, t: Throwable) { // 실패 처리
t.printStackTrace()
}
})
}
}
- 안드로이드에서 HTTP 요청은 viewModelScope 안에서 처리하는게 가장 낫다. 메인 스레드에서 HTTP 요청을 처리해도 되지만 응답이 오기 전까지 UI가 멈춰 사용자가 앱이 멈춘것 처럼 느끼고, 만약 응답이 아직 오지 않았는데 사용자가 해당 페이지를 나가서 Activity가 Destory 되었을 때 HTTP의 응답이 오면 이미 요청한 Activity가 사라졌기 때문에 NullPointerException가 떠 오류로 앱이 종료될 수 있다.
- 반면 viewModelScope를 사용하면 ViewModel이 소멸되면 자동으로 취소됨
MainActivity용 Adapter 작성
package com.lunadev.members.widget
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.lunadev.members.R
import com.lunadev.members.model.User
class UserAdapter: RecyclerView.Adapter<UserAdapter.UserViewHolder>() {
private var listener:OnItemSelected? = null
private var data:List<User> = listOf()
fun updateData(data:List<User>) {
this.data = data
notifyItemRangeChanged(0, data.size)
}
fun addListener(listener:OnItemSelected){
this.listener = listener
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val view = LayoutInflater
.from(parent.context)
.inflate(R.layout.item_user, parent, false)
return UserViewHolder(view)
}
override fun getItemCount(): Int = data.size
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(data[position])
}
fun interface OnItemSelected {
fun onItemSelected(user: User)
}
inner class UserViewHolder(v: View): RecyclerView.ViewHolder(v){
private val textViewName: TextView = v.findViewById(R.id.textViewName)
private val textViewEmail:TextView = v.findViewById(R.id.textViewEmail)
private val textViewPhone:TextView = v.findViewById(R.id.textViewPhone)
private val textViewCompanyName:TextView = v.findViewById(R.id.textViewCompanyName)
private lateinit var user:User
init {
v.setOnClickListener {
listener?.onItemSelected(user)
}
}
fun bind(user:User){
this.user = user
textViewName.text = user.name
textViewEmail.text = user.email
textViewPhone.text = user.phone
textViewCompanyName.text = user.company.name
}
}
}
MainActivity
- 서버 데이터 수신 및 출력 확인
package com.lunadev.members
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.lunadev.members.databinding.ActivityMainBinding
import com.lunadev.members.widget.UserAdapter
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val viewModel: MainViewModel by viewModels()
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root)
binding.recyclerViewUsers.layoutManager = LinearLayoutManager(this)
binding.recyclerViewUsers.adapter = adapter
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
viewModel.users.observe(this) {users -> // MainViewModel의 users LiveData를 관찰하고 있음
// 처음에 MainViewModel의 users가 null인데 누군가 getUsers() API를 호출하여 LiveData의 데이터가 변경되면
// RecyclerView의 어댑터에 데이터를 업데이트하도록 함
adapter.updateData(users)
Log.d("MainActivity", "users: $users.size")
}
viewModel.getUser() // MainViewModel의 getUsers() 호출
}
}
- 서버 데이터 수신 및 출력 확인
package com.lunadev.members
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.lunadev.members.databinding.ActivityMainBinding
import com.lunadev.members.widget.UserAdapter
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val viewModel: MainViewModel by viewModels()
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root)
binding.recyclerViewUsers.layoutManager = LinearLayoutManager(this)
binding.recyclerViewUsers.adapter = adapter
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
viewModel.users.observe(this) {users -> // MainViewModel의 users LiveData를 관찰하고 있음
// 처음에 MainViewModel의 users가 null인데 누군가 getUsers() API를 호출하여 LiveData의 데이터가 변경되면
// RecyclerView의 어댑터에 데이터를 업데이트하도록 함
adapter.updateData(users)
Log.d("MainActivity", "users: $users.size")
}
viewModel.getUser() // MainViewModel의 getUsers() 호출
}
}