Feature 모듈간 순환 참조 이슈를 해결하는 방법

이지훈·2023년 10월 7일
1
post-thumbnail
post-custom-banner

사진: UnsplashWarlen G Vasco

서두

feature 단위로 모듈을 나누어 앱을 개발하는 도중 발생한 문제를 소개하고, 이를 해결한 방법에 대해 공유하고자 한다.

평소에는 feature 단위가 아닌 clean architecture 형태에 data, domain, presentation의 형태로 모듈을 나누어 진행 해왔었다.
화면(기능)끼리는 하나의 모듈(presentation)내에 포함되어 있기 때문에 의존성에 대해 신경 쓸 필요가 없었기에, 생각 해보지 못했던 문제인지라, 이를 해결하는데 오래 걸렸다...

글을 읽는 feature 모듈을 도입 해보기 시작한 개발자분들도 마찬가지 상황일 것이라 생각한다.

TL;DR

Navigator 모듈을 도입하는 것을 통해 Feature 모듈간의 순환 참조 문제를 해결할 수 있다.

문제의 발단

구현하고 하는 기능은 로그아웃이었다. 로그아웃 기능 구현 자체는 간단하였다.

API를 호출하는 것도 아닌, LocalDB(datastore)에 저장된 jwt 토큰들을 제거하고, intent 를 통해 LoginActivity로 이동하면서, 기존에 열려있던 MainActivity는 finish 해주면 되는 것이었다.

다만 이를 로그인 기능을 하는 로그인 모듈과, 메인 기능들을 수행하는 메인 모듈로, 모듈이 분리된 경우 얘기가 달라진다.
우선 연관된 모듈은 로그인 모듈, 온보딩 모듈, 메인 모듈이 있다.
(앱 모듈에서 intro 및, 자동 로그인이 진행하긴 하는데 앱 모듈은 모든 모듈에 대한 의존성을 가지고 있기 때문에 문제가 되지 않는다.)

앱의 플로우
1. 로그인 화면에서 로그인을 진행 -> 서버에 등록되어 있지 않은 사용자인 경우-> 온보딩 화면으로 이동-> 온보딩 진행-> 메인 화면으로 이동
2. 로그인 화면에서 로그인을 진행 -> 서버에 이미 등록된 사용자인 경우 -> 메인 화면으로 이동

intent를 통해 화면을 이동해야 하므로 로그인 모듈은 온보딩 모듈과 메인 모듈의 의존성을 가지는 형태이다.

현재까지의 의존성 관계

Main <- Login
Onboard <- Login
Main <- Onboard

문제 발생

근데 이제 메인 화면에서 로그아웃 기능을 개발하려고 하면 문제가 발생한다.
메인 화면 내에 설정 화면에서 로그아웃을 진행 -> 로그인 화면으로 이동
마찬가지로 intent 로 화면을 이동해야 하므로 메인 모듈은 로그인 화면의 의존성을 가져야 한다.
Login <- Main

Login <- Main <- Login...
망할 놈의 모듈간 순환 참조가 발생하였다.
메인 모듈내에 너무 많은 기능을 포함 하고 있기 때문에 이와 같은 문제가 발생한다고 생각하여, setting 모듈을 따로 분리 해봤지만, 마찬가지로 순환 참조가 발생하는 것은 해결할 수 없었다.
(Login <- Main <- Setting <- etc.. <- Login)
모듈을 잘게 쪼개는 것 만으로는 근본적인 문제를 해결할 수 없다고 생각하였고, 여러 날 동안 고민하고, 질문을 하고, 레퍼런스를 찾아 결국 문제를 해결할 수 있었다.

문제 해결

Feature 모듈들이 서로가 서로를 의존하는 것이 이 문제의 근본적인 원인이기 때문에, Feature 모듈들이 서로를 의존하지 않도록 하고, Navigator 모듈을 생성한 후에 feature 모듈들이 이를 전부 의존하도록 하여, 순환을 끊어주었다.

Navigator 모듈은 다음과 같이 만들어주면 된다.
(참고로 Activity 간의 이동을 위해 파라미터에 Activity, Intent 가 존재하므로 Android Library 모듈로 만들어주어야 한다.)

우선, Navigator interface 를 만들어 준다.

interface Navigator {
  fun navigateFrom(
    activity: Activity,
    intentBuilder: Intent.() -> Intent = { this },
    withFinish: Boolean = false,
  )
}

이후 각각의 feature 모듈에서 사용할 interface 들을 하나씩 만들어준다.

LoginNavigator.kt

