Android Stateful Repository

최혜성·2024년 4월 20일
0

클-린한 아키텍쳐, MVVM

이전 게시글에서 MVVM에 대해 작성했고, 그 과정에서 LiveData를 어디다 두어야 할지에 대해 고민도 했었다.
MVVM 패턴은 진짜 깔끔하고 테스트 하기 좋은 반면, 기존 MVC에서 작성하던 코드를 어디다 둬야 하는지 참 난감할때가 많다.

또또 생긴 문제점

MVVM GOSU라고 스스로 자만할 시점, 상태를 포함하는 데이터(도메인)은 어떻게 처리해야하는지 뇌정지가 왔다.
만약, 바로 Request를 보내서 Response를 받고 끝! 하는 형식이 아니라, 지속적으로 Connect을 열어놓고 서버랑 상호작용 하는 Socket, 백그라운드에서 작업을 처리하는 Service 계열등이 있었다.
단순 Retrofit과 같이 1회성으로 Stateless한 요청이면 문제 없는데, 이런 부분을 처리하려니 막막했다.

어떻하지

내가 배운 MVVM 패턴이라면, 위 로직은 뷰와 관련이 없으므로 Activity/Fragment에서 처리해선 안됐다.
당연히 해당 요청들도 Context를 사용하지 않으므로 배제 했다. (Service의 경우 이번 게시글에서 다루지 않으므로 예외, Application Context를 사용해서 DI 하는 방식이 있다더라~ 라는 내용을 봤음)

그러면 일단 Domain / Data레이어로 내려가야 하는데, 평소에 기본적으로 UseCase - Repository로 이어지는 Flow로 패키지 및 코드를 구성했다.

예시

이번 사태의 원흉인 레거시 STOMP 코드이다.
MVVM 배우면서 작성했던 어플인데, 지금 와서 보니 MVVM이라고 주장하는 무언가였다 아무튼 MVVM 아님
https://github.com/choi-hyeseong/Mudle/blob/master/app/src/main/java/com/comet/mudle/web/stomp/StompService.kt

class StompService(private val localUserService: LocalUserService,
                   private val serverUserService: ServerUserService,
                   private val musicService: MusicService,
                   private val okHttpClient: OkHttpClient
) {

    private lateinit var connection: Disposable
    private lateinit var subscribe: Disposable
    private lateinit var stompClient: StompClient
    private val user = localUserService.getUser()
    private val mapper = ObjectMapper()
    val chatLiveData: ListLiveData<Chat> = ListLiveData()
    val serverStatLiveData: MutableLiveData<Boolean> = MutableLiveData()


    fun connect() {
        //okhttp내 websocket 사용 -> 코루틴 / Thread 없이도 자체 쓰레드로 돌아감
        val url = "ws://192.168.219.106:8080/ws"
        val intervalMillis = 1000L
        stompClient = StompClient(okHttpClient, intervalMillis).let {
            it.url = url
            it
        }
        connection = stompClient.connect().subscribe {
            //connection type
            when (it.type) {
                Event.Type.OPENED -> {
                    CoroutineScope(Dispatchers.IO).launch {
                        serverUserService.renewUser(user.uuid)
                        musicService.renewMusic()
                    }
                    serverStatLiveData.postValue(true)
                    startSubscribe() //open시 재구독 -> 다시 접속해도 메시지 주고 받을 수 있게
                }

                else -> {
                    serverStatLiveData.postValue(false)
                    if (this@StompService::subscribe.isInitialized) //this@Class 순으로 반대로 써야댐..
                        subscribe.dispose() //구독만 취소 (connection 끊으면 reconnect 안함)
                }
            }
            Log.i(LOG, "${it.type}")
        }

    }

    fun send(message: String) {
        //subscribe 까지
        val mapper = ObjectMapper()
        val chat = Chat(MessageType.USER, user.uuid, user.name, message, System.currentTimeMillis())
        stompClient.send("/pub/message", mapper.writeValueAsString(chat)).subscribe()
    }

    private fun startSubscribe() {
        subscribe = stompClient.join("/sub/message").subscribe {
            //message

            //kotlin은 NoArgsConstructor 미지원 -> dataclass에서 설정 필요..ㅅ
            val chat: Chat = mapper.readValue(it, Chat::class.java)
            if (chat.type == MessageType.REQUEST)
                CoroutineScope(Dispatchers.IO).launch { musicService.renewMusic() }
            else if (chat.type == MessageType.UPDATE && UUID.fromString(chat.message)
                    .equals(user.uuid))
                CoroutineScope(Dispatchers.IO).launch { serverUserService.renewUser(user.uuid) }
            else if (chat.type == MessageType.USER || chat.type == MessageType.ALERT)
                chatLiveData.add(chat)
        }
    }

    fun close() {
        if (this::subscribe.isInitialized)
            subscribe.dispose()
        if (this::connection.isInitialized)
            connection.dispose()
    }

}

