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 으로 접속하여 실시간 채팅 기능을 구현해볼 예정이다.

코틀린에서 함수를 작성할 때 가변 인자(variable arguments)를 처리해야 하는 경우가 있다.

이럴때 유용하게 사용할 수 있는 것이 바로 vararg 키워드다.

오늘은 코틀린의 vararg 에 대해 자세히 알아보려고 한다.

 

vararg란?

vararg 는 variable arguments의 줄임말로, 함수가 임의의 개수의 인자를 받을 수 있게 해주는 기능이다.

자바의 가변 인자(...)와 유사한 개념으로, 코틀린에서는 vararg 키워드를 사용해 구현한다.

 

1. 기본사용법

fun printSorted(vararg items: Int) {
    items.sort()
    println(items.contentToString())
}

fun main() {
    printSorted(6, 2, 10, 1) // [1, 2, 6, 10]
}

 

2. 스프레드 연산자(*)

이미 가지고 있는 배열을 vararg 파라미터로 전달할 때는 스프레드 연산자(*)를 사용한다.

val numbers = intArrayOf(6, 2, 10, 1)
printSorted(*numbers)
printSorted(numbers) // Error: passing IntArray instead of Int

 

스프레드는 배열을 복사하다는 점에 유의한다. 따라서 파라미터 배열의 내용을 바꿔도 원본 원소에는 영향을 미치지 않는다.

fun main() {
    val a = intArrayOf(6, 2, 10, 1)
    printSorted(*a)              // [1, 2, 6, 10]
    println(a.contentToString()) // [6, 2, 10, 1]
}

 

하지만 이때 얕은(shallow) 복사가 이뤄진다. 얕은 복사는 객체의 참조값만 복사하는 복사 방식으로, 원본 객체와 복사된 객체가 같은 메모리 위치를 참조하게 된다. 이로 인해 한쪽에서 데이터를 변경하면 다른 쪽에도 영향을 미치게 된다.

fun change(vararg items: IntArray) { 
    items[0][0] = 100 
}

fun main() {
    val a = intArrayOf(1, 2, 3)
    val b = intArrayOf(4, 5, 6)
    change(a, b)
    println(a.contentToString()) // [100, 2, 3]
    println(b.contentToString()) // [4, 5, 6]
}

 

  1. a와 b는 각각 정수 배열을 담고 있는 변수이다.
  2. change 함수를 호출할 때 a, b를 vararg 매개변수로 전달한다.
  3. vararg 는 내부적으로 배열로 처리되므로, items는 IntArray 타입의 배열이 된다. 즉 items는 [a참조, b의 참조] 를 담고 있는 배열이다.
  4. vararg 매개변수로 전달될 때 원본 배열 객체의 복사본이 생성되는 것이 아니라, 원본 배열에 대한 참조(메모리 주소)만 복사된다.
  5. items[0][0] = 100 은 a[0] = 100 과 동일한 효과를 가진다. 즉, 원본 배열 a의 첫번째 요소가 변경된다.
  6. 반면, b는 items[1]에 해당하며, 원래 값을 유지한다.

원본 배열의 모든 요소를 새 배열로 복사하는 깊은 복사는 copyOf(), toTypedArray() 등의 메서드를 사용하여 수행할 수 있다.

 

3. 다른 파라미터와 함께 사용

둘 이상을 vararg 파라미터로 선언하는 것은 금지된다. 하지만 vararg 파라미터에 콤마로 분리한 여러 인자와 스프레드를 섞어서 전달하는 것은 괜찮다. 호출 시 이런 호출은 원래의 순서가 유지 되는 단일 배열로 합쳐진다.

printSorted(6, 1, *intArrayOf(3, 8), 2) // 6, 1, 3, 8, 2 배열이 전달, [1, 2, 3, 6, 8] 반환

 

일반적으로 vararg 파라미터는 마지막에 위치시킨다. 만약 마지막에 있는 파라미터가 아니라면, vararg 파라미터 이후의 파라미터는 이름 붙은 인자로만 전달할 수 있다. vararg 파라미터도 디폴트 값과 비슷하게 마지막에 위치시키는 것이 좋은 코딩 스타일이다. vararg 파라미터를 이름 붙은 인자로 전달 할 수 없다. 단, 이름 붙은 인자에 스프레드를 사용해서 가변 인자를 전달 할 수 있다.

printSorted(items = *intArrayOf(1, 2, 3))
printSorted(items = 1, 2, 3) // Error: assigning single elements to varargs in named

 

