[Android 클린 아키텍처 멀티모듈 제작기] 8편 - Data 레이어 구현: ML Kit 연동과 Repository 구현체

김보현·2026년 4월 20일

android

목록 보기
11/12
post-thumbnail

Domain을 만들었으니, 이제 실제로 구현해야 한다

7편에서 FaceDetectionRepository 인터페이스를 선언했다. Flow<List<DetectedFace>>를 내보내고, startDetection()stopDetection()을 제공하는 계약서. 근데 그 계약서를 이행하는 주체가 아직 없었다.

이번 편이 바로 그 구현체를 만드는 편이다. Data 레이어에서 CameraX와 ML Kit를 실제로 다루고, 그 결과를 domain 모델로 변환해서 올려보내는 작업.

솔직히 7편을 쓸 때 "data 레이어가 제일 복잡해질 것 같다"고 했는데... 맞았다.


Data 레이어의 역할

클린 아키텍처에서 Data 레이어가 하는 일은 두 가지다.

  1. Domain Interface 구현FaceDetectionRepository라는 계약을 실제로 이행
  2. 외부 라이브러리 격리 — ML Kit, CameraX 같은 외부 SDK를 domain과 presentation으로부터 차단

두 번째가 중요하다. ML Kit는 Face, FaceContour, PointF 같은 자체 타입을 쓴다. 이 타입들이 presentation이나 domain으로 새어 나가면 나중에 ML Kit를 다른 SDK로 교체하거나 업그레이드할 때 전체를 다 고쳐야 한다. Data 레이어가 변환을 전담하면 바꿀 때 data 레이어만 건드리면 된다.


의존성 추가 — 드디어 모듈이 연결된다

data/build.gradle.kts에 의존성을 추가했다.

dependencies {
    implementation(project(":domain"))
    implementation(libs.camerax.core)
    implementation(libs.camerax.camera2)
    implementation(libs.camerax.lifecycle)
    implementation(libs.camerax.view)
    implementation(libs.mlkit.face.detection)
    implementation(libs.kotlinx.coroutines.android)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
}

implementation(project(":domain")) — 이 한 줄이 의미하는 게 꽤 크다. 드디어 멀티모듈 프로젝트에서 모듈끼리 연결이 시작됐다. 7편까지는 각 모듈이 독립적으로 존재했는데, 이제 data가 domain을 바라보는 단방향 의존 구조가 실제로 코드에 적용된다.

CameraX는 core, camera2, lifecycle, view 네 가지를 다 추가했다. 7편에서 libs.versions.toml에 미리 등록해뒀던 것들이라 libs.camerax.core 이런 식으로 바로 참조할 수 있다. Convention Plugin 덕분에 이 부분은 깔끔하다.


파일 구조

data/src/main/java/com/dantariun/data/
├── mapper/
│   └── FaceMapper.kt
└── repository/
    └── FaceDetectionRepositoryImpl.kt

크게 두 파일이다. ML Kit → domain 변환을 담당하는 FaceMapper와, 실제 Repository 구현체인 FaceDetectionRepositoryImpl. 역할이 명확하게 분리된다.


FaceMapper — ML Kit 타입을 Domain 타입으로

internal fun Face.toDomain(): DetectedFace = DetectedFace(
    boundingRect = boundingBox.run {
        BoundingRect(left = left, top = top, right = right, bottom = bottom)
    },
    contours = allContours.mapNotNull { it.toDomain() },
    eyeState = EyeState(
        leftOpenProbability = leftEyeOpenProbability ?: 0f,
        rightOpenProbability = rightEyeOpenProbability ?: 0f
    ),
    mouthState = detectMouthState(),
    headDirection = HeadDirection(
        eulerX = headEulerAngleX,
        eulerY = headEulerAngleY,
        eulerZ = headEulerAngleZ
    )
)

ML Kit의 Face 객체를 받아서 domain의 DetectedFace로 변환하는 extension function이다.

internal fun인 이유

여기서 internal이 중요하다. FaceMapper는 data 모듈 내부에서만 써야 한다. 만약 public으로 열어두면 presentation 레이어에서 FaceMapper를 직접 호출해서 ML Kit 타입을 직접 다루는 코드가 생길 수 있다. internal로 막아두면 외부에서는 이 변환 함수가 있는지조차 알 수 없다.

레이어 경계를 코드 레벨에서 강제하는 방법이다.

FaceContourType 매핑

ML Kit는 윤곽선 종류를 정수 상수로 반환한다. FaceContour.FACE, FaceContour.LEFT_EYE 이런 식으로. Domain에서는 이걸 enum으로 추상화했으니 여기서 변환해줘야 한다.

