Tiny Star
본문 바로가기
Dev Log/Kotlin

[우테코] 🏪편의점 미션: 흩어진 데이터를 하나의 객체로 모으는 기술 (feat. 프로모션/일반 재고 관리)

by 별터 2026. 1. 6.

https://github.com/woowacourse8/kotlin-convenience-store-7-2/tree/todays-sun-day

 

GitHub - woowacourse8/kotlin-convenience-store-7-2

Contribute to woowacourse8/kotlin-convenience-store-7-2 development by creating an account on GitHub.

github.com

1. 서론: 문제의 시작

우테코 편의점 미션을 시작하자마자 마주친 첫 번째 난관은 `products.md` 파일의 구조였습니다. 보통은 상품 하나당 한 줄의 데이터가 있을 거라 생각하지만, 이번 미션은 달랐습니다.

콜라,1000,10,탄산2+1
콜라,1000,10,null
사이다,1000,8,탄산2+1

보시다시피 '콜라'라는 같은 상품이 두 줄로 나뉘어 있습니다. 하나는 프로모션 재고, 하나는 일반 재고죠. 처음에는 이걸 단순히 `List<Product>`로 로딩해서 사용하려 했습니다. 하지만 로직을 짜다 보니 문제가 터졌습니다.

  • "재고를 차감할 때 어떤 객체를 찾아야 하지?"
  • "프로모션 재고가 떨어지면 일반 재고를 써야 하는데, 두 객체를 어떻게 연결하지?"

결국, 파일에 있는 2줄의 데이터를 1개의 `Product` 객체로 합치는 설계로 변경해야만 했습니다.


2. 본론 1: 파일 읽기에서의 복병 (공백과 한글 경로)

본격적인 로직 구현 전, 파일 읽기(`FileReader`)부터 에러가 났습니다. `ClassLoader.getResource().path`를 사용했더니, 파일 경로 중간에 있는 띄어쓰기가 `%20`으로 변환되어 `FileNotFoundException`이 발생했습니다.

 

[해결책] URL 인코딩 문제를 해결하기 위해 `.toURI()`를 거쳐 `File` 객체를 생성해야 합니다.

package store.common // 패키지 분리 추천

import java.io.File

object FileReader {
    fun loadFile(fileName: String): List<String> {
        // 1. classLoader로 resources 폴더 내 파일 찾기 (절대경로 X)
        val resource = javaClass.classLoader.getResource(fileName) 
            ?: throw IllegalArgumentException("[ERROR] $fileName 파일을 찾을 수 없습니다.")

        // 2. 파일 읽기 -> 첫 줄(헤더) 제거 -> 빈 줄 제거 -> 반환
        return File(resource.toURI()).readLines()
            .drop(1) // 첫 번째 줄(헤더)을 버림
            .filter { it.isNotBlank() } // 혹시 모를 빈 줄 제거
    }
}

// 수정 전 (에러 발생)
File(resource.path) 

// 수정 후 (해결)
File(resource.toURI())

 

이 작은 차이가 개발 환경(Windows/Mac)에 따라 큰 에러를 유발할 수 있다는 점을 배웠습니다.


3. 본론 2: 데이터 병합의 기술 (`groupBy`)

파일을 읽어온 뒤, 같은 이름을 가진 상품들을 묶어주는 작업이 필요했습니다. 이때 Kotlin 컬렉션 함수인 `groupBy`가 빛을 발했습니다.

  1. 일단 파일의 모든 줄을 임시 객체(`RawProduct`)로 변환합니다.
  2. `groupBy { it.name }`을 사용해 이름이 같은 것끼리 묶습니다.
  3. 묶인 리스트를 순회하며 하나의 진짜 `Product` 객체로 합칩니다(Merge).
// ProductRepository.kt 핵심 로직
private fun loadProducts(): List<Product> {
    val lines = FileReader.loadFile("products.md")
    // 1. 임시 객체로 변환
    val rawProducts = lines.map { parseToRaw(it) }
    
    // 2. 이름 기준으로 그룹핑 (Map<String, List<RawProduct>> 반환)
    val grouped = rawProducts.groupBy { it.name }
    
    // 3. 병합 (List<Product> 반환)
    return grouped.map { (name, rawList) -> 
        mergeToProduct(name, rawList) 
    }
}

4. 본론 3: 완성된 Product 모델

이렇게 합쳐진 `Product` 객체는 내부에서 스스로 재고를 관리할 수 있게 됩니다. `quantity`(일반)와 `promotionQuantity`(프로모션)를 필드로 나누고, 각각의 차감 메서드를 구현했습니다.

data class Product(
    val name: String,
    val price: Int,
    var quantity: Int,          // 일반 재고
    var promotionQuantity: Int, // 프로모션 재고
    val promotionName: String?
) {
    fun decreasePromotionStock(amount: Int) { ... }
    fun decreaseGeneralStock(amount: Int) { ... }
}

 

이렇게 데이터를 로딩하는 시점에 구조를 잡아두니, 이후 Service 레이어에서 비즈니스 로직을 구현할 때 훨씬 직관적이고 편했습니다.


5. 결론

데이터가 파일에 어떻게 저장되어 있든, 내 프로그램 안에서는 다루기 쉬운 형태(객체)로 가공해서 들고 있어야 합니다. Repository 계층에서 '지저분한 데이터 처리'를 담당해준 덕분에, 비즈니스 로직은 깔끔해질 수 있었습니다. "데이터의 형태가 코드의 구조를 결정한다"는 것을 절감한 미션이었습니다.