[FEAT] 크루위키 KMP 앱 초기 기능 구현#2
Conversation
- libs.versions.toml에 ktor 3.1.3, kotlinx-coroutines 1.9.0 버전 추가 - ktor-client-core, content-negotiation, logging, serialization 공통 의존성 추가 - androidMain에 ktor-client-okhttp, iosMain에 ktor-client-darwin 추가
- CrewWikiHttpClient: Ktor 클라이언트 설정 (JSON 직렬화, 로깅) - ApiDto: API 응답 래퍼(ApiResponse), 페이지네이션, 문서/그룹 DTO 정의 - DocumentApiService: 문서 상세·로그 목록·로그 상세·인기 문서·검색 API - GroupApiService: 조직(그룹) 문서 상세 API
- GroupModels: OrganizationEvent, LinkedCrewDocument, GroupDocumentDetail 도메인 모델 - NetworkDocumentRepository: 문서 상세·로그·인기 문서 실제 API 연동 - GroupDocumentRepository: 그룹 문서 실제 API 연동 - AppContainer: 수동 DI 컨테이너 (HttpClient → ApiService → Repository)
- PopularDocumentsViewModel: 조회수/수정수 기준 인기 문서 로드 - DocumentDetailViewModel: UUID로 문서 상세 로드 - DocumentLogsViewModel: 편집 기록 목록 페이지네이션 지원 - DocumentLogDetailViewModel: 특정 버전 로그 상세 로드 - GroupDetailViewModel: 그룹 문서 상세 및 타임라인 이벤트 로드 - 공통 UiState sealed interface (Loading / Success / Error) 패턴 적용
- DocumentLogsScreen: 버전·생성일시·문서크기·편집자 헤더 + 무한 스크롤 목록 - DocumentLogDetailScreen: 특정 버전 마크다운 본문 렌더링 - GroupDetailScreen: 그룹 문서 본문·연관 크루 문서·타임라인 이벤트 카드 - MarkdownSectionParser: 마크다운 파싱 공통 유틸 분리 - LoadingScreen / ErrorScreen 공통 컴포넌트 추가
- 모든 목적지에서 AppContainer 통해 ViewModel 생성 (수동 ViewModelProvider.Factory) - Popular: PopularDocumentsViewModel → 실제 API 인기 문서 표시 - Document: DocumentDetailViewModel → uuid 기반 실제 문서 상세 - DocumentLogs: DocumentLogsViewModel → 페이지네이션 편집 기록 - DocumentLog: DocumentLogDetailViewModel → 특정 버전 상세 - GroupDetail: GroupDetailViewModel → 그룹 문서 + 타임라인 - GroupLogs / GroupLog: 그룹 편집 기록 및 상세 연결 - DocumentDetailScreen: onEditClick·onLogsClick·onWriteClick 콜백 외부 주입
- BASE_URL = http://3.35.253.192:8080 - AndroidManifest에 cleartext 트래픽 허용 필요 (HTTP)
- INTERNET 퍼미션 추가 - android:usesCleartextTraffic=true 설정 (HTTP 서버 3.35.253.192:8080 대응)
- BASE_URL을 https://api.crew-wiki.site 로 변경 (HTTP → HTTPS) - ApiDto.kt: DocumentResponseDto, HistoryResponseDto 등 실제 API 스펙 필드에 맞게 재작성 - DocumentResponse: organizationDocumentResponses, viewCount, latestVersion 반영 - HistoryResponse.version: Int → Long - OrganizationDocumentAndEventResponse 필드명 수정 - DocumentApiService: 신규 엔드포인트 추가 (search, random, organizationDocuments) - NetworkDocumentRepository: DTO → Domain 매핑 업데이트 - GroupDocumentRepository: 실제 DTO 필드명으로 매핑 수정 - PopularDocument: editCount 제거 (API 미지원), viewCount 기준 단일 목록 - PopularUiState.Success: documentsByViews/Edits → documents 단일 필드 - PopularDocumentsScreen: 탭 제거, viewCount 기준 UI로 단순화 - AndroidManifest: usesCleartextTraffic 제거 (HTTPS 전환)
- DocumentDetailScreen.kt: private DocumentDetailUiState → DocumentDetailScreenState (sealed interface 이름 충돌 해소) - DocumentLogsViewModel.kt: fetchDocumentLogsByUUID → fetchDocumentLogs (함수명 오타) - DocumentLogsScreen.kt: String.format() KMP 미지원 → 정수 연산으로 대체 - InMemoryDocumentRepository.kt: PopularDocument editCount 제거, PopularSortType.EDITS 제거, OrganizationReference 생성자 수정 - CrewWikiNavHost.kt: ViewModelProvider.Factory SAM 람다 → object : ViewModelProvider.Factory 패턴으로 교체
- DocumentDetailScreen: DocumentDetailUiState 생성자 호출 → DocumentDetailScreenState - CrewWikiNavHost: ViewModelProvider.Factory.create(Class<T>) → create(KClass<T>, CreationExtras) (KMP iOS에서 java.lang.Class 미지원)
최근에 확인한 문서는 세션 동안 유지되는 메모리 저장소(RecentlyViewedStore)로 추적
레거시 런처 아이콘은 icon.png를 밀도별로 리사이즈해 사용하고, 적응형 아이콘은 브랜드 teal 배경 위에 icon.png를 18% 인셋으로 배치
대문 콘텐츠만 표시하도록 단순화
- 표를 행 단위 Row+weight 대신 2패스 측정 그리드(TableGrid)로 렌더링해 열 너비/행 높이를 모든 셀에 동일하게 동기화, 경계선이 어긋나던 문제 해결 - [텍스트](url) 링크에 LinkAnnotation.Url을 부여해 클릭 시 실제로 해당 URL로 이동하도록 수정 (기존에는 스타일만 있고 클릭 핸들러가 없었음)
1열은 검정 배경 + 흰색 굵은 텍스트(중앙 정렬), 나머지 열은 흰 배경 + 검정 텍스트(좌측 정렬)로 렌더링하고, 두꺼운 검정 테두리와 둥근 외곽 모서리를 적용
- 최근 편집/최근 확인 리스트 각 항목을 카드형 보더 박스로 감싸 인기문서 스타일과 통일 - 마크다운 렌더러에 HTML <table> 태그(colspan, 이미지 셀 포함) 파싱 및 렌더링 추가
제목 디바이더 바로 아래에 h1~h3 헤딩 기반 목차를 표시하고, 항목 클릭 시 해당 헤딩으로 스크롤 이동한다. 헤더를 탭하면 펼침/접힘 토글 가능.
Android 런처 아이콘(전 밀도)과 어댑티브 아이콘 foreground, iOS 앱 아이콘을 모두 새 icon.png 아트워크로 교체한다.
Android app_name과 iOS CFBundleDisplayName을 크루위키로 통일한다.
There was a problem hiding this comment.
Code Review
This pull request migrates the Crew Wiki application to a Kotlin Multiplatform (KMP) project targeting Android and iOS, introducing shared repositories, view models, and a custom Markdown renderer alongside the necessary build configurations. The review feedback highlights several critical improvements: optimizing the TableGridLayout in the Markdown renderer using Intrinsic Measurement to avoid redundant subcompositions, resolving potential race conditions in SearchViewModel, DocumentEditorViewModel, and DocumentLogsViewModel by utilizing collectLatest and synchronous state updates, ensuring thread safety in RecentlyViewedStore with atomic updates, improving UX in the editor's writer validation, and refactoring the DocumentRepository interface to support suspend functions for proper polymorphism.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| SubcomposeLayout(modifier) { _ -> | ||
| val minWidthPx = cellMinWidth.roundToPx() | ||
|
|
||
| // 1차: 제약 없이 측정하여 각 셀의 자연스러운 크기를 파악한다. | ||
| val naturalPlaceables = subcompose(0, content).map { it.measure(Constraints()) } | ||
|
|
||
| val colWidths = IntArray(totalColumns) { minWidthPx } | ||
| val rowHeights = IntArray(rowSpans.size) | ||
| var idx = 0 | ||
| for ((rowIndex, row) in rowSpans.withIndex()) { | ||
| var col = 0 | ||
| for (span in row) { | ||
| val placeable = naturalPlaceables[idx] | ||
| val perColWidth = (placeable.width + span - 1) / span | ||
| for (c in col until (col + span).coerceAtMost(totalColumns)) { | ||
| colWidths[c] = maxOf(colWidths[c], perColWidth) | ||
| } | ||
| rowHeights[rowIndex] = maxOf(rowHeights[rowIndex], placeable.height) | ||
| col += span | ||
| idx++ | ||
| } | ||
| } | ||
|
|
||
| // 표가 화면보다 좁으면 남는 너비를 열에 균등 분배해 꽉 채운다. | ||
| val naturalTotal = colWidths.sum() | ||
| if (minTotalWidthPx > naturalTotal) { | ||
| val extra = minTotalWidthPx - naturalTotal | ||
| val per = extra / totalColumns | ||
| val remainder = extra % totalColumns | ||
| for (i in colWidths.indices) { | ||
| colWidths[i] += per + if (i < remainder) 1 else 0 | ||
| } | ||
| } | ||
|
|
||
| // 2차: 확정된 열 너비·행 높이로 모든 셀을 동일하게 고정 측정한다. | ||
| val finalPlaceables = subcompose(1, content) | ||
| val placeables = arrayOfNulls<Placeable>(finalPlaceables.size) | ||
| idx = 0 | ||
| for ((rowIndex, row) in rowSpans.withIndex()) { | ||
| var col = 0 | ||
| for (span in row) { | ||
| val cellWidth = (col until (col + span).coerceAtMost(totalColumns)).sumOf { colWidths[it] } | ||
| placeables[idx] = finalPlaceables[idx].measure( | ||
| Constraints.fixed(cellWidth, rowHeights[rowIndex]), | ||
| ) | ||
| col += span | ||
| idx++ | ||
| } | ||
| } |
There was a problem hiding this comment.
TableGridLayout에서 subcompose를 서로 다른 slot ID(0과 1)로 두 번 호출하여 측정(measure)하는 방식은 Compose의 성능 안티패턴입니다. 이 방식은 자식 컴포저블들을 완전히 새로 생성하고 구성(Composition)하므로, 표 내부에 이미지(AsyncImage) 등이 있을 경우 이미지를 중복으로 로드하고 불필요한 네트워크 요청과 리소스 낭비를 유발합니다.
대신 Compose의 **Intrinsic Measurement(고유 크기 측정)**를 사용하면 실제 measure를 호출하지 않고도 자식들의 자연스러운 너비와 높이를 미리 계산할 수 있습니다. 이를 통해 단 한 번의 subcompose와 단 한 번의 measure 호출만으로 2-pass 레이아웃을 완벽하고 효율적으로 구현할 수 있습니다.
SubcomposeLayout(modifier) { constraints ->
val minWidthPx = cellMinWidth.roundToPx()
val measurables = subcompose(Unit, content)
// Intrinsic 크기를 사용하여 실제 측정(measure) 없이 자연스러운 너비와 높이를 계산합니다.
// 이를 통해 이미지 중복 로드 및 불필요한 Composition을 방지할 수 있습니다.
val naturalWidths = measurables.map { it.maxIntrinsicWidth(Int.MAX_VALUE) }
val naturalHeights = measurables.map { it.maxIntrinsicHeight(Int.MAX_VALUE) }
val colWidths = IntArray(totalColumns) { minWidthPx }
val rowHeights = IntArray(rowSpans.size)
var idx = 0
for ((rowIndex, row) in rowSpans.withIndex()) {
var col = 0
for (span in row) {
val naturalWidth = naturalWidths[idx]
val naturalHeight = naturalHeights[idx]
val perColWidth = (naturalWidth + span - 1) / span
for (c in col until (col + span).coerceAtMost(totalColumns)) {
colWidths[c] = maxOf(colWidths[c], perColWidth)
}
rowHeights[rowIndex] = maxOf(rowHeights[rowIndex], naturalHeight)
col += span
idx++
}
}
// 표가 화면보다 좁으면 남는 너비를 열에 균등 분배해 꽉 채운다.
val naturalTotal = colWidths.sum()
if (minTotalWidthPx > naturalTotal) {
val extra = minTotalWidthPx - naturalTotal
val per = extra / totalColumns
val remainder = extra % totalColumns
for (i in colWidths.indices) {
colWidths[i] += per + if (i < remainder) 1 else 0
}
}
// 최종 제약 조건으로 각 셀을 딱 한 번만 측정합니다.
val placeables = arrayOfNulls<Placeable>(measurables.size)
idx = 0
for ((rowIndex, row) in rowSpans.withIndex()) {
var col = 0
for (span in row) {
val cellWidth = (col until (col + span).coerceAtMost(totalColumns)).sumOf { colWidths[it] }
placeables[idx] = measurables[idx].measure(
Constraints.fixed(cellWidth, rowHeights[rowIndex]),
)
col += span
idx++
}
}| init { | ||
| _query | ||
| .debounce(300L) | ||
| .distinctUntilChanged() | ||
| .onEach { q -> | ||
| if (q.isBlank()) { | ||
| _uiState.value = SearchUiState.Idle | ||
| } else { | ||
| search(q) | ||
| } | ||
| } | ||
| .launchIn(viewModelScope) | ||
| } |
There was a problem hiding this comment.
현재 _query Flow의 onEach 블록 내부에서 search(q)를 호출할 때마다 viewModelScope.launch를 통해 새로운 코루틴을 비동기적으로 실행하고 있습니다. 이로 인해 이전 검색 요청이 완료되기 전에 새로운 검색 요청이 시작되면, 네트워크 응답 속도에 따라 이전 검색 결과가 최신 검색 결과를 덮어쓰는 경쟁 상태(Race Condition) 버그가 발생할 수 있습니다.
collectLatest를 사용하면 새로운 검색어가 입력될 때 이전의 검색 작업(네트워크 요청 포함)을 자동으로 취소하므로, 항상 가장 마지막에 입력된 검색어의 결과만 안전하게 반영할 수 있습니다. 또한 불필요한 search 함수를 제거하고 코드를 훨씬 간결하게 만들 수 있습니다.
init {
viewModelScope.launch {
_query
.debounce(300L)
.distinctUntilChanged()
.collectLatest { q ->
if (q.isBlank()) {
_uiState.value = SearchUiState.Idle
} else {
_uiState.value = SearchUiState.Loading
try {
val results = apiService.searchDocuments(q)
_uiState.value = SearchUiState.Success(results)
} catch (e: Exception) {
_uiState.value = SearchUiState.Error(e.message ?: "검색 실패")
}
}
}
}
}| fun record(document: RecentDocument) { | ||
| _viewedDocuments.value = listOf(document) + | ||
| _viewedDocuments.value.filterNot { it.uuid == document.uuid } | ||
| .take(MAX_SIZE - 1) | ||
| } |
There was a problem hiding this comment.
RecentlyViewedStore는 싱글톤 객체(object)이며 여러 화면이나 백그라운드 작업에서 동시에 record를 호출할 수 있습니다. 현재 구현은 _viewedDocuments.value를 읽고 수정하여 다시 쓰는 방식이므로, 멀티스레드 환경이나 빠른 연속 호출 시 업데이트가 유실되는 **경쟁 상태(Race Condition)**가 발생할 수 있습니다.
MutableStateFlow.update 함수를 사용하면 원자적(Atomic)으로 상태를 업데이트하여 스레드 안전성을 보장할 수 있습니다. (상단에 import kotlinx.coroutines.flow.update를 추가해야 합니다.)
fun record(document: RecentDocument) {
_viewedDocuments.update { currentList ->
listOf(document) + currentList.filterNot { it.uuid == document.uuid }
.take(MAX_SIZE - 1)
}
}|
|
||
| fun loadNextPage() { | ||
| val state = _uiState.value as? DocumentLogsUiState.Success ?: return | ||
| if (state.isLoadingMore || currentPage + 1 >= state.totalPage) return | ||
|
|
||
| viewModelScope.launch { | ||
| _uiState.value = state.copy(isLoadingMore = true) | ||
| fetchPage(currentPage + 1) | ||
| } |
There was a problem hiding this comment.
loadNextPage가 호출될 때 _uiState.value = state.copy(isLoadingMore = true)를 비동기 코루틴(viewModelScope.launch) 내부에서 설정하고 있습니다. 이로 인해 코루틴이 실제로 실행되기 전에 loadNextPage가 연속으로 빠르게 호출되면, state.isLoadingMore가 여전히 false로 판단되어 동일한 페이지에 대한 중복 네트워크 요청이 발생할 수 있습니다.
상태 변경(isLoadingMore = true)을 코루틴 실행 전에 **동기적(Synchronous)**으로 처리하면 이러한 중복 호출을 완벽하게 방지할 수 있습니다. 또한, 새로운 페이지 로딩 작업이 시작될 때 기존의 작업(fetchJob)을 취소하도록 관리하면 더욱 안전합니다.
fun loadNextPage() {
val state = _uiState.value as? DocumentLogsUiState.Success ?: return
if (state.isLoadingMore || currentPage + 1 >= state.totalPage) return
_uiState.value = state.copy(isLoadingMore = true)
fetchJob = viewModelScope.launch {
fetchPage(currentPage + 1)
}
}| init { | ||
| organizationQuery | ||
| .debounce(250L) | ||
| .distinctUntilChanged() | ||
| .onEach { query -> | ||
| if (query.isBlank()) { | ||
| _uiState.value = _uiState.value.copy( | ||
| organizationSuggestions = emptyList(), | ||
| isSearchingOrganizations = false, | ||
| ) | ||
| } else { | ||
| searchOrganizations(query) | ||
| } | ||
| } | ||
| .launchIn(viewModelScope) | ||
| } |
There was a problem hiding this comment.
organizationQuery Flow의 onEach 블록 내부에서 searchOrganizations(query)를 호출하고 있습니다. onEach + launchIn 조합은 이전 검색 요청이 진행 중일 때 새로운 검색어가 입력되어도 이전 요청을 취소하지 않으므로, 네트워크 응답 속도에 따라 이전 검색 결과가 최신 결과를 덮어쓰거나 불필요한 네트워크 트래픽을 유발할 수 있습니다.
collectLatest를 사용하면 새로운 검색어가 입력되는 즉시 이전의 검색 작업(네트워크 요청 포함)을 자동으로 취소하여, 항상 가장 최신의 검색 결과만 안전하고 빠르게 화면에 반영할 수 있습니다.
| init { | |
| organizationQuery | |
| .debounce(250L) | |
| .distinctUntilChanged() | |
| .onEach { query -> | |
| if (query.isBlank()) { | |
| _uiState.value = _uiState.value.copy( | |
| organizationSuggestions = emptyList(), | |
| isSearchingOrganizations = false, | |
| ) | |
| } else { | |
| searchOrganizations(query) | |
| } | |
| } | |
| .launchIn(viewModelScope) | |
| } | |
| init { | |
| viewModelScope.launch { | |
| organizationQuery | |
| .debounce(250L) | |
| .distinctUntilChanged() | |
| .collectLatest { query -> | |
| if (query.isBlank()) { | |
| _uiState.value = _uiState.value.copy( | |
| organizationSuggestions = emptyList(), | |
| isSearchingOrganizations = false, | |
| ) | |
| } else { | |
| searchOrganizations(query) | |
| } | |
| } | |
| } | |
| } |
| fun onWriterChange(value: String) { | ||
| val onlyKorean = value.filter { it.isKoreanCharacter() } | ||
| val limited = onlyKorean.take(WRITER_MAX_LENGTH) | ||
| val error = when { | ||
| onlyKorean.length != value.length -> WRITER_KOREAN_ONLY_ERROR | ||
| onlyKorean.length > WRITER_MAX_LENGTH -> WRITER_MAX_LENGTH_ERROR | ||
| value.length > WRITER_MAX_LENGTH -> WRITER_MAX_LENGTH_ERROR | ||
| else -> null | ||
| } |
There was a problem hiding this comment.
현재 onWriterChange 구현은 사용자가 한글 이외의 문자(예: 영문, 숫자 등)를 입력하면 onlyKorean 필터를 통해 즉시 지워버리면서 동시에 WRITER_KOREAN_ONLY_ERROR 에러를 표시하고 있습니다. 이 방식은 사용자가 입력한 잘못된 문자가 텍스트 필드에 보이지도 않는데 에러 메시지만 나타나므로 사용자 경험(UX) 측면에서 매우 혼란스럽습니다.
사용자가 입력한 문자열을 그대로 상태(writer)에 유지하되, 에러 검증만 수행하도록 변경하면 사용자가 어떤 문자를 잘못 입력했는지 명확히 인지하고 스스로 수정할 수 있어 훨씬 자연스러운 UX를 제공할 수 있습니다.
fun onWriterChange(value: String) {
val limited = value.take(WRITER_MAX_LENGTH)
val error = when {
value.any { !it.isKoreanCharacter() } -> WRITER_KOREAN_ONLY_ERROR
value.length > WRITER_MAX_LENGTH -> WRITER_MAX_LENGTH_ERROR
else -> null
}| class NetworkDocumentRepository( | ||
| private val apiService: DocumentApiService, | ||
| ) : DocumentRepository { | ||
|
|
||
| override fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail? = null | ||
| override fun getPopularDocuments(sortType: PopularSortType): List<PopularDocument> = emptyList() |
There was a problem hiding this comment.
NetworkDocumentRepository가 DocumentRepository 인터페이스를 구현하고 있지만, 인터페이스의 메서드(getDocumentDetail, getPopularDocuments)들이 비동기(suspend)를 지원하지 않아 실제 구현체에서는 null과 emptyList()를 반환하는 더미(Stub) 메서드로 방치되어 있습니다. 대신 별도의 fetchDocumentByUUID 등 독자적인 suspend 메서드를 정의하여 사용하고 있어 다형성(Polymorphism)을 활용하지 못하는 설계적 결함이 존재합니다.
DocumentRepository 인터페이스의 메서드들을 suspend 함수로 정의하고, NetworkDocumentRepository가 이를 올바르게 재정의(override)하도록 개선하면 InMemoryDocumentRepository와 NetworkDocumentRepository를 동일한 인터페이스 타입으로 유연하게 교체하여 사용할 수 있어 유지보수성과 테스트 용이성이 크게 향상됩니다.
class NetworkDocumentRepository(
private val apiService: DocumentApiService,
) : DocumentRepository {
override suspend fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail? {
return fetchDocumentByUUID(documentId)
}
override suspend fun getPopularDocuments(sortType: PopularSortType): List<PopularDocument> {
return fetchPopularDocuments(sortType)
}
작업 내용
크루위키 Kotlin Multiplatform 앱의 초기 기능을 구현했습니다.
이번 PR에는 아래 내용이 포함됩니다.
상세 변경 사항
1. 프로젝트 및 공통 기반 구성
2. 디자인 시스템 및 브랜딩 적용
크루위키로 변경3. 주요 화면 구현
4. 문서 상세 및 마크다운 렌더링 개선
5. 문서 작성/편집 기능
참고 사항
shared,iosApp변경이 함께 포함되어 있습니다.