Skip to content

[FEAT] 크루위키 KMP 앱 초기 기능 구현#2

Open
nadajinny wants to merge 58 commits into
Crew-Wiki:mainfrom
nadajinny:feature/initial-features
Open

[FEAT] 크루위키 KMP 앱 초기 기능 구현#2
nadajinny wants to merge 58 commits into
Crew-Wiki:mainfrom
nadajinny:feature/initial-features

Conversation

@nadajinny

Copy link
Copy Markdown

작업 내용

크루위키 Kotlin Multiplatform 앱의 초기 기능을 구현했습니다.

이번 PR에는 아래 내용이 포함됩니다.

  • KMP 프로젝트 골격 및 Android/iOS 실행 환경 구성
  • 크루위키 디자인 토큰, 테마, 폰트, 이미지 에셋 적용
  • 실제 백엔드 연동을 위한 네트워크/DTO/Repository 레이어 구성
  • 앱 내 라우팅 및 상단 헤더, 하단 네비게이션 구조 추가
  • 홈 / 인기 문서 / 문서 상세 / 그룹 상세 / 편집 기록 / 검색 / 설정 화면 구현
  • 문서 작성하기 / 편집하기 플로우 및 저장 처리 구현
  • 마크다운 렌더러 커스텀 구현 및 문서 본문 표현 보강
  • 앱 아이콘 및 앱 표시 이름을 크루위키 기준으로 정리

상세 변경 사항

1. 프로젝트 및 공통 기반 구성

  • KMP 프로젝트 기본 구조 추가
  • Android / iOS 엔트리포인트 구성
  • 공통 라우팅 구조 및 AppContainer 구성
  • 실제 서버 주소 기반 API 연동 구조 반영

2. 디자인 시스템 및 브랜딩 적용

  • 크루위키 색상, 타이포, 디자인 토큰 추가
  • Pretendard, BM Hanna Pro 폰트 리소스 적용
  • 로고/아이콘/파비콘 등 에셋 추가
  • 앱 아이콘 교체 및 앱 표시 이름을 크루위키로 변경

3. 주요 화면 구현

  • 홈 화면
  • 인기 문서 화면
  • 문서 상세 화면
  • 그룹 상세 화면
  • 편집 기록 목록/상세 화면
  • 검색 화면
  • 최근 편집 / 최근 확인 화면
  • 설정 화면

4. 문서 상세 및 마크다운 렌더링 개선

  • 마크다운 렌더링 적용
  • 표(Table) 렌더링 및 스타일 보정
  • HTML 표 렌더링 지원
  • 이미지, 링크 이미지, 인용문, 들여쓴 목록 파싱 보정
  • 내부 링크 클릭 시 앱 내 라우팅 처리
  • 접을 수 있는 목차 추가
  • 본문/구분선/버튼 배치 등 웹과 유사한 스타일로 조정

5. 문서 작성/편집 기능

  • 문서 작성하기 / 편집하기 화면 구현
  • 문서 저장 API 연동
  • 소속 연결 처리
  • 충돌 처리 및 저장 요청 헤더 보정

참고 사항

  • 브랜치가 크기 때문에 초기 앱 구성, UI 구현, 문서 렌더링 개선, 편집 기능을 하나의 PR로 정리했습니다.
  • Android 중심 작업이지만 KMP 구조상 shared, iosApp 변경이 함께 포함되어 있습니다.

nadajinny added 30 commits June 22, 2026 10:02
- 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 미지원)
ember added 28 commits June 22, 2026 13:50
최근에 확인한 문서는 세션 동안 유지되는 메모리 저장소(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을 크루위키로 통일한다.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +452 to +500
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++
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

TableGridLayout에서 subcompose를 서로 다른 slot ID(01)로 두 번 호출하여 측정(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++
            }
        }

Comment on lines +35 to +47
init {
_query
.debounce(300L)
.distinctUntilChanged()
.onEach { q ->
if (q.isBlank()) {
_uiState.value = SearchUiState.Idle
} else {
search(q)
}
}
.launchIn(viewModelScope)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 _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 ?: "검색 실패")
                        }
                    }
                }
        }
    }

Comment on lines +18 to +22
fun record(document: RecentDocument) {
_viewedDocuments.value = listOf(document) +
_viewedDocuments.value.filterNot { it.uuid == document.uuid }
.take(MAX_SIZE - 1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)
        }
    }

Comment on lines +46 to +54

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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)
        }
    }

Comment on lines +80 to +95
init {
organizationQuery
.debounce(250L)
.distinctUntilChanged()
.onEach { query ->
if (query.isBlank()) {
_uiState.value = _uiState.value.copy(
organizationSuggestions = emptyList(),
isSearchingOrganizations = false,
)
} else {
searchOrganizations(query)
}
}
.launchIn(viewModelScope)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

organizationQuery Flow의 onEach 블록 내부에서 searchOrganizations(query)를 호출하고 있습니다. onEach + launchIn 조합은 이전 검색 요청이 진행 중일 때 새로운 검색어가 입력되어도 이전 요청을 취소하지 않으므로, 네트워크 응답 속도에 따라 이전 검색 결과가 최신 결과를 덮어쓰거나 불필요한 네트워크 트래픽을 유발할 수 있습니다.

collectLatest를 사용하면 새로운 검색어가 입력되는 즉시 이전의 검색 작업(네트워크 요청 포함)을 자동으로 취소하여, 항상 가장 최신의 검색 결과만 안전하고 빠르게 화면에 반영할 수 있습니다.

Suggested change
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)
}
}
}
}

Comment on lines +131 to +139
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 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
        }

Comment on lines +13 to +18
class NetworkDocumentRepository(
private val apiService: DocumentApiService,
) : DocumentRepository {

override fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail? = null
override fun getPopularDocuments(sortType: PopularSortType): List<PopularDocument> = emptyList()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

NetworkDocumentRepositoryDocumentRepository 인터페이스를 구현하고 있지만, 인터페이스의 메서드(getDocumentDetail, getPopularDocuments)들이 비동기(suspend)를 지원하지 않아 실제 구현체에서는 nullemptyList()를 반환하는 더미(Stub) 메서드로 방치되어 있습니다. 대신 별도의 fetchDocumentByUUID 등 독자적인 suspend 메서드를 정의하여 사용하고 있어 다형성(Polymorphism)을 활용하지 못하는 설계적 결함이 존재합니다.

DocumentRepository 인터페이스의 메서드들을 suspend 함수로 정의하고, NetworkDocumentRepository가 이를 올바르게 재정의(override)하도록 개선하면 InMemoryDocumentRepositoryNetworkDocumentRepository를 동일한 인터페이스 타입으로 유연하게 교체하여 사용할 수 있어 유지보수성과 테스트 용이성이 크게 향상됩니다.

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)
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant