Android

힐트, 클린아키텍처 그리고 로그인 리이슈...

sekong 2024. 8. 7. 23:35

 

안녕하세요, 한끼 안드로이드 개발자 공세영입니다.

 

저는 이번 프로젝트를 진행하며 적용한 힐트와 클린아키텍처에 대한 간단한 설명을 하고

제가 구현한 로그인의 순환호출 이슈 해결에 대해 이야기하고자 합니다ㅎㅅㅎ

이 글을 읽고 조금이나마 도움이 되었으면 좋겠습니다..ㅎㅎ

 

 

먼저 힐트란?

Hilt는 애플리케이션에 DI를 삽입하는 표준 방식으로, 프로젝트의 모든 Android 구성요소에 컨테이너를 제공하고 컨테이너의 수명 주기를 자동으로 관리합니다. 이 방식은 많이 사용하는 DI 라이브러리인 Dagger를 활용한 것입니다.

 

여기서 DI(Dependency Injection) 즉, 의존성 주입은 소프트웨어 디자인 패턴 중 하나로,

특정 객체의 인스턴스가 필요할 때 이를 직접 생성하지 않고 외부에서 생성된 객체를 전달하는 기법입니다.

이를 통해 객체 생성과 객체 사용을 분리하여 코드의 결합도를 낮추고 유연성을 높일 수 있습니다.

 

말이 좀… 어렵죠…ㅎㅎㅎ

 

처음 힐트 공부할 때 나

 

ㅎㅎ..]

예시를 들어서 이야기하자면!

 

 

먼저 아래와 같이 의존성주입의 역할이 있습니다.

  1. 의존성: 객체가 기능을 수행하기 위해 필요로 하는 다른 객체.
  2. 주입: 외부에서 필요한 객체(의존성)를 전달하는 행위.
interface UserRepository {
    suspend fun getUser(userId: Int): User
}

class UserRepositoryImpl @Inject constructor(
    private val userService: UserService
) : UserRepository {
    override suspend fun getUser(userId: Int): User {
        return userService.getUser(userId)
    }
}

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> get() = _user

    fun loadUser(userId: Int) {
        viewModelScope.launch {
            _user.value = userRepository.getUser(userId)
        }
    }
}

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CleanArchitectureHiltTheme {
                Surface(color = MaterialTheme.colors.background) {
                    UserScreen()
                }
            }
        }
    }
}

@Composable
fun UserScreen() {
    val userViewModel: UserViewModel = hiltViewModel()
    val user by userViewModel.user.collectAsStateWithLifecycle()

    user?.let {
        Text(text = "User: ${it.name}, Email: ${it.email}")
    }

    // ex) 사용자 ID 1 불러오기
    userViewModel.loadUser(1)
}
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> get() = _user

    fun loadUser(userId: Int) {
        viewModelScope.launch {
            _user.value = userRepository.getUser(userId)
        }
    }
}

class UserViewModelFactory(private val userRepository: UserRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return UserViewModel(userRepository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel")
    }
}

class MainActivity : ComponentActivity() {
    private val userViewModel: UserViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CleanArchitectureTheme {
                Surface(color = MaterialTheme.colors.background) {
                    UserScreen(userViewModel)
                }
            }
        }
    }
}

@Composable
fun UserScreen(userViewModel: UserViewModel) {
    val user by userViewModel.user.collectAsStateWithLifecycle()

    user?.let {
        Text(text = "User: ${it.name}, Email: ${it.email}")
    }

     // ex) 사용자 ID 1 불러오기
    userViewModel.loadUser(1)
}

 

이 두 코드의 차이점을 확인하셨나요?

 

Hilt를 사용한 부분:

  • 의존성 주입을 Hilt로 처리 (@Inject, @HiltViewModel, @AndroidEntryPoint).
  • hiltViewModel()를 사용하여 ViewModel을 가져옴.

Hilt를 사용하지 않은 부분:

  • 의존성 주입을 수동으로 처리 (UserViewModelFactory).
  • ViewModel을 직접 인스턴스화하여 전달.

이 두 차이점을 확인하셨다면 코드에서의 차이점도 찾아보고 오시면 좋을 거 같아요.

 

 

이렇게 위 코드에서 보이는 것처럼 Hilt를 사용하면 의존성 주입이 자동화되어 코드가 간결하고 유지보수성이 높아질 수 있습니다.