대충 개판인 코드

일단, 해당 코드를 MVVM으로 복구해보기 위해 생각해봤다.

Repair

일단, 해당 코드는 STOMP로 웹소켓에 연결하고, 메시지를 읽어오고, 전송하는 역할 2개를 맡고 있다.
그리고, 소켓과 연결시 유저 정보와 현재 재생중인 노래를 갱신하는 역할도 한다.

아주 완벽하게 SOLID중 S를 위배하는 코드다.

이를 Repository로 구성해보고자 한다.

class MusicRepository(val dao : StompDao) {
	
    private lateinit var stompClient : StompClient //connect 정보를 다 담고 있는 Client, state를 갖고 있음
    private lateinit var callback : MessageCallback
    
	suspend fun connect(messageCallback : MessageCallback) {
    	this.callback = messageCallback
    	stompClient = dao.connect {
        	when (it.type) {
            	Event.Type.OPENED -> messageCallback.onOpen(it)
                Event.Type.ERROR -> messageCallback.onError(it)
            }
        }
        stompClient.join("").subscribe {
        	val chat = deserialize(it)
            messageCallback.onMessageReceived(chat)
        }
        
    }
    
    suspend fun isConnected() = stompClient::isInit~
    
    suspend fun disconnect() : Boolean {
    	return if (연결안됨)
        	false
       	else {
        	callback.onClose()
            stompClient.disconnect()
           }
    }
    
    suspend fun sendMessage(message : String) {
    	stompClient.sendMessage(message)
    }
}

interface MessageCallback {
	fun onMessageReceived(message : Chat) 
    
    fun onError(error: Enum)
    
    fun onOpen()
    
    fun onClose()
}

대충 이런 느낌으로 Repository와 StompDao를 이용해서 웹소켓에 연결하고 이를 다룰 수 있는 콜백을 생성했다.

그러면 Callback을 VM에 구현하면 되지 않을까?

class AwesomeVM : ViewModel, MessageCallback {
	val message : MutableLiveData<List<Chat>> 
    val error : MutableLiveData<String>
    
    override fun onMessageReceived(message : Chat) {
    	this.message.value.add(message) //이 방식은 작동안한다. postValue로 notify해줘야 함 - ListLiveData로 구현해줘야됨
    }
    
    override fun onOpen() {
    	//open
    }
    
    override fun onError(error : Enum) {
    	this.error.value = error
    }
    
    fun connect() {
    	// 필요시 musicRepository.isConnected 사용
    	musicRepository.connect(this)
    }
}

이렇게 콜백 인터페이스를 구현하고 레포지토리를 구성해서 MVVM 형식으로 리팩토링을 진행했다.
만약 웹소켓 연결, 종료시 갱신등이 필요하다면 콜백 함수에 이를 구현함으로써 해결할 수 있다.

매우 만족스럽게 리팩토링이 된것 같지만, 뭔가 하면 안될것 같은 코드를 작성한것 같다.
기존 Repository 패턴에서는 Stateless한 Retrofit 요청만 넣어서 상태에 대해 신경 쓸 필요가 없었다.
단순히 GET요청만 넣고, 만약 인증 정보가 필요한 POST를 사용할때는 JWT를 이용하면 됐으니..

그런데 위 코드에선 Stomp를 이용해 웹소켓에 연결하고, 해당 연결정보를 저장해두고 있다. (StompClient)
이 방식이면 Stateful한 방식인데, 레포지토리를 이렇게 상태를 부여해도 괜찮은걸까?

https://stackoverflow.com/questions/47839180/can-a-ddd-repository-be-stateful
DDD (Domain Driven Development) 방식에서 Repository는 Stateful한 방식으로 구현되어도 된다고 한다.

GPT4도 그렇다고 한다.

네, Repository 패턴에서 상태를 관리하는 것은 괜찮습니다. 실제로, Repository 패턴의 목적 중 하나는 데이터 소스로부터 데이터를 추상화하여, 애플리케이션의 나머지 부분에서 데이터를 쉽게 사용할 수 있도록 하는 것입니다. 이 과정에서 Repository가 데이터 상태를 내부적으로 관리하는 것은 자연스러운 일입니다.

Repository는 데이터를 캐싱, 데이터 변환, 메모리 내 데이터 저장 등의 방식으로 상태를 관리할 수 있습니다. 이렇게 데이터 상태를 관리하는 것은 네트워크 호출 최적화, 사용자 경험 향상, 데이터 일관성 유지 등의 목적을 달성할 수 있게 해줍니다.

