1편에서, RESTful API 를 이용한 방만들기, 방 조회 까지의 부분을 다뤘다.

이번 블로그에서는 STOMP를 이용하여 본격적으로 실시간 양방향 통신을 구현하는 방법에 대해 알아보려 한다.

build.gradle.kts(:app)

dependencies {
    ...
    // STOMP protocol client
    implementation("com.github.NaikSoftware:StompProtocolAndroid:1.6.6")

    // rx
    implementation("io.reactivex.rxjava2:rxjava:2.2.21")
    implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
}

settings.gradle.kts

repositories {
    ...
    maven("https://jitpack.io")
}

ApiConstants

object ApiConstants {
    ...
    // WebSocket 엔드포인트
    const val WS_BASE_URL = "ws://192.168.0.34:9999/ws-chat-native"
    const val WS_BASE_URL_VD = "ws://10.0.2.2:9999/ws-chat-native" // 가상머신

    // STOMP 경로
    const val STOMP_PUBLISH_PREFIX = "/pub"
    const val STOMP_SUBSCRIBE_PREFIX = "/sub"
    ...
}

StompClientManager

/**
 *  STOMP 프로토콜을 사용한 웹소켓 연결 관리 클래스.
 */
@Singleton
class StompClientManager @Inject constructor() {
    private val TAG = "StompClientManager"

    private var stompClient: StompClient? = null
    // RxJava의 Disposable을 관리하기 위한 컴포지트
    private val compositeDisposable = CompositeDisposable()

    private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.DISCONNECTED)
    val connectionState: StateFlow<ConnectionState> = _connectionState

    fun connect() {
        if (stompClient != null && stompClient?.isConnected == true) {
            return
        }

        val url = ApiConstants.getWebSocketUrl(ApiConstants.isEmulator)
        Log.d(TAG, "Connecting to WebSocket: $url")

        stompClient = Stomp.over(Stomp.ConnectionProvider.OKHTTP, url)

        val headers = listOf(
            StompHeader("Origin", "app//com.example.chatapp"),
            StompHeader("X-App-Package", "com.example.chatapp")
        )

        stompClient?.connect(headers)

        stompClient?.lifecycle()?.subscribe { lifecycleEvent ->
            when (lifecycleEvent.type) {
                LifecycleEvent.Type.OPENED -> {
                    _connectionState.value = ConnectionState.CONNECTED
                }
                LifecycleEvent.Type.CLOSED -> {
                    _connectionState.value = ConnectionState.DISCONNECTED
                }
                LifecycleEvent.Type.ERROR -> {
                    _connectionState.value = ConnectionState.ERROR
                }
                else -> {}
            }
        }?.let { compositeDisposable.add(it) } // Disposable 추가하여 메모리 누수 방지
    }

    fun disconnect() {
        stompClient?.disconnect()
        compositeDisposable.clear()
        _connectionState.value = ConnectionState.DISCONNECTED
    }

    fun subscribeToRoom(roomId: String, onMessageReceived: (ChatMessage) -> Unit): Disposable? {
        if (stompClient?.isConnected != true) {
            _connectionState.value = ConnectionState.ERROR
            return null
        }

        val topicDisposable = stompClient?.topic("$STOMP_SUBSCRIBE_PREFIX/$roomId")?.subscribe({ topicMessage ->
            val chatMessage = Gson().fromJson(topicMessage.payload, ChatMessage::class.java)
            onMessageReceived(chatMessage)
        }, { throwable ->
            Log.e(TAG, "Error subscribing to room: ${throwable.message}")
        })

        // Disposable 관리를 위해 CompositeDisposable에 추가
        topicDisposable?.let { compositeDisposable.add(it) }
        return topicDisposable
    }

    fun sendMessage(roomId: String, chatMessage: ChatMessage) {
        if (stompClient?.isConnected != true) {
            _connectionState.value = ConnectionState.ERROR
            return
        }

        stompClient?.send("${STOMP_PUBLISH_PREFIX}/$roomId", Gson().toJson(chatMessage))?.subscribe({
        }, { throwable ->
            Log.e("StompClientManager", "Error sending message: ${throwable.message}")
        })?.let { compositeDisposable.add(it) }
    }

    fun onDestroy() {
        disconnect()
    }
}

enum class ConnectionState {
    CONNECTED, DISCONNECTED, ERROR
}

ChatRepository

