์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ๋„คํŠธ์›Œํ‚น์˜ ํ•ต์‹ฌ! HTTP ํด๋ผ์ด์–ธํŠธ Retrofit๊ณผ Alamofire ์™„๋ฒฝ ํ™œ์šฉ ๊ฐ€์ด๋“œ

๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ๋„คํŠธ์›Œํ‚น์˜ ํ•ต์‹ฌ! HTTP ํด๋ผ์ด์–ธํŠธ Retrofit๊ณผ Alamofire ์™„๋ฒฝ ํ™œ์šฉ ๊ฐ€์ด๋“œ

์•ˆ๋“œ๋กœ์ด๋“œ์™€ iOS ๊ฐœ๋ฐœ์ž๋ฅผ ์œ„ํ•œ ์‹ค์ „ ๋„คํŠธ์›Œํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋งˆ์Šคํ„ฐํ•˜๊ธฐ

๐Ÿš€ ์™œ ๋ชจ๋ฐ”์ผ ๋„คํŠธ์›Œํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ• ๊นŒ?

์•ˆ๋…•! ์˜ค๋Š˜์€ ๋ชจ๋ฐ”์ผ ์•ฑ ๊ฐœ๋ฐœ์—์„œ ์ ˆ๋Œ€ ๋นผ๋†“์„ ์ˆ˜ ์—†๋Š” ๋„คํŠธ์›Œํ‚น์— ๋Œ€ํ•ด ์ด์•ผ๊ธฐํ•ด๋ณผ๊ฒŒ. ์š”์ฆ˜ ์•ฑ๋“ค ๋ณด๋ฉด ๊ฑฐ์˜ ๋Œ€๋ถ€๋ถ„์ด ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•˜์ž–์•„? ๋‚ ์”จ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๊ฑฐ๋‚˜, ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ฑฐ๋‚˜, ์†Œ์…œ ๋ฏธ๋””์–ด ํ”ผ๋“œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋“ฑ ๋ง์ด์•ผ. ๐ŸŒ

๊ทผ๋ฐ ๋ง์ด์ง€, ์ฒ˜์Œ๋ถ€ํ„ฐ ๋„คํŠธ์›Œํ‚น ์ฝ”๋“œ๋ฅผ ์ง์ ‘ ์งœ๋ ค๊ณ  ํ•˜๋ฉด... ์ •๋ง ๋จธ๋ฆฌ๊ฐ€ ์•„ํŒŒ์ง„๋‹ค๊ณ ! HTTP ์š”์ฒญ ๋งŒ๋“ค๊ณ , ์‘๋‹ต ํŒŒ์‹ฑํ•˜๊ณ , ์—๋Ÿฌ ํ•ธ๋“ค๋งํ•˜๊ณ , ์Šค๋ ˆ๋“œ ๊ด€๋ฆฌํ•˜๊ณ ... ์ƒ๊ฐ๋งŒ ํ•ด๋„ ๋ณต์žกํ•˜์ง€? ๐Ÿ˜ต

๊ทธ๋ž˜์„œ ๋“ฑ์žฅํ•œ ๊ฒŒ ๋ฐ”๋กœ **Retrofit**(์•ˆ๋“œ๋กœ์ด๋“œ)๊ณผ **Alamofire**(iOS)์•ผ. ์ด ์นœ๊ตฌ๋“ค์€ ๋ณต์žกํ•œ ๋„คํŠธ์›Œํ‚น ์ž‘์—…์„ ์—„์ฒญ๋‚˜๊ฒŒ ๊ฐ„๋‹จํ•˜๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ๋Š” ๋งˆ๋ฒ• ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋“ค์ด์ง€. ๋งˆ์น˜ ์žฌ๋Šฅ๋„ท์—์„œ ํ•„์š”ํ•œ ์žฌ๋Šฅ์„ ์‰ฝ๊ฒŒ ์ฐพ์„ ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ์ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋“ค๋„ ๋„คํŠธ์›Œํ‚น์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ค€๋‹ค๊ณ  ๋ณด๋ฉด ๋ผ!

๐Ÿ’ก ํ•ต์‹ฌ ํฌ์ธํŠธ โ€ข Retrofit: ์•ˆ๋“œ๋กœ์ด๋“œ๋ฅผ ์œ„ํ•œ ํƒ€์ž… ์„ธ์ดํ”„ํ•œ HTTP ํด๋ผ์ด์–ธํŠธ
โ€ข Alamofire: iOS/macOS๋ฅผ ์œ„ํ•œ ์šฐ์•„ํ•œ HTTP ๋„คํŠธ์›Œํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
โ€ข ๋‘ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ชจ๋‘ ๋ณต์žกํ•œ ๋„คํŠธ์›Œํ‚น ์ž‘์—…์„ ๋‹จ์ˆœํ™”ํ•˜๊ณ  ์ƒ์‚ฐ์„ฑ์„ ๊ทน๋Œ€ํ™”ํ•จ
Android Retrofit iOS Alamofire HTTP Networking REST API

๐Ÿค– Retrofit: ์•ˆ๋“œ๋กœ์ด๋“œ์˜ ๋„คํŠธ์›Œํ‚น ๊ฐ•์ž

๐Ÿ“Œ Retrofit์ด ๋ญ๊ธธ๋ž˜?

Retrofit์€ Square์‚ฌ์—์„œ ๋งŒ๋“  ์•ˆ๋“œ๋กœ์ด๋“œ์šฉ HTTP ํด๋ผ์ด์–ธํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. 2013๋…„์— ์ฒ˜์Œ ๋‚˜์™”๋Š”๋ฐ, ์ง€๊ธˆ๊นŒ์ง€๋„ ์•ˆ๋“œ๋กœ์ด๋“œ ๊ฐœ๋ฐœ์ž๋“ค ์‚ฌ์ด์—์„œ ๊ฐ€์žฅ ์‚ฌ๋ž‘๋ฐ›๋Š” ๋„คํŠธ์›Œํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ค‘ ํ•˜๋‚˜์ง€. ๐Ÿ†

Retrofit์˜ ๊ฐ€์žฅ ํฐ ํŠน์ง•์€ ํƒ€์ž… ์„ธ์ดํ”„(Type-safe)ํ•˜๋‹ค๋Š” ๊ฑฐ์•ผ. ์‰ฝ๊ฒŒ ๋งํ•˜๋ฉด, ์ปดํŒŒ์ผ ํƒ€์ž„์— ์—๋Ÿฌ๋ฅผ ์žก์•„๋‚ผ ์ˆ˜ ์žˆ๋‹ค๋Š” ๋œป์ด์ง€. ๋Ÿฐํƒ€์ž„์— ํ„ฐ์ง€๋Š” ๊ฒƒ๋ณด๋‹ค ํ›จ์”ฌ ์•ˆ์ „ํ•˜์ž–์•„?

๐ŸŽฏ Retrofit์˜ ํ•ต์‹ฌ ์žฅ์  1. ์ธํ„ฐํŽ˜์ด์Šค ๊ธฐ๋ฐ˜ ์„ค๊ณ„: API ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ž๋ฐ”/์ฝ”ํ‹€๋ฆฐ ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์ •์˜
2. ์ž๋™ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”: JSON์„ ๊ฐ์ฒด๋กœ, ๊ฐ์ฒด๋ฅผ JSON์œผ๋กœ ์ž๋™ ๋ณ€ํ™˜
3. ๋‹ค์–‘ํ•œ ์ปจ๋ฒ„ํ„ฐ ์ง€์›: Gson, Moshi, Jackson ๋“ฑ
4. RxJava, Coroutines ์ง€์›: ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๊ฐ€ ๋งค์šฐ ํŽธ๋ฆฌ
5. ์ธํ„ฐ์…‰ํ„ฐ ์ง€์›: ๋กœ๊น…, ์ธ์ฆ ํ† ํฐ ์ถ”๊ฐ€ ๋“ฑ์ด ์‰ฌ์›€

๐Ÿ”ง Retrofit ์„ค์ •ํ•˜๊ธฐ

์ž, ์ด์ œ ์‹ค์ „์œผ๋กœ ๋“ค์–ด๊ฐ€๋ณผ๊นŒ? ๋จผ์ € Retrofit์„ ํ”„๋กœ์ ํŠธ์— ์ถ”๊ฐ€ํ•ด์•ผ๊ฒ ์ง€? Gradle ํŒŒ์ผ์— ๋‹ค์Œ ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•ด์ค˜:

// build.gradle (Module)
dependencies {
    // Retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    
    // Gson ์ปจ๋ฒ„ํ„ฐ (JSON ํŒŒ์‹ฑ์šฉ)
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    
    // OkHttp (Retrofit์ด ๋‚ด๋ถ€์ ์œผ๋กœ ์‚ฌ์šฉ)
    implementation 'com.squareup.okhttp3:okhttp:4.11.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0'
}

๊ทธ๋ฆฌ๊ณ  ๋‹น์—ฐํžˆ ์ธํ„ฐ๋„ท ๊ถŒํ•œ๋„ ํ•„์š”ํ•˜๊ฒ ์ง€? AndroidManifest.xml์— ์ถ”๊ฐ€ํ•ด์ค˜:

<uses-permission android:name="android.permission.INTERNET" />

๐Ÿ“ API ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ํ•˜๊ธฐ

Retrofit์˜ ํ•ต์‹ฌ์€ API๋ฅผ ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์ •์˜ํ•˜๋Š” ๊ฑฐ์•ผ. ์˜ˆ๋ฅผ ๋“ค์–ด, ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” API๊ฐ€ ์žˆ๋‹ค๊ณ  ํ•ด๋ณด์ž:

interface UserApiService {
    
    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: Int): Response<User>
    
    @GET("users")
    suspend fun getUserList(
        @Query("page") page: Int,
        @Query("per_page") perPage: Int
    ): Response<List<User>>
    
    @POST("users")
    suspend fun createUser(@Body user: User): Response<User>
    
    @PUT("users/{id}")
    suspend fun updateUser(
        @Path("id") userId: Int,
        @Body user: User
    ): Response<User>
    
    @DELETE("users/{id}")
    suspend fun deleteUser(@Path("id") userId: Int): Response<Unit>
}