디폴트 값이 있는 파라미터와 vararg 를 섞어 쓰는 것은 어렵다. 디폴트를 vararg 보다 앞에 두면 vararg 파라미터에 첫 번째로 전달돼야 하는 값이 디폴트가 지정된 파라미터에 전달될 값으로 간주된다. 이를 피하려면 vararg 파라미터를 이름 붙은 인자와 스프레드를 사용해 전달해야 한다. 하지만 이런 식으로 전달하는 코드는 원래 vararg 를 도입했던 목적에 위배된다.

fun printSorted(prefix: String = "", vararg items: Int) { }
fun main() {
    printSorted(6, 2, 10, 1) // Error: 6 is taken as value of prefix
    printSorted(items = *intArrayOf(6, 2, 10, 1)) // 정상

 

반면 vararg 파라미터 뒤에 디폴트 파라미터가 있는 경우에는 디폴트 파라미터를 이름 붙은 인자로 호출해야 사용할 수 있다.

fun printSorted(vararg items: Int, prefix: String = "") { }
fun main() {
    printSorted(6, 2, 10, 1, "!") // Error: type mismatch: inferred type is String but Int was expected
    printSorted(6, 2, 10, 1, prefix = "!") // 정상
}

 

4. 예제

enum class LogLevel { DEBUG, INFO, WARNING, ERROR }

class Logger(private val name: String) {
    fun log(level: LogLevel, vararg messages: String) {
        val timestamp = System.currentTimeMillis()
        for (message in messages) {
            println("[$timestamp] [$level] [$name] - $message")
        }
    }
}

val logger = Logger("UserService")
logger.log(LogLevel.INFO, "User created", "Email sent")

/**
 * 출력 결과
 * [1712043267890] [INFO] [UserService] - User created
 * [1712043267890] [INFO] [UserService] - Email sent
 */

 

sealed class UiText {
    data class DynamicString(val value: String): UiText()
    class StringResource(
        @StringRes val resId: Int,
        vararg val args: Any
    ): UiText()
    
    @Composable
    fun asString(): String {
        return when(this) {
            is DynamicString -> value
            is StringResource -> stringResource(resId, *args)
        }
    }
    
    fun asString(context: Context): String {
        return when(this) {
            is DynamicString -> value
            is StringResource -> context.getString(resId, *args)
        }
    }
}

 

String Resource 를 쉽게 관리 하는 방법이 없을까 검색을 해보다 해당 영상을 접하고, vararg 에 대해서 공부를 해보게 되었다.

 

https://www.youtube.com/watch?v=mB1Lej0aDus

 

개요

실시간 토론 앱 TocKing을 개발하기 전에 채팅 기능 구현 방법을 팀원들과 공유하기 위해 이 예제를 만들었다. Socket 통신은 클라이언트와 서버 간 양방향 통신을 가능하게 하는 기술이다. 전통적인 HTTP 통신과 달리 연결을 계속 유지하면서 실시간으로 데이터를 주고받을 수 있어 채팅, 알림, 실시간 게임 등에 적합하다. 실시간 통신 구현 방법으로는 여러 방식이 있다.

  • 폴링(Polling): 클라이언트가 주기적으로 서버에 요청
  • 롱 폴링(Long Polling): 서버가 이벤트 발생할 때까지 응답을 지연
  • 서버-센트 이벤트(SSE): 서버에서 클라이언트로 단방향 이벤트 스트림
  • WebSocket: 양방향 전이중 통신 채널

이 블로그에서는 Spring Boot(Kotlin)와 WebSocket + STOMP를 활용한 실시간 채팅 구현 방법을 다룬다. STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 메시징 프로토콜로, 메시지 라우팅과 구독 개념을 쉽게 적용할 수 있어 복잡한 채팅 시스템 구현에 유리하다.

build.gradle.kts

dependencies {
	...
    implementation("org.springframework.boot:spring-boot-starter-websocket")
    ...
}

 

WebSocket + STOMP 기능을 사용하기 위해 라이브러리를 추가해 주었다. 해당 라이브러리는 다음 기능들을 포함하고 있다.

  • WebSocket 서버 구현
  • STOMP 메시징 프로토콜 지원
  • SockJS 폴백 메커니즘 (WebSocket을 지원하지 않는 브라우저용)

프로젝트구조

이 예제는 학습과 팀 공유 목적으로 만든 간단한 구현이라 DB연동 없이 진행했다. 채팅방과 메시지 데이터는 모두 서버 메모리(인메모리)에 저장되는 방식으로 구현했다. ChatRoomService 에서 Map 으로 채팅방 정보만 관리 하고 있다.

WebSocketConfig

/**
 * WebSocket 설정 클래스
 * 
 * @Configuration: 스프링의 설정 클래스임을 나타냄
 * @EnableWebSocketMessageBroker: WebSocket 메시지 브로커 기능을 활성화
 */
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig: WebSocketMessageBrokerConfigurer {

    /**
     * 메시지 브로커 설정
     *
     * setApplicationDestinationPrefixes("/pub"): 클라이언트에서 서버로 메시지 발행(publish)할 때 사용할 주소 접두사 설정
     * 즉, 클라이언트가 메시지를 보낼 때는 /pub로 시작하는 주소로 메시지를 보냄
     * 
     * enableSimpleBroker("/sub"): 해당 주소를 구독(subscribe)하는 클라이언트에게 메시지를 전달하는 간단한 인메모리 브로커 설정
     * 즉, 서버에서 클라이언트로 메시지를 보낼 때는 /sub로 시작하는 주소로 메시지를 발송
     */
    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.setApplicationDestinationPrefixes("/pub")
        registry.enableSimpleBroker("/sub")
    }

    /**
     * STOMP 엔드포인트 등록
     * 
     * addEndpoint("/ws-chat"): 웹소켓 연결 엔드포인트 설정 (/ws-chat 경로로 웹소켓 연결 가능)
     * setAllowedOriginPatterns("*"): CORS 설정으로 모든 도메인에서의 접근 허용
     * withSockJS(): WebSocket을 지원하지 않는 브라우저를 위한 SockJS 폴백 옵션 활성화
     * 
     * 두 번째 엔드포인트(/ws-chat-native)는 네이티브 앱에서의 접근을 위한 설정으로,
     * 특정 앱 스키마(app://com.example.chatapp)만 접근 허용하고 SockJS는 사용하지 않음
     */
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry
            .addEndpoint("/ws-chat")
            .setAllowedOriginPatterns("*")
            .withSockJS()

        registry
            .addEndpoint("/ws-chat-native")
            .setAllowedOriginPatterns("app://com.example.chatapp")
    }
}

 

 

