Kotlin 리팩토링 groupBy, Map 그리고 DB

가변적인 요소들과 긴 콜체인으로 복잡했던 코드를 Kotlin의 컬렉션 처리 함수인 groupBy와 map을 활용하여 간결하고 불변성을 유지할 수 있다

BookStatResponse (가변과 함수)

class BookStatResponse(
    val type: BookType,
    var count: Int // 증감해야 하기 때문에 가변인 var
) {
    fun plusOne() { // 증감해주는 함수
        count++
    }
}

if-else를 활용한 일반(?)적인 코드

@Service
class BookService(
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
        val results = mutableListOf<BookStatResponse>()
        val books = bookRepository.findAll()
        for (book in books) {
            val targetDto = results.firstOrNull { dto -> book.type == dto.type }
            if (targetDto == null) {
                results.add(BookStatResponse(book.type, 1))
            } else {
                targetDto.plusOne()
            }
        }
        return results
    }
}

Nullable과 Elvis Operation을 활용한 리팩토링 코드

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
        val results = mutableListOf<BookStatResponse>()

        val books = bookRepository.findAll()
        for (book in books) {
            results.firstOrNull { dto -> book.type == dto.type }?.plusOne()
                ?: results.add(BookStatResponse(book.type, 1))
        }

        return results
    }
}
  • 가변인 mutableListOf와 BookStatResponse의 var count는 사용하여 상태를 변경하였다
  • firstOrNull, ?.(Nullable), ?:(엘비스 연산자), plusOne() 등의 콜체인을 통해 가독성이 떨어지고 실수할 여지가 충분히 있다

BookStatResponse (불변)

class BookStatResponse(
    val type: BookType,
    val count: Int
)

groupBy와 Map을 활용한 리팩토링 코드

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
        return bookRepository.findAll() // List<Book>을 가져온다
            .groupBy { book -> book.type }
            // BookType을 key로 하는 Map<BookType, List<Book>>으로 그룹화

            .map { (type, books) -> BooksStateResponse(type, books.size) 
            // 각 그룹을 BooksStateResponse로 변환 (타입, 책 개수)
    }
}
  • 가독성 향상: 코드의 의도가 명확해지고, 한 줄로 간결하게 표현된다
  • 불변성: mutableListOf와 var 변수 대신 불변 객체와 컬렉션을 사용하여 side-effect 발생 가능성을 줄인다. 이는 동시성 프로그래밍 환경에서 특히 중요하다
  • 유지보수 용이성: 불필요한 상태 변경 로직이 사라져 오류 발생 가능성이 낮아지고, 코드 변경 시 영향 범위가 줄어즌다
  • 함수형 프로그래밍: Kotlin의 강력한 컬렉션 함수들을 활용하여 함수형 프로그래밍 스타일을 적용, 더 안전하고 효율적인 코드 작성을 가능하게 해준다

애플리케이션신 DB로 기능 구현 최적화

통계 데이터 가져오기(GROUP BY 쿼리 활용)

애플리케이션에서 그룹화 및 집계

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
        return bookRepository.findAll()
            .groupBy { book -> book.type }
            .map { (type, books) -> BooksStateResponse(type, books.size)
    }
}
  • query: select * from book;
  • 문제점: 모든 책 데이터를 애플리케이션 서버로 가져온 후, 서버 메모리에서 groupBy와 map 연산을 통해 통계를 집계한다. 데이터 양이 많아질수록 네트워크 및 애클리케이션 부하가 증가한다

통계 데이터 가져오기 – DB에서 직접 그룹화 및 집계(@Query 활용)

interface BookRepository : JpaRepository<Book, Long> {
    