๋ณด์ด์ง€? ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ HTTP ๋ฉ”์„œ๋“œ๋ฅผ ์ง€์ •ํ•˜๊ณ , ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๊ฒฝ๋กœ ๋ณ€์ˆ˜๋‚˜ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ์–ด. suspend ํ‚ค์›Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ฝ”ํ‹€๋ฆฐ ์ฝ”๋ฃจํ‹ด๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์ง€! ๐ŸŽ‰

๐Ÿ” ์ฃผ์š” ์–ด๋…ธํ…Œ์ด์…˜ ์„ค๋ช… @GET, @POST, @PUT, @DELETE: HTTP ๋ฉ”์„œ๋“œ ์ง€์ •
@Path: URL ๊ฒฝ๋กœ์— ๋ณ€์ˆ˜ ์‚ฝ์ž…
@Query: ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€
@Body: ์š”์ฒญ ๋ณธ๋ฌธ์— ๊ฐ์ฒด ์ „๋‹ฌ
@Header: HTTP ํ—ค๋” ์ถ”๊ฐ€
@FormUrlEncoded: Form ๋ฐ์ดํ„ฐ ์ „์†ก
@Multipart: ํŒŒ์ผ ์—…๋กœ๋“œ

๐Ÿ—๏ธ Retrofit ์ธ์Šคํ„ด์Šค ์ƒ์„ฑํ•˜๊ธฐ

์ด์ œ Retrofit ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•ด. ๋ณดํ†ต ์‹ฑ๊ธ€ํ†ค ํŒจํ„ด์œผ๋กœ ๋งŒ๋“ค์–ด์„œ ์•ฑ ์ „์ฒด์—์„œ ์žฌ์‚ฌ์šฉํ•˜์ง€:

object RetrofitClient {
    
    private const val BASE_URL = "https://api.example.com/"
    
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }
    
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .build()
    
    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    
    val userApiService: UserApiService = retrofit.create(UserApiService::class.java)
}

์—ฌ๊ธฐ์„œ ์ค‘์š”ํ•œ ํฌ์ธํŠธ๋“ค์„ ์งš์–ด๋ณผ๊ฒŒ:

1. HttpLoggingInterceptor: ๋„คํŠธ์›Œํฌ ์š”์ฒญ๊ณผ ์‘๋‹ต์„ ๋กœ๊ทธ๋กœ ์ฐ์–ด์ค˜. ๋””๋ฒ„๊น…ํ•  ๋•Œ ์—„์ฒญ ์œ ์šฉํ•˜์ง€! ๐Ÿ“Š

2. OkHttpClient: ํƒ€์ž„์•„์›ƒ ์„ค์ •, ์ธํ„ฐ์…‰ํ„ฐ ์ถ”๊ฐ€ ๋“ฑ ์„ธ๋ฐ€ํ•œ ์„ค์ •์ด ๊ฐ€๋Šฅํ•ด.

3. GsonConverterFactory: JSON์„ ์ž๋™์œผ๋กœ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ด์ค˜. Gson ๋ง๊ณ  Moshi๋‚˜ Jackson๋„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด.

๐Ÿ’ป ์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์ œ

์ž, ์ด์ œ ์‹ค์ œ๋กœ API๋ฅผ ํ˜ธ์ถœํ•ด๋ณด์ž! ViewModel์—์„œ ์ฝ”๋ฃจํ‹ด๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์ œ์•ผ:

class UserViewModel : ViewModel() {
    
    private val _userState = MutableLiveData<UiState<User>>()
    val userState: LiveData<UiState<User>> = _userState
    
    fun fetchUser(userId: Int) {
        viewModelScope.launch {
            _userState.value = UiState.Loading
            
            try {
                val response = RetrofitClient.userApiService.getUser(userId)
                
                if (response.isSuccessful) {
                    response.body()?.let { user ->
                        _userState.value = UiState.Success(user)
                    } ?: run {
                        _userState.value = UiState.Error("์‘๋‹ต ๋ณธ๋ฌธ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค")
                    }
                } else {
                    _userState.value = UiState.Error("์—๋Ÿฌ ์ฝ”๋“œ: ${response.code()}")
                }
            } catch (e: Exception) {
                _userState.value = UiState.Error(e.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜")
            }
        }
    }
    
    fun createUser(name: String, email: String) {
        viewModelScope.launch {
            try {
                val newUser = User(name = name, email = email)
                val response = RetrofitClient.userApiService.createUser(newUser)
                
                if (response.isSuccessful) {
                    // ์„ฑ๊ณต ์ฒ˜๋ฆฌ
                    Log.d("UserViewModel", "์‚ฌ์šฉ์ž ์ƒ์„ฑ ์„ฑ๊ณต: ${response.body()}")
                }
            } catch (e: Exception) {
                Log.e("UserViewModel", "์‚ฌ์šฉ์ž ์ƒ์„ฑ ์‹คํŒจ", e)
            }
        }
    }
}

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

์ด ์ฝ”๋“œ์—์„œ ์ฃผ๋ชฉํ•  ์ ์€:

โ€ข viewModelScope.launch: ์ฝ”๋ฃจํ‹ด์„ ViewModel์˜ ์ƒ๋ช…์ฃผ๊ธฐ์— ๋งž์ถฐ ์‹คํ–‰
โ€ข response.isSuccessful: HTTP ์ƒํƒœ ์ฝ”๋“œ๊ฐ€ 200~299์ธ์ง€ ํ™•์ธ
โ€ข try-catch: ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
โ€ข Sealed Class: UI ์ƒํƒœ๋ฅผ ํƒ€์ž… ์„ธ์ดํ”„ํ•˜๊ฒŒ ๊ด€๋ฆฌ

๐ŸŽ Alamofire: iOS์˜ ์šฐ์•„ํ•œ ๋„คํŠธ์›Œํ‚น ์†”๋ฃจ์…˜

๐ŸŒŸ Alamofire ์†Œ๊ฐœ

์ด์ œ iOS ์ง„์˜์œผ๋กœ ๋„˜์–ด๊ฐ€๋ณผ๊นŒ? Alamofire๋Š” Swift๋กœ ์ž‘์„ฑ๋œ HTTP ๋„คํŠธ์›Œํ‚น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. ์ด๋ฆ„๋ถ€ํ„ฐ ๋ฉ‹์ง€์ง€ ์•Š์•„? ๐Ÿ”ฅ

Alamofire๋„ Retrofit์ฒ˜๋Ÿผ ๋ณต์žกํ•œ ๋„คํŠธ์›Œํ‚น ์ž‘์—…์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ๋Š”๋ฐ, Swift์˜ ๊ฐ•๋ ฅํ•œ ํƒ€์ž… ์‹œ์Šคํ…œ๊ณผ ํด๋กœ์ €๋ฅผ ํ™œ์šฉํ•ด์„œ ๋”์šฑ ์šฐ์•„ํ•œ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์–ด.

โœจ Alamofire์˜ ์ฃผ์š” ํŠน์ง• 1. ์ฒด์ด๋‹ ๊ฐ€๋Šฅํ•œ ์š”์ฒญ/์‘๋‹ต ๋ฉ”์„œ๋“œ
2. URL/JSON ํŒŒ๋ผ๋ฏธํ„ฐ ์ธ์ฝ”๋”ฉ
3. ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ
4. ์ธ์ฆ ์ฒ˜๋ฆฌ
5. ๋„คํŠธ์›Œํฌ ๋„๋‹ฌ์„ฑ ์ฒดํฌ
6. Combine ํ”„๋ ˆ์ž„์›Œํฌ ์ง€์›
7. ์ž๋™ ์žฌ์‹œ๋„ ๋ฐ ์ ์‘ํ˜• ์š”์ฒญ

๐Ÿ“ฆ Alamofire ์„ค์น˜ํ•˜๊ธฐ

Alamofire๋ฅผ ์„ค์น˜ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ์—ฌ๋Ÿฌ ๊ฐ€์ง€๊ฐ€ ์žˆ์–ด. ๊ฐ€์žฅ ๋งŽ์ด ์“ฐ๋Š” ๋ฐฉ๋ฒ•๋“ค์„ ์†Œ๊ฐœํ• ๊ฒŒ:

1. CocoaPods ์‚ฌ์šฉ:

# Podfile
platform :ios, '13.0'
use_frameworks!

target 'YourApp' do
  pod 'Alamofire', '~> 5.8'
end

2. Swift Package Manager ์‚ฌ์šฉ:

Xcode์—์„œ File โ†’ Add Packages... โ†’ ๋‹ค์Œ URL ์ž…๋ ฅ:
https://github.com/Alamofire/Alamofire.git

3. Carthage ์‚ฌ์šฉ:

github "Alamofire/Alamofire" ~> 5.8

๊ฐœ์ธ์ ์œผ๋กœ๋Š” Swift Package Manager๋ฅผ ์ถ”์ฒœํ•ด! Xcode์— ๋‚ด์žฅ๋˜์–ด ์žˆ์–ด์„œ ๋ณ„๋„ ์„ค์น˜๊ฐ€ ํ•„์š” ์—†๊ฑฐ๋“ . ๐Ÿ˜Š

๐Ÿš€ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

Alamofire์˜ ๊ฐ€์žฅ ๊ธฐ๋ณธ์ ์ธ ์‚ฌ์šฉ๋ฒ•๋ถ€ํ„ฐ ๋ณผ๊นŒ? GET ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์˜ˆ์ œ์•ผ:

import Alamofire

// ๊ฐ„๋‹จํ•œ GET ์š”์ฒญ
AF.request("https://api.example.com/users")
    .responseJSON { response in
        switch response.result {
        case .success(let value):
            print("์‘๋‹ต ์„ฑ๊ณต: \(value)")
        case .failure(let error):
            print("์—๋Ÿฌ ๋ฐœ์ƒ: \(error)")
        }
    }

// Decodable์„ ์‚ฌ์šฉํ•œ ์ž๋™ ํŒŒ์‹ฑ
struct User: Decodable {
    let id: Int
    let name: String
    let email: String
}

AF.request("https://api.example.com/users/1")
    .responseDecodable(of: User.self) { response in
        switch response.result {
        case .success(let user):
            print("์‚ฌ์šฉ์ž ์ด๋ฆ„: \(user.name)")
        case .failure(let error):
            print("ํŒŒ์‹ฑ ์—๋Ÿฌ: \(error)")
        }
    }

๋ณด์ด์ง€? responseDecodable์„ ์‚ฌ์šฉํ•˜๋ฉด JSON์„ ์ž๋™์œผ๋กœ Swift ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ด์ค˜! Codable ํ”„๋กœํ† ์ฝœ์˜ ๋งˆ๋ฒ•์ด์ง€. โœจ

๐Ÿ”ง ๋‹ค์–‘ํ•œ HTTP ๋ฉ”์„œ๋“œ ์‚ฌ์šฉํ•˜๊ธฐ

GET๋งŒ ์žˆ๋Š” ๊ฒŒ ์•„๋‹ˆ์ง€! POST, PUT, DELETE ๋“ฑ ๋‹ค์–‘ํ•œ ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด:

// POST ์š”์ฒญ
let parameters: [String: Any] = [
    "name": "ํ™๊ธธ๋™",
    "email": "hong@example.com",
    "age": 25
]

AF.request("https://api.example.com/users",
           method: .post,
           parameters: parameters,
           encoding: JSONEncoding.default)
    .responseDecodable(of: User.self) { response in
        // ์‘๋‹ต ์ฒ˜๋ฆฌ
    }

// PUT ์š”์ฒญ
AF.request("https://api.example.com/users/1",
           method: .put,
           parameters: parameters,
           encoding: JSONEncoding.default)
    .response { response in
        // ์‘๋‹ต ์ฒ˜๋ฆฌ
    }

// DELETE ์š”์ฒญ
AF.request("https://api.example.com/users/1",
           method: .delete)
    .response { response in
        if response.response?.statusCode == 204 {
            print("์‚ญ์ œ ์„ฑ๊ณต!")
        }
    }

๐ŸŽฏ ํ—ค๋”์™€ ์ธ์ฆ ์ฒ˜๋ฆฌ

API ํ˜ธ์ถœํ•  ๋•Œ ์ธ์ฆ ํ† ํฐ์ด๋‚˜ ์ปค์Šคํ…€ ํ—ค๋”๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•  ๋•Œ๊ฐ€ ๋งŽ์ง€? Alamofire์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ํ•ด:

// ํ—ค๋” ์ถ”๊ฐ€
let headers: HTTPHeaders = [
    "Authorization": "Bearer your_token_here",
    "Content-Type": "application/json",
    "Accept": "application/json"
]

AF.request("https://api.example.com/protected",
           headers: headers)
    .responseJSON { response in
        // ์‘๋‹ต ์ฒ˜๋ฆฌ
    }

// Interceptor๋ฅผ ์‚ฌ์šฉํ•œ ์ž๋™ ํ† ํฐ ์ถ”๊ฐ€
class AuthenticationInterceptor: RequestInterceptor {
    
    func adapt(_ urlRequest: URLRequest, 
               for session: Session, 
               completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var urlRequest = urlRequest
        
        // ํ† ํฐ ๊ฐ€์ ธ์˜ค๊ธฐ (์˜ˆ: Keychain์—์„œ)
        if let token = getAccessToken() {
            urlRequest.setValue("Bearer \(token)", 
                              forHTTPHeaderField: "Authorization")
        }
        
        completion(.success(urlRequest))
    }
    
    func retry(_ request: Request, 
               for session: Session, 
               dueTo error: Error, 
               completion: @escaping (RetryResult) -> Void) {
        
        // 401 ์—๋Ÿฌ๋ฉด ํ† ํฐ ๊ฐฑ์‹  ํ›„ ์žฌ์‹œ๋„
        if let response = request.task?.response as? HTTPURLResponse,
           response.statusCode == 401 {
            refreshToken { success in
                completion(success ? .retry : .doNotRetry)
            }
        } else {
            completion(.doNotRetry)
        }
    }
    
    private func getAccessToken() -> String? {
        // ์‹ค์ œ๋กœ๋Š” Keychain์ด๋‚˜ UserDefaults์—์„œ ๊ฐ€์ ธ์˜ด
        return "your_access_token"
    }
    
    private func refreshToken(completion: @escaping (Bool) -> Void) {
        // ํ† ํฐ ๊ฐฑ์‹  ๋กœ์ง
        completion(true)
    }
}

// Session์— Interceptor ์ ์šฉ
let interceptor = AuthenticationInterceptor()
let session = Session(interceptor: interceptor)

session.request("https://api.example.com/protected")
    .responseJSON { response in
        // ์ž๋™์œผ๋กœ ํ† ํฐ์ด ์ถ”๊ฐ€๋˜๊ณ , 401 ์—๋Ÿฌ ์‹œ ์žฌ์‹œ๋„๋จ
    }

์ด ์ฝ”๋“œ๋Š” ์ข€ ๋ณต์žกํ•ด ๋ณด์ด์ง€๋งŒ, ํ•œ ๋ฒˆ ์„ค์ •ํ•ด๋‘๋ฉด ๋ชจ๋“  ์š”์ฒญ์— ์ž๋™์œผ๋กœ ํ† ํฐ์ด ์ถ”๊ฐ€๋˜๊ณ , ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜๋ฉด ์ž๋™์œผ๋กœ ๊ฐฑ์‹ ํ•ด์ค˜. ์™„์ „ ํŽธ๋ฆฌํ•˜์ง€? ๐ŸŽ‰

๋„คํŠธ์›Œํ‚น ์š”์ฒญ ํ๋ฆ„๋„ ๋ชจ๋ฐ”์ผ ์•ฑ Retrofit / Alamofire HTTP ํด๋ผ์ด์–ธํŠธ JSON ๋ณ€ํ™˜ Gson / Codable ์„œ๋ฒ„ API REST / GraphQL ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ ๋กœ๊น…/์ธ์ฆ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„/๋ณต๊ตฌ

๐Ÿ”„ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ: Coroutines vs Async/Await

โšก ์•ˆ๋“œ๋กœ์ด๋“œ: Kotlin Coroutines

๋„คํŠธ์›Œํ‚น์€ ๋ณธ์งˆ์ ์œผ๋กœ ๋น„๋™๊ธฐ ์ž‘์—…์ด์•ผ. UI ์Šค๋ ˆ๋“œ๋ฅผ ๋ธ”๋กœํ‚นํ•˜๋ฉด ์•ฑ์ด ๋ฉˆ์ถฐ๋ฒ„๋ฆฌ๋‹ˆ๊นŒ ๋ง์ด์ง€. ์•ˆ๋“œ๋กœ์ด๋“œ์—์„œ๋Š” Kotlin Coroutines๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด!

class UserRepository(private val apiService: UserApiService) {
    