private fun MlKitFaceContour.toDomain(): FaceContour? {
    val domainType = when (faceContourType) {
        MlKitFaceContour.FACE -> FaceContourType.FACE
        MlKitFaceContour.LEFT_EYE -> FaceContourType.LEFT_EYE
        MlKitFaceContour.RIGHT_EYE -> FaceContourType.RIGHT_EYE
        MlKitFaceContour.LEFT_EYEBROW_TOP -> FaceContourType.LEFT_EYEBROW_TOP
        MlKitFaceContour.LEFT_EYEBROW_BOTTOM -> FaceContourType.LEFT_EYEBROW_BOTTOM
        MlKitFaceContour.RIGHT_EYEBROW_TOP -> FaceContourType.RIGHT_EYEBROW_TOP
        MlKitFaceContour.RIGHT_EYEBROW_BOTTOM -> FaceContourType.RIGHT_EYEBROW_BOTTOM
        MlKitFaceContour.UPPER_LIP_TOP -> FaceContourType.UPPER_LIP_TOP
        MlKitFaceContour.UPPER_LIP_BOTTOM -> FaceContourType.UPPER_LIP_BOTTOM
        MlKitFaceContour.LOWER_LIP_TOP -> FaceContourType.LOWER_LIP_TOP
        MlKitFaceContour.LOWER_LIP_BOTTOM -> FaceContourType.LOWER_LIP_BOTTOM
        MlKitFaceContour.NOSE_BRIDGE -> FaceContourType.NOSE_BRIDGE
        MlKitFaceContour.NOSE_BOTTOM -> FaceContourType.NOSE_BOTTOM
        else -> return null
    }
    return FaceContour(
        type = domainType,
        points = points.map { Point2D(x = it.x, y = it.y) }
    )
}

13개 윤곽선 타입을 전부 매핑했다. else -> return null로 예상치 못한 타입은 걸러낸다. 호출하는 쪽에서 mapNotNull을 써서 null은 자동으로 제거된다.


입 열림 감지 — ML Kit 한계를 직접 돌파

여기서 예상치 못한 장벽이 있었다.

ML Kit Face Detection은 눈 열림 확률은 leftEyeOpenProbability, rightEyeOpenProbability로 직접 제공한다. 근데 입 열림 확률은 없다. API 문서를 아무리 뒤져봐도 입에 해당하는 확률값은 없다.

그래서 직접 계산했다. 윤곽선 좌표를 이용해서.

private const val MOUTH_OPEN_THRESHOLD = 10f

private fun Face.detectMouthState(): MouthState {
    val upperLipBottom = getContour(MlKitFaceContour.UPPER_LIP_BOTTOM)?.points
    val lowerLipTop = getContour(MlKitFaceContour.LOWER_LIP_TOP)?.points

    if (upperLipBottom.isNullOrEmpty() || lowerLipTop.isNullOrEmpty()) {
        return MouthState(isOpen = false)
    }

    val upperAvgY = upperLipBottom.map { it.y }.average().toFloat()
    val lowerAvgY = lowerLipTop.map { it.y }.average().toFloat()
    val gap = lowerAvgY - upperAvgY

    return MouthState(isOpen = gap > MOUTH_OPEN_THRESHOLD)
}

아이디어는 간단하다. 윗입술 아랫선(UPPER_LIP_BOTTOM)의 Y좌표 평균과 아랫입술 윗선(LOWER_LIP_TOP)의 Y좌표 평균을 구해서, 그 차이가 임계값(10f)보다 크면 입이 열려있다고 판단한다.

이미지 좌표계에서 Y는 아래로 갈수록 커지니까, 입이 열려있으면 lowerAvgY > upperAvgY가 되고 gap이 양수로 커진다.

완벽한 방법은 아니다. 얼굴 크기나 카메라와의 거리에 따라 같은 입 벌림 정도도 픽셀 차이가 다를 수 있다. 하지만 ML Kit가 제공하는 API 안에서 할 수 있는 가장 현실적인 접근법이다. 임계값(MOUTH_OPEN_THRESHOLD)을 private const로 빼놨으니 실제로 돌려보면서 조정할 수 있다.


FaceDetectionRepositoryImpl — 실제 구현체

class FaceDetectionRepositoryImpl : FaceDetectionRepository {

    private val _detectedFaces = MutableStateFlow<List<DetectedFace>>(emptyList())
    override val detectedFaces: Flow<List<DetectedFace>> = _detectedFaces.asStateFlow()

    private val detector: FaceDetector by lazy {
        val options = FaceDetectorOptions.Builder()
            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL)
            .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
            .enableTracking()
            .build()
        FaceDetection.getClient(options)
    }

    val imageAnalyzer = ImageAnalysis.Analyzer { imageProxy ->
        processImage(imageProxy)
    }

    private fun processImage(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image ?: run { imageProxy.close(); return }
        val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
        detector.process(inputImage)
            .addOnSuccessListener { faces ->
                _detectedFaces.value = faces.map { it.toDomain() }
            }
            .addOnFailureListener { _detectedFaces.value = emptyList() }
            .addOnCompleteListener { imageProxy.close() }
    }

    override fun startDetection() { _detectedFaces.value = emptyList() }
    override fun stopDetection() { _detectedFaces.value = emptyList(); detector.close() }
}

