[독일어 학습 앱 개발기 4편] 단어장 데이터 구조 설계 – CSV, 뉴스, 로컬 데이터를 하나로 묶는 법

2026. 1. 16. 06:27AI Product Building/HalloGerman

반응형

이 글의 핵심:
언어 학습 앱에서 데이터 구조는 곧 제품의 정체성이다. 특히 HalloGerman처럼 “사용자가 직접 단어장을 만드는 앱”에서는 데이터 설계가 곧 사용자 경험을 결정한다. 4편에서는 CSV 단어장, 뉴스에서 추출한 단어, 앱 내부 데이터가 어떻게 하나의 구조로 통합되는지를 설명한다.


1. 왜 단어장 데이터 구조가 가장 중요한가

단어 암기 앱에서 가장 흔한 실패 원인은 데이터 구조가 기능에 끌려다니는 것이다. 처음에는 단어만 저장하다가, 발음이 필요해지고, 예문이 필요해지고, 출처가 필요해진다. 이때 구조가 유연하지 않으면 마이그레이션 지옥이 시작된다.

그래서 HalloGerman에서는 처음부터 다음 질문을 던졌다.

“이 단어는 어디서 왔는가?”

뉴스에서 왔는지, CSV 파일에서 왔는지, 앱에서 직접 입력했는지. 이 출처 정보가 모든 데이터 설계의 중심이 되었다.


2. 단어 데이터의 최소 단위 정의

가장 먼저 정의한 것은 “단어 하나가 가져야 할 최소 정보”였다.

  • 단어 자체 (독일어)
  • 뜻 (모국어 번역)
  • 발음 재생 가능 여부
  • 출처 (CSV / 뉴스 / 직접 입력)
  • 학습 상태 (미학습 / 학습 중 / 완료)

이를 기준으로 데이터 클래스를 설계했다.

data class Word(
    val id: String,
    val term: String,
    val meaning: String,
    val source: WordSource,
    val isLearned: Boolean = false
)

여기서 중요한 점은, 예문이나 발음 URL 같은 확장 정보는 처음부터 넣지 않았다는 것이다. 확장 정보는 나중에 붙일 수 있지만, 기본 구조는 최대한 가볍게 유지했다.


3. WordSource: 출처를 코드로 명확히 남기기

출처를 문자열로 저장하는 방식은 위험하다. 그래서 enum으로 명확히 정의했다.

enum class WordSource {
    CSV,
    NEWS,
    MANUAL
}

이렇게 해두면 나중에 “뉴스에서 온 단어만 복습하기” 같은 기능을 만들 때 매우 유리하다. 또한 디버깅 시에도 단어가 어디서 들어왔는지 즉시 파악할 수 있다.


4. CSV 단어장을 어떻게 앱 데이터로 바꾸는가

CSV 업로드 기능은 HalloGerman의 핵심 기능 중 하나다. 하지만 CSV 포맷은 사용자마다 제각각이다. 그래서 엄격한 규칙 대신 최소 규칙만 강제했다.

  • 1열: 독일어 단어
  • 2열: 의미

그 외 컬럼은 무시한다. 이 단순한 규칙 덕분에 사용자는 부담 없이 단어장을 만들 수 있다.

CSV를 읽어 Word 리스트로 변환하는 흐름은 다음과 같다.

CSV 파일
 → CsvParser
 → List<Word>
 → VocabularyRepository

UI는 이 과정에 관여하지 않는다. 모든 처리는 data.source 영역에서 끝낸다.


5. 뉴스에서 단어를 저장할 때의 차이점

뉴스에서 단어를 선택해 저장할 경우, CSV와는 다른 정보가 필요하다.

  • 어떤 뉴스에서 나왔는지
  • 문맥(문장)

하지만 이 정보 역시 Word 자체에 모두 넣지 않는다. 대신 연결 구조를 사용한다.

data class NewsWordLink(
    val wordId: String,
    val newsId: String,
    val sentence: String
)

이렇게 분리해 두면, 단어장 자체는 가볍게 유지하면서도 “이 단어가 어떤 문맥에서 나왔는지”를 언제든지 조회할 수 있다.