    // Flow๋ฅผ ์‚ฌ์šฉํ•œ ๋ฐ˜์‘ํ˜• ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆผ
    fun getUserFlow(userId: Int): Flow<Result<User>> = flow {
        emit(Result.Loading)
        
        try {
            val response = apiService.getUser(userId)
            if (response.isSuccessful) {
                response.body()?.let {
                    emit(Result.Success(it))
                } ?: emit(Result.Error("๋ฐ์ดํ„ฐ ์—†์Œ"))
            } else {
                emit(Result.Error("HTTP ${response.code()}"))
            }
        } catch (e: Exception) {
            emit(Result.Error(e.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜"))
        }
    }.flowOn(Dispatchers.IO)
    
    // ์—ฌ๋Ÿฌ API๋ฅผ ๋ณ‘๋ ฌ๋กœ ํ˜ธ์ถœ
    suspend fun getUserWithPosts(userId: Int): Pair<User?, List<Post>?> {
        return coroutineScope {
            val userDeferred = async { apiService.getUser(userId) }
            val postsDeferred = async { apiService.getUserPosts(userId) }
            
            val user = userDeferred.await().body()
            val posts = postsDeferred.await().body()
            
            Pair(user, posts)
        }
    }
    
    // ์ˆœ์ฐจ์  API ํ˜ธ์ถœ
    suspend fun createUserAndProfile(user: User, profile: Profile): Result<Boolean> {
        return try {
            // 1. ์‚ฌ์šฉ์ž ์ƒ์„ฑ
            val userResponse = apiService.createUser(user)
            if (!userResponse.isSuccessful) {
                return Result.Error("์‚ฌ์šฉ์ž ์ƒ์„ฑ ์‹คํŒจ")
            }
            
            val createdUser = userResponse.body() ?: return Result.Error("์‘๋‹ต ์—†์Œ")
            
            // 2. ํ”„๋กœํ•„ ์ƒ์„ฑ (์‚ฌ์šฉ์ž ID ํ•„์š”)
            val profileResponse = apiService.createProfile(createdUser.id, profile)
            if (profileResponse.isSuccessful) {
                Result.Success(true)
            } else {
                Result.Error("ํ”„๋กœํ•„ ์ƒ์„ฑ ์‹คํŒจ")
            }
        } catch (e: Exception) {
            Result.Error(e.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜")
        }
    }
}

sealed class Result<out T> {
    object Loading : Result<Nothing>()
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
}

์—ฌ๊ธฐ์„œ ํ•ต์‹ฌ ํฌ์ธํŠธ๋“ค:

โ€ข Flow: ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆผ์„ ํ‘œํ˜„. LiveData๋ณด๋‹ค ๊ฐ•๋ ฅํ•˜๊ณ  ์œ ์—ฐํ•ด! ๐ŸŒŠ
โ€ข async/await: ์—ฌ๋Ÿฌ API๋ฅผ ๋ณ‘๋ ฌ๋กœ ํ˜ธ์ถœํ•ด์„œ ์„ฑ๋Šฅ ํ–ฅ์ƒ
โ€ข coroutineScope: ๊ตฌ์กฐํ™”๋œ ๋™์‹œ์„ฑ์œผ๋กœ ์•ˆ์ „ํ•œ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ
โ€ข Dispatchers.IO: ๋„คํŠธ์›Œํฌ ์ž‘์—…์— ์ตœ์ ํ™”๋œ ์Šค๋ ˆ๋“œ ํ’€

๐ŸŽ iOS: Swift Concurrency (async/await)

iOS๋„ Swift 5.5๋ถ€ํ„ฐ async/await๋ฅผ ์ง€์›ํ•ด! Alamofire๋„ ์ด๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ์ง€์›ํ•˜์ง€:

class UserService {
    
    // async/await๋ฅผ ์‚ฌ์šฉํ•œ ๊ฐ„๋‹จํ•œ ์š”์ฒญ
    func fetchUser(id: Int) async throws -> User {
        let response = await AF.request("https://api.example.com/users/\(id)")
            .serializingDecodable(User.self)
            .response
        
        switch response.result {
        case .success(let user):
            return user
        case .failure(let error):
            throw error
        }
    }
    
    // ์—ฌ๋Ÿฌ API๋ฅผ ๋ณ‘๋ ฌ๋กœ ํ˜ธ์ถœ
    func fetchUserWithPosts(id: Int) async throws -> (User, [Post]) {
        async let user = fetchUser(id: id)
        async let posts = fetchPosts(userId: id)
        
        return try await (user, posts)
    }
    
    // Task Group์„ ์‚ฌ์šฉํ•œ ๋‹ค์ค‘ ์š”์ฒญ
    func fetchMultipleUsers(ids: [Int]) async throws -> [User] {
        try await withThrowingTaskGroup(of: User.self) { group in
            for id in ids {
                group.addTask {
                    try await self.fetchUser(id: id)
                }
            }
            
            var users: [User] = []
            for try await user in group {
                users.append(user)
            }
            return users
        }
    }
    
    private func fetchPosts(userId: Int) async throws -> [Post] {
        let response = await AF.request("https://api.example.com/users/\(userId)/posts")
            .serializingDecodable([Post].self)
            .response
        
        switch response.result {
        case .success(let posts):
            return posts
        case .failure(let error):
            throw error
        }
    }
}

// SwiftUI์—์„œ ์‚ฌ์šฉ
struct UserView: View {
    @State private var user: User?
    @State private var isLoading = false
    @State private var errorMessage: String?
    
    let userService = UserService()
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else if let user = user {
                Text(user.name)
                Text(user.email)
            } else if let error = errorMessage {
                Text("์—๋Ÿฌ: \(error)")
            }
        }
        .task {
            await loadUser()
        }
    }
    
    func loadUser() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            user = try await userService.fetchUser(id: 1)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

Swift์˜ async/await๋Š” ์ •๋ง ๊น”๋”ํ•˜์ง€? ์ฝ”๋“œ๊ฐ€ ๋งˆ์น˜ ๋™๊ธฐ ์ฝ”๋“œ์ฒ˜๋Ÿผ ์ฝํ˜€์„œ ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์›Œ! ๐Ÿ˜Š

๐Ÿ›ก๏ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ์™€ ์žฌ์‹œ๋„ ์ „๋žต

๋„คํŠธ์›Œํ‚น์—์„œ ์—๋Ÿฌ๋Š” ํ”ผํ•  ์ˆ˜ ์—†์–ด. ๋„คํŠธ์›Œํฌ๊ฐ€ ๋Š๊ธฐ๊ฑฐ๋‚˜, ์„œ๋ฒ„๊ฐ€ ๋‹ค์šด๋˜๊ฑฐ๋‚˜, ํƒ€์ž„์•„์›ƒ์ด ๋ฐœ์ƒํ•˜๊ฑฐ๋‚˜... ์ด๋Ÿฐ ์ƒํ™ฉ๋“ค์„ ์šฐ์•„ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒŒ ์ค‘์š”ํ•˜์ง€! ๐ŸŽฏ

๐Ÿ”ง Retrofit ์—๋Ÿฌ ์ฒ˜๋ฆฌ

sealed class NetworkResult<T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error<T>(val code: Int, val message: String) : NetworkResult<T>()
    data class Exception<T>(val e: Throwable) : NetworkResult<T>()
}

suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): NetworkResult<T> {
    return try {
        val response = apiCall()
        
        if (response.isSuccessful) {
            response.body()?.let {
                NetworkResult.Success(it)
            } ?: NetworkResult.Error(response.code(), "์‘๋‹ต ๋ณธ๋ฌธ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค")
        } else {
            val errorBody = response.errorBody()?.string()
            NetworkResult.Error(response.code(), errorBody ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜")
        }
    } catch (e: IOException) {
        NetworkResult.Exception(e)
    } catch (e: HttpException) {
        NetworkResult.Exception(e)
    } catch (e: Exception) {
        NetworkResult.Exception(e)
    }
}

// ์žฌ์‹œ๋„ ๋กœ์ง
suspend fun <T> retryApiCall(
    times: Int = 3,
    initialDelay: Long = 100,
    maxDelay: Long = 1000,
    factor: Double = 2.0,
    block: suspend () -> NetworkResult<T>
): NetworkResult<T> {
    var currentDelay = initialDelay
    
    repeat(times - 1) {
        when (val result = block()) {
            is NetworkResult.Success -> return result
            is NetworkResult.Error -> {
                // 4xx ์—๋Ÿฌ๋Š” ์žฌ์‹œ๋„ํ•˜์ง€ ์•Š์Œ
                if (result.code in 400..499) {
                    return result
                }
            }
            is NetworkResult.Exception -> {
                // ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ๋งŒ ์žฌ์‹œ๋„
            }
        }
        
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
    }
    
    return block() // ๋งˆ์ง€๋ง‰ ์‹œ๋„
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
class UserRepository(private val apiService: UserApiService) {
    
    suspend fun getUser(id: Int): NetworkResult<User> {
        return retryApiCall {
            safeApiCall { apiService.getUser(id) }
        }
    }
}
โš ๏ธ ์žฌ์‹œ๋„ ์‹œ ์ฃผ์˜์‚ฌํ•ญ โ€ข POST, PUT, DELETE ๊ฐ™์€ ๋ฉฑ๋“ฑ์„ฑ์ด ์—†๋Š” ์š”์ฒญ์€ ์žฌ์‹œ๋„์— ์ฃผ์˜ํ•ด์•ผ ํ•ด
โ€ข ๋ฌดํ•œ ์žฌ์‹œ๋„๋Š” ์ ˆ๋Œ€ ๊ธˆ๋ฌผ! ์ตœ๋Œ€ ํšŸ์ˆ˜๋ฅผ ์ •ํ•ด์•ผ ํ•ด
โ€ข Exponential Backoff๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์„œ๋ฒ„ ๋ถ€ํ•˜๋ฅผ ์ค„์—ฌ์•ผ ํ•ด
โ€ข 4xx ํด๋ผ์ด์–ธํŠธ ์—๋Ÿฌ๋Š” ์žฌ์‹œ๋„ํ•ด๋„ ์†Œ์šฉ์—†์–ด

๐ŸŽ Alamofire ์—๋Ÿฌ ์ฒ˜๋ฆฌ

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError
    case serverError(statusCode: Int)
    case networkError(Error)
    
    var localizedDescription: String {
        switch self {
        case .invalidURL:
            return "์ž˜๋ชป๋œ URL์ž…๋‹ˆ๋‹ค"
        case .noData:
            return "๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค"
        case .decodingError:
            return "๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .serverError(let code):
            return "์„œ๋ฒ„ ์—๋Ÿฌ (์ฝ”๋“œ: \(code))"
        case .networkError(let error):
            return "๋„คํŠธ์›Œํฌ ์—๋Ÿฌ: \(error.localizedDescription)"
        }
    }
}

class NetworkService {
    
    func request<T: Decodable>(
        _ url: String,
        method: HTTPMethod = .get,
        parameters: Parameters? = nil
    ) async throws -> T {
        
        return try await withCheckedThrowingContinuation { continuation in
            AF.request(url, method: method, parameters: parameters)
                .validate(statusCode: 200..<300)
                .responseDecodable(of: T.self) { response in
                    
                    switch response.result {
                    case .success(let value):
                        continuation.resume(returning: value)
                        
                    case .failure(let error):
                        if let statusCode = response.response?.statusCode {
                            continuation.resume(
                                throwing: NetworkError.serverError(statusCode: statusCode)
                            )
                        } else if let underlyingError = error.underlyingError {
                            continuation.resume(
                                throwing: NetworkError.networkError(underlyingError)
                            )
                        } else {
                            continuation.resume(throwing: NetworkError.decodingError)
                        }
                    }
                }
        }
    }
    
    // ์žฌ์‹œ๋„ ๋กœ์ง
    func requestWithRetry<T: Decodable>(
        _ url: String,
        method: HTTPMethod = .get,
        parameters: Parameters? = nil,
        maxRetries: Int = 3
    ) async throws -> T {
        
        var lastError: Error?
        
        for attempt in 0..<maxRetries {
            do {
                let result: T = try await request(url, method: method, parameters: parameters)
                return result
            } catch let error as NetworkError {
                lastError = error
                
                // 4xx ์—๋Ÿฌ๋Š” ์žฌ์‹œ๋„ํ•˜์ง€ ์•Š์Œ
                if case .serverError(let code) = error, (400...499).contains(code) {
                    throw error
                }
                
                // ๋งˆ์ง€๋ง‰ ์‹œ๋„๊ฐ€ ์•„๋‹ˆ๋ฉด ๋Œ€๊ธฐ
                if attempt < maxRetries - 1 {
                    let delay = pow(2.0, Double(attempt)) // Exponential backoff
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                }
            } catch {
                lastError = error
            }
        }
        
        throw lastError ?? NetworkError.networkError(NSError(domain: "", code: -1))
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
struct ContentView: View {
    @State private var users: [User] = []
    @State private var errorMessage: String?
    
    let networkService = NetworkService()
    
    var body: some View {
        List(users) { user in
            Text(user.name)
        }
        .task {
            do {
                users = try await networkService.requestWithRetry(
                    "https://api.example.com/users"
                )
            } catch let error as NetworkError {
                errorMessage = error.localizedDescription
            } catch {
                errorMessage = "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"
            }
        }
        .alert("์—๋Ÿฌ", isPresented: .constant(errorMessage != nil)) {
            Button("ํ™•์ธ") {
                errorMessage = nil
            }
        } message: {
            Text(errorMessage ?? "")
        }
    }
}

๐Ÿ“Š ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ

๋„คํŠธ์›Œํ‚น ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•˜๋ฉด ์•ฑ์ด ํ›จ์”ฌ ๋นจ๋ผ์ง€๊ณ  ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์ข‹์•„์ ธ! ๋ช‡ ๊ฐ€์ง€ ์‹ค์šฉ์ ์ธ ํŒ๋“ค์„ ๊ณต์œ ํ• ๊ฒŒ. ๐Ÿš€

1๏ธโƒฃ ์‘๋‹ต ์บ์‹ฑ

๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜๋ณตํ•ด์„œ ์š”์ฒญํ•˜๋Š” ๊ฑด ๋‚ญ๋น„์•ผ. ์บ์‹ฑ์„ ํ™œ์šฉํ•˜๋ฉด ๋„คํŠธ์›Œํฌ ํŠธ๋ž˜ํ”ฝ์„ ์ค„์ด๊ณ  ์‘๋‹ต ์†๋„๋ฅผ ๋†’์ผ ์ˆ˜ ์žˆ์–ด!

// Retrofit - OkHttp ์บ์‹œ ์„ค์ •
val cacheSize = 10 * 1024 * 1024 // 10 MB
val cache = Cache(context.cacheDir, cacheSize.toLong())

val okHttpClient = OkHttpClient.Builder()
    .cache(cache)
    .addInterceptor { chain ->
        var request = chain.request()
        
        // ๋„คํŠธ์›Œํฌ๊ฐ€ ์žˆ์œผ๋ฉด ์บ์‹œ๋ฅผ 5๋ถ„๊ฐ„ ์œ ์ง€
        // ์—†์œผ๋ฉด 7์ผ๊ฐ„ ์œ ์ง€
        request = if (hasNetwork(context)) {
            request.newBuilder()
                .header("Cache-Control", "public, max-age=" + 300)
                .build()
        } else {
            request.newBuilder()
                .header("Cache-Control", "public, only-if-cached, max-stale=" + 60 * 60 * 24 * 7)
                .build()
        }
        
        chain.proceed(request)
    }
    .build()

// Alamofire - URLCache ์„ค์ •
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .returnCacheDataElseLoad
configuration.urlCache = URLCache(
    memoryCapacity: 10 * 1024 * 1024,  // 10 MB
    diskCapacity: 50 * 1024 * 1024,    // 50 MB
    diskPath: "alamofire_cache"
)

let session = Session(configuration: configuration)

2๏ธโƒฃ ์š”์ฒญ ์••์ถ•

๋ฐ์ดํ„ฐ๋ฅผ ์••์ถ•ํ•˜๋ฉด ์ „์†ก ์‹œ๊ฐ„์ด ์ค„์–ด๋“ค์–ด! ํŠนํžˆ ์ด๋ฏธ์ง€๋‚˜ ํฐ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค๋ฃฐ ๋•Œ ํšจ๊ณผ์ ์ด์•ผ.

// OkHttp๋Š” ์ž๋™์œผ๋กœ gzip ์••์ถ•์„ ์ง€์›ํ•ด
// Accept-Encoding: gzip ํ—ค๋”๋ฅผ ์ž๋™์œผ๋กœ ์ถ”๊ฐ€ํ•จ

// ์ˆ˜๋™์œผ๋กœ ์••์ถ• ์ธํ„ฐ์…‰ํ„ฐ ์ถ”๊ฐ€
class GzipRequestInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        
        if (originalRequest.body == null || 
            originalRequest.header("Content-Encoding") != null) {
            return chain.proceed(originalRequest)
        }
        
        val compressedRequest = originalRequest.newBuilder()
            .header("Content-Encoding", "gzip")
            .method(originalRequest.method, gzip(originalRequest.body!!))
            .build()
        
        return chain.proceed(compressedRequest)
    }
    
    private fun gzip(body: RequestBody): RequestBody {
        return object : RequestBody() {
            override fun contentType() = body.contentType()
            
            override fun contentLength() = -1L
            
            override fun writeTo(sink: BufferedSink) {
                val gzipSink = GzipSink(sink).buffer()
                body.writeTo(gzipSink)
                gzipSink.close()
            }
        }
    }
}

3๏ธโƒฃ ๋ฐฐ์น˜ ์š”์ฒญ

์—ฌ๋Ÿฌ ๊ฐœ์˜ ์ž‘์€ ์š”์ฒญ๋ณด๋‹ค ํ•˜๋‚˜์˜ ํฐ ์š”์ฒญ์ด ๋” ํšจ์œจ์ ์ด์•ผ. API ์„ค๊ณ„ ์‹œ ๋ฐฐ์น˜ ์—”๋“œํฌ์ธํŠธ๋ฅผ ๊ณ ๋ คํ•ด๋ด!

// ๋‚˜์œ ์˜ˆ: ์—ฌ๋Ÿฌ ๋ฒˆ ์š”์ฒญ
for (id in userIds) {
    val user = apiService.getUser(id)
    // ์ฒ˜๋ฆฌ...
}

// ์ข‹์€ ์˜ˆ: ํ•œ ๋ฒˆ์— ์š”์ฒญ
val users = apiService.getUsers(userIds)

// API ์ธํ„ฐํŽ˜์ด์Šค
interface UserApiService {
    @GET("users/batch")
    suspend fun getUsers(@Query("ids") ids: List<Int>): Response<List<User>>
}

4๏ธโƒฃ ํŽ˜์ด์ง€๋„ค์ด์…˜

ํฐ ๋ฐ์ดํ„ฐ์…‹์„ ํ•œ ๋ฒˆ์— ๋กœ๋“œํ•˜์ง€ ๋ง๊ณ , ํŽ˜์ด์ง€ ๋‹จ์œ„๋กœ ๋‚˜๋ˆ ์„œ ๋กœ๋“œํ•ด! ์‚ฌ์šฉ์ž๋Š” ์ฒ˜์Œ ๋ช‡ ๊ฐœ๋งŒ ๋ณด๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ๊ฑฐ๋“ .

// Retrofit ํŽ˜์ด์ง€๋„ค์ด์…˜
interface PostApiService {
    @GET("posts")
    suspend fun getPosts(
        @Query("page") page: Int,
        @Query("limit") limit: Int = 20
    ): Response<PostResponse>
}

data class PostResponse(
    val posts: List<Post>,
    val totalPages: Int,
    val currentPage: Int
)

// ViewModel์—์„œ ์‚ฌ์šฉ
class PostViewModel : ViewModel() {
    private var currentPage = 1
    private val _posts = MutableLiveData<List<Post>>()
    val posts: LiveData<List<Post>> = _posts
    
    fun loadMorePosts() {
        viewModelScope.launch {
            val response = apiService.getPosts(currentPage)
            if (response.isSuccessful) {
                response.body()?.let { postResponse ->
                    val currentList = _posts.value.orEmpty()
                    _posts.value = currentList + postResponse.posts
                    currentPage++
                }
            }
        }
    }
}
๐Ÿ’ก ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ฒดํฌ๋ฆฌ์ŠคํŠธ
  • โœ… ์บ์‹ฑ ์ „๋žต ์ˆ˜๋ฆฝ
  • โœ… ์ด๋ฏธ์ง€ ์ตœ์ ํ™” (WebP, ์ ์ ˆํ•œ ํ•ด์ƒ๋„)
  • โœ… ๋ถˆํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ ํ•„๋“œ ์ œ๊ฑฐ
  • โœ… ์—ฐ๊ฒฐ ํ’€๋ง ํ™œ์šฉ
  • โœ… HTTP/2 ์‚ฌ์šฉ (OkHttp๋Š” ์ž๋™ ์ง€์›)
  • โœ… ํƒ€์ž„์•„์›ƒ ์ ์ ˆํžˆ ์„ค์ •
  • โœ… ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ํ”„๋ฆฌํŽ˜์นญ

๐Ÿ” ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ

๋„คํŠธ์›Œํ‚น์—์„œ ๋ณด์•ˆ์€ ์ •๋ง์ •๋ง ์ค‘์š”ํ•ด! ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ๋ณดํ˜ธํ•˜๋Š” ๊ฑด ๊ฐœ๋ฐœ์ž์˜ ์ฑ…์ž„์ด๋‹ˆ๊นŒ. ๐Ÿ›ก๏ธ

1๏ธโƒฃ HTTPS ์‚ฌ์šฉ ํ•„์ˆ˜

HTTP๋Š” ์ ˆ๋Œ€ ์•ˆ ๋ผ! ๋ชจ๋“  ํ†ต์‹ ์€ HTTPS๋กœ ์•”ํ˜ธํ™”ํ•ด์•ผ ํ•ด. ์•ˆ๋“œ๋กœ์ด๋“œ 9(API 28)๋ถ€ํ„ฐ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ HTTP ์—ฐ๊ฒฐ์ด ์ฐจ๋‹จ๋˜๊ฑฐ๋“ .

// network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

// AndroidManifest.xml
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

2๏ธโƒฃ SSL Pinning

์ค‘๊ฐ„์ž ๊ณต๊ฒฉ(MITM)์„ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด SSL Pinning์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ด. ์„œ๋ฒ„์˜ ์ธ์ฆ์„œ๋‚˜ ๊ณต๊ฐœ ํ‚ค๋ฅผ ์•ฑ์— ๊ณ ์ •์‹œํ‚ค๋Š” ๊ฑฐ์ง€.

// OkHttp SSL Pinning
val hostname = "api.example.com"
val certificatePinner = CertificatePinner.Builder()
    .add(hostname, "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add(hostname, "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
    .build()

val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

// Alamofire SSL Pinning
let evaluators = [
    "api.example.com": PinnedCertificatesTrustEvaluator()
]

let serverTrustManager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: serverTrustManager)

3๏ธโƒฃ ๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ

API ํ‚ค๋‚˜ ํ† ํฐ์„ ์ฝ”๋“œ์— ํ•˜๋“œ์ฝ”๋”ฉํ•˜๋ฉด ์ ˆ๋Œ€ ์•ˆ ๋ผ! ์•ˆ์ „ํ•˜๊ฒŒ ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌํ•ด์•ผ ํ•ด.

// ์•ˆ๋“œ๋กœ์ด๋“œ - Keystore ์‚ฌ์šฉ
class SecureStorage(private val context: Context) {
    
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
        load(null)
    }
    
    fun saveToken(token: String) {
        val cipher = getCipher()
        val encryptedToken = cipher.doFinal(token.toByteArray())
        
        context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE)
            .edit()
            .putString("token", Base64.encodeToString(encryptedToken, Base64.DEFAULT))
            .apply()
    }
    