ChatMessage

/**
 * 채팅 메시지를 표현하는 데이터 클래스
 * 
 * @property roomId 메시지가 속한 채팅방의 ID
 * @property sender 메시지 발신자 이름
 * @property message 메시지 내용
 * @property type 메시지 타입(기본값: TALK)
 * 
 */
data class ChatMessage(
    val roomId: String,
    val sender: String,
    val message: String,
    val type: MessageType = MessageType.TALK,
)

/**
 * 메시지 타입을 정의하는 열거형
 * 
 * ENTER: 사용자가 채팅방에 입장했을 때 발생하는 메시지 타입
 * TALK: 일반적인 대화 메시지 타입
 * LEAVE: 사용자가 채팅방을 퇴장했을 때 발생하는 메시지 타입
 * 
 * 클라이언트는 이 타입에 따라 다른 UI 처리를 할 수 있음
 */
enum class MessageType {
    ENTER, TALK, LEAVE
}

 

ChatRoom

/**
 * 채팅방을 표현하는 데이터 클래스
 * 
 * @property roomId 채팅방의 고유 식별자
 * 
 * roomId에 UUID를 기본값으로 설정해 객체 생성 시 자동으로 고유한 ID가 부여됨
 */
data class ChatRoom(
    val roomId: String = UUID.randomUUID().toString(),
)

 

ChatController

/**
 * 채팅 메시지를 처리하는 컨트롤러
 */
@Controller
class ChatController {

