가변적인 요소들과 긴 콜체인으로 복잡했던 코드를 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)를 통해 비동기적으로 값을 업데이트하거나, 이벤트 발행 및 메시지 큐를 활용하여 시스템간의 느슨한 결합을 유지하는 아키텍처를 고민해야 할 수도 있다
- 종합적 고려: 데이터의 양, 트래픽, 비즈니스 요구사항, 시스템 아키텍처, 쿼리의 동작 방식, 메모리 구로 등을 종합적으로 고려하여 가장 적절하고 효율적인 방법을 선택하는 것이 중요하다