    fun getToken(): String? {
        val encryptedToken = context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE)
            .getString("token", null) ?: return null
        
        val cipher = getCipher()
        val decryptedToken = cipher.doFinal(Base64.decode(encryptedToken, Base64.DEFAULT))
        
        return String(decryptedToken)
    }
    
    private fun getCipher(): Cipher {
        // Cipher ์ดˆ๊ธฐํ™” ๋กœ์ง
        return Cipher.getInstance("AES/GCM/NoPadding")
    }
}

// iOS - Keychain ์‚ฌ์šฉ
import Security

class KeychainManager {
    
    func save(token: String, for key: String) -> Bool {
        guard let data = token.data(using: .utf8) else { return false }
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        
        SecItemDelete(query as CFDictionary)
        
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }
    
    func get(for key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            return nil
        }
        
        return token
    }
}
โš ๏ธ ๋ณด์•ˆ ์ฃผ์˜์‚ฌํ•ญ โ€ข API ํ‚ค๋ฅผ ์ ˆ๋Œ€ Git์— ์ปค๋ฐ‹ํ•˜์ง€ ๋ง ๊ฒƒ
โ€ข ๋กœ๊ทธ์— ๋ฏผ๊ฐํ•œ ์ •๋ณด๋ฅผ ์ถœ๋ ฅํ•˜์ง€ ๋ง ๊ฒƒ
โ€ข ๋ฃจํŒ…/ํƒˆ์˜ฅ๋œ ๊ธฐ๊ธฐ ๊ฐ์ง€ ๊ณ ๋ ค
โ€ข ์ •๊ธฐ์ ์œผ๋กœ ํ† ํฐ ๊ฐฑ์‹ 
โ€ข ์ตœ์†Œ ๊ถŒํ•œ ์›์น™ ์ ์šฉ
๋ณด์•ˆ ๊ณ„์ธต ๊ตฌ์กฐ HTTPS / TLS 1.3 SSL Pinning ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ ๋ฐ์ดํ„ฐ ์•”ํ˜ธํ™” Keychain / Keystore ํด๋ผ์ด์–ธํŠธ ์•ฑ ๋ณด์•ˆ ์„œ๋ฒ„ API ๋ณด์•ˆ