이와 반대로 사용하지 않으면 수동으로 의존성을 관리해야 하므로 코드의 복잡성이 증가하고 유지보수가 어려워질 수 있습니다.


 

다음으로는 클린 아키텍처입니다!

클린 아키텍처는 로버트 C. 마틴(Robert C. Martin, Uncle Bob)에 의해 제안된 소프트웨어 설계 원칙으로, 애플리케이션을 여러 계층으로 나누어 각 계층이 명확한 책임을 가지도록 합니다. 이러한 계층화는 시스템의 모듈화를 촉진하며, 코드의 유연성과 유지보수성을 높이는 데 중요한 역할을 합니다.

 

위 설명처럼 클린 아키텍처를 코드를 구현할 때 여러 계층으로 나누어 각 계층이 명확한 책임을 지도록 합니다.

아래에서 각 계층에서 어떤 역할과 어떠한 파일들이 들어갈 지에 관한 간단한 설명을 하도록 하겠습니다.

 

Model: 사용자 정보를 저장하는 User 데이터 클래스, 이는 비즈니스 도메인 객체를 정의합니다.

→ 이 계층은 애플리케이션의 핵심 비즈니스 로직을 포함하며, 다른 계층과 독립적으로 존재해야 합니다. 이를 통해 비즈니스 로직의 변경이 다른 계층에 영향을 미치지 않도록 합니다.

 

Service: 서버와 통신하여 사용자 정보를 가져오는 Retrofit 서비스인 UserService 인터페이스 → 이 계층은 외부 시스템과의 통신을 담당하며, 데이터 소스의 구현을 캡슐화합니다. 서비스 계층은 외부 API와의 상호 작용을 추상화하여, API 변경 시 다른 계층에 영향을 최소화합니다.

 

DataSource: UserRemoteDataSource 클래스는 UserService를 사용하여 사용자 데이터를 가져온다.

→ DataSource 인터페이스는 데이터 접근 로직을 추상화합니다. 이는 데이터 소스가 변경될 때(예: 로컬 데이터베이스로 변경) Repository 계층에 영향을 미치지 않도록 합니다.

 

Repository: UserRepository 클래스는 UserRemoteDataSource로부터 데이터를 가져와 가공한다.

→ Repository 계층은 데이터 소스와 상호 작용하고 데이터를 가공합니다. 이를 통해 비즈니스 로직 계층과 데이터 소스 간의 결합도를 낮추고, 데이터 소스의 변경이 비즈니스 로직에 영향을 미치지 않도록 합니다.

 

Domain Layer: GetUserUseCase 클래스는 UserRepository를 사용하여 사용자 정보를 가져오는 비즈니스 로직을 포함한다.

→ Domain Layer는 애플리케이션의 비즈니스 규칙을 포함합니다. Use Case는 특정 작업을 수행하는 비즈니스 로직을 캡슐화하며, 이를 통해 비즈니스 로직과 프레젠테이션 로직을 분리할 수 있습니다.

 

ㅋㅋ;;;;;;

 

 

 

사실 클린아키텍처를 정말 간단히 이해만 한다면!!

코드를 계층화시켜 구현하는데,

이는 각 계층이 명확한 책임을 가지므로 유지보수성, 유연성, 가독성이 좋다!

그리고 의존성 역전 원칙을 포함한 SOLID 원칙을 준수할 수 있다!!

DIP(Dependency Inversion Principle) - 의존성 역전 원칙 의존성 역전 원칙은 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화된 인터페이스에 의존하도록 만드는 것입니다. 이는 코드의 결합도를 낮추고, 변경에 대한 유연성을 높이는 데 도움이 됩니다. 클린 아키텍처는 이 원칙을 잘 준수합니다.

 

 

???

힐트는 의존성 주입인데 왜 클린은 의존성 역전 원칙이야 둘이 왜 같이 쓰는데?

클린 아키텍처와 Hilt를 사용하는 이유와 이 두 가지가 어떻게 연관되는지 명확히 이해하는 것이 중요합니다.

 

- 클린 아키텍처는 설계 원칙이고

- Hilt는 의존성 주입(DI)을 위한 도구입니다.

 

