[Android 클린 아키텍처 멀티모듈 제작기] 7편 - Domain 레이어 설계

김보현·2026년 4월 14일

android

목록 보기
10/12

드디어 앱이 뭘 하는지 말할 수 있다

6편까지 Convention Plugin 만들고, 빌드 설정 정리하고, boilerplate 제거하고... 솔직히 그동안 "그래서 앱이 뭘 만드는 건데?"라는 질문을 계속 미뤄왔다. 이번 편에서 드디어 그 답을 공개하고, 클린 아키텍처의 핵심인 Domain 레이어 설계까지 마무리한다.

MorphView가 만들 기능은 이렇다:

  • CameraX로 실시간 카메라 프리뷰
  • Google ML Kit Face Detection으로 얼굴 감지
  • 얼굴 윤곽(Contour)을 라인으로 프리뷰에 오버레이
  • 눈 뜨고/감음, 입 벌림, 정면/측면 방향 등 상태를 UI에 표시

카메라로 얼굴을 비추면 실시간으로 얼굴 외곽선이 그려지고, 상태 정보도 함께 나오는 앱이다. 이걸 클린 아키텍처 멀티모듈로 어떻게 구조화할지가 이번 편의 주제다.


의존성부터 — CameraX, ML Kit, Coroutines

Domain 레이어를 짜기 전에 libs.versions.toml에 필요한 의존성을 먼저 등록해뒀다. 아직 실제로 사용하는 건 아니고, 버전 카탈로그에 선언만 해두는 것.

[versions]
camerax = "1.3.4"
mlkitFaceDetection = "16.1.7"
coroutines = "1.8.1"

[libraries]
# CameraX
camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkitFaceDetection" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }

CameraX는 core, camera2, lifecycle, view 네 가지로 쪼개져 있어서 다 등록했다. ML Kit face detection은 하나, Coroutines는 core와 android 두 가지. 실제 build.gradle.kts에 추가하는 건 data, presentation 레이어를 만들 때 하게 된다.


왜 Domain 레이어부터 짜는가

클린 아키텍처에서 Domain 레이어는 의존 방향의 중심이다. 다른 레이어(data, presentation)는 domain을 바라보지만, domain은 어느 레이어도 바라보지 않는다.

그래서 Domain을 먼저 정의하는 게 맞다. "앱이 어떤 데이터를 다루고, 어떤 행동을 하는가"를 먼저 언어로 정리하고, 그다음에 그걸 어떻게 구현할지(data), 어떻게 보여줄지(presentation)를 붙이는 순서다.

이 순서를 지키면 좋은 점이 있다. ML Kit가 얼굴 좌표를 어떻게 반환하든, CameraX가 어떤 API를 쓰든 — domain은 그런 세부 사항에 영향을 받지 않는다. 나중에 ML Kit를 다른 라이브러리로 교체하더라도 domain 코드는 건드릴 필요가 없다.


파일 구조

domain/src/main/java/com/dantariun/domain/
├── model/
│   ├── Point2D.kt
│   ├── BoundingRect.kt
│   ├── FaceContourType.kt
│   ├── FaceContour.kt
│   ├── EyeState.kt
│   ├── MouthState.kt
│   ├── HeadDirection.kt
│   └── DetectedFace.kt
├── repository/
│   └── FaceDetectionRepository.kt
└── usecase/
    └── ObserveFaceDetectionUseCase.kt

model, repository, usecase 세 패키지로 나눴다. 클래스는 총 8개 + 인터페이스 1개 + UseCase 1개. 하나씩 살펴보자.


모델 설계

Point2D — 순수 Kotlin 좌표

data class Point2D(
    val x: Float,
    val y: Float
)

처음엔 그냥 android.graphics.PointF를 쓰려고 했다. 좌표를 나타내는 클래스인데 굳이 새로 만들어야 하나 싶어서. 그런데 PointF를 쓰는 순간 domain 모듈이 Android 프레임워크에 의존하게 된다. Domain 레이어는 순수 Kotlin이어야 한다는 원칙을 위반하는 것.

그래서 직접 만들었다. 코드는 두 줄이지만, 이 선택이 가진 의미는 꽤 크다.


BoundingRect — 얼굴 감지 영역

data class BoundingRect(
    val left: Int,
    val top: Int,
    val right: Int,
    val bottom: Int
) {
    val width: Int get() = right - left
    val height: Int get() = bottom - top
    val centerX: Float get() = left + width / 2f
    val centerY: Float get() = top + height / 2f
}

마찬가지로 Android의 Rect를 쓰지 않고 직접 정의했다. width, height, centerX, centerY는 나중에 오버레이를 그릴 때 자주 쓰게 될 것 같아서 computed property로 추가해뒀다.


FaceContourType — ML Kit 정수 상수를 도메인 언어로

enum class FaceContourType {
    FACE,
    LEFT_EYEBROW_TOP,
    LEFT_EYEBROW_BOTTOM,
    RIGHT_EYEBROW_TOP,
    RIGHT_EYEBROW_BOTTOM,
    LEFT_EYE,
    RIGHT_EYE,
    UPPER_LIP_TOP,
    UPPER_LIP_BOTTOM,
    LOWER_LIP_TOP,
    LOWER_LIP_BOTTOM,
    NOSE_BRIDGE,
    NOSE_BOTTOM
}

ML Kit는 윤곽선 종류를 FaceContour.FACE, FaceContour.LEFT_EYE 같은 정수 상수로 표현한다. 이걸 그대로 domain에 가져오면 ML Kit 타입이 domain으로 침투하게 된다.

enum 하나로 추상화하면, data 레이어에서 ML Kit 상수 → FaceContourType 변환을 담당하고, domain과 presentation은 이 enum만 보면 된다. 나중에 ML Kit를 다른 SDK로 바꿔도 enum 값은 그대로고 변환 로직만 data 레이어에서 수정하면 된다.


FaceContour — 윤곽선 한 파트

data class FaceContour(
    val type: FaceContourType,
    val points: List<Point2D>
)

윤곽선 하나는 "어떤 부위의 윤곽선인가(type)"와 "그 윤곽선을 구성하는 점들(points)"로 구성된다. 심플하게.


EyeState — 눈 상태 (확률값 + 임계값 판단)

private const val EYE_OPEN_THRESHOLD = 0.5f

data class EyeState(
    val leftOpenProbability: Float,   // 0.0 ~ 1.0
    val rightOpenProbability: Float
) {
    val isLeftOpen: Boolean get() = leftOpenProbability >= EYE_OPEN_THRESHOLD
    val isRightOpen: Boolean get() = rightOpenProbability >= EYE_OPEN_THRESHOLD
    val isBothOpen: Boolean get() = isLeftOpen && isRightOpen
}

ML Kit는 눈이 열려있을 확률을 0.0~1.0 float로 반환한다. 이 확률값을 raw로 들고 다니면서, 임계값(EYE_OPEN_THRESHOLD = 0.5f)과 비교해서 열림/감음을 판단하는 computed property를 함께 제공한다.

임계값을 private const로 파일 상단에 두면 나중에 조정이 쉽다. "50% 이상이면 열린 눈"이라는 판단 기준이 바뀌어도 한 곳만 수정하면 된다.


MouthState — 입 상태

data class MouthState(
    val isOpen: Boolean
)

눈과 달리 입은 단순하게 열림/닫힘 Boolean으로만 표현했다. ML Kit가 입에 대해서는 확률값을 직접 제공하지 않기 때문에, data 레이어에서 입 윤곽 좌표를 분석해서 Boolean으로 변환할 예정이다.


HeadDirection — 머리 방향 (Euler 각도 기반)

private const val FRONT_FACING_THRESHOLD = 15f

data class HeadDirection(
    val eulerX: Float,  // pitch: 위아래 (-: 아래, +: 위)
    val eulerY: Float,  // yaw: 좌우 (-: 왼쪽, +: 오른쪽)
    val eulerZ: Float   // roll: 기울기
) {
    val isFrontFacing: Boolean get() = eulerY in -FRONT_FACING_THRESHOLD..FRONT_FACING_THRESHOLD
    val isLeftFacing: Boolean get() = eulerY < -FRONT_FACING_THRESHOLD
    val isRightFacing: Boolean get() = eulerY > FRONT_FACING_THRESHOLD
}

ML Kit는 머리 방향을 Euler 각도 세 값으로 반환한다. eulerY(yaw)가 좌우 방향을 나타내는데, ±15도 이내면 정면, 그 밖이면 왼쪽 또는 오른쪽으로 판단한다.