๐Ÿงช ํ…Œ์ŠคํŠธ์™€ ๋””๋ฒ„๊น…

๋„คํŠธ์›Œํ‚น ์ฝ”๋“œ๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฑด ์ข€ ๊นŒ๋‹ค๋กœ์›Œ. ์‹ค์ œ ์„œ๋ฒ„์— ์˜์กดํ•˜๋ฉด ํ…Œ์ŠคํŠธ๊ฐ€ ๋ถˆ์•ˆ์ •ํ•ด์ง€๊ฑฐ๋“ . ๊ทธ๋ž˜์„œ Mock์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ด! ๐ŸŽญ

๐Ÿค– ์•ˆ๋“œ๋กœ์ด๋“œ ํ…Œ์ŠคํŠธ

// MockWebServer๋ฅผ ์‚ฌ์šฉํ•œ ํ…Œ์ŠคํŠธ
class UserRepositoryTest {
    
    private lateinit var mockWebServer: MockWebServer
    private lateinit var apiService: UserApiService
    private lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        mockWebServer = MockWebServer()
        mockWebServer.start()
        
        val retrofit = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        
        apiService = retrofit.create(UserApiService::class.java)
        repository = UserRepository(apiService)
    }
    
    @After
    fun tearDown() {
        mockWebServer.shutdown()
    }
    
    @Test
    fun `์‚ฌ์šฉ์ž ์กฐํšŒ ์„ฑ๊ณต ํ…Œ์ŠคํŠธ`() = runTest {
        // Given
        val mockResponse = """
            {
                "id": 1,
                "name": "ํ™๊ธธ๋™",
                "email": "hong@example.com"
            }
        """.trimIndent()
        
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setBody(mockResponse)
        )
        
        // When
        val result = repository.getUser(1)
        
        // Then
        assertTrue(result is NetworkResult.Success)
        val user = (result as NetworkResult.Success).data
        assertEquals("ํ™๊ธธ๋™", user.name)
        assertEquals("hong@example.com", user.email)
    }
    
    @Test
    fun `๋„คํŠธ์›Œํฌ ์—๋Ÿฌ ํ…Œ์ŠคํŠธ`() = runTest {
        // Given
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(500)
                .setBody("Internal Server Error")
        )
        
        // When
        val result = repository.getUser(1)
        
        // Then
        assertTrue(result is NetworkResult.Error)
        val error = result as NetworkResult.Error
        assertEquals(500, error.code)
    }
}

// ์˜์กด์„ฑ
testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'

๐ŸŽ iOS ํ…Œ์ŠคํŠธ

import XCTest
@testable import YourApp

class UserServiceTests: XCTestCase {
    
    var sut: UserService!
    var mockSession: URLSession!
    
    override func setUp() {
        super.setUp()
        
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        mockSession = URLSession(configuration: configuration)
        
        sut = UserService(session: mockSession)
    }
    
    override func tearDown() {
        sut = nil
        mockSession = nil
        super.tearDown()
    }
    
    func testFetchUserSuccess() async throws {
        // Given
        let mockUser = User(id: 1, name: "ํ™๊ธธ๋™", email: "hong@example.com")
        let mockData = try JSONEncoder().encode(mockUser)
        
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 200,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, mockData)
        }
        
        // When
        let user = try await sut.fetchUser(id: 1)
        
        // Then
        XCTAssertEqual(user.name, "ํ™๊ธธ๋™")
        XCTAssertEqual(user.email, "hong@example.com")
    }
    
    func testFetchUserFailure() async {
        // Given
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 404,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, Data())
        }
        
        // When & Then
        do {
            _ = try await sut.fetchUser(id: 999)
            XCTFail("์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด์•ผ ํ•จ")
        } catch {
            XCTAssertNotNil(error)
        }
    }
}

// Mock URLProtocol
class MockURLProtocol: URLProtocol {
    
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
    
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            fatalError("Handler is unavailable")
        }
        
        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
    
    override func stopLoading() {
        // ํ•„์š”์‹œ ๊ตฌํ˜„
    }
}

๐Ÿ” ๋””๋ฒ„๊น… ๋„๊ตฌ

๋„คํŠธ์›Œํ‚น ๋ฌธ์ œ๋ฅผ ๋””๋ฒ„๊น…ํ•  ๋•Œ ์œ ์šฉํ•œ ๋„๊ตฌ๋“ค์ด ์žˆ์–ด:

๋„๊ตฌ ํ”Œ๋žซํผ ์„ค๋ช…
Charles Proxy Both HTTP/HTTPS ํŠธ๋ž˜ํ”ฝ์„ ๊ฐ€๋กœ์ฑ„์„œ ๋ถ„์„
Postman Both API ํ…Œ์ŠคํŠธ ๋ฐ ๋ฌธ์„œํ™”
Android Profiler Android ๋„คํŠธ์›Œํฌ ํ™œ๋™ ๋ชจ๋‹ˆํ„ฐ๋ง
Network Link Conditioner iOS ๋‹ค์–‘ํ•œ ๋„คํŠธ์›Œํฌ ํ™˜๊ฒฝ ์‹œ๋ฎฌ๋ ˆ์ด์…˜
Flipper Both Facebook์˜ ๋ชจ๋ฐ”์ผ ๋””๋ฒ„๊น… ํ”Œ๋žซํผ
๐Ÿ’ก ๋””๋ฒ„๊น… ํŒ โ€ข ๋กœ๊น… ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ํ™œ์šฉํ•ด์„œ ์š”์ฒญ/์‘๋‹ต ํ™•์ธ
โ€ข ๋А๋ฆฐ ๋„คํŠธ์›Œํฌ ํ™˜๊ฒฝ์—์„œ ํ…Œ์ŠคํŠธ (3G, Edge ๋“ฑ)
โ€ข ํƒ€์ž„์•„์›ƒ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ
โ€ข ์„œ๋ฒ„ ์—๋Ÿฌ ์‘๋‹ต ์‹œ๋ฎฌ๋ ˆ์ด์…˜
โ€ข ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ์ฒดํฌ (ํŠนํžˆ ์ฝœ๋ฐฑ ์‚ฌ์šฉ ์‹œ)

๐ŸŽจ ์‹ค์ „ ํ”„๋กœ์ ํŠธ ์˜ˆ์ œ

์ด๋ก ๋งŒ ๋ฐฐ์šฐ๋ฉด ์žฌ๋ฏธ์—†์ง€! ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ์•ฑ์„ ๋งŒ๋“ค์–ด๋ณด์ž. ๊ฐ„๋‹จํ•œ ๋‚ ์”จ ์•ฑ์„ ์˜ˆ์ œ๋กœ ๋“ค๊ฒŒ. โ˜€๏ธ๐ŸŒง๏ธ

๐ŸŒค๏ธ ๋‚ ์”จ ์•ฑ - ์•ˆ๋“œ๋กœ์ด๋“œ ๋ฒ„์ „

// ๋ฐ์ดํ„ฐ ๋ชจ๋ธ
data class Weather(
    val temperature: Double,
    val description: String,
    val humidity: Int,
    val windSpeed: Double,
    val cityName: String
)

