Circuit에서는 Logging 또는 Analytics를 위한 SideEffect API인 ImpressionEffect 를 지원한다. 이름이 멋있다

해당 API의 특징과 기존에 Compose 환경에서 Logging 또는 Analytics를 위해 사용하였던 LaunchedEffect(key or Unit) 와 어떤 차이점이 있는지 알아보도록 하겠다.
구현체를 확인해보면 다음과 같다.
/**
 * A side effect that will run an [impression]. This [impression] will run only once until it is
 * forgotten based on the current [RetainedStateRegistry] or until the [inputs] change. This is
 * useful for single fire side effects like logging or analytics.
 *
 * @param inputs A set of inputs that when changed will cause the [impression] to be re-run.
 * @param impression The side effect to run.
 */
@Composable
public fun ImpressionEffect(vararg inputs: Any?, impression: () -> Unit) {
  rememberRetained(*inputs, init = impression)
}
함수에 적혀있는 주석을 해석해보면
Impression 을 실행하는 SideEffect 이다.
이 Impressions 은 현재 RetainedStateRegistry에 의해 잊혀지거나, inputs가 변경될 때까지 단 한번만 실행된다.
이는 Logging이나 Analytics와 같이 한번만 실행되면 되는 SideEffect에 유용하다.
함수의 시그니처를 분석해보면 알 수 있는 사실이 두가지가 있는데
-> 일반 함수 () -> Unit(즉시 실행, 일시 중단 불가)
-> 즉시 완료되는 작업을 위한 용도이다.
ImpressionEffect 내부 블럭에서 호출되는 함수가 rememberRetained 에 의해 관리된다.
rememberRetained 함수에 대해선 이전에 알아본 바 있어 자세한 설명을 링크로 대체하도록 하겠다.
rememberRetained, produceRetainedState 함수 분석(1)
rememberRetained, produceRetainedState 함수 분석(2)
간단하게 요약하면 rememberRetained 는 다음과 같은 특징을 가진다.
configuration change로 부터 상태를 유지한다.
-> configuration change가 발생해도 블럭 내에 함수가 다시 호출되지 않는다.
navigation을 통해 A 화면 -> B 화면으로 이동해도 A 화면(Screen)은 아직 backstack에 남아있기 때문에, A 화면 내 rememberRetained 를 통해 관리하는 상태는 제거되지 않는다.
-> A 화면에 다시 돌아와도 함수가 다시 호출되지 않는다
A -> B -> A 로 B 화면이 pop() 되는 경우, B(Screen) 는 더 이상 backstack 에도 남아있지 않기 때문에 rememberRetained 를 통해 관리하는 B 화면 내에 상태는 제거된다.
-> pop()이 호출되어 화면이 백스택에서 제거되고, 그 이후 다시 화면에 진입하는 경우엔 블럭 내 함수가 다시 호출된다.   
차이를 알기 위해 LaunchedEffect의 내부 구현을 확인해보도록 하자.
/**
 * When [LaunchedEffect] enters the composition it will launch [block] into the composition's
 * [CoroutineContext]. The coroutine will be [cancelled][Job.cancel] and **re-launched** when
 * [LaunchedEffect] is recomposed with a different [key1], [key2] or [key3]. The coroutine will be
 * [cancelled][Job.cancel] when the [LaunchedEffect] leaves the composition.
 *
 * This function should **not** be used to (re-)launch ongoing tasks in response to callback events
 * by way of storing callback data in [MutableState] passed to [key]. Instead, see
 * [rememberCoroutineScope] to obtain a [CoroutineScope] that may be used to launch ongoing jobs
 * scoped to the composition in response to event callbacks.
 */
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(key1: Any?, key2: Any?, key3: Any?, block: suspend CoroutineScope.() -> Unit) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1, key2, key3) { LaunchedEffectImpl(applyContext, block) }
}
internal class LaunchedEffectImpl(
    parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
    private val scope = CoroutineScope(parentCoroutineContext)
    private var job: Job? = null
    override fun onRemembered() {
        // This should never happen but is left here for safety
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }
    override fun onForgotten() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
    override fun onAbandoned() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
}
LaunchedEffect는 remember 로 LaunchedEffectImpl 객체를 관리한다. 따라서 A 에서 B로 이동할 때 A 컴포저블이 파괴되면서 LaunchedEffectImpl도 함께 제거된다.
A -> B -> A로 다시 돌아올 때는 새로운 LaunchedEffectImpl이 생성되고, onRemembered()에서 job을 새로 실행하므로 A 화면에 대한 로그가 총 2번 호출된다.
맞다.
화면을 이동하면서 key값이 바뀌지 않았다면 1번만 수집된다.
개인적인 생각으로 A -> B -> B(pop) -> A 로 돌아오는 상황에서의 A 재진입에 대한 로그 수집은 팀의 정책에 따라 다르겠지만 안하는게 맞다고 생각하고,
이 방식으로 정책이 정해지는 경우 Compose 환경에서 구현하기 용이한 SideEffect 가 ImpressionEffect 라서 적용 해보았다.
LaunchedEffect의 경우 key를 지정하더라도, 위에 상황에서 다시 A로 돌아왔을때 A 컴포저블이 Composition 단계부터 다시 시작하므로, 로그를 다시 수집하게 된다.
ImpressionEffect와 같은 동작을 구현하기 위해선 Composable이 파괴되어도 그 상태를 유지하는 변수를 A에 도입하여 메모리에 기억해두었다가 로그를 수집할지 결정하는 방식으로 처리를 해줘야 할 듯하다.
사용 방법은 LaunchedEffect와 같아서 예제 코드는 따로 첨부하지 않았고, key값에 필요한 값(ex.userId, email)이 있으면 추가하면 될듯하다.