예를 들어, 사용자가 앱을 사용하는 동안 여러 화면에서 동일한 데이터를 요청할 수 있습니다. 이때, Repository가 첫 번째 요청에 대한 데이터를 메모리에 캐싱해두었다가, 동일한 요청이 들어올 때 네트워크 호출 없이 즉시 데이터를 제공할 수 있습니다. 이 방식은 데이터 로딩 시간을 단축시키고 네트워크 트래픽을 줄이는 데 도움이 됩니다.

하지만, 상태를 관리할 때는 데이터 일관성과 최신 상태 유지에 주의해야 합니다. 예를 들어, 데이터가 변경되었을 때 캐싱된 데이터도 업데이트되어야 하며, 사용자에게 항상 최신 데이터가 보여지도록 해야 합니다.

결론적으로, Repository가 상태를 관리하는 것은 애플리케이션의 데이터 관리를 효율적으로 만들어 주지만, 데이터 일관성과 최신성을 유지하는 것이 중요합니다.

웹소켓 또한 데이터 소스로 볼 수 있으므로, 이를 추상화 하는 레포지토리의 특성상 상태를 관리할 수 밖에 없다.
그래서 상태를 갖는거에는 큰 부담을 가질 필요는 없되, '상태'를 가지기 때문에 같은 메소드라도 다른 결과값을 반환하는것에 유의해서 사용해야 할것이다.

근데도 좀 State는 VM에서 관리하고 싶은데.. 라는 생각이 들긴 한다.

Enhancement

class MusicRepository {
	suspend fun connect(callback : Callback) : Client = dao.connect(callback)
}

class Client {
	fun close() 
    
    fun sendMessage(message : String)
}

이런식으로 Repository에서 상태를 저장하는게 아니라, 상태를 갖는 객체인 Client를 밖으로 빼서, ViewModel에서 상태를 관리하도록 한다.
이렇게 하면 Repository는 connect하는 기능밖에 없지만, 상태를 갖지 않는 Stateless로 볼 수 있다.

근데 이렇게 하면 Repository에 기능이 너무 없어서 잘 모르겠다는 생각이 든다.

Off The Record

그러면 Stateful한 웹 요청에 사용되는 세션 정보를 레포지토리에 담아도 될까?

레포지토리 내에서 세션을 직접 관리하는 것은 일반적으로 추천되지 않습니다. 레포지토리 패턴의 주 목적 중 하나는 데이터 소스의 추상화와 데이터 접근의 단순화입니다. 이를 통해 애플리케이션 나머지 부분에서 데이터를 쉽게 사용할 수 있도록 하는 것이죠. 하지만, 세션 관리는 보통 인증과 상태 관리와 같은 다른 책임을 수반하는데, 이는 레포지토리의 주 책임과는 거리가 있습니다.

세션 관리는 주로 사용자의 로그인 상태, 사용자 특정 설정, 사용자 세션에 대한 정보 등을 관리하는데 사용됩니다. 이러한 세션 데이터는 애플리케이션의 다양한 부분에서 필요로 하지만, 이를 데이터 접근 로직과 직접적으로 연결하는 것은 여러 문제를 야기할 수 있습니다:

책임의 분리(Separation of Concerns): 레포지토리가 데이터 접근 로직 외에 세션 관리까지 담당하게 되면, 책임이 혼합되어 코드의 유지보수가 어려워집니다. 각 구성 요소는 한 가지 책임만 가져야 하는 원칙을 위배하게 됩니다.

재사용성: 세션 관리 로직이 레포지토리에 포함되어 있으면, 세션 관리가 필요하지 않은 다른 상황이나 애플리케이션에서 해당 레포지토리의 재사용성이 떨어집니다.

테스트 용이성: 세션 관리 로직이 포함된 레포지토리는 테스트가 더 어려워질 수 있습니다. 데이터 접근 로직과 세션 관리 로직을 분리하여 각각 독립적으로 테스트하는 것이 더 나을 수 있습니다.

따라서, 세션 관리는 서비스 레이어나 미들웨어, 인터셉터 같은 다른 구성 요소를 통해 처리하는 것이 좋습니다. 이러한 구성 요소는 사용자 인증, 세션 관리 등의 책임을 전문적으로 다루며, 레포지토리는 순수하게 데이터 접근과 관련된 로직에만 집중할 수 있게 됩니다.

GPT는 일단 안된다곤 한데, SOLID 관점에서 사용하지 말라고 한다.
그러면 세션을 관리하는 레포지토리를 만들어도 되고, 세션 정보를 잘 관리하는 방법도 있겠다

profile
KRW 채굴기

0개의 댓글