운전이 오랜만인 면허 소지자가 자주 가는 길을 연습하는 앱입니다. 주차 중 목표와 과제를 정하고, 연습을 마치면 차량 기록과 메모를 보며 다음 과제를 고릅니다.
Pleos 에뮬레이터 시연 (1분 57초)
hangil-demo.mp4
| 홈 | 오늘의 과제 |
|---|---|
![]() |
![]() |
| 연습 중 | 연습 마무리 |
|---|---|
![]() |
![]() |
| 저장된 차량 기록 | 다음 과제 저장 |
|---|---|
![]() |
![]() |
- 준비 목표, 코스, 과제를 저장합니다. 판교를 거쳐 양재까지 코스는 경유지와 도착지를 Pleos 기본 내비에 보낼 수 있습니다.
- 연습 시작과 종료 시각, SDK가 전달한 기어, 방향지시등, 브레이크를 기록합니다. 위치 표본은 직접 켜면 한길 화면에서 기록합니다. 내비 사용 중에도 차량 상태를 기록하려면 별도로 켭니다.
- 복습 지난 연습의 차량 기록과 위치 표본을 읽고 메모를 남깁니다. 공개 도로선을 복습 구간으로 골라 다음 과제와 함께 저장할 수 있습니다.
- 주차 확인 기어 P와 화면 조작 허용을 모두 확인해야 준비, 복습과 상세 조작을 열 수 있습니다.
화면의 코스는 시연용입니다. 복습에서는 시연 코스, 저장된 SDK 관찰, 사용자가 남긴 메모의 출처를 각각 표시합니다.
Kotlin, Jetpack Compose, Coroutines, Hilt, Room, MapLibre, Pleos Vehicle SDK, FusedLocation, NaviHelper
flowchart TB
App["app<br/>화면 전환과 Hilt"]
Feature["feature<br/>home / course / map<br/>화면과 ViewModel"]
Database["core:database<br/>Room 저장소"]
Vehicle["core:vehicle<br/>SDK와 AAOS"]
Domain["core:domain<br/>Kotlin 계약"]
Design["core:designsystem<br/>공통 Compose UI"]
App --> Feature
App --> Database
App --> Vehicle
Feature --> Domain
Feature --> Design
Database --> Domain
Vehicle --> Domain
classDef mint fill:#D8F0E6,stroke:#237561,color:#10382F
classDef cream fill:#FFF3D9,stroke:#79633D,color:#392D18
class App,Domain mint
class Feature,Design,Database,Vehicle cream
화살표는 주요 모듈 의존 방향입니다. app이 Hilt로 Room 저장소와 Pleos 어댑터를 연결하고, 화면의 ViewModel은 core:domain 계약을 사용합니다.
차량 어댑터가 읽은 기어와 AAOS 제한으로 상세 조작을 판정합니다. 복습을 열면 Room에서 선택한 종료 기록을 읽습니다. 차량 관찰과 메모는 각 영역을 펼칠 때, 위치 표본은 위치 기록 화면을 열 때 불러옵니다.
문제 연습을 마친 뒤 이력을 열면, 그 사이 저장된 다른 연습이 표시될 수 있었습니다. 방금 마친 기록을 바로 여는 경로를 붙인 뒤에는 이미 닫힌 화면의 조회가 정상 복습 화면을 잠그는 문제도 재현됐습니다.
원인 마침 응답에 종료 기록이 있었지만 화면에는 완료 메시지만 남았습니다. 새 조회는 Job과 화면 상태를 확인하기 전에 공유 reader를 교체했습니다.
해결 Room 트랜잭션에서 종료 전에 기록 전체를 대조했습니다. 저장 응답과 이력에서 읽은 종료 원본이 일치할 때 복습 요청을 한 번 소비하고, 조회 Job과 화면이 유효할 때만 reader를 등록했습니다.
결과 다른 종료 기록이 생겨도 방금 마친 연습이 열렸습니다. 주행 중 잠겼다가 주차와 조작 허용이 돌아오면 같은 기록에서 복습을 이어갔습니다. 취소된 조회도 정상 조회를 덮지 못했습니다. 전후 성능은 미측정입니다.
대체안/회고 ID가 같아도 코스나 과제 내용이 바뀔 수 있어 기록 전체를 대조했습니다. 화면은 앱이 DB에서 받아 온 변경을 기준으로 복습을 허용합니다.
코드
if (mutation.isLocked || operation.value == SessionOperationState.Saving ||
receipts.value !== receipt || !finishAccessLocked(canKeepOpen) || receipts.value !== receipt ||
currentRecordsLocked()?.singleOrNull { it.sessionId == receipt.source.sessionId } != receipt.source
) {
return@synchronized null
}
receipts.value = null
acknowledgedFinish = null
receipt.sourceowner.job.ensureActive()
if (entry is SessionHistoryEntry.FinishedReview && !hostAllowed(owner)) return@synchronized false
owner.job.ensureActive()
invalidateDeletionLocked()
reader = owner
ended = null문제 삭제 확인창을 띄운 뒤 같은 문장을 다시 저장해도 메모 버전이 바뀝니다. 세션 ID만으로 지우면 확인창을 열 때 없던 변경까지 삭제할 수 있었습니다.
원인 확인 화면을 열 때 읽은 값과 실제로 지울 때의 값 사이에 변경이 들어올 수 있었습니다. 이력에서 행이 사라졌다는 알림도 DB의 삭제 응답보다 먼저 올 수 있었습니다.
해결 확인 시점의 종료 기록, 차량 관찰 수, 메모 버전과 위치 기록 등을 Room 트랜잭션에서 다시 대조했습니다. 부모 행이 삭제되면 외래 키로 연결 자료도 함께 삭제합니다.
결과 확인한 내용이 바뀌면 다시 확인을 요청합니다. 쓰기 후 커밋 검사를 통과하지 못하면 연결 자료도 함께 되돌립니다. Pleos 에뮬레이터 Android 14(API 34), JDK 17 debug, 기본 라이트와 글꼴 1.0에서 예열 5회 후 이력 진입을 전후 각 30회 측정했습니다. 중앙값은 263.03ms에서 260.93ms로 바뀌었습니다. 원자료 기준 2.11ms(0.80%) 차이이며, 사분위 범위가 겹쳐 성능 향상 여부는 미확정입니다.
대체안/회고 삭제 버튼을 잠가도 다른 화면의 메모 저장은 막을 수 없어 DB에서 대조했습니다. 삭제 완료 표시는 저장소의 성공 응답을 기다리도록 했습니다.
코드
database.withTransaction {
requirePermission(canCommit)
val actual = findCheckedSession(target.session.sessionId)
val result =
when {
actual == null -> SessionDeletionResult.Missing
actual.isActive -> SessionDeletionResult.Active
actual != target.session -> SessionDeletionResult.Changed
readTarget(actual) != target -> SessionDeletionResult.Changed
else -> {
check(database.practiceSessionDao().deleteFinished(actual.sessionId) == 1)
SessionDeletionResult.Deleted
}
}
requirePermission(canCommit)
result
}문제 제어 입력으로 주행 상태를 전달했을 때 화면에 이전 주차 지도가 잠시 남았습니다. 이때 드래그하면 카메라가 움직였습니다.
원인 Compose에 잠금이 그려지기 전에 MapView가 입력을 받았습니다. 코스가 바뀐 뒤 이전 View에서 늦게 도착한 렌더 완료도 새 지도 상태에 영향을 줄 수 있었습니다.
해결 조회와 렌더 완료에 현재 준비 원본과 View 토큰을 연결했습니다. 네이티브 전달 직전에 최신 주차 상태와 조작 허용 여부를 읽고, 같은 첫 터치에서 이어진 입력인지 검사했습니다.
결과 제어 입력에서 이전 화면의 드래그와 휠을 막았습니다. 실제 Pleos에서 P→D→P로 바꿔 지도 제거와 복귀를 확인했습니다. 위와 같은 환경에서 예열 5회 후 지도 진입을 전후 각 30회 측정했습니다. 중앙값은 2127.52ms에서 2000.33ms로 127.19ms(5.98%) 차이가 났습니다. 사분위 범위가 겹쳐 성능 향상 여부는 미확정입니다.
대체안/회고 터치 리스너만으로는 자식 View 입력과 이미 예약된 지도 작업까지 정리하기 어려웠습니다. 네이티브 전달 지점에서 입력을 검사하고 허용이 끝나면 진행 중인 제스처를 취소했습니다.
코드
return current.id == attempt && current.token == token && current.load == MapLoadState.READY &&
canRender(current) && render.value === currentvalidTime(downTime, eventTime, now) && downTime >= permittedAt &&
touch?.let {
owns(it.epoch) && it.downTime == downTime && it.deviceId == deviceId && it.source == source
} == true문제 Pleos에서 시스템 런처로 기본 내비를 열자 같은 연습에 차량 기록이 계속 쌓였습니다. 한길로 돌아와 배경 기록을 끄면 전면 기록은 다시 저장됐지만 화면에는 일시 중지가 남았습니다.
원인 소유권을 반환하자 StateFlow를 보던 전면 수집이 즉시 재개됐습니다. 배경 실행의 정리 코드가 뒤에서 Paused를 써 새 상태를 덮었습니다.
해결 배경 실행을 정리하고 화면 상태를 먼저 쓴 뒤 마지막에 소유권을 반환했습니다. 이전 프로세스가 종료 기록을 남기지 못한 실행은 다시 열 때 종료 시각을 알 수 없는 중단으로 표시했습니다.
결과 Pleos 기본 라이트와 확대 다크에서 배경 기록을 끈 뒤 같은 연습의 전면 수집과 화면 표시가 함께 돌아왔습니다. 앱 프로세스를 종료한 시험에서는 재실행 후 이전 기록이 중단 상태로 남았습니다. 전후 성능은 미측정입니다.
대체안/회고 기록을 끈 직후 무조건 기록 중으로 표시하면 활성 연습이 없는 경우도 틀립니다. 전면 수집기가 현재 연습을 확인해 다음 상태를 정하도록 했습니다.
코드
currentBackground.value =
if (cleanupFailed) {
BackgroundRecordingState.Failed(null, request.source.sessionId, checkNotNull(failure))
} else {
BackgroundRecordingState.Disabled
}
currentState.value = VehicleRecordingState.Paused
owner.value = null2026년 10월부터 기획, 디자인, Android 개발을 혼자 맡았습니다.