2025-08-18 16:45:46.159 24788-24788 PRETTY_LOGGER           com.ninecraft.booket.dev             D  │ ImpressionEffect Analytics - Screen View:home_main
2025-08-18 16:45:46.244 24788-24788 PRETTY_LOGGER           com.ninecraft.booket.dev             D  │ LaunchedEffect Analytics - Screen View:home_main
2025-08-18 16:45:48.436 24788-24788 PRETTY_LOGGER           com.ninecraft.booket.dev             D  │ ImpressionEffect Analytics - Screen View:settings_main
2025-08-18 16:45:48.480 24788-24788 PRETTY_LOGGER           com.ninecraft.booket.dev             D  │ LaunchedEffect Analytics - Screen View:settings_main
2025-08-18 16:45:50.102 24788-24788 PRETTY_LOGGER           com.ninecraft.booket.dev             D  │ LaunchedEffect Analytics - Screen View:home_main
현재 개발중인 앱에서 홈 화면에서 설정 탭으로 이동했다가 pop() 하여 다시 홈 화면으로 돌아왔을때에 대한 로그를 출력해보았고,
위에 설명했던 것 처럼, 홈 화면에 다시 돌아왔을 때 ImpressionEffect에 대한 로그가 찍히지 않는 것을 확인할 수 있었다.
이번 글을 통해 Circuit 의 ImpressionEffect 와 LaunchedEffect의 차이를 알아볼 수 있었다.
Circuit을 사용하는 경우, Presenter 내부에 present() 함수도 Composable 함수이기 때문에, 이러한 Compose 에서 지원하는 SideEffect API 들을 Presenter 에서 호출해도 상관 없다.
취향에 따라 UI에 심을지, Presenter에 심을지 결정하면 될 것 같다.
하나의 Presenter 로 여러 화면을 관리하고, 각 화면에 대해 로그를 수집해야한다면, UI 에 심어야 원하는 요구사항을 만족할 수 있을 것 이다.
Zac Sweers 선생님은 다 생각이 있으시다.
ImpressionEffect 를 지원하는 circuitx 라이브러리 의존성을 추가하면 LaunchedImpressionEffect 도 사용할 수 있다.
/**
 * A [LaunchedEffect] that will run a suspendable [impression]. The [impression] will run once until
 * it is forgotten based on the [RetainedStateRegistry], and/or until the [inputs] change. This is
 * useful for async single fire side effects like logging or analytics.
 *
 * @param inputs A set of inputs that when changed will cause the [impression] to be re-run.
 * @param impression The impression side effect to run.
 */
@Composable
public fun LaunchedImpressionEffect(vararg inputs: Any?, impression: suspend () -> Unit) {
  var impressed by rememberRetained(*inputs) { mutableStateOf(false) }
  LaunchedEffect(*inputs) {
    val wasImpressed = impressed
    impressed = true
    if (!wasImpressed) {
      impression()
    }
  }
}
impressed 라는 플래그 역할을 하는 변수를 rememberRetained로 관리하여, LaunchedEffect 내부 블럭의 호출 여부를 플래그를 통해 관리하여 중복 실행을 방지할 수 있다.
생각해보니 이 LaunchedImpressionEffect 의 구현체가 위에서 언급한 'Composable이 파괴되어도 그 상태를 유지하는 변수를 A에 도입하여 메모리에 기억해두었다가 로그를 수집할지 결정하는 방식으로 처리'의 예시 중 하나일 듯 하다.
reference)
https://slackhq.github.io/circuit/api/0.x/circuitx/effects/com.slack.circuitx.effects/-impression-effect.html
https://github.com/slackhq/circuit/blob/668f6ab5fbf910b13e74c3a5bdf89493262b6f9b/circuitx/effects/src/commonMain/kotlin/com/slack/circuitx/effects/ImpressionEffect.kt
https://slackhq.github.io/circuit/api/0.x/circuitx/effects/com.slack.circuitx.effects/-launched-impression-effect.html
저도 최근 TIVI 오픈소스 분석하다가, circuit의 Presenter를 쓰는걸 처음알았는데요! 이거 요즘에 많이 쓰이나요? 유익한 글 감사합니다!