data class WeatherResponse(
    val main: Main,
    val weather: List<WeatherInfo>,
    val wind: Wind,
    val name: String
) {
    data class Main(
        val temp: Double,
        val humidity: Int
    )
    
    data class WeatherInfo(
        val description: String
    )
    
    data class Wind(
        val speed: Double
    )
    
    fun toWeather(): Weather {
        return Weather(
            temperature = main.temp,
            description = weather.firstOrNull()?.description ?: "",
            humidity = main.humidity,
            windSpeed = wind.speed,
            cityName = name
        )
    }
}

// API ์ธํ„ฐํŽ˜์ด์Šค
interface WeatherApiService {
    @GET("weather")
    suspend fun getWeather(
        @Query("q") city: String,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric",
        @Query("lang") lang: String = "kr"
    ): Response<WeatherResponse>
}

// Repository
class WeatherRepository(private val apiService: WeatherApiService) {
    
    suspend fun getWeather(city: String): NetworkResult<Weather> {
        return try {
            val response = apiService.getWeather(
                city = city,
                apiKey = BuildConfig.WEATHER_API_KEY
            )
            
            if (response.isSuccessful) {
                response.body()?.let {
                    NetworkResult.Success(it.toWeather())
                } ?: NetworkResult.Error(response.code(), "๋ฐ์ดํ„ฐ ์—†์Œ")
            } else {
                NetworkResult.Error(response.code(), "๋‚ ์”จ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")
            }
        } catch (e: Exception) {
            NetworkResult.Exception(e)
        }
    }
}

// ViewModel
class WeatherViewModel(
    private val repository: WeatherRepository
) : ViewModel() {
    
    private val _weatherState = MutableStateFlow<UiState<Weather>>(UiState.Idle)
    val weatherState: StateFlow<UiState<Weather>> = _weatherState.asStateFlow()
    
    fun fetchWeather(city: String) {
        viewModelScope.launch {
            _weatherState.value = UiState.Loading
            
            when (val result = repository.getWeather(city)) {
                is NetworkResult.Success -> {
                    _weatherState.value = UiState.Success(result.data)
                }
                is NetworkResult.Error -> {
                    _weatherState.value = UiState.Error(result.message)
                }
                is NetworkResult.Exception -> {
                    _weatherState.value = UiState.Error(
                        result.e.message ?: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜"
                    )
                }
            }
        }
    }
}

sealed class UiState<out T> {
    object Idle : UiState<Nothing>()
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

// Compose UI
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = viewModel()) {
    val weatherState by viewModel.weatherState.collectAsState()
    var cityInput by remember { mutableStateOf("Seoul") }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        OutlinedTextField(
            value = cityInput,
            onValueChange = { cityInput = it },
            label = { Text("๋„์‹œ ์ด๋ฆ„") },
            modifier = Modifier.fillMaxWidth()
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(
            onClick = { viewModel.fetchWeather(cityInput) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("๋‚ ์”จ ์กฐํšŒ")
        }
        
        Spacer(modifier = Modifier.height(32.dp))
        
        when (val state = weatherState) {
            is UiState.Idle -> {
                Text("๋„์‹œ๋ฅผ ์ž…๋ ฅํ•˜๊ณ  ์กฐํšŒ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”")
            }
            is UiState.Loading -> {
                CircularProgressIndicator()
            }
            is UiState.Success -> {
                WeatherCard(weather = state.data)
            }
            is UiState.Error -> {
                Text(
                    text = "์—๋Ÿฌ: ${state.message}",
                    color = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

@Composable
fun WeatherCard(weather: Weather) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = weather.cityName,
                style = MaterialTheme.typography.headlineMedium
            )
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Text(
                text = "${weather.temperature}ยฐC",
                style = MaterialTheme.typography.displayLarge
            )
            
            Text(
                text = weather.description,
                style = MaterialTheme.typography.bodyLarge
            )
            
            Spacer(modifier = Modifier.height(16.dp))
            
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceAround
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text("์Šต๋„", style = MaterialTheme.typography.labelMedium)
                    Text("${weather.humidity}%")
                }
                
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text("ํ’์†", style = MaterialTheme.typography.labelMedium)
                    Text("${weather.windSpeed} m/s")
                }
            }
        }
    }
}

์ด ์˜ˆ์ œ๋Š” ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ์™„์ „ํ•œ ๋‚ ์”จ ์•ฑ์ด์•ผ! OpenWeatherMap API๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด. ๐ŸŒ

๐Ÿš€ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๊ณผ ํŒจํ„ด

1๏ธโƒฃ GraphQL ์ง€์›

REST API ๋ง๊ณ  GraphQL์„ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด? Retrofit๊ณผ Alamofire ๋ชจ๋‘ ์ง€์›ํ•ด!

// Retrofit with GraphQL
interface GraphQLApiService {
    @POST("graphql")
    suspend fun query(@Body request: GraphQLRequest): Response<GraphQLResponse>
}

data class GraphQLRequest(
    val query: String,
    val variables: Map<String, Any>? = null
)

data class GraphQLResponse(
    val data: JsonObject?,
    val errors: List<GraphQLError>?
)

// ์‚ฌ์šฉ ์˜ˆ์ œ
val query = """
    query GetUser(${'$'}id: ID!) {
        user(id: ${'$'}id) {
            id
            name
            email
            posts {
                title
                content
            }
        }
    }
""".trimIndent()

val variables = mapOf("id" to "123")
val request = GraphQLRequest(query, variables)
val response = apiService.query(request)

2๏ธโƒฃ WebSocket ์ง€์›

์‹ค์‹œ๊ฐ„ ํ†ต์‹ ์ด ํ•„์š”ํ•˜๋‹ค๋ฉด WebSocket์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ด. ์ฑ„ํŒ… ์•ฑ์ด๋‚˜ ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ์— ํ•„์ˆ˜์ง€! ๐Ÿ’ฌ

// OkHttp WebSocket
class WebSocketManager {
    
    private var webSocket: WebSocket? = null
    private val client = OkHttpClient()
    
    fun connect(url: String, listener: WebSocketListener) {
        val request = Request.Builder()
            .url(url)
            .build()
        
        webSocket = client.newWebSocket(request, listener)
    }
    
    fun send(message: String) {
        webSocket?.send(message)
    }
    