class ChatRepository @Inject constructor(
    private val stompClientManager: StompClientManager,
) {
    private val TAG = "ChatRepository"
    fun connectToSocket() {
        stompClientManager.connect()
    }

    fun disconnectFromSocket() {
        stompClientManager.disconnect()
    }

    val connectionState = stompClientManager.connectionState

    fun subscribeToRoom(roomId: String, onMessageReceived: (ChatMessage) -> Unit): Disposable? {
        Log.d(TAG, "Subscribing to room: $roomId")
        return stompClientManager.subscribeToRoom(roomId) { message ->
            Log.d(TAG, "Message received: $message")
            onMessageReceived(message)
        }
    }

    fun sendMessage(roomId: String, message: String, sender: String) {
        val chatMessage = ChatMessage(
            roomId = roomId,
            sender = sender,
            message = message
        )
        stompClientManager.sendMessage(roomId, chatMessage)
    }

    fun enterRoom(roomId: String, username: String) {
        val chatMessage = ChatMessage(
            roomId = roomId,
            sender = username,
            message = "$username has entered the room",
            type = MessageType.ENTER
        )
        stompClientManager.sendMessage(roomId, chatMessage)
    }

    fun leaveRoom(roomId: String, username: String) {
        val chatMessage = ChatMessage(
            roomId = roomId,
            sender = username,
            message = "$username has left the room",
            type = MessageType.LEAVE
        )
        stompClientManager.sendMessage(roomId, chatMessage)
    }
}

RoomViewModel

@HiltViewModel
class RoomViewModel @Inject constructor(
    private val chatRoomRepository: ChatRoomRepository,
    private val chatRepository: ChatRepository,
) : ViewModel() {

    private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages

    private val _currentRoom = MutableStateFlow<ChatRoom?>(null)
    val currentRoom: StateFlow<ChatRoom?> = _currentRoom

    private val _messageText = mutableStateOf<String>("")
    val messageText: State<String> = _messageText

    private val _username = mutableStateOf<String>(UUID.randomUUID().toString())
    val username: State<String> = _username

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    val connectionState = chatRepository.connectionState

    private var messageSubscription: Disposable? = null

    init {
        connectToSocket()
    }

    fun connectToSocket() {
        chatRepository.connectToSocket()
    }

    fun disconnectFromSocket() {
        messageSubscription?.dispose()
        chatRepository.disconnectFromSocket()
    }

    fun updateMessageText(newText: String) {
        _messageText.value = newText
    }

    fun joinRoom(roomId: String, username: String) {
        viewModelScope.launch {
            try {
                val room = chatRoomRepository.getRoom(roomId)
                _currentRoom.value = room

                messageSubscription?.dispose()

                _chatMessages.value = emptyList()

                messageSubscription = chatRepository.subscribeToRoom(roomId) { message ->
                    viewModelScope.launch {
                        val currentMessages = _chatMessages.value
                        val newMessages = currentMessages + message
                        _chatMessages.value = newMessages

                        Log.d("RoomViewModel", "Updated messages list, size: ${newMessages.size}")
                    }
                }

                chatRepository.enterRoom(roomId, username)
            } catch (e: Exception) {
                Log.e("ChatRoomViewModel", "Error joining room: ${e.message}")
            }
        }
    }

    fun sendMessage(message: String, username: String) {
        _currentRoom.value?.let { room ->
            chatRepository.sendMessage(room.roomId, message, username)
        }
    }

    fun leaveRoom(username: String) {
        _currentRoom.value?.let { room ->
            chatRepository.leaveRoom(room.roomId, username)
            messageSubscription?.dispose()
            messageSubscription = null
            _currentRoom.value = null
            _chatMessages.value = emptyList()
        }
    }

    override fun onCleared() {
        super.onCleared()
        messageSubscription?.dispose()
        chatRepository.disconnectFromSocket()
    }
}

 

 

이전 포스팅에서는 Spring Boot에서 WebSocket + STOMP를 구현했다.

이번에는 안드로이드 앱에서 서버와 통신하여 채팅방을 생성하는 기능을 구현해 볼 것이다.

채팅방 생성은 WebSocket 연결 전에 필요한 RESTful API 호출로 구현할 것이며, 이를 위해 Retrofit 라이브러리를 활용할 예정이다.

build.gradle.kts(:app)

plugins {
    ...
    // Hilt
    id("kotlin-kapt")
    id("com.google.dagger.hilt.android")
}

dependencies {
    ...
    // Network
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    
    // Navigation
    implementation("androidx.navigation:navigation-compose:2.8.9")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    
    // Hilt
    implementation("com.google.dagger:hilt-android:2.51.1")
    kapt("com.google.dagger:hilt-android-compiler:2.51.1")
}

// Hilt
kapt {
    correctErrorTypes = true
}

 

build.gradle.kts(Project: chatapp)