EyeState와 마찬가지로 임계값을 top-level private const로 분리해서 조정하기 쉽게 했다. 실제로 테스트해보면서 "15도가 너무 좁다" 싶으면 바로 바꿀 수 있다.


DetectedFace — 핵심 도메인 Entity

data class DetectedFace(
    val boundingRect: BoundingRect,
    val contours: List<FaceContour>,
    val eyeState: EyeState,
    val mouthState: MouthState,
    val headDirection: HeadDirection
)

지금까지 만든 모델들이 여기서 하나로 모인다. 감지된 얼굴 하나를 표현하는 Entity다. 앱이 실제로 다루는 핵심 데이터 구조.


Repository Interface 설계

interface FaceDetectionRepository {
    val detectedFaces: Flow<List<DetectedFace>>
    fun startDetection()
    fun stopDetection()
}

Domain 레이어에서는 어떤 데이터가 필요한가만 선언한다. 어떻게 가져오는지는 data 레이어의 몫.

Flow<List<DetectedFace>>를 쓴 이유는 카메라가 연속 스트림이기 때문이다. 단일값이 아니라 프레임마다 계속 들어오는 데이터를 표현하려면 Flow가 맞다.

startDetection()stopDetection()은 카메라와 얼굴 감지를 시작/종료하는 시점을 제어하기 위한 메서드다. 화면이 활성화될 때 시작하고, 백그라운드로 가거나 화면을 떠날 때 정리하는 라이프사이클 연동에 쓸 예정이다.


UseCase 설계

class ObserveFaceDetectionUseCase(
    private val repository: FaceDetectionRepository
) {
    operator fun invoke(): Flow<List<DetectedFace>> = repository.detectedFaces
}

UseCase가 딱 한 줄이라 처음엔 "이거 의미있나?" 싶었다. 사실 Repository를 직접 ViewModel에 주입해도 당장은 동일하게 동작한다.

그런데 UseCase를 두는 이유는 비즈니스 로직의 진입점을 명확히 하는 것이다. 지금은 단순히 Repository를 위임하는 형태지만, 나중에 "특정 확률 이하의 결과는 필터링한다"거나 "감지 결과를 정규화한다" 같은 로직이 생기면 UseCase 안에서 처리하면 된다. ViewModel이 직접 Repository에 의존하는 것보다 훨씬 수정하기 좋다.


설계 포인트 정리

Domain 레이어에 Android import 없음

domain 모듈의 모든 파일을 보면 android.*, androidx.* import가 하나도 없다. 순수 Kotlin과 kotlinx.coroutines만 쓴다.

이게 중요한 이유:

  1. 테스트 용이성 — Android 프레임워크 없이 JVM에서 바로 유닛 테스트할 수 있다. Robolectric 같은 별도 도구 없이도.
  2. 이식성 — 이 Domain 레이어는 Android가 아닌 Kotlin 프로젝트에서도 재사용할 수 있다.
  3. 의존성 방향 강제 — Android 타입이 domain에 없으니 data 레이어가 항상 변환을 담당하게 된다. 레이어 경계가 자연스럽게 유지된다.

PointF 대신 Point2D, Rect 대신 BoundingRect를 직접 만든 게 이 원칙을 지키기 위한 선택이었다.

ML Kit 추상화

ML Kit가 domain에 직접 노출되지 않는다. FaceContourType enum이 ML Kit 정수 상수를 감싸고, data 레이어가 ML Kit 타입 → domain 타입 변환을 전담한다.

덕분에 presentation 레이어는 ML Kit가 존재하는지도 모른 채 FaceContourType.LEFT_EYE라는 도메인 언어로 일할 수 있다.


다음 편 예고

Domain 레이어 코드는 다 짰다. Android도 없고 ML Kit도 없다. 그러면 다음 편에서는 그 반대가 온다 — Data 레이어다.

CameraX로 카메라를 연결하고, ML Kit로 얼굴을 감지하고, 감지 결과를 domain 모델로 변환하는 FaceDetectionRepository 구현체를 만들 예정이다. ML Kit → domain 타입 변환 로직이 여기서 처음 등장하게 된다.

솔직히 data 레이어가 제일 코드가 복잡해질 것 같다. CameraX 설정, ML Kit 초기화, Flow 연결, 좌표 변환까지. 한 편에 다 담을 수 있을지 모르겠지만 일단 해보겠다.

profile
Android Developer

0개의 댓글