interface LoginNavigator : Navigator

MainNavigator.kt

interface MainNavigator : Navigator

OnboardNavigator.kt

interface OnboardNavigator : Navigator

그리고 각각의 feature 모듈 내에 구현체를 구현 해주고, interface 와 구현체를 매핑해주는 hilt module 을 만들어준다. (이부분이 boilerplate 이긴 한데 문제를 해결하기 위해선 어쩔 수 없다...)

LoginNavigatorModule.kt

internal class LoginNavigatorImpl @Inject constructor() : LoginNavigator {
  override fun navigateFrom(
    activity: Activity,
    intentBuilder: Intent.() -> Intent,
    withFinish: Boolean,
  ) {
    activity.startActivityWithAnimation<LoginActivity>(
      intentBuilder = intentBuilder,
      withFinish = withFinish,
    )
  }
}

@Module
@InstallIn(SingletonComponent::class)
internal abstract class LoginNavigatorModule {
  @Singleton
  @Binds
  abstract fun bindLoginNavigator(loginNavigatorImpl: LoginNavigatorImpl): LoginNavigator
}

startActivityWithAnimation 확장 함수는 다음과 같다.

inline fun <reified T : Activity> Activity.startActivityWithAnimation(
  intentBuilder: Intent.() -> Intent = { this },
  withFinish: Boolean = true,
) {
  startActivity(Intent(this, T::class.java).intentBuilder())
  if (Build.VERSION.SDK_INT >= 34) {
    overrideActivityTransition(
      Activity.OVERRIDE_TRANSITION_OPEN,
      android.R.anim.fade_in,
      android.R.anim.fade_out,
    )
  } else {
    @Suppress("DEPRECATION")
    overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
  }
  if (withFinish) finish()
}

(reified는 타입의 실제 정보를 런타임에도 유지하게 한다.)

Activity에서 이를 적용해보도록 하자.

LoginActivity.kt

@AndroidEntryPoint
class LoginActivity : BaseActivity() {
  override val binding by lazy { ActivityLoginBinding.inflate(layoutInflater) }

  private val viewModel by viewModels<LoginViewModel>()

  @Inject
  lateinit var mainNavigator: MainNavigator

  @Inject
  lateinit var onboardNavigator: OnboardNavigator

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    initGoogleLogin()
    initListener()
    initObserver()
  }

  private fun initObserver() {
    repeatOnStarted {
      launch {
        viewModel.navigateToMainEvent.collect {
          // startActivity(Intent(this@LoginActivity, MainActivity::class.java))
          // finish()
          mainNavigator.navigateFrom(
            activity = this@LoginActivity,
            withFinish = true,
          )
        }
      }

      launch {
        viewModel.navigateToOnBoardingEvent.collect {
          // val intent = Intent(this@LoginActivity, OnboardActivity::class.java)
          // intent.putExtra("id_token", idToken)
          // startActivity(intent)
          // finish()
          onboardNavigator.navigateFrom(
            activity = this@LoginActivity,
            intentBuilder = {
              putExtra("id_token", idToken)
            },
            withFinish = true,
          )
        }
      }
    }
  }
}

(참고로 @Inject 로 선언된 필드는 의존성 주입이 발생하는 시점에 자동으로 초기화가 이루어진다. 따라서 lateinit으로 선언된 mainNavigator, onboardNavigator는 activity가 생성될 때, Hilt에 의해 초기화가 되므로, 별도로 초기화 코드를 작성할 필요가 없다.)

이로써 feature 모듈간의 순환 참조를 해결할 수 있었다. 다시 개발을 할 수 있게 되었다.

해당 레포지토리에서 전체 코드를 확인할 수 있다.(개발 중인 레포라서 코드가 많이 달라질 수 있습니다.)
https://github.com/Wedemy/eggeum-android

reference)
https://blog.duckie.team/%EB%AA%A8%EB%93%88%ED%99%94-%EC%B2%98%EC%9D%8C%EC%9D%B4%EB%9D%BC%EB%A9%B4-%EC%9D%B4%EA%B2%83-%EB%A7%8C%EC%9D%80-%EC%95%8C%EC%95%84%EB%91%90%EC%84%B8%EC%9A%94-47540b78e190

https://www.youtube.com/watch?v=Y0szAIW_tFs&t=492s

https://stackoverflow.com/questions/52543936/circular-dependency-between-the-following-tasks

https://velog.io/@kyy00n/kotlin-in-Action-Extension-Function-%ED%99%95%EC%9E%A5-%ED%95%A8%EC%88%98

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

0개의 댓글