plugins {
    ...
    // Hilt
    id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

 

프로젝트 구조

Model

data class ChatMessage(
    val roomId: String,
    val sender: String,
    val message: String,
    val type: MessageType = MessageType.TALK,
)

enum class MessageType {
    ENTER, TALK, LEAVE
}

data class ChatRoom(
    val roomId: String,
)

 

Api

object ApiConstants {
    // Http API 엔드포인트
    const val HTTP_BASE_URL = "http://localhost:9999"
    const val HTTP_BASE_URL_VD = "http://10.0.2.2:9999" // 가상머신

    // 경로 상수
    const val URL_PATH_ROOMS = "/rooms"

    fun getHttpUrl(isEmulator: Boolean): String {
        return if (isEmulator) HTTP_BASE_URL_VD else HTTP_BASE_URL
    }

    // 에뮬레이터 여부 확인 (Application Class 에서 초기화)
    var isEmulator: Boolean = false
        private set

    fun initialize() {
        isEmulator = Build.FINGERPRINT.contains("generic") ||
                Build.FINGERPRINT.startsWith("unknown") ||
                Build.MODEL.contains("google_sdk") ||
                Build.MODEL.contains("Emulator") ||
                Build.MODEL.contains("Android SDK built for x86") ||
                Build.MANUFACTURER.contains("Genymotion") ||
                (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
                "google_sdk" == Build.PRODUCT ||
                Build.DEVICE.contains("emu") ||
                Build.PRODUCT.contains("sdk") ||
                Build.HARDWARE.contains("ranchu") ||
                Build.MODEL.contains("sdk")

        Log.d("ApiConstants", "isEmulator : $isEmulator")
    }
}
interface ChatRoomApiService {
    @GET(URL_PATH_ROOMS)
    suspend fun getRooms(): List<ChatRoom>

    @POST("$URL_PATH_ROOMS/create")
    suspend fun createRoom(): ChatRoom

    @GET("$URL_PATH_ROOMS/{roomId}")
    suspend fun getRoom(@Path("roomId") roomId: String): ChatRoom

    @DELETE("$URL_PATH_ROOMS/{roomId}")
    suspend fun deleteRoom(@Path("roomId") roomId: String)
}

 

Application

@HiltAndroidApp
class ChatApplication: Application() {
    override fun onCreate() {
        super.onCreate()

        ApiConstants.initialize()
    }
}

Repository

class ChatRoomRepository @Inject constructor(
    private val apiService: ChatRoomApiService,
) {
    suspend fun getRooms(): List<ChatRoom> = apiService.getRooms()

    suspend fun createRoom(): ChatRoom = apiService.createRoom()

    suspend fun getRoom(roomId: String): ChatRoom = apiService.getRoom(roomId)

    suspend fun deleteRoom(roomId: String) = apiService.deleteRoom(roomId)
}

 

DI

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        val baseUrl = ApiConstants.getHttpUrl(ApiConstants.isEmulator)

        return Retrofit.Builder()
            .baseUrl(baseUrl)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideChatRoomApiService(retrofit: Retrofit): ChatRoomApiService {
        return retrofit.create(ChatRoomApiService::class.java)
    }
}

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application
        ...
        // Http 허용
        android:usesCleartextTraffic="true"
        ...>
        <activity
            ...
        </activity>
    </application>
</manifest>

 

마치며

이번 포스팅에서는 안드로이드 앱에서 Retrofit 을 활용하여 채팅방을 생성하는 기능을 구현해봤다. 기본적인 REST API 통신 구조를 잡았고, 다음 포스팅에서는 이렇게 생성된 채팅방에 WebSocket 으로 접속하여 실시간 채팅 기능을 구현해볼 예정이다.

Gradle 설정

/* build.gradle.kts(Module:app)
 * CURRENT_VERSION 은
 * https://github.com/tosspayments/payment-sdk-android/blob/master/CHANGELOG.md
 * 여기에서 확인가능
 */ 
dependencies {
  ...
  implementation 'com.github.tosspayments:payment-sdk-android:<CURRENT_VERSION>'
}
// settings.gradle.kts
dependencyResolutionManagement {
  ...
  repositories {
    ...
    mavenCentral()
    // maven { url "https://jitpack.io" }
    maven("https://jitpack.io")
  }
}

 

Layout 설정

개발자센터 가이드에 XML 로 되어있어서, XML 로 진행했습니다.

<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=".TossPayActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_editor_absoluteX="0dp"
        app:layout_editor_absoluteY="0dp">

        <com.tosspayments.paymentsdk.view.PaymentMethod
            android:id="@+id/payment_widget"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp" />

        <com.tosspayments.paymentsdk.view.Agreement
            android:id="@+id/agreement_widget"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <Button
            android:id="@+id/pay_button"
            android:layout_width="match_parent"
            android:layout_height="64dp"
            android:text="결제하기"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.738" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

 

해당 레이아웃을 작성하면

payment_widget, agreement_widget 은 안나오고 Button 만 나오고 Activity.kt 에서 설정을 해주도록 합니다.

 

Activity.kt 설정

class TossPayActivity : AppCompatActivity() {
    private lateinit var binding: ActivityTossPayBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityTossPayBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ...
    }
}
class TossPayActivity : AppCompatActivity() {
    private lateinit var binding: ActivityTossPayBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        // 테스트를 위해 발급받은 임의 키 값들
        val clientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm"
        