6. Repository를 중심으로 모든 데이터를 통합

UI에서 데이터를 직접 다루지 않는다는 원칙은 여기서도 동일하다.

class VocabularyRepository(
    private val csvSource: CsvSource,
    private val newsSource: NewsSource
) {
    fun getAllWords(): List<Word> { ... }
    fun addWord(word: Word) { ... }
}

UI는 단지 “단어를 가져온다”, “단어를 추가한다”라는 행위만 요청한다. 단어가 CSV에서 왔는지, 뉴스에서 왔는지는 Repository 내부의 책임이다.


7. 로컬 저장 전략: 복잡한 DB를 쓰지 않은 이유

초기 버전에서는 Room 같은 DB를 사용하지 않았다. 이유는 단순하다.

  • 데이터 양이 크지 않다
  • 구조 변경이 잦다
  • 1인 개발에서 마이그레이션 비용이 크다

대신 JSON 기반 로컬 저장으로 시작했다. 구조가 안정된 이후에 DB로 옮기는 것이 훨씬 안전하다.


8. 이 구조가 주는 가장 큰 장점

이 데이터 구조 덕분에 다음과 같은 확장이 매우 쉬워졌다.

  • 문장 학습 기능 추가
  • 단어 통계, 학습 히스토리
  • 서버 동기화

핵심은 “단어는 단어로 남겨두고, 맥락은 연결로 처리한다”는 원칙이다.

 

[주요 코드]

//CSV 단어 기본 로딩
//WordRepository.kt
private fun readWordsFromCsv(context: Context): List<Word> {
    val words = mutableListOf<Word>()
    try {
        context.assets.open("words.csv").use { inputStream ->
            BufferedReader(InputStreamReader(inputStream)).use { reader ->
                reader.readLine() // Skip header
                var line: String?
                while (reader.readLine().also { line = it } != null) {
                    val tokens = line!!.split(",")
                    if (tokens.size > 13) {
                        words.add(Word(tokens[5], tokens[8], tokens[9], tokens[10], tokens[11], tokens[12], tokens[13]))
                    }
                }
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return words
}
//CSV는 “기본 학습 데이터셋”으로 고정한다.

//뉴스 단어는 출처(URL) 포함
//NewsWebViewScreen.kt
data class ExtractedWord(
    val word: String,
    val meaning: String,
    val source: String,
    val notebook: String? = null,
    val createdAt: Long = System.currentTimeMillis()
)
//그리고 실제 저장 시 source = currentBookmark.value.url 로 URL을 붙여 기록한다.

//CSV + 노트북을 하나로 합치기
//ScopeWordLoader.kt
val wordTopics = scopes.filter { it.source == LearnScopeSource.WordList }.map { it.id }.distinct()
val notebookNames = scopes.filter { it.source == LearnScopeSource.Notebook }.map { it.id }.distinct()

val wordsFromTopics = if (wordTopics.isNotEmpty()) {
    wordTopics.flatMap { topicId -> wordRepository.getWords(topicId) }
} else emptyList()

val notebooks = if (notebookNames.isNotEmpty()) loadNotebooks(context) else emptyList()
val wordsFromNotebooks = notebookNames.flatMap { name ->
    val nb = notebooks.firstOrNull { it.name == name }
    nb?.words?.map {
        Word(
            topic = "Notebook:$name",
            german = it.word,
            ipa = "",
            korean = it.meaning,
            koreanPronunciation = "",
            english = it.meaning,
            englishKoreanPronunciation = ""
        )
    } ?: emptyList()
}

val combined = wordsFromTopics + wordsFromNotebooks
//CSV와 노트북을 한 리스트로 묶어 “학습 범위”를 통합한다.

9. 다음 편 예고

5편. 단어 암기 UI 설계 – 듣기·말하기·쓰기 흐름 만들기
다음 글에서는 실제 단어 암기 화면을 어떻게 설계했는지, 듣기/말하기/쓰기 기능을 하나의 학습 흐름으로 묶은 방법을 다룬다.


참조 라이브러리 및 도구 모음

 

반응형