설계 포인트가 몇 가지 있다.

MutableStateFlow를 선택한 이유

카메라 프레임은 쉬지 않고 들어오는 연속 스트림이다. SharedFlow를 써도 되지만, StateFlow가 더 적합하다고 판단했다.

StateFlow는 항상 최신값을 보유한다. 구독자가 잠깐 처리에 바쁘더라도 최신 감지 결과는 유지된다. 카메라 프리뷰 오버레이처럼 "현재 얼굴이 어디 있나"를 보여주는 UI에는 가장 최근 프레임의 결과가 항상 있어야 한다. SharedFlow는 구독자가 없을 때 값을 버릴 수 있어서 이 용도엔 맞지 않는다.

lazy 초기화

FaceDetector는 생성 비용이 있다. 클래스가 만들어질 때 바로 초기화하면 실제로 감지가 필요하지 않은 상황에서도 리소스를 점유하게 된다. by lazy로 첫 번째 사용 시점에 초기화하면 필요할 때만 만들어진다.

imageProxy.close()addOnCompleteListener에서 닫는 이유

처음엔 addOnSuccessListener에서 닫으려 했다. 근데 그러면 ML Kit 처리가 실패했을 때 imageProxy가 안 닫힌다. addOnCompleteListener는 성공이든 실패든 항상 호출되기 때문에 여기서 닫아야 누락이 없다.

ImageProxy를 닫지 않으면 CameraX가 다음 프레임을 넘겨주지 않는다. 즉 카메라가 멈추는 것처럼 보이게 된다. 리소스 관리가 생각보다 중요한 포인트다.


imageAnalyzer가 왜 인터페이스 밖에 있는가

FaceDetectionRepository 인터페이스를 보면 imageAnalyzer가 없다.

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

Domain의 인터페이스는 CameraX를 모른다. ImageAnalysis.Analyzer라는 타입이 domain에 들어가는 순간 domain이 CameraX에 의존하게 된다. Domain은 외부 라이브러리를 몰라야 한다.

imageAnalyzer는 data 레이어 구현의 세부 사항이다. Presentation 레이어는 DI로 FaceDetectionRepositoryImpl을 주입받고, 그 구현체에서 imageAnalyzer를 꺼내서 CameraX에 등록한다. Domain 인터페이스를 통해서가 아니라, 구현체를 직접 알고 있는 레이어(presentation)에서만 접근하는 구조다.

이게 레이어 경계를 지키면서도 CameraX 연동이 가능한 방식이다.


빌드 검증

파일을 다 만들고 빌드를 돌려봤다.

./gradlew :data:assembleDebug

처음엔 import 경로가 꼬여서 한 번 실패했다. ML Kit의 FaceContour와 domain의 FaceContour가 이름이 같아서 충돌이 났다. import com.google.mlkit.vision.face.FaceContour as MlKitFaceContour 같은 alias로 해결했다.

그 다음엔 internal fun이 제대로 격리되는지도 확인했다. presentation 모듈에서 FaceMapper를 직접 접근하려 하면 컴파일 오류가 나야 한다. 실제로 확인해보니 접근이 차단됐다. internal이 의도대로 동작하는 거다.

빌드는 통과했다. Domain과 Data 레이어가 연결됐고, 타입 변환도 컴파일 레벨에서 검증됐다.


정리

이번 편에서 한 작업:

  • data/build.gradle.kts에 domain, CameraX, ML Kit, Coroutines 의존성 추가
  • FaceMapper.kt — ML Kit Face → domain DetectedFace 변환, internal fun으로 격리
  • ML Kit에 없는 입 열림 감지를 윤곽선 좌표 계산으로 직접 구현
  • FaceDetectionRepositoryImpl.ktMutableStateFlow, lazy FaceDetector, imageAnalyzer
  • imageAnalyzer를 interface 밖에 두고 data 레이어 구현 세부사항으로 분리

Domain은 ML Kit를 모르고, ML Kit는 domain을 모른다. 이 둘을 연결하는 게 data 레이어의 역할이고, 그 역할을 이번 편에서 구현했다.


다음 편 예고

이제 domain도 있고 data도 있다. 남은 건 Presentation 레이어다.

CameraX 프리뷰를 화면에 띄우고, FaceDetectionRepositoryImplimageAnalyzer를 CameraX에 등록하고, detectedFaces Flow를 ViewModel에서 collect해서 얼굴 윤곽 오버레이와 상태 UI를 그리는 작업이다.

오버레이 그리는 부분이 생각보다 까다로울 것 같다. Canvas로 직접 그려야 하는데, 카메라 이미지 좌표와 화면 좌표를 맞추는 변환이 들어간다. 다음 편에서 해보겠다.

profile
Android Developer

0개의 댓글