본문 바로가기

개발 이야기/Spring Boot

[10분 Boot-up] Kotlin으로 스프링부트 Web Application 만들기 - (6) Service Layer 적용하기

Kotiln과 Spring Boot을 모르더라도 10분씩 따라하면서 자연스럽게 Web Application을 만들어보는 것이 목표입니다.
각 챕터에서 나오는 중요한 내용은 별도로 [더 알아보기]에 보충해서 작성할 예정입니다.

시작하기

보통 Spring Boot로 Web Application을 만들다보면 대부분의 프로젝트가 pakage 구조를 controller, service, repository로 나누고 있는 것을 발견할 수 있다. 대부분의 프로젝트가 이런 일관된 구조를 가지는 이유는 Spring에서 지향하는 계층 구조인 3 Tier Architecture(3계층 구조 - Presentation Layer, Business Layer, Persistence Layer)를 반영하기 위해서라고 생각하면 된다.

출처: https://anchormen.nl/blog/big-data-services/spring-boot-tutorial

그렇다면 계층 구조를 가져야하는 이유는 무엇일까? 물론 몇 가지 이유가 있지만, 계층 구조 아키텍처를 사용하여 계층을 구분하는 가장 큰 핵심적인 이유는 관심사의 분리를 위함이다. 보통 모든 소프트웨어 설계에서의 가장 중요한 원칙은 응집도를 높이고 결합도를 낮추는 것인데, 대부분의 Web Application의 동작 구조에서는 3계층 구조가 적당한 수준의 계층 분리로 본다. 즉, 3가지 계층 구조에 맞게 클래스나 모듈을 설계하여 책임을 나누고 이를 집중해서 개발하고, 테스트해야한다. 만약, Web Appliction를 Multi-module 구조로 만든다면 PersistenceLayer는 별도의 모듈로 분리하기도 한다.

다만, 착각하면 안되는 것은 계층구조와 클래스 이름을 일치하면 안된다는 것이다. 우리가 흔히 모든 Business Layer에 해당하는 클래스의 이름을 무분별하게 XXXService로 만드는 경향이 있다. 이는 Spring의 @Service 어노테이션을 사용하면서 흔히 겪는 실수이다. 클래스의 Naming은 해당 클래스의 목적에 맞게, 누구나 이름만 보면 클래스의 역할을 상상할 수 있게 작명해야한다. 

Service 생성

우선 Stock 정보를 단순하게 조회해서 처리하는 Business Layer에 해당하는 StockQueryService를 만든다. 굳이 Query라는 단어를 붙인 이유는 단순하게 조회하는 성격의 클래스를 만들기 위함이다. 일단 간단하게 아래처럼 service 패키지를 추가하고 클래스를 만든다.

package me.sample.myapp.service

import me.sample.myapp.domain.Stock
import me.sample.myapp.repository.StockRepository
import org.springframework.stereotype.Service

@Service
class StockQueryService(
    private val stockRepository: StockRepository
) {

    fun getStocks(): List<Stock> {
        return stockRepository.findAll()
    }
}

간단하게 만들었지만 사실 이 클래스는 많은 문제를 가지고 있다. 일단 2가지 문제가 있는데, 첫 번째는 findAll()을 그대로 사용하고 있다는 점이다. 만약에 데이터베이스에 Stock 정보가 엄청나게 많아진다면 갈수록 해당 함수는 엄청난 시간이 소요될 것이다. 즉, 장기적으로는 Pagination을 사용하거나 Limit를 사용해야한다. 두 번째는 Entity 클래스를 그대로 return하고 있다는 것이다. Entity라는 객체 자체는 Data Access Layer이기 때문에 사실 Service Layer 입장에서는 Data의 영역과 분리되면 좋다. 만약에 데이터베이스의 테이블 구조에 변경이 생긴다면 이를 Business Layer에서도 반영해야하는 경우가 생긴다. 또한 Entity에는 실제 Presentation Layer에서 알면 안되는 정보가 있을 수도 있다. 그래서 Entity와 Service Model을 분리하는 것이 좋다.

DTO(Service Model) 생성

일단 기존의 Entity 클래스의 이름을 StockEntity로 변경하여 확실하게 Entity로 인지할 수 있도록 하고, DTO로 변환하는 확장함수를 해당 코틀린 파일에 추가한다. Entity 클래스가 정의된 코틀린 파일에 확장함수를 위치한 이유 역시 관심사를 한 파일로 집중하기 위해서이다. DTO 입장에서는 어떤 Entity가 자신으로 변환됐는지 알 필요가 없다. 그리고 실제 DTO 클래스도 만든다.

package me.sample.myapp.entity

import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.Table
import me.sample.myapp.model.Stock

@Entity
@Table(name = "stocks")
class StockEntity(
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    val id: String,
    val type: String,
    val name: String,
    val code: String,
    val ticker: String,
    val updatedDate: LocalDateTime,
    val createdDate: LocalDateTime,
)

fun StockEntity.toModel(): Stock {
    return Stock(
        id = this.id,
        type = this.type,
        name = this.name,
        code = this.code,
        ticker = this.ticker,
    )
}
package me.sample.myapp.model

data class Stock(
    val id: String,
    val type: String,
    val name: String,
    val code: String,
    val ticker: String,
)

그리고 기존의 StockRepository interface에 아래와 같은 함수를 추가한다. 이는 Entity가 아닌 DTO를 반환해준다.

package me.sample.myapp.repository

import me.sample.myapp.entity.StockEntity
import me.sample.myapp.entity.toModel
import me.sample.myapp.model.Stock
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface StockRepository : JpaRepository<StockEntity, String> {

    fun findAllAsModel(): List<Stock> {
        return this.findAll().map { it.toModel() }
    }
}

최종적으로 StockQueryService에서 호출하는 StockRepository 함수는 findAllAsModel()로 변경하고,

package me.sample.myapp.service

import me.sample.myapp.model.Stock
import me.sample.myapp.repository.StockRepository
import org.springframework.stereotype.Service

@Service
class StockQueryService(
    private val stockRepository: StockRepository
) {

    fun getStocks(): List<Stock> {
        return stockRepository.findAllAsModel()
    }
}

Controller의 return type도 DTO로 수정하여 마무리한다.

package me.sample.myapp.controller

import me.sample.myapp.model.Stock
import me.sample.myapp.service.StockQueryService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class StockController(
    private val stockService: StockQueryService
) {

    @GetMapping("/stocks")
    fun getStocks(): List<Stock> {
        return stockService.getStocks()
    }
}

마무리

간단하게 계층 구조 아키텍처를 프로젝트에 반영해봤다. Controller, Service, Repository pakage 경로로 3가지 계층 구조를 반영하는 클래스를 만들고, 데이터를 담는 역할의 클래스도 Entity와 DTO로 구분했다. StockQueryService는 Entity를 알지 못하며, 이것은 서비스가 Persistence(Storage) 영역의 관심을 끊고, 알지도 못한다는 것을 의미한다. Repository에서 전달해준 DTO 모델과 본인의 Business Logic에만 신경쓰면 된다. 사실 위에서 구성한 구조는 꼭 정해진 방법이 아니다. Repository의 결과를 ModelMapper라는 라이브러리를 활용하여 DTO로 변환하거나, Entity를 그대로 사용하는 경우도 있다. 상황에 맞게 다르게 구성해도 전혀 문제는 없다. 다만 관심사와 역할을 분리한다는 핵심은 꼭 기억하고 지키려고 노력해야 한다.

다음 포스팅은 Service Layer를 조금 더 발전시키고 Unit Test를 적용해본다.