
3부 - Composable Architecture에서는 Alert/Toast는 어떻게 사용할 수 있는가?
개인 광고 영역
이 글에서는 Composable Architecture에서 Alert(Dialog)/Toast(Snackbar) 활용법을 소개한다.
기본 형태는 1부/2부에서 소개한 Action을 활용하는 시스템을 적용했다.
이 글은 디자인 시스템이 있을 경우 Dialog/Toast를 공통화시키는 방식을 다루고 있다.
이 글에서는
- Alert/Toast(Snackbar)를 공통화 시켜 다루는 내용
- 앞에서 설명한 Action을 설명하지는 않는다.
- 기본적인 내용을 담지 않고있어 앞선 글을 참고하면 좋다.
이전 글
Action을 활용한 공통화
Action을 도입한 이유는 무엇일까?
- A 케이스 : 디자인 시스템을 활용한 Alert/Toast를 SideEffect로 항상 만들다 보니 공통화 작업이 필요했다.
- 어떻게 접근해 볼 수 있을까?
- B 케이스 : 네트워크 오류 등이 발생했을 때 공통적인 SideEffect 처리가 필요했다.
- 어떻게 접근해 볼 수 있을까?
결국 둘 다 어떻게 접근해 볼 수 있으며, 더 쉬운 접근은 없을까? 테스트가 가능할까?로 이어질 수 있다.
A 케이스 / B 케이스 모두를 포함한 예시 코드
시나리오: 사용자가 보내기 버튼을 누른다. 버튼을 누르면 “이 메시지를 전송하시겠습니까?” Yes/No Dialog가 노출된다. 이때 ‘Yes’를 제외한 모든 선택(No 클릭, 바깥 영역 클릭, 뒤로 가기)은 취소로 간주한다. ‘Yes’를 누르면 네트워크 API 통신을 시작한다.
추가 시나리오: API 통신 중 실패해서 Snackbar를 노출해야 한다면?
기존 방식 코드: (전반적인 데이터 흐름 파악을 위한 예시)
// ViewModel 부분
class SendViewModel(/* private val apiRepository: ApiRepository */) : ViewModel() {
private val _sideEffect = Channel<SendSideEffect>(Channel.BUFFERED)
val sideEffect: Flow<SendSideEffect> = _sideEffect.receiveAsFlow()
// --- 보내기 버튼 클릭 시 ---
fun onSendClicked() {
// 메시지가 비어있는 경우, 확인 단계에서 처리하도록 여기서는 바로 다이얼로그 요청
sendSideEffect(SendSideEffect.ShowConfirmationDialog)
}
// --- 확인 다이얼로그에서 '예'를 눌렀을 때 ---
fun onConfirmationAccepted() {
val message = _uiState.value.messageToSend
// 메시지 비어있는지 확인 (여기서 하거나 API 호출 전에 할 수 있음)
if (message.isBlank()) {
sendSideEffect(SendSideEffect.ShowSendMessageError("메시지는 비워둘 수 없습니다"))
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } // 로딩 상태 시작
kotlinx.coroutines.delay(1500) // 네트워크 통신 시뮬레이션
val success = false // API 호출 결과 시뮬레이션 (실패 케이스)
_uiState.update { it.copy(isLoading = false) } // 로딩 상태 종료
if (success) {
sendSideEffect(SendSideEffect.ShowSendMessageSuccess("메시지를 성공적으로 보냈습니다!"))
_uiState.update { it.copy(messageToSend = "") } // 성공 시 메시지 초기화
} else {
// 실패 시나리오
sendSideEffect(SendSideEffect.ShowSendMessageError("메시지 전송 실패"))
}
}
}
// --- 확인 다이얼로그에서 '아니요' 또는 외부 클릭으로 취소했을 때 ---
fun onConfirmationCancelled() {
println("사용자가 메시지 전송을 취소했습니다.")
}
// --- SideEffect를 채널로 보내는 내부 함수 ---
private fun sendSideEffect(effect: SendSideEffect) {
viewModelScope.launch {
_sideEffect.send(effect)
}
}
}
// SideEffect 정의
sealed interface SendSideEffect {
data object ShowConfirmationDialog : SendSideEffect // 확인 다이얼로그 표시 요청
data class ShowSendMessageSuccess(val successMessage: String) : SendSideEffect // 성공 메시지 표시 요청
data class ShowSendMessageError(val errorMessage: String) : SendSideEffect // 에러 메시지 표시 요청
}
아래는 View 부분의 코드이다.
// SideEffect 처리 Composable
@Composable
fun HandleSideEffects(
sideEffectFlow: Flow<SendSideEffect>, // ViewModel로부터 SideEffect Flow 받기
snackbarHostState: SnackbarHostState, // Snackbar를 표시하기 위한 상태 객체
onShowConfirmationDialog: () -> Unit // 확인 다이얼로그를 표시하는 람다 함수
) {
// LaunchedEffect는 Composable의 생명주기 내에서 안전하게 코루틴을 실행한다.
// key1 = true는 Composable이 처음 생성될 때 한 번만 실행되도록 보장한다.
LaunchedEffect(true) {
// collectLatest: 새로운 SideEffect가 도착하면 이전 처리 로직이 끝나지 않았더라도 취소하고 새 로직을 시작한다.
sideEffectFlow.collectLatest { effect ->
when (effect) {
// 확인 다이얼로그 표시 요청 처리
is SendSideEffect.ShowConfirmationDialog -> {
onShowConfirmationDialog() // 전달받은 람다 실행하여 다이얼로그 표시 상태 변경
}
// 성공 메시지 표시 요청 처리
is SendSideEffect.ShowSendMessageSuccess -> {
snackbarHostState.showSnackbar(
message = effect.successMessage, // ViewModel에서 전달된 성공 메시지
duration = SnackbarDuration.Short // 짧게 표시
)
}
// 에러 메시지 표시 요청 처리
is SendSideEffect.ShowSendMessageError -> {
snackbarHostState.showSnackbar(
message = effect.errorMessage, // ViewModel에서 전달된 에러 메시지
duration = SnackbarDuration.Long // 길게 표시
)
}
}
}
}
}
// 메인 화면 Composable
@Composable
fun SendScreen(
modifier: Modifier = Modifier,
viewModel: SendViewModel = viewModel() // ViewModel 인스턴스 가져오기
) {
// 확인 다이얼로그의 표시 여부를 관리하는 상태 변수
var showDialog by remember { mutableStateOf(false) }
// Snackbar 메시지를 표시하기 위한 상태 객체
val snackbarHostState = remember { SnackbarHostState() }
// SideEffect 처리 로직 실행 (별도 Composable 사용)
HandleSideEffects(
sideEffectFlow = viewModel.sideEffect,
snackbarHostState = snackbarHostState,
onShowConfirmationDialog = { showDialog = true } // SideEffect 발생 시 showDialog 상태를 true로 변경
)
// Scaffold: 기본적인 머티리얼 디자인 레이아웃 구조 제공
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) } // Snackbar가 표시될 위치 지정
) { paddingValues -> // Scaffold 내부 컨텐츠 영역의 패딩 값
// UI 생략
}
// showDialog 상태가 true일 때만 확인 다이얼로그 표시
if (showDialog) {
AlertDialog(
onDismissRequest = {
showDialog = false // 다이얼로그 닫기
viewModel.onConfirmationCancelled() // ViewModel에 취소 알림
},
title = { Text("전송 확인") },
text = { Text("이 메시지를 전송하시겠습니까?") },
confirmButton = {
Button(
onClick = {
showDialog = false // 다이얼로그 닫기
viewModel.onConfirmationAccepted() // ViewModel에 확인 알림
}
) { Text("예") }
},
dismissButton = {
Button(
onClick = {
showDialog = false // 다이얼로그 닫기
viewModel.onConfirmationCancelled() // ViewModel에 취소 알림
}
) { Text("아니요") }
}
)
}
}
위 코드는 앞서 설명한 2개의 시나리오(정상 확인 후 실패, 또는 확인 단계에서 실패)를 처리하는 일반적인 형태의 코드이다. 사용자의 확인(Confirm)을 받고, 네트워크 API 통신을 시작하고, 결과를 처리한다.
공통 부분 찾기
이 흐름(flow)을 보면, 1개의 Dialog가 노출될 수 있고, 2개의 Snackbar(성공/실패)가 동작할 수 있다.
그럼 위 코드와 흐름(flow) 차트를 기반으로 공통적으로 발생하는 부분을 찾아본다.
- N개의 View(@Composable)에서 동일한 UI/동작을 가진 Dialog를 노출해야 한다.
- N개의 View(@Composable)에서 동일한 동작(단순 메시지 표시 등)의 Snackbar(Toast)가 필요하다.
나는 여기서 N개의 화면에서 반복되는 이 부분을 중요하게 생각했고, 이걸 공통화해서 줄이고 싶었다.
UI의 공통화
별도의 모듈을 통해 Alert/Toast(Snackbar) 부분을 공통화했다.
composable-architecture-alert-system - link
일단 라이브러리 형태로 만들어서 Alert UI의 커스텀 가능성을 열어두었다. 기본 Dialog UI 외에 다른 디자인을 쓰고 싶다면 onDialogScreen 파라미터를 사용하면 된다.
// Alert/Snackbar 처리를 위한 공통 Composable
@Composable
fun CaAlertScreen(
snackbarHostState: SnackbarHostState, // Snackbar 상태 관리
// Dialog UI를 커스텀하고 싶을 때 제공하는 람다
onDialogScreen: (@Composable (caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, onAction: (nextAction: CaAction) -> Unit) -> Unit)? = null,
) {
// 내부 구현 호출
InternalCaAlertScreen(
snackbarHostState = snackbarHostState,
onDialogScreen = onDialogScreen,
)
}
커스텀이 필요하다면 다음과 같이 Dialog를 직접 그리는 Composable을 만들어서 onDialogScreen으로 넘겨주면 된다.
// 예시: 커스텀 Dialog Composable
@Composable
internal fun CustomDialogScreen(
caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, // Dialog 상태 정보
onAction: (nextAction: CaAction) -> Unit, // Dialog 내 버튼 클릭 시 실행할 Action 전달 함수
) {
AlertDialog(
icon = { /* 아이콘 로직 */ },
title = { /* 타이틀 로직 */ },
onDismissRequest = {
// 바깥 영역 클릭 또는 뒤로가기 시 실행될 Action 전달
onAction(caAlertUiStateDialogUiState.onDismissRequest)
},
confirmButton = { /* 확인 버튼 로직, 클릭 시 onAction(confirmAction) 호출 */ },
dismissButton = { /* 취소 버튼 로직, 클릭 시 onAction(dismissAction) 호출 */ }
// 생략
)
}
그럼 이 공통 UI(CaAlertScreen)를 동작시키는 부분은 어떻게 하는가 하면, 바로 우리가 일반적으로 사용하는 UiState
와 SideEffect
방식을 활용했다. 이 공통 시스템을 위한 별도의 ViewModel(CaAlertViewModel)을 만들었다.
여기서 중요한 건, 공통화를 시켰지만 이 CaAlertViewModel
은 특정 화면의 로직을 모른다. 단순히 CaAlertAction
이라는 특정 Action
을 받아서, 그에 맞는 Dialog 상태(UiState)
를 만들고 화면에 표시하라는 SideEffect
를 보낼 뿐이다. 마치 이벤트를 받아서 전달(포워딩)하는 역할 정도이다.
이때 1부/2부에서 소개한 Action 시스템을 사용했다는 점이 중요하다. 이 Action 스트림
은 앱 전역에서 접근 가능(싱글턴 또는 앱 범위 스코프)하기 때문에, 어떤 ViewModel이나 Composable에서도 CaAlertAction
을 보낼 수 있고, CaAlertViewModel
은 이걸 받아서 처리할 수 있다. 같은 범위 내의 View에서는 동작하는 데 문제가 없다는 점이다.
// Alert/Snackbar 처리를 전담하는 ViewModel
@HiltViewModel
class CaAlertViewModel @Inject constructor(
private val flowCaActionStream: FlowCaActionStream, // Action 스트림 주입
) : CaViewModel<CaAlertAction>( // CaAlertAction 타입의 Action만 수신
flowCaActionStream = flowCaActionStream,
actionClass = CaAlertAction::class,
) {
// Dialog의 내용을 담는 UiState
private val _alertUiStateDialogUiState = MutableStateFlow(CaAlertUiStateDialogUiState.Default)
val alertUiStateDialogUiState = _alertUiStateDialogUiState.asStateFlow()
// Dialog/Snackbar 표시/숨김 명령을 위한 SideEffect 채널
private val _sideEffect = Channel<CaAlertSideEffect>(Channel.BUFFERED)
internal val sideEffect = _sideEffect.receiveAsFlow()
// CaAlertAction을 받았을 때 처리하는 로직 (Reducer)
override suspend fun reducer(action: CaAlertAction) {
when (action) {
is CaAlertAction.ShowDialog -> {
// Action의 정보로 Dialog UiState 생성
val dialogItem = CaAlertUiStateDialogUiState( /* ... action의 데이터 사용 ... */ )
_alertUiStateDialogUiState.value = dialogItem
// Dialog를 표시하라는 SideEffect 전송
_sideEffect.send(CaAlertSideEffect.ShowDialog)
}
is CaAlertAction.HideDialog -> {
// 기본 상태로 되돌리고 Dialog 숨김 SideEffect 전송
_alertUiStateDialogUiState.value = CaAlertUiStateDialogUiState.Default
_sideEffect.send(CaAlertSideEffect.HideDialog)
}
is CaAlertAction.ShowSnack -> {
// Action 정보로 Snackbar SideEffect 생성 및 전송
val snackItem = CaAlertSideEffect.ShowSnack( /* ... action의 데이터 사용 ... */ )
_sideEffect.send(snackItem)
}
is CaAlertAction.None -> { /* 무시 */ }
}
}
// 내부 Duration 타입을 Material의 SnackbarDuration으로 변환하는 함수
private fun CaAlertAction.ShowSnack.Duration.convert(): SnackbarDuration = /* ... 변환 로직 ... */
}
// CaAlertViewModel에서 사용하는 SideEffect 정의
internal sealed interface CaAlertSideEffect {
data object ShowDialog : CaAlertSideEffect
data object HideDialog : CaAlertSideEffect
data class ShowSnack(
val message: String,
val actionLabel: String,
val onAction: CaAction,
val onDismiss: CaAction,
val duration: SnackbarDuration,
) : CaAlertSideEffect
}
// CaAlertViewModel의 UiState 정의
internal data class CaAlertUiStateDialogUiState( /* ... Dialog 속성들 ... */ ) {
companion object {
val Default = CaAlertUiStateDialogUiState( /* ... 기본값 ... */ )
}
}
// CaAlertViewModel이 수신하는 Action 정의
sealed interface CaAlertAction : CaAction {
// Dialog 표시 Action
data class ShowDialog( /* ... Dialog 속성 및 클릭 시 발생시킬 Action 정의 ... */ ) : CaAlertAction
// Dialog 숨김 Action
data object HideDialog : CaAlertAction
// Snackbar 표시 Action
data class ShowSnack( /* ... Snackbar 속성 및 클릭/닫기 시 발생시킬 Action 정의 ... */ ) : CaAlertAction
// 빈 Action
data object None : CaAlertAction
}
위 CaAlertViewModel의 이벤트를 받아서 실제 UI(Dialog, Snackbar)를 노출하는 부분은 InternalCaAlertScreen Composable이다.
// CaAlertScreen의 내부 구현 Composable
@Composable
private fun InternalCaAlertScreen(
snackbarHostState: SnackbarHostState,
onDialogScreen: (@Composable (caAlertUiStateDialogUiState: CaAlertUiStateDialogUiState, onAction: (nextAction: CaAction) -> Unit) -> Unit)? = null,
caAlertViewModel: CaAlertViewModel = viewModel(), // CaAlertViewModel 인스턴스
) {
// Dialog 표시 여부 상태
var showDialog by remember { mutableStateOf(false) }
// Action 전송을 위한 객체 (CompositionLocal 활용)
val actionSender = LocalCaActionOwner.current
// CaAlertViewModel의 SideEffect를 구독하고 처리
caAlertViewModel.sideEffect.collectLifecycleEvent { event -> // collectLifecycleEvent는 Lifecycle 고려한 확장 함수로 가정
when (event) {
is CaAlertSideEffect.ShowDialog -> showDialog = true // Dialog 표시 상태 변경
is CaAlertSideEffect.HideDialog -> showDialog = false // Dialog 숨김 상태 변경
is CaAlertSideEffect.ShowSnack -> {
// Snackbar 표시 로직
val result = snackbarHostState.showSnackbar( /* ... event 데이터 사용 ... */ )
// Snackbar 결과에 따라 후속 Action 전송
when (result) {
SnackbarResult.ActionPerformed -> actionSender.send(event.onAction) // 'Action' 버튼 클릭 시
SnackbarResult.Dismissed -> actionSender.send(event.onDismiss) // 닫혔을 때
}
}
}
}
// CaAlertViewModel의 Dialog UiState 구독
val caAlertUiStateDialogUiState by caAlertViewModel.alertUiStateDialogUiState.collectAsStateWithLifecycle()
// showDialog 상태가 true이면 Dialog 표시
if (showDialog) {
// Dialog 내 버튼 클릭 시 실행될 공통 로직 (Dialog 숨기고, 지정된 다음 Action 전송)
val onAction: (nextAction: CaAction) -> Unit = { nextAction ->
actionSender.send(CaAlertAction.HideDialog) // 먼저 Dialog 숨김 Action 전송
actionSender.send(nextAction) // 그 다음, 버튼에 지정된 Action 전송
}
// 커스텀 Dialog 람다가 제공되면 그것을 사용, 아니면 기본 CaDialogScreen 사용
onDialogScreen?.invoke(caAlertUiStateDialogUiState, onAction)
?: CaDialogScreen(caAlertUiStateDialogUiState = caAlertUiStateDialogUiState, onAction = onAction)
}
// CaAlertViewModel의 생명주기 관리를 위한 Composable (내부 구현은 생략)
LaunchedLifecycleViewModel(viewModel = caAlertViewModel)
}
이제 이 공통 Alert/Snackbar 시스템을 사용하는 쪽(예: 특정 화면의 ViewModel이나 Composable)에서는 단순히 CaAlertAction을 Action 스트림으로 보내기만 하면 된다.
// Composable에서 Action 전송 예시
val actionSender = LocalCaActionOwner.current
actionSender.send(
CaAlertAction.ShowDialog(
title = "전송 확인",
message = "메시지를 보내시겠습니까?",
confirmButtonText = "예",
// '예' 버튼 클릭 시 실행될 Action 정의 (여기서는 Snackbar 표시 Action)
onConfirmButtonAction = CaAlertAction.ShowSnack(message = "확인됨"),
dismissButtonText = "아니요",
// '아니요' 버튼 클릭 시 실행될 Action 정의
onDismissButtonAction = CaAlertAction.ShowSnack(message = "취소됨"),
// 바깥 영역 클릭 시 실행될 Action
onDismissRequest = CaAlertAction.None // 아무것도 안함
)
)
// ViewModel에서 Action 전송 예시 (nextAction 함수 사용 가정)
nextAction( // BaseViewModel 등에 구현된 Action 전송 함수
CaAlertAction.ShowDialog( /* ... 위와 동일 ... */ )
)
이 코드의 데이터 흐름을 보자
위의 공통화 코드를 기반으로 다음과 같은 시나리오의 데이터 흐름을 생각해 볼 수 있다.
시나리오:
- 사용자 액션: 사용자가 ‘보내기’ 버튼을 클릭. (메시지 유효성 검사는 이미 통과했다고 가정)
- 화면 ViewModel:
SendViewModel
은onSendClicked
함수 내에서CaAlertAction.ShowDialog
를 Action 스트림으로 전송. - 공통 Alert ViewModel:
CaAlertViewModel
이 Action 스트림에서CaAlertAction.ShowDialog
를 수신. - Dialog 상태 업데이트:
CaAlertViewModel
은 Dialog 내용을 담은UiState
를 업데이트하고,ShowDialog
SideEffect를 전송. - Dialog 표시:
CaAlertScreen
Composable이CaAlertViewModel
의SideEffect
를 받아showDialog
상태를true
로 변경.UiState
를 사용해 Dialog 내용을 그림. - 사용자 Dialog 액션: 사용자가 Dialog의 ‘예’(Confirm) 버튼을 클릭.
- Dialog 내부 처리:
CaAlertScreen
내의onAction
람다가 실행됨.CaAlertAction.HideDialog
Action을 전송하여CaAlertViewModel
이 Dialog를 닫도록 함.ShowDialog
Action 정의 시onConfirmButtonAction
으로 지정했던CaAlertAction.ShowSnack(message = "확인됨")
Action을 전송.
- Snackbar 표시:
CaAlertViewModel
이ShowSnack
Action을 받아ShowSnack
SideEffect를 전송하고,CaAlertScreen
이 이를 받아 Snackbar를 표시. (만약 Confirm 버튼에 다른 Action, 예를 들어SendMessageConfirmAction
같은 것을 지정했다면 그게 전송됨) - (만약 Confirm 시 네트워크 호출이 필요하다면):
SendViewModel
이SendMessageConfirmAction
같은 Action을 수신하고,onConfirmationAccepted
로직(네트워크 호출 등)을 실행. 그 결과에 따라 다시CaAlertAction.ShowSnack
(성공/실패 메시지) 등을 전송할 수 있음.
이를 도식화한 부분이 아래와 같은데, 이전 흐름과 달리 User Action이 ViewModel을 거쳐 Action 전송으로 이어지고, 이걸 CaAlertViewModel이 받아서 처리 후 CaAlertScreen에 Dialog/Snackbar를 표시하는 구조이다. Dialog 내 사용자 액션은 다시 Action 전송으로 이어져 CaAlertViewModel이나 다른 ViewModel이 처리할 수 있게 된다.
여기서 특이한 점은 SendViewModel
에서 바로 Dialog를 표시하는 SideEffect를 보내는 게 아니라, CaAlertAction
이라는 Action을 보낸다는 것이다. 그리고 이 Action을 구독하고 있던 별도의 Dialog 처리 ViewModel(CaAlertViewModel
)에서 작업을 이어받아 처리한다는 것이다.
장/단점
Action을 어디서든 보낼 수 있다는 점은 확실히 편하다. 하지만 이 이벤트가 어디서 발생했는지 추적하기 어려울 수 있다는 단점이 있다. 마치 옛날의 이벤트 버스(Event Bus)를 떠올리게 한다. 결국 필터(filter)를 걸더라도 비슷한 문제가 생길 수 있다. 디버깅 시 Action의 출처를 명확히 하거나, Action 자체에 출처 정보를 포함하는 등의 추가적인 고민이 필요할 수 있다.
Next
이번 글에서는 Action을 활용한 Alert/Toast(Snackbar)의 공통화 작업 코드를 살펴봤다. “공통화한다면 어떤 식으로 접근해볼 수 있을까?” 라는 질문에서 출발한 코드이다.
이런 작업을 쉽게 하려고 Action 시스템을 만들었는데, 이건 TCA(The Composable Architecture)의 방식과도 유사한 면이 있다. TCA에서도 Reducer에서 바로 Alert 관련 상태를 변경하고 이를 활용하는 방식과 비슷하다.
우리가 작성하는 대부분의 코드는 View와 ViewModel 사이를 오가며 진행된다. 특히 Dialog는 View로 이벤트를 던지고 나면 ViewModel과 직접적인 흐름을 이어가기 어려운 경우가 많다. 하지만 Action 시스템을 사용하면 Dialog의 결과(예: 확인/취소)를 다시 Action으로 변환하여 다른 ViewModel이 수신하고 후속 작업을 이어갈 수 있게 된다.
다른 이야기지만, 이런 Action 기반 시스템은 테스트에도 이점이 있을 수 있다. (만약 구현한다면) await()
같은 메커니즘으로 Action 처리 결과를 기다릴 수 있게 만든다면, 한 사이클의 시나리오 기반 유닛 테스트(Unit Test)가 가능해진다. View를 검증하지 않고도 로직 테스트만으로 충분히 검증할 수 있고, 안정적인 테스트가 가능하다는 장점이 있다. 이런 방식(await)을 바로 적용하기엔 설계가 복잡해질 수 있으므로, 우선은 Action을 통해 서로 다른 ViewModel들이 이벤트를 구독하고 처리할 수 있도록 하고, 테스트 가능한 구조를 만들어 보았다.
다음 글에서는 ‘그럼 이 구조에서 테스트는 어떻게 할 수 있는가?’에 대해 다룰 예정이다.
작성 글 이어보기
- 1부 - 컴포즈에 사용할 Composable Architecture 설명(리엑트?)
- 2부 - Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.
- 3부 - Composable Architecture에서는 Alert/Toast는 어떻게 사용할 수 있는가?
- 예고 - Composable Architecutre를 활용한 Alert/Toast?의 테스트는 어떻게 하나요?
- 예고 - Composable Architecutre를 활용한 Router?
Comments