즉,  클린 아키텍처와 Hilt를 함께 사용하면
각 계층 간의 의존성을 Hilt를 통해 주입하여 의존성 역전 원칙을 더 쉽게 구현할 수 있습니다.
이렇게 이 두 가지를 함께 사용하면 코드를 더 유연하고 유지보수하기 쉽게 만들 수 있습니다.

 

 

 

 

그래서 이 둘의 합작으로

로그인 리이슈 코드가 구현이 되는데......

 

 

 

 

 

 

ㄷㄱㄷㄱ

 

 

 

 

 

 

 

?????

 

진짜.. 나는 붐명... 진짜 제대로 했다구.....

 

ㅋ... 사실 이때까지만 해도 제가 힐트와 클린을 좀 어려워했기에 로직을 분리하는 과정에서 에러가 났다고 생각하여 로직 검사를 계속해서 하였음에도 불구하고 아무런 문제가 없어서...

클린빌드, 클린캐시 무한 반복 ㅋㅋㅎㅋㄹ

 

 

그러나

마음을 좀 가다듬고.. 천천히 에러를 다시 읽어보니

어지럽다.

 

순환 호출이 되었다는 것이 눈에 들어와서 전체적인 에러를 찬찬히 살펴보니

 

  • 주입된 종속성들
    • ReissueOKHttpClient가 retrofit2.Retrofit을 주입받고 이는 ServiceModule의 provideReissueTokenService 메서드에서 사용되고 있었다.
  • 주입 경로
    • 주입된 경로로는 ReissueTokenService가 ReissueTokenDataSourceImpl 클래스에서 주입되고 있다.

라는 것을 알게되었습니다.

 

그리고

 

로그인 토큰 재발급 기능에서 ReissueOKHttpClient가 retrofit2.Retrofit을 주입받고 있다는 메세지를 보고

 

토큰 재발급을 구현할 때 인터셉터 2개가 아닌 1개로 사용하고 있어

기존의 로그인과 재발급 로직이 하나의 http 클라이언트를 사용하여 순환참조가 일어날 수 있겠다는 것을 깨달을 수 있었습니다.

 

첫 번째 방법으로는 Provider<T> 를 사용했습니다.

public interface Provider<T> {
    T get();
}

Provider 인터페이스는 필요한 경우에만 클라이언트를 제공합니다.

즉, 객체가 실제로 필요할 때까지 초기화하고 필요할 때마다 새로운 인스턴스를 생성할 수 있습니다.

 

그러나 Provider를 통해 잠시라도 순환 의존성 문제를 해결한 것이고 결국에는 구조적 문제를 완전히 해결하지 못한 상태이기에

 

 

 

최종적으로 인터셉터 2개를 만들어 통신하는 구조로 다시 구현했습니다.

먼저, 우리는 서버에서 엑세스 토큰으로 소통을 하는데 여기서
리프레시 토큰이 우리가 정말 이 앱의 유저인지 판단해주는 역할을 하게됩니다.

 

  1. 그래서 리프레시 토큰을 통해 만료된 엑세스 토큰을 재발급 받을 수 있는 것이고
  2. 엑세스 토큰이 만료 되었을 때 통신하기 위해 리프레시 토큰이 필요한 것입니다.

그렇기에 로그인 과정에서 인터셉터 2개가 필요한 이유는 다음과 같습니다다.

  1. 보통의 경우에는 엑세스 토큰을 가지고 통신을 하고
  2. 엑세스 토큰이 만료되었을 때 리프레시 토큰을 사용하여 통신을 해야한다.

 

 

이를 구분하기 위해 먼저 quelifire라는 것을 만들어주었습니다.

@Qualifier는 주입할 때 어떤 인스턴스를 사용할지를 지정한다. 이를 통해 어떤 인스턴스가 주입되는지 명확히 알 수 있습니다.

 

그리고

레트로핏 통신시 아래와 같이 리이슈토큰이 필요한 곳과 아닌 곳에 어노테이션을 붙여 구현하였습니다.

 

 

어떄요? 참 쉽죠?

 

 

 

사실,, 이렇게 쉽게 끝나버려서 아쉽기두 시간이 허무하기도 했지만...

그만큼 힐트와 클린에 대해 다시한번 더 생각해볼 수 있는 시간이 되지 않았나 싶습니다..ㅎㅎ