        /* customerKey = secretKey
		 * 구매자 ID입니다. 다른 사용자가 이 값을 알게 되면 악의적으로 사용될 수 있습니다.
         * 자동 증가하는 숫자 또는 이메일・전화번호・사용자 아이디와 같이 유추가 가능한 값은 안전하지 않습니다.
         * UUID와 같이 충분히 무작위적인 고유 값으로 생성해주세요.
         * 영문 대소문자, 숫자, 특수문자 -, _, =, ., @ 를 최소 1개 이상 포함한
         * 최소 2자 이상 최대 50자 이하의 문자열이어야 합니다.
		 * 비회원 결제에는 PaymentWidget.ANONYMOUS를 사용하세요.
         */
        val secretKey = "test_ssk_docs_OaPz8L5KdmQXkzRz3y47BMw6"
        
        val paymentWidget = PaymentWidget(
            activity = this@TossPayActivity,
            clientKey = clientKey,
            customerKey = secretKey,
        )
        
        val paymentMethodWidgetStatusListener = object : PaymentWidgetStatusListener {
            override fun onFail(fail: TossPaymentResult.Fail) {
                TODO("Not yet implemented")
            }

    	    override fun onLoad() {
    	        val message = "결제위젯 렌더링 완료"
    	        Log.d("PaymentWidgetStatusListener", message)
    	    }
        }
        
        paymentWidget.run {
            renderPaymentMethods(
            	method = binding.paymentWidget,
                amount = PaymentMethod.Rendering.Amount(10000), // Number 타입
                paymentWidgetStatusListener = paymentMethodWidgetStatusListener
                // Listener 는 nullable 이지만 추가해 봤습니다.
            )
            renderAgreement(binding.agreementWidget)
        }
    }
}

 

Activity 에 진입을 하면 이제는 paymentWidget.run { ... } 으로 인해서 UI 가 그려집니다.

아직 결제하기 버튼은 동작을 안하기 때문에 이벤트리스너를 달아주도록 하겠습니다.

 

...
paymentWidget.run {
	...
}

binding.payButton.setOnClickListener {
    paymentWidget.requestPayment(
        paymentInfo = PaymentMethod.PaymentInfo(
            orderId = "eEBFBe12759b127VEne", // 주문 번호 6~64자 무작위 생성 문자열
            orderName = "생수 1병 외 1건"       // 구매상품
        ),
        paymentCallback = object : PaymentCallback {
            override fun onPaymentSuccess(success: TossPaymentResult.Success) {
                Log.i("TossPay success", success.paymentKey)
                Log.i("TossPay success", success.orderId)
                Log.i("TossPay success", success.amount.toString())
                
                /* 결제 완료 동작 추가
                 * 현재 결제방법 선택 Activity 로 돌아오기때문에
                 * Activity 종료 후 이동 등 추가 동작 작성 필요
                 */
            }

            override fun onPaymentFailed(fail: TossPaymentResult.Fail) {
                Log.e("TossPay fail",fail.errorMessage)
            }
        }
    )
}

 

https://docs.tosspayments.com/sdk/widget-android
 

결제위젯 Android SDK(Version 1) | 토스페이먼츠 개발자센터

결제위젯 Android SDK를 추가하고 메서드를 사용하는 방법을 알아봅니다.

docs.tosspayments.com

 

기본 UI 는 왼쪽(국내 일반결제) 방식으로 나오고 변경 및 결제방법 추가 등을 할 수 있다.

https://docs.tosspayments.com/guides/payment-widget/admin#%EC%83%88%EB%A1%9C%EC%9A%B4-%EA%B2%B0%EC%A0%9C-ui-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0
 

결제위젯 어드민 사용하기 | 토스페이먼츠 개발자센터

토스페이먼츠와 계약을 완료했으면 어드민에서 결제 UI를 커스터마이징할 수 있어요.

docs.tosspayments.com

 

터미널 에서 안드로이드 프로젝트 디렉토리 내부 해당 폴더 까지 이동

ex) (본인 프로젝트 경로 로 수정)

cd Documents/WorkSpace/Android/Days/app/build/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib

--

 

find . -name "__MACOSX" -exec rm -rf {} +

find . -name ".DS_Store" -exec rm -rf {} +

 

zip -r ../symbols_clean.zip ./*

 

App bundle 탐색기 > 다운로드 > 네이티브 디버그 기호 에 해당 zip 파일 업로드

+ Recent posts