    @Query("SELECT NEW com.group.libraryapp.dto.book.response.BookStatResponse(b.type, COUNT(b.id))" +
            " FROM Book b GROUP BY b.type")
    fun getStats(): List<BookStatResponse> // DB에서 그룹화된 통계 데이터를 가져옴
}

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
        return bookRepository.getStats() // DB에서 최적화된 통계 데이터 반환
    }
}
  • query: select b.type, COUNT(b.id) FROM Book b GROUP BY b.type; (JPQL이 실제 SQL로 변환됨)
  • 장점
    • 네트워크 / 애플리케이션 부하 감소: 필요한 최소한의 데이터만 전송되고 처리되므로 부하가 현저히 줄어든다
    • DB 최적화 활용: 데이터베이스는 그룹화 및 집계 연산에 최적화되어 있으며, 적절한 인덱스 튜닝을 통해 성능을 더욱 향상시킬 수 있다

개수 세기 (countBy 활용)

애플리케이션에서 데이터 처리

interface UserLoanHistoryRepository : JpaRepository<UserLoanHistory, Long> {

    // 모든 대출 기록을 가져온다
    fun findAllByStatus(status: UserLoanStatus): List<UserLoanHistory>

}

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun getBookStatistics(): List<BookStatResponse> {
       // 메모리에서 size 계산
       return userLoanHistoryRepository.findAllByStatus(UserLoanStatus.LOANED).size
    }
}
  • query: SELECT * FROM user_loan_history WHERE status = ?;
  • 문제점: 데이터베이스의 모든 UserLoanHistory 객체를 애플리케이션 서버 메모리로 로딩한 후, 그 리스트의 size를 계산한다. 데이터 건수가 많아질수록(예: 약 1억건) 네트워크 전송량, 서버 메모리 사용량 그리고 애플리케이션의 처리 부하가 급격히 증가한다

DB에서 직접 개수 세기 (count 쿼리)

interface UserLoanHistoryRepository : JpaRepository<UserLoanHistory, Long> {
    fun countByStatus(status: UserLoanStatus):Long // DB에서 직접 개수를 센다
}

@Service
class BookService(/*의존성 주입*/) {
    @Transactional(readOnly = true)
    fun countLoanedBook(): Int {
       // DB에서 받은 숫자를 타입 반환
       return userLoanHistoryRepository.countByStatus(UserLoanStatus.LOANED).toInt()
    }
}
  • query: SELECT COUNT(*) FROM user_loan_history WHERE status = ?;
  • 장점: 데이터베이스는 조건에 맞는 레코드의 개수만 세어 숫자 하나를 반환한다. 이로 인해 네트워크 트래픽, 애플리케이션 서버의 메모리 및 CPU 부하가 훨씬 줄어즌다. 대용량 데이터 처리 시 성능 향상에 효과적이다

쿼리 최적화의 중요성 및 고려사항

  • 쿼리 최적화는 일반적으로 애플리케이션 레벨에서 데이터를 처리하는 것보다 선호되는 방법이지만 항상 모든 상황에 적용되는 “완벽한 방법”은 아니고 그러한 방법은 없다
상황별 판단
  • 데이터 양: 데이터 양이 적거나, 애플리케이션에서 이미 대부분의 데이터가 필요한 경우라면 애플리케이션 레벨 처리가 더 간단하고 오버헤드가 적을 수 있다
  • 복잡성: DB 쿼리가 너무 복잡해지면 유지보수가 어려워질 수 있다
  • 네트워크 지연: 네트워크 지연이 심한 환경에서는 여러 번의 DB 접근보다 한 번에 많은 데이터를 가져와 애플리케이션에서 처리하는 것이 나을 수 있다
  • 애플리케이션 로직: 비즈니스 로직이 DB에서 처리하기 복잡하거나 DB에 부담을 줄 수 있는 연산이라면 애플리케이션에서 처리하는 것이 나을 수 있다
  • 고급 최적화: 대용량 통계 처리의 경우 배치 처리(Batch Processing)를 통해 비동기적으로 값을 업데이트하거나, 이벤트 발행 및 메시지 큐를 활용하여 시스템간의 느슨한 결합을 유지하는 아키텍처를 고민해야 할 수도 있다
  • 종합적 고려: 데이터의 양, 트래픽, 비즈니스 요구사항, 시스템 아키텍처, 쿼리의 동작 방식, 메모리 구로 등을 종합적으로 고려하여 가장 적절하고 효율적인 방법을 선택하는 것이 중요하다

출처 – 실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링)