    /**
     * 특정 채팅방으로 들어오는 메시지를 처리하는 메서드
     * 
     * @MessageMapping("/{roomId}"): 클라이언트가 /pub/{roomId} 주소로 메시지를 보내면 이 메서드가 처리
     *                             (WebSocketConfig에서 "/pub" 접두사 설정했기 때문)
     * @SendTo("/sub/{roomId}"): 처리 결과를 /sub/{roomId} 주소를 구독 중인 모든 클라이언트에게 전송
     * 
     * @param roomId 메시지가 전송된 채팅방 ID (URL 경로에서 추출)
     * @param chatMessage 클라이언트가 전송한 메시지 객체
     * @return 모든 구독자에게 전달할 메시지 객체
     */
    @MessageMapping("/{roomId}")
    @SendTo("/sub/{roomId}")
    fun message(@DestinationVariable("roomId") roomId: String, chatMessage: ChatMessage) : ChatMessage {

        /**
         * 메시지 타입에 따라 다른 처리를 수행
         * 여기서는 간단히 로그만 출력하지만, 실제 서비스에서는 DB 저장, 알림 발송 등의 작업을 수행할 수 있음
         */
        when (chatMessage.type) {
            MessageType.ENTER -> println("User ${chatMessage.sender} entered room $roomId")
            MessageType.LEAVE -> println("User ${chatMessage.sender} left room $roomId")
            MessageType.TALK -> println("Message from ${chatMessage.sender} in room $roomId: ${chatMessage.message}")
        }

        // 받은 메시지를 그대로 구독자들에게 반환
        return chatMessage
    }
}

 

ChatRoomController

/**
 * 채팅방 관리를 위한 REST API 컨트롤러
 */
@RestController
@RequestMapping("/rooms")
class ChatRoomController(
    private val chatRoomService: ChatRoomService
) {

    /**
     * 모든 채팅방 목록을 조회하는 API
     */
    @GetMapping
    fun getRooms(): List<ChatRoom> {
        return chatRoomService.findAllRooms()
    }

    /**
     * 새로운 채팅방을 생성하는 API
     */
    @PostMapping("/create")
    fun createChatRoom(): ChatRoom {
        return chatRoomService.createRoom()
    }

    /**
     * 특정 ID의 채팅방을 조회하는 API
     */
    @GetMapping("/{roomId}")
    fun getRoom(@PathVariable roomId: String): ChatRoom? {
        return chatRoomService.findRoomById(roomId)
    }

    /**
     * 특정 ID의 채팅방을 삭제하는 API
     */
    @DeleteMapping("/{roomId}")
    fun deleteRoom(@PathVariable roomId: String) {
        chatRoomService.deleteRoom(roomId)
    }
}

 

ChatRoomService

/**
 * 채팅방 관리를 위한 서비스 클래스
 * 이 예제에서는 DB 연동 없이 인메모리 방식으로 채팅방 정보를 관리함
 */
@Service
class ChatRoomService() {

    /**
     * 채팅방 정보를 저장하는 인메모리 맵
     * 키: 채팅방 ID, 값: 채팅방 객체
     * 
     * 애플리케이션이 재시작되면 모든 데이터가 초기화됨
     */
    private val chatRooms: MutableMap<String, ChatRoom> = HashMap()

    /**
     * 모든 채팅방 목록을 조회하는 메서드
     */
    fun findAllRooms(): List<ChatRoom> {
        return ArrayList(chatRooms.values)
    }

    /**
     * 특정 ID의 채팅방을 조회하는 메서드
     */
    fun findRoomById(roomId: String): ChatRoom? {
        return chatRooms[roomId]
    }

    /**
     * 새로운 채팅방을 생성하는 메서드
     * @return 생성된 채팅방 객체 (자동 생성된 UUID를 roomId로 가짐)
     */
    fun createRoom(): ChatRoom {
        val chatRoom = ChatRoom()
        chatRooms[chatRoom.roomId] = chatRoom
        return chatRoom
    }

    /**
     * 특정 ID의 채팅방을 삭제하는 메서드
     */
    fun deleteRoom(roomId: String) {
        chatRooms.remove(roomId)
    }
}

 

마무리

지금까지 Spring Boot 와 WebSocket + STOMP를 활용한 기본적인 채팅 서버를 구현해봤다. 이 예제는 학습목적으로 최소한의 기능만 구현했기 때문에, 실제 프로젝트에서는 추가적인 기능을 고려해야 할 거 같다.

  1. 메시지 핸들러와 예외 처리
    1. STOMP 에러 핸들링
    2. 연결 끊김 처리 및 자동 재연결
    3. 메시지 유효성 검증 로직
  2. 보안 기능
    1. JWT 기반 사용자 인증/인가
    2. 채팅방 접근 권한 관리
    3. WebSocket 연결 인증 처리 (WebSocketHandlerDecoratorFactory 구현)
  3. 데이터 영속성
  4. 성능 최적화
    1. 메시지 큐 도입 (RabbitMQ, Kafka)
    2. WebSocket 연결 풀 관리
  5. 모니터링
    1. 연결 상태 모니터링
    2. 메시지 처리량 측정
    3. 오류 로깅 및 알림
  6. 추가 기능
    1. 파일/이미지 첨부 기능
    2. 알림 시스템 연동