    fun close() {
        webSocket?.close(1000, "Goodbye")
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
val listener = object : WebSocketListener() {
    override fun onOpen(webSocket: WebSocket, response: Response) {
        println("์—ฐ๊ฒฐ๋จ!")
    }
    
    override fun onMessage(webSocket: WebSocket, text: String) {
        println("๋ฉ”์‹œ์ง€ ์ˆ˜์‹ : $text")
    }
    
    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
        println("์—๋Ÿฌ: ${t.message}")
    }
}

val wsManager = WebSocketManager()
wsManager.connect("wss://echo.websocket.org", listener)

3๏ธโƒฃ ํŒŒ์ผ ์—…๋กœ๋“œ/๋‹ค์šด๋กœ๋“œ

์ด๋ฏธ์ง€๋‚˜ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๋Š” ๊ฑด ๋ชจ๋ฐ”์ผ ์•ฑ์—์„œ ํ”ํ•œ ์ž‘์—…์ด์ง€. ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋„ ๋ณด์—ฌ์ค˜์•ผ ํ•˜๊ณ !

// Retrofit ํŒŒ์ผ ์—…๋กœ๋“œ
interface FileApiService {
    @Multipart
    @POST("upload")
    suspend fun uploadFile(
        @Part file: MultipartBody.Part,
        @Part("description") description: RequestBody
    ): Response<UploadResponse>
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
fun uploadImage(uri: Uri) {
    viewModelScope.launch {
        val file = File(uri.path!!)
        val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
        val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
        val description = "ํ”„๋กœํ•„ ์‚ฌ์ง„".toRequestBody("text/plain".toMediaTypeOrNull())
        
        val response = apiService.uploadFile(body, description)
        // ์ฒ˜๋ฆฌ...
    }
}

// Alamofire ํŒŒ์ผ ์—…๋กœ๋“œ with Progress
AF.upload(
    multipartFormData: { multipartFormData in
        if let imageData = image.jpegData(compressionQuality: 0.8) {
            multipartFormData.append(
                imageData,
                withName: "file",
                fileName: "image.jpg",
                mimeType: "image/jpeg"
            )
        }
    },
    to: "https://api.example.com/upload"
)
.uploadProgress { progress in
    print("์—…๋กœ๋“œ ์ง„ํ–‰๋ฅ : \(progress.fractionCompleted * 100)%")
}
.responseDecodable(of: UploadResponse.self) { response in
    switch response.result {
    case .success(let uploadResponse):
        print("์—…๋กœ๋“œ ์„ฑ๊ณต: \(uploadResponse)")
    case .failure(let error):
        print("์—…๋กœ๋“œ ์‹คํŒจ: \(error)")
    }
}

๐Ÿ’ก ์‹ค๋ฌด ํŒ๊ณผ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•  ๋•Œ ์•Œ์•„๋‘๋ฉด ์ข‹์€ ํŒ๋“ค์„ ์ •๋ฆฌํ•ด๋ดค์–ด! ์ด๊ฑด ์ •๋ง ๊ฒฝํ—˜์—์„œ ๋‚˜์˜จ ๋…ธํ•˜์šฐ๋“ค์ด์•ผ. ๐Ÿ˜Ž

๐ŸŽฏ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด Repository ํŒจํ„ด ์‚ฌ์šฉ: ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์ถ”์ƒํ™”ํ•ด์„œ ViewModel์ด ๋„คํŠธ์›Œํ‚น ์„ธ๋ถ€์‚ฌํ•ญ์„ ๋ชฐ๋ผ๋„ ๋˜๊ฒŒ ํ•ด

์˜์กด์„ฑ ์ฃผ์ž…: Hilt(Android)๋‚˜ Swinject(iOS)๋ฅผ ์‚ฌ์šฉํ•ด์„œ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ ์ž‘์„ฑ

๋‹จ์ผ ์ฑ…์ž„ ์›์น™: ๋„คํŠธ์›Œํ‚น, ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋ถ„๋ฆฌํ•ด
โšก ์„ฑ๋Šฅ ์ตœ์ ํ™” ์—ฐ๊ฒฐ ํ’€๋ง: OkHttp๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์—ฐ๊ฒฐ์„ ์žฌ์‚ฌ์šฉํ•ด. ์ปค์Šคํ…€ ์„ค์ • ์‹œ ์ฃผ์˜!

์ด๋ฏธ์ง€ ์ตœ์ ํ™”: Glide(Android)๋‚˜ Kingfisher(iOS) ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ

๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋™๊ธฐํ™”: WorkManager(Android)๋‚˜ Background Tasks(iOS) ํ™œ์šฉ
๐Ÿ” ๋ชจ๋‹ˆํ„ฐ๋ง๊ณผ ๋กœ๊น… Firebase Crashlytics: ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ ์ถ”์ 

Analytics: API ํ˜ธ์ถœ ์„ฑ๊ณต๋ฅ , ์‘๋‹ต ์‹œ๊ฐ„ ์ธก์ •

๋กœ๊ทธ ๋ ˆ๋ฒจ ๊ด€๋ฆฌ: ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ฏผ๊ฐํ•œ ์ •๋ณด ๋กœ๊น… ๋น„ํ™œ์„ฑํ™”

๊ทธ๋ฆฌ๊ณ  ํ˜น์‹œ ๋ชจ๋ฐ”์ผ ์•ฑ ๊ฐœ๋ฐœ์ด๋‚˜ ๋„คํŠธ์›Œํ‚น ๊ด€๋ จํ•ด์„œ ๋„์›€์ด ํ•„์š”ํ•˜๋‹ค๋ฉด, ์žฌ๋Šฅ๋„ท์—์„œ ์ „๋ฌธ๊ฐ€๋“ค์˜ ๋„์›€์„ ๋ฐ›์„ ์ˆ˜๋„ ์žˆ์–ด! ์‹ค์ „ ๊ฒฝํ—˜์ด ํ’๋ถ€ํ•œ ๊ฐœ๋ฐœ์ž๋“ค์ด ๋งŽ๊ฑฐ๋“ . ๐Ÿค

๐Ÿ“š ์ถ”๊ฐ€ ํ•™์Šต ๋ฆฌ์†Œ์Šค

๋” ๊นŠ์ด ๊ณต๋ถ€ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ์ด๋Ÿฐ ์ž๋ฃŒ๋“ค์„ ์ถ”์ฒœํ•ด:

๐Ÿ“– ๊ณต์‹ ๋ฌธ์„œ

Retrofit: https://square.github.io/retrofit/
Alamofire: https://github.com/Alamofire/Alamofire
OkHttp: https://square.github.io/okhttp/
Kotlin Coroutines: https://kotlinlang.org/docs/coroutines-overview.html

๐ŸŽ“ ์˜จ๋ผ์ธ ๊ฐ•์˜

โ€ข Udemy, Coursera์—์„œ "Android Networking" ๋˜๋Š” "iOS Networking" ๊ฒ€์ƒ‰
โ€ข YouTube์—์„œ ์‹ค์ „ ์˜ˆ์ œ ์˜์ƒ๋“ค
โ€ข Medium, Dev.to์—์„œ ์ตœ์‹  ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ

๐Ÿ’ฌ ์ปค๋ฎค๋‹ˆํ‹ฐ

โ€ข Stack Overflow: ๊ตฌ์ฒด์ ์ธ ๋ฌธ์ œ ํ•ด๊ฒฐ
โ€ข Reddit (r/androiddev, r/iOSProgramming): ํŠธ๋ Œ๋“œ์™€ ํ† ๋ก 
โ€ข GitHub Issues: ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๊ด€๋ จ ์งˆ๋ฌธ
โ€ข ํ•œ๊ตญ ์•ˆ๋“œ๋กœ์ด๋“œ/iOS ๊ฐœ๋ฐœ์ž ์ปค๋ฎค๋‹ˆํ‹ฐ

๐ŸŽฌ ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€, ์—ฌ๊ธฐ๊นŒ์ง€ ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ์–ด! ๐ŸŽ‰ Retrofit๊ณผ Alamofire์— ๋Œ€ํ•ด ๊ธฐ์ดˆ๋ถ€ํ„ฐ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๊นŒ์ง€ ๋‹ค๋ค„๋ดค๋Š”๋ฐ, ์–ด๋• ์–ด?

๋ชจ๋ฐ”์ผ ๋„คํŠธ์›Œํ‚น์€ ์•ฑ ๊ฐœ๋ฐœ์˜ ํ•ต์‹ฌ์ด์•ผ. ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ขŒ์šฐํ•˜๋Š” ์ค‘์š”ํ•œ ๋ถ€๋ถ„์ด๊ฑฐ๋“ . ๋น ๋ฅด๊ณ  ์•ˆ์ •์ ์ธ ๋„คํŠธ์›Œํ‚น ๊ตฌํ˜„์€ ๋‹จ์ˆœํžˆ ๊ธฐ์ˆ ์ ์ธ ๋ฌธ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ, ์‚ฌ์šฉ์ž์—๊ฒŒ ์‹ ๋ขฐ๋ฅผ ์ฃผ๋Š” ๊ฑฐ์•ผ. ๐Ÿ’ช

๊ธฐ์–ตํ•ด์•ผ ํ•  ํ•ต์‹ฌ ํฌ์ธํŠธ๋“ค:

1. ์ ์ ˆํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ ํƒ: Retrofit(Android)๊ณผ Alamofire(iOS)๋Š” ๊ฐ ํ”Œ๋žซํผ์˜ ๋ฒ ์ŠคํŠธ ์ดˆ์ด์Šค
2. ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ: Coroutines์™€ async/await๋กœ ๊น”๋”ํ•œ ์ฝ”๋“œ ์ž‘์„ฑ
3. ์—๋Ÿฌ ์ฒ˜๋ฆฌ: ๋ชจ๋“  ๊ฐ€๋Šฅํ•œ ์‹คํŒจ ์‹œ๋‚˜๋ฆฌ์˜ค ๊ณ ๋ ค
4. ๋ณด์•ˆ: HTTPS, SSL Pinning, ์•ˆ์ „ํ•œ ํ† ํฐ ๊ด€๋ฆฌ
5. ํ…Œ์ŠคํŠธ: Mock์„ ํ™œ์šฉํ•œ ์•ˆ์ •์ ์ธ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ
6. ์ตœ์ ํ™”: ์บ์‹ฑ, ์••์ถ•, ๋ฐฐ์น˜ ์š”์ฒญ์œผ๋กœ ์„ฑ๋Šฅ ํ–ฅ์ƒ

์ฒ˜์Œ์—๋Š” ๋ณต์žกํ•ด ๋ณด์ผ ์ˆ˜ ์žˆ์ง€๋งŒ, ํ•˜๋‚˜์”ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ์ตํ˜€๊ฐ€๋‹ค ๋ณด๋ฉด ์–ด๋А์ƒˆ ๋„คํŠธ์›Œํ‚น ๋งˆ์Šคํ„ฐ๊ฐ€ ๋˜์–ด ์žˆ์„ ๊ฑฐ์•ผ! ๐Ÿš€

์‹ค์ „์—์„œ ๋ถ€๋”ชํžˆ๋ฉด์„œ ๋ฐฐ์šฐ๋Š” ๊ฒŒ ๊ฐ€์žฅ ๋น ๋ฅธ ๋ฐฉ๋ฒ•์ด๋‹ˆ๊นŒ, ์ž‘์€ ํ”„๋กœ์ ํŠธ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ด๋ด. ๋‚ ์”จ ์•ฑ, To-Do ์•ฑ, ๋‰ด์Šค ๋ฆฌ๋” ๋“ฑ ๊ฐ„๋‹จํ•œ ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด๋ณด๋ฉด์„œ ๊ฐ์„ ์žก๋Š” ๊ฑฐ์ง€!

๊ทธ๋ฆฌ๊ณ  ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ์œผ๋ฉด ์ฃผ์ €ํ•˜์ง€ ๋ง๊ณ  ์ปค๋ฎค๋‹ˆํ‹ฐ์— ์งˆ๋ฌธํ•˜๊ฑฐ๋‚˜, ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•ด. ํ˜ผ์ž ๊ณ ๋ฏผํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค ํ›จ์”ฌ ๋น ๋ฅด๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์–ด. ๐Ÿ˜Š

๋ชจ๋ฐ”์ผ ๊ฐœ๋ฐœ์˜ ์„ธ๊ณ„๋Š” ๊ณ„์† ๋ฐœ์ „ํ•˜๊ณ  ์žˆ์–ด. ์ƒˆ๋กœ์šด ๊ธฐ์ˆ ๊ณผ ํŒจํ„ด๋“ค์ด ๊ณ„์† ๋‚˜์˜ค๋‹ˆ๊นŒ, ๊พธ์ค€ํžˆ ํ•™์Šตํ•˜๋Š” ์ž์„ธ๊ฐ€ ์ค‘์š”ํ•ด. ํ•˜์ง€๋งŒ ๊ธฐ๋ณธ๊ธฐ๋งŒ ํƒ„ํƒ„ํ•˜๋ฉด ์–ด๋–ค ๋ณ€ํ™”๋„ ๋‘๋ ต์ง€ ์•Š์„ ๊ฑฐ์•ผ!

์ž, ์ด์ œ ๋‹น์‹ ์˜ ์ฐจ๋ก€์•ผ! ๋ฉ‹์ง„ ์•ฑ์„ ๋งŒ๋“ค์–ด๋ด. ํ™”์ดํŒ…! ๐ŸŽŠโœจ

๐ŸŒŸ Happy Coding! ๐ŸŒŸ
๋ชจ๋ฐ”์ผ ๋„คํŠธ์›Œํ‚น์˜ ์„ธ๊ณ„์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!
์งˆ๋ฌธ์ด๋‚˜ ํ”ผ๋“œ๋ฐฑ์€ ์–ธ์ œ๋“  ํ™˜์˜์ด์—์š” ๐Ÿ˜Š

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.