본문 바로가기

안드로이드

Clean Architecture 실전 적용

반응형

Clean Architecture는 의존성 방향을 제어하여 비즈니스 로직을 외부 요소로부터 분리하는 아키텍처다. 핵심은 내부 레이어가 외부 레이어를 알지 못하게 하는 것이다. Domain 레이어는 Android에 의존하지 않으므로 테스트와 유지보수가 용이하다.

 

1. 핵심 원칙

의존성 규칙

Presentation → Domain ← Data

Domain은 아무것도 모른다.
Presentation과 Data가 Domain에 의존한다.

Domain 레이어는 아무것도 모른다. Presentation과 Data가 Domain에 의존한다. 이것이 의존성 역전 원칙(DIP)이다.

 

2. Domain 레이어

Domain은 순수 Kotlin으로 작성한다. Android 의존성이 없어야 한다.

Entity (Domain Model)

data class Movie(
    val id: Long,
    val title: String,
    val overview: String,
    val posterPath: String?,
    val voteAverage: Double,
    val isFavorite: Boolean = false
) {
    val isHighlyRated: Boolean get() = voteAverage >= 8.0
    fun toggleFavorite(): Movie = copy(isFavorite = !isFavorite)
}

Entity는 비즈니스 로직을 포함할 수 있다.

Repository Interface

interface MovieRepository {
    fun getPopularMovies(): Flow<List<Movie>>
    fun getMovie(id: Long): Flow<Movie>
    suspend fun toggleFavorite(movie: Movie)
}

Repository는 인터페이스로 정의한다. Domain은 구현 세부사항을 알 필요가 없다.

UseCase

class GetPopularMoviesUseCase @Inject constructor(
    private val movieRepository: MovieRepository
) {
    operator fun invoke(): Flow<List<Movie>> {
        return movieRepository.getPopularMovies()
    }
}

UseCase가 단순해 보여도 괜찮다. UseCase의 가치는 ViewModel에서 Repository 직접 의존을 제거하고, 비즈니스 규칙의 진입점을 명확히 하는 데 있다.

 

3. Data 레이어

Data 레이어는 Domain에만 의존한다. API, DB 같은 외부 시스템과 통신한다.

DTO

@Serializable
data class MovieDto(
    val id: Long,
    val title: String,
    @SerialName("poster_path")
    val posterPath: String?,
    @SerialName("vote_average")
    val voteAverage: Double
)

Mapper

fun MovieDto.toDomain(): Movie = Movie(
    id = id,
    title = title,
    posterPath = posterPath,
    voteAverage = voteAverage
)

fun Movie.toEntity(): MovieEntity = MovieEntity(
    id = id,
    title = title,
    posterPath = posterPath,
    voteAverage = voteAverage,
    isFavorite = isFavorite
)

핵심: Mapper는 확장 함수로 작성한다. DTO → Domain 변환은 Repository에서 한다.

Repository 구현

class MovieRepositoryImpl @Inject constructor(
    private val movieApi: MovieApi,
    private val movieDao: MovieDao
) : MovieRepository {
    override fun getPopularMovies(): Flow<List<Movie>> = flow {
        val cached = movieDao.getAll()
        if (cached.isNotEmpty()) emit(cached.map { it.toDomain() })
        try {
            val response = movieApi.getPopularMovies()
            val movies = response.results.map { it.toDomain() }
            movieDao.insertAll(movies.map { it.toEntity() })
            emit(movies)
        } catch (e: Exception) {
            if (cached.isEmpty()) throw e
        }
    }
}

 

4. Presentation 레이어

ViewModel

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getPopularMoviesUseCase: GetPopularMoviesUseCase,
    private val toggleFavoriteUseCase: ToggleFavoriteUseCase
) : ViewModel() {
    private val _state = MutableStateFlow(HomeState())
    val state: StateFlow<HomeState> = _state.asStateFlow()

    init { loadMovies() }

    private fun loadMovies() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            getPopularMoviesUseCase()
                .catch { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
                .collect { movies -> _state.update { it.copy(isLoading = false, movies = movies) } }
        }
    }
}

 

5. 의존성 주입

@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
    @Binds
    @Singleton
    abstract fun bindMovieRepository(impl: MovieRepositoryImpl): MovieRepository
}

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl("https://api.tmdb.org/3/")
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .build()
}
어노테이션 용도 사용 위치
@Binds 인터페이스 ↔ 구현체 연결 abstract 함수
@Provides 직접 인스턴스 생성 일반 함수
@Singleton 앱 전체 단일 인스턴스 SingletonComponent

 

6. 테스트

UseCase 테스트

class ToggleFavoriteUseCaseTest {
    private val repository: MovieRepository = mockk()
    private val useCase = ToggleFavoriteUseCase(repository)

    @Test
    fun `toggleFavorite calls repository with toggled movie`() = runTest {
        val movie = Movie(id = 1, title = "Test", isFavorite = false)
        coEvery { repository.toggleFavorite(any()) } just runs
        useCase(movie)
        coVerify { repository.toggleFavorite(match { it.isFavorite == true }) }
    }
}

핵심: Domain은 순수 Kotlin이므로 Android 없이 테스트할 수 있다.

 

7. 실무 판단 기준

UseCase를 만들어야 할 때

  • 여러 Repository를 조합하는 경우
  • 복잡한 비즈니스 로직이 있는 경우
  • 여러 ViewModel에서 재사용하는 경우

UseCase를 생략해도 될 때

  • 단순한 CRUD 작업
  • Repository 메서드를 그대로 호출하는 경우
  • 프로토타입 단계

에러 처리 위치

위치 역할
Repository 기술적 에러 → 도메인 에러로 변환
ViewModel 도메인 에러 → UI 메시지로 변환

 

8. sealed interface를 활용한 에러 전파

sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val exception: Throwable) : Result<Nothing>
}

// ViewModel
when (val result = getPopularMoviesUseCase()) {
    is Result.Success -> _state.update { it.copy(movies = result.data) }
    is Result.Error -> _state.update { it.copy(error = result.exception.toUiMessage()) }
}

핵심: Result 패턴을 사용하면 에러가 데이터로 전파된다. try-catch 없이 when 분기로 처리할 수 있어 코드가 명확해진다.

 

면접 예상 질문

1. Clean Architecture에서 각 레이어의 역할과 의존성 방향을 설명하라

2. Domain 레이어에 Android 의존성이 없어야 하는 이유는?

3. UseCase가 필요한 이유는? Repository를 직접 호출하면 안 되는가?

4. Mapper를 각 레이어에 두는 이유는?

5. Mock과 Fake의 차이는 무엇이며, 테스트에서 어떤 것을 선호하는가?

 

정리

  • Clean Architecture의 핵심은 의존성 방향을 Domain 중심으로 유지하는 것이다
  • Domain은 순수 Kotlin으로 작성하여 Android에 의존하지 않는다
  • Repository는 인터페이스로 정의하고 구현은 Data 레이어에서 한다
  • UseCase는 비즈니스 규칙의 진입점이다
  • Result 패턴으로 에러를 데이터로 전파하면 코드가 명확해진다
  • 프로젝트 규모에 맞게 레이어를 조정한다

 

참고

반응형