AI Agent를 위한 프로젝트 컨텍스트 문서. 코드베이스 이해와 일관된 개발을 위해 이 파일을 먼저 참조할 것.
운다방(Undabang) - 운동 기록 및 매칭 Android 앱
- 패키지명:
com.project200.undabang - 최소 SDK: 26 (Android 8.0)
- 타겟 SDK: 36 (Android 16)
- 컴파일 SDK: 36
- Kotlin 버전: 1.9.23
- JDK: 17
app/ # Application entry point, DI 통합, MainActivity
├── domain/ # Pure Kotlin (UseCase, Repository interfaces, Models)
├── data/ # Repository 구현체, API, DTO, Mapper, Room DB
├── presentation/ # 공통 UI 컴포넌트, Base 클래스, Utils
├── common/ # 공유 유틸, 상수, DI Qualifiers
├── core/
│ └── oauth/ # OAuth 인증 (Cognito/AppAuth)
└── feature/ # 피처 모듈 (화면 단위)
├── auth/ # 로그인, 회원가입
├── profile/ # 마이페이지, 설정
├── exercise/ # 운동 기록
├── timer/ # 타이머 기능
├── matching/ # 지도 기반 매칭
├── chatting/ # 채팅
└── feed/ # 피드
View(Fragment/Activity) → ViewModel → UseCase → Repository(interface) → RepositoryImpl → RemoteDataSource(ApiService) / LocalDataSource(Room)
// Feature 모듈 의존성 패턴
dependencies {
implementation(projects.domain) // 필수
implementation(projects.common) // 필수
implementation(projects.presentation) // UI 관련
}
// Data 모듈
dependencies {
implementation(projects.domain)
implementation(projects.common)
}| Category | Technology |
|---|---|
| DI | Hilt |
| Network | Retrofit2 + Moshi + OkHttp |
| Local DB | Room (UndabangDatabase, 엔티티 3개, DAO 2개, 스키마 data/schemas/) |
| Preferences | 회원ID는 PreferenceManager의 평문 SharedPreferences(undabangPrefs), 토큰은 core/oauth의 EncryptedSharedPreferences (DataStore는 의존성만, 구현 코드 없음) |
| Async | Coroutines + Flow |
| Category | Technology |
|---|---|
| View | Compose 우선 + ViewBinding 잔존 (아래 4.4 참조) |
| Navigation | Navigation Component + SafeArgs |
| Image Loading | Glide (XML), Coil (Compose) |
| Map | Kakao Map SDK |
| Category | Technology |
|---|---|
| Unit Test | JUnit4 + MockK + Truth + Turbine |
| UI Test | Espresso |
| Coverage | JaCoCo |
| Category | Technology |
|---|---|
| Lint | Ktlint (org.jlleitschuh.gradle.ktlint) |
| CI | GitHub Actions |
| Distribution | Firebase App Distribution |
| Type | Convention | Example |
|---|---|---|
| DTO | {Action}{Entity}DTO |
GetProfileDTO, PostLoginRequest |
| UseCase | {Action}{Entity}UseCase |
LoginUseCase, GetExerciseRecordListUseCase |
| Repository | {Entity}Repository |
AuthRepository, TimerRepository |
| ViewModel | {Screen}ViewModel |
LoginViewModel, ExerciseFormViewModel |
| Fragment | {Screen}Fragment |
RegisterFragment, ChattingRoomFragment |
feature/{feature-name}/
└── src/main/java/com/project200/feature/{feature-name}/
├── {screen}/
│ ├── {Screen}Fragment.kt
│ ├── {Screen}ViewModel.kt
│ └── adapter/ (if needed)
└── utils/ (if needed)
- Ktlint 적용 (
.editorconfig참조) - DTO 파일명에서
ktlint_standard_filename비활성화 - ktlint는
domain모듈에 적용되지 않음 (convention.kotlin.jvm만 적용). domain은 옆 파일 스타일을 따를 것
주석은 한국어로 쓴다. 종결어미가 붙는 문장은 존댓말로 통일한다 (~ 합니다, ~ 됩니다). 반말(~ 한다, ~ 없다)은 쓰지 않는다. KDoc이 이미 존댓말로 쌓여 있어 여기에 맞춘다
- 라인 끝 마침표는 붙이지 않는다
- 짧은 라벨성 주석은 종결어미 없이 명사구로 끝내도 된다 (
// 운동 장소 삭제,// 유효성 검사) - 코드를 그대로 옮겨 적지 말고 왜 그렇게 했는지를 남긴다
- 리팩터링할 때 맥락이 맞는 기존 주석은 그대로 둔다. 테스트의 Given/When/Then 포함
- em-dash 남용, 따옴표 강조, 자기자랑 단어("안전하다", "최적화되어 있다")를 피한다
현재 Compose로 전환 중. 두 방식이 함께 있으므로 고칠 파일의 옆 파일을 먼저 볼 것.
| 현황 | |
|---|---|
{Screen}Screen.kt (Compose) |
22개 |
@Composable 선언 |
186개 |
BindingFragment 상속 |
10개 |
- 새 화면은 Compose로 만든다.
AppTheme으로 감싸고@Preview를 붙인다 - Composable은 상태를 파라미터로 받고 이벤트를 콜백으로 올린다. ViewModel을 직접 참조하지 않는다
- 컨테이너는 Fragment.
ComposeView에 붙인다 - 기존 XML 화면을 고칠 때는
BindingFragment를 그대로 쓴다. 화면 하나를 고치면서 Compose로 갈아엎지 않는다
sealed class BaseResult<out T> {
data class Success<T>(val data: T) : BaseResult<T>()
data class Error(
val errorCode: String? = null,
val message: String?,
val cause: Throwable? = null
) : BaseResult<Nothing>()
}sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val failure: Failure) : UiState<Nothing>
}
sealed interface Failure {
data object NetworkError : Failure
data class ServerError(val code: String?, val message: String?) : Failure
data object Unknown : Failure
}class LoginUseCase @Inject constructor(
private val userRepository: AuthRepository
) {
suspend operator fun invoke(): BaseResult<Unit> {
return userRepository.login()
}
}class AuthRepositoryImpl @Inject constructor(
private val apiService: ApiService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
override suspend fun login(): BaseResult<Unit> {
return apiCallBuilder(
ioDispatcher = ioDispatcher,
apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) },
mapper = { Unit },
)
}
}@AndroidEntryPoint
class SomeFragment : BindingFragment<FragmentSomeBinding>(R.layout.fragment_some) {
private val viewModel: SomeViewModel by viewModels()
override fun getViewBinding(view: View): FragmentSomeBinding {
return FragmentSomeBinding.bind(view)
}
override fun setupViews() {
// View 초기화, 클릭 리스너 설정
}
override fun setupObservers() {
// LiveData observe
viewModel.someData.observe(viewLifecycleOwner) { data ->
// UI 업데이트
}
}
}StateFlow가 기본(111곳). LiveData는 기존 XML 화면에 남아 있다(41곳). 새 ViewModel은 StateFlow로 쓴다.
@HiltViewModel
class SomeViewModel @Inject constructor(
private val someUseCase: SomeUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UiState<SomeData>>(UiState.Loading)
val state: StateFlow<UiState<SomeData>> = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.value = someUseCase().toUiState()
}
}
}수집은 repeatOnLifecycle(Lifecycle.State.STARTED) 안에서 한다. repeatOnLifecycle은 재진입할 때 현재 값을 다시 흘리므로, 한 번만 일어나야 하는 일은 플래그나 compareAndSet으로 막는다.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}오프라인 캐시는 data 모듈 안에서만 다룬다. domain과 feature는 Room을 모른다.
- 모든 테이블에
memberId컬럼을 두고 DAO 쿼리에 계정 조건을 강제한다 - 행 식별은
localId(UUID),serverId는 nullable. 오프라인에서 만든 행은 서버 ID를 받기 전이다 syncState는 연산 로그가 아니라 행마다의 현재 상태다 (SYNCED/CREATE_PENDING/UPDATE_PENDING/DELETE_PENDING)- 삭제는 행을 바로 지우지 않고 삭제 대기로 표시한다. 예외는 서버에 없는 생성 대기 행뿐이다
- 서버 목록을 로컬에 반영할 때
syncState != SYNCED행은 대체하지 않는다 - 읽기는 스냅샷 1회다. 화면이 지속
Flow를 구독하지 않는다. 서버 응답은 화면으로 직접 가지 않고 항상 Room을 거친다 - 서버 조회 트리거는 화면 최초 진입과 수동 새로고침 둘뿐이다. 회전, 백스택 복귀, 온라인 전환은 트리거가 아니다. 복귀할 때 Room 값은 다시 읽는다
- API 호출은
RemoteDataSource, Room 접근은LocalDataSource로 나누고RepositoryImpl이 합친다
로컬에 데이터를 쌓는 저장소를 추가하면 ClearSessionUseCase에 정리를 연결한다. domain의 SessionDataCleaner 인터페이스를 통해 data 구현이 지운다.
정리 범위는 이탈 이유가 정한다. ClearSessionUseCase(reason)에 SessionExitReason을 넘긴다. 호출부는 무엇을 지울지 고르지 않고 무슨 일이 있었는지만 알린다.
| 이유 | 캐시 | 회원ID | 토큰 |
|---|---|---|---|
USER_INITIATED 로그아웃, 탈퇴 |
삭제 | 삭제 | 삭제 |
FORCED 토큰 만료, 갱신 실패, 진입 실패 |
보존 | 보존 | 삭제 |
- 강제 이탈에서 캐시를 남기는 이유는 아직 서버에 올리지 못한 전송 대기 행을 지키기 위해서다. 사용자가 의도하지 않은 이탈로 오프라인에서 만든 데이터를 잃게 하지 않는다
- 강제 이탈에서 회원ID도 남긴다. 회원ID가 없으면 재로그인 때 계정 전환 판정이 "이전 값과 다름"으로 떨어져 캐시를 지운다
- 캐시 삭제를 회원ID 소거보다 먼저 한다. 계정 스코프 삭제로 좁히면 회원ID 없이는 지울 대상을 특정할 수 없다
- 캐시 삭제가 실패해도 세션 정리와 예약 작업 취소는 수행한다
- 계정이 바뀔 때도 지운다.
AuthRepositoryImpl.checkIsRegistered()가 회원ID를 저장할 때 이전 값과 다르면 캐시를 먼저 비운다 - 예약한 Worker는 이유와 무관하게 취소한다. 세션이 없으면 전송할 수 없다. 강제 이탈에서 남긴 대기 행은 재로그인 때 다시 예약해 올린다
@JsonClass(generateAdapter = true)
data class BaseResponse<T>(
val succeed: Boolean,
val code: String,
val message: String,
val data: T? = null,
)모든 Repository API 호출은 apiCallBuilder 함수를 사용:
suspend fun <DTO, Domain> apiCallBuilder(
ioDispatcher: CoroutineDispatcher,
apiCall: suspend () -> BaseResponse<DTO>,
mapper: (dto: DTO?) -> Domain,
): BaseResult<Domain>@IdTokenApi // ID Token 필요 (인증 전)
@AccessTokenApi // Access Token 필요 (인증 후)
@AccessTokenWithFcmApi // Access Token + FCM Token 필요@ExperimentalCoroutinesApi
class GetExerciseRecordListUseCaseTest {
@get:Rule
val mockkRule = MockKRule(this)
@MockK
private lateinit var mockRepository: ExerciseRecordRepository
private lateinit var useCase: GetExerciseRecordListUseCase
@Before
fun setUp() {
useCase = GetExerciseRecordListUseCase(mockRepository)
}
@Test
fun `invoke 호출 시 성공 결과 반환`() = runTest {
// Given
coEvery { mockRepository.getList(any()) } returns BaseResult.Success(listOf())
// When
val result = useCase.invoke(LocalDate.now())
// Then
assertThat(result).isInstanceOf(BaseResult.Success::class.java)
}
}@ExperimentalCoroutinesApi
class SomeViewModelTest {
@get:Rule
val mockkRule = MockKRule(this)
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}// Feature 모듈 build.gradle.kts
plugins {
id("convention.android.library")
id("convention.android.hilt")
alias(libs.plugins.navigation.safeargs)
}| Plugin | Description |
|---|---|
convention.android.application |
App 모듈용 |
convention.android.library |
Library 모듈용 (ViewBinding, Timber, 테스트 의존성 포함) |
convention.android.hilt |
Hilt DI 설정 |
convention.kotlin.jvm |
Pure Kotlin 모듈 (domain) |
convention.ktlint |
Ktlint 설정 |
convention.android.jacoco |
Android JaCoCo 설정 |
convention.jvm.jacoco |
JVM JaCoCo 설정 |
@IoDispatcher // Dispatchers.IO
@MainDispatcher // Dispatchers.Main
@DefaultDispatcher // Dispatchers.Default# Lint
./gradlew lint
# Ktlint
./gradlew ktlintCheck
./gradlew ktlintFormat
# Unit Test
./gradlew testDebugUnitTest
# Build Debug
./gradlew assembleDebug
# Build Release
./gradlew assembleRelease
# JaCoCo Report
./gradlew jacocoFullReport| File | Purpose |
|---|---|
gradle/libs.versions.toml |
버전 카탈로그 (의존성 버전 관리) |
local.properties |
로컬 비밀키 (KAKAO_NATIVE_APP_KEY 등) |
build-logic/convention/ |
Convention Plugin 정의 |
.editorconfig |
Ktlint 규칙 설정 |
- main: Production 브랜치 (CI/CD → Play Store)
- dev: Development 브랜치 (CI → Firebase App Distribution). 작업 브랜치는 여기서 딴다
- 브랜치 이름:
{타입}/{설명}-{이슈번호}(예:fix/fcm-token-sync-565,refactor/entry-state-562)- 타입은
featfixrefactorchoredocs
- 타입은
- PR Template:
.github/PULL_REQUEST_TEMPLATE.md참조. 이슈를 닫으려면Closes #번호를 쓴다 - PR 제목:
{접두} {한국어 제목} #{이슈번호}(예:[Fix] 계정 전환 시 이전 계정의 로컬 캐시 삭제 #593). 이슈가 없는 작업만 번호를 뺀다
apiCallBuilder사용하여 API 호출BaseResult로 결과 래핑- Convention Plugin 사용
- Repository interface를 domain에 정의
- UseCase는 single responsibility
- ViewModel에서 StateFlow로 상태 노출
- 새 화면은 Compose로 작성
by viewModels()위임 사용- 새 Room 테이블에
memberId컬럼과 DAO 계정 조건 적용
- data 모듈에서 domain 구현체 직접 참조 금지
- Presentation/Feature에서 Retrofit/저장소 직접 사용 금지
- 기존 XML 화면을 고치면서 Compose로 갈아엎기 금지
- DataStore가 이미 있다고 가정하고 코드 작성 금지 (의존성만 있고 구현이 없음)
- Compose Compiler 버전을 Kotlin 버전과 따로 올리기 금지
- CancellationException 삼키지 않기 (반드시 rethrow)
- Fragment/Activity에서 ViewModel의 StateFlow/LiveData에
.value직접 접근 금지 (반드시collect또는observe사용)