추가적으로 Node.js와 Socket.io를 이용한 동일한 채팅 기능도 구현해보고, 두 기술 스택 모두 장단점이 있어서 직접 구현해보고 비교 분석하는게 중요할 것 같다.

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 파일 업로드

자바 스크립트의 탄생

1995년 넷스케이프 커뮤니케이션즈는 웹 페이지의 보조적인 기능을 수행하기 위해 브라우저에서 동작하는 경량 프로그래밍 언어를 도입하기로 결정.

1996년 3월, 모카로 명명 되었다가 그해 9월 라이브스크립트로 이름이 바뀌 었다가

12월에 자바스크립트로 최종 명명되었다.

성장의 역사

초창기 자바스크립트는 웹페이지의 보조적인 기능을 수행하기 위해 한정적인 용도로 사용되었다.

이 시기 대부분의 로직은 주로 웹서버에서 실행되었고,

브라우저는 서버로부터 전달받은 HTML과 CSS를 단순히 렌더링하는 수준이었다.

Ajax !

1999년, 자바스크립트를 이용해 서버와 브라우저가 비동기(asynchronous) 방식으로 데이터를 교환할 수 있는 통신 기능인

Ajax(Asynchronous JavaScript and XML)가 XMLHttpRequest라는 이름으로 등장했다.

Ajax의 등장으로 웹페이지에서 변경할 필요가 없는 부분은 다시 렌더링하지 않고, 서버로부터 필요한 데이터만 전송받아 변경해야 하는 부분만 한정적으로 렌더링하는 방식이 가능해진 것이다.

이로써 웹 브라우저에서도 데스크톱 애플리케이션과 유사한 빠른 성능과 부드러운 화면전환이 가능해졌다.

jQuery !

2006년, jQuery의 등장으로 다소 번거롭고 논란이 있던 DOM(Document Object Model)을 더욱 쉽게 제어할 수 있게 되었고 크로스 브라우징 이슈도 어느정도 해결되었다. jQuery는 넓은 사용자 층을 순식간에 확보했다.

이로 인해 배우기가 다소 까다로운 자바스크립트보다 배우기 쉽고 직관적인 jQuery를 더 선호하는 개발자가 양산되기도 했다.

Node.js

2009년, Ryan Dahl이 발표한 Node.js는 구글 V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임 환경이다.

Node.js는 브라우저의 자바스크립트 엔진에서만 동작하던 자바스크립트를 브라우저 이외의 환경에서도 동작할 수 있도록 자바스크립트 엔진을 브라우저에서 독립시킨 자바스크립트 실행 환경이다.

자바스크립트의 특징

웹 브라우저에서 동작하는 유일한 프로그래밍 언어이다.

개발자가 별도의 컴파일 작업을 수행하지 않는 인터프리터 언어이다.

명령형, 함수형, 프로토타입 기반 객체지향 프로그래밍을 지원하는 멀티 패러다임 프로그래밍 언어이다.


예제나 풀어보자!!

F12를 눌러 DevTool 에서 다음과 같은 것들을 할 수 있다.

콘솔창에 뜬 에러는 Sources 패널로 이동하여 디버깅을 할 수 있다.

14번 줄 getElementByID 에서 counter-x를 counter 로 고치고

11번 줄 버튼들의 class를 id로 바꿔주면 오류는 다 없어진다.

VSCode에서 index.js 를 하나 만들어서 연습해보자

Code Runner 확장플러그인을 설치하고 Ctrl+Alt+N 키를 누르면 실행할수 있다.

잘 실행 되는 모습이다.

alert로 바꿧을때는 정의되지 않는 거라고 뜬다.

alert는 브라우저 알림창을 띄우는 함수로 웹 브라우저에서만 동작하는 클라이언트 사이드 Web API 이다.

즉, alert 함수는 브라우저 환경에서만 유효하다.

라이브서버 확장 플러그인을 이용하면 소스코드를 수정할 때마다 수정 사항을 브라우저에 자동으로 반영해주기 때문에 매우 편리하다.

Go Live 버튼을 클릭하면 가상 서버가 기동되어 브라우저에 HTML 파일이 자동 로딩된다. 이후 소스코드를 수정 하면 수정 사항이 가상 서버에 자동으로 반영된다.

+ Recent posts