본문 바로가기
프로그래밍/코프링

코프링, 스프링 부트(Spring Boot) 코틀린으로 배워보자!

by K-인사이터 2024. 3. 20.
반응형

안녕하세요 

K-인사이트 입니다. 

 

 

사기업부터 공공기관까지 스프링 프레임워크는 대한민국의 표준이자 엔터프라이즈급 솔루션의 상징이었습니다. 그리고 오래된 역사와 에코시스템들이 생산성 향상에 기여를 하였습니다. 이어서 스프링 부트에게 왕좌를 내어주고 신뢰성의 상징으로 자리매김하는 지금에 이르러 코틀린이라는 강력한 언어가 탄생하였습니다. 

 

이번 시간에는 코틀린을 이용해 스프링 부트 예시를 만들면서 스프링 부트가 코틀린에서 어떻게 구현되는지 알아보겠습니다. 또한, 코틀린으로 스프링 부트를 구현해야하는 이유에 대해서도 간략히 살펴보면서 여러분이 프로젝트에서 코프링을 해야될 이유도 간략히 제시를 드리겠습니다. 이 글 마지막에 실습 코드를 다운로드 받을 수 있도록 링크를 드렸으니 참고 부탁드립니다.

스프링 부트(Spring Boot)를 왜 사용해야할까? 

 

기존에 우리는 전자 정부 프레임워크(eGov)처럼 스프링 프레임워크를 사용했습니다. 점차 스프링 부트(Spring Boot)가 대세로 자리잡게되었는데요. 이는 스프링 부트가 기존 스프링 프레임워크의 문제점들을 보완하였으며 여러 장점을 가졌기에 개발자들의 선택을 받게되었습니다.  스프링 부트의 장점을 한번 살펴보겠습니다. 이는 기존 스프링 프레임워크의 단점이기도 합니다. 

 

  • XML 기반의 복잡한 설계 방식을 지양
  • 애플리케이션 설정을 자동으로 구성
  • 프로덕션급 애플리케이션의 손쉬운 빌드
  • 내장된 WAS를 사용하기 때문에 배포가 용이
  • 개발자들의 개발 생산성을 높이고, 애플리케이션의 유연성, 확장성을 제공
  • 스프링 프레임워크들과 강력하게 호환되고, 생태계와의 통합이 용이
  • 초기 설정을 간편하게 할 수 있음
  • 자체적인 웹 서버를 내장하고 있어, 빠르고 간편하게 배포가 가능
  • 독립적으로 실행 가능한 Jar 파일로 프로젝트를 빌드 가능
  • 클라우드 서비스 및 도커와 같은 가상화 환경에 빠르게 배포 가능
  • 비즈니스 로직 개발에만 집중할 수 있도록 함


예전 스프링 프레임워크를 사용하셨던 분들이라면 느린 빌드 속도, 복잡한 XML 설정, 배포의 어려움 등등을 경험하였을 것입니다. 그러나 스프링 부트는 이러한 어려움들을 효과적으로 해결하였고 실제로 많은 개발자들의 사랑을 받는 도구로 자리매김하였습니다. 

스프링 부트까진 괜찮아 그런데 코틀린을 사용해야될까? 

새롭게 무언가를 배워서 적용한다는 것은 기존 보다 나음을 의미합니다. 그렇다면 코틀린이 우리에게 어떤 이점을 줄 수 있을까요? 

  • 간결한 코드로 생산성을 높일 수 있음
  • Null Safe와 같은 안전한 코드를 작성 가능
  • 람다식, 함수형 프로그래밍을 적극 지원
  • 자바와 100% 호환되어 상호 운용성
  • 대부분의 자바 프로젝트에서 활용 가능
  • 성능이 자바와 비슷
  • Null 체크, 타입추론 캐스팅 등에서 안전성을 보장

코틀린은 기존의 Java 언어가 제공하지 못했던 편의성 기능을 통해 코드 생산성을 높혀줍니다. 파이썬 언어가 생산성으로 대세가 되어버린 것처럼 Java 의 이점과 생산성이 향상된다면 막강한 시너지를 낼 수 있게됩니다. 즉, 엔터프라이즈 레벨의 프로덕트를 파이썬과 같은 자유도로 구현하게되어 개발자들의 생산성의 향상을 꾀할 수 있게되는 셈입니다. 

 

레스토랑 정보를 관리하는 CRUD RESTful API 제작

코틀린 언어를 이용해 스프링 부트 프로젝트를 구성하고 MongoDB를 데이터 저장소로 활용하는 레스토랑 정보를 관리하는 RESTful API 를 제작해보겠습니다. 또한, 데이터 레이어에 대한 Unit 테스트를 제작하는 팁까지 제공해드리니 순서대로 설명을 이해하면서 따라오시면 개발부터 Unit 테스트까지 팁을 얻어가실 수 있습니다. 

 

RESTful API 에 대한 이해가 필요하다면 아래의 글을 추천드립니다. 

🔗 2024.03.19 - [프로그래밍] - 면접, REST API vs RESTful API 란 무엇?

 

면접, REST API vs RESTful API 란 무엇?

안녕하세요 K-인사이트 입니다. REST/RESTful API 라는 용어는 일반적으로 많이 사용되지만 실제로 그 정의를 정확하게 이해하는 경우는 드물다고 할 수 있습니다. 아마도 열명 중 한명은 "JSON 데이터

k-in.tistory.com

 

환경구성 

IntelliJ 에서 New Project 를 클릭하고 Spring Initializer 를 선택합니다.

종속성 설정에서 Spring Data MongoDB, Spring Web 를 추가합니다.

그리고 프로젝트를 생성하면 코틀린 기반으로 Spring Boot 프로젝트가 자동으로 구성됩니다. 

Gradle 설정

프로젝트 ROOT 디렉토리에 build.gradle.kts 파일을 확인할 수 있습니다. 

해당 파일을 열어보면 "spring-boot-starter-data-mongodb" 와 "spring-boot-starter-web" 를 확인합니다. 

  • spring-boot-starter-web 는 RestController, GetMapping 등의 어노테이션을 사용하도록 지원합니다. 
  • @RestController 어노테이션은 RESTful API를 개발할 때 사용됩니다. 객체를 반환하면 JSON, XML 등의 형식으로 직접 반환합니다.
  • Spring Data MongoDB(spring-boot-starter-data-mongodb)는 MongoRepository를 상속하여 추가 코드 없이 CRUD 기능을 제공할 수 있어 편리합니다. 모든 코드를 개발자가 직접 코딩하지 않아도 되어서 시간을 매우 단축시켜줍니다. 
dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.springframework.boot:spring-boot-starter-web")
	testImplementation("org.springframework.boot:spring-boot-starter-test") {
	    exclude(module = "mockito-core")
	}
}

MongoDB Connection String 설정

src/main/resources 폴더로 이동하여 application.properties 설정 파일을 생성합니다. 

Spring Data MongoDB 는 속성 파일의 spring.data.mongodb.uri 설정을 읽어들여 MongoDB 와 연결 시 해당 정보를 이용합니다. 

 

# MongoDB Connection String
spring.data.mongodb.uri=mongodb://localhost:27017/sample_restaurants

 

레스토랑 DB 정보 로드

실습을 위해 미리 레스토랑 DB 백업 파일을 생성해두었습니다. 아래의 링크로 이동해 파일을 다운로드 받고 명령어를 통해서 복원과정을 거치면 레스토랑 DB를 구성할 수 있습니다. 

 

🔗 Bitbucket, 레스토랑 DB 파일 링크 

 

다운로드를 하였다면, 아래 명령어를 통해 데이터를 여러분의 MongoDB 에 복원합니다. 

mongorestore --uri="mongodb://localhost:27017/sample_restaurants" ./samples/sample_restaurants

 

복원된 데이터를 확인하면 아래의 포맷으로 데이터가 굿어된 것을 확인할 수 있습니다. 클래스 모델링을 위해 사용될 데이터이니 샘플을 유심히 살펴봅니다. 

 

{
    "_id": {"$oid": "5eb3d668b31de5d588f4292a"},
    "address": {
      "building": "2780",
      "coord": [-73.98241999999999, 40.579505],
      "street": "Stillwell Avenue",
      "zipcode": "11224"
    },
    "borough": "Brooklyn",
    "cuisine": "American",
    "grades": [
      {
        "date": {"$date": "2014-06-10T00:00:00.000Z"},
        "grade": "A",
        "score": 5
      },
      {
        "date": {"$date": "2013-06-05T00:00:00.000Z"},
        "grade": "A",
        "score": 7
      },
      {
        "date": {"$date": "2012-04-13T00:00:00.000Z"},
        "grade": "A",
        "score": 12
      },
      {
        "date": {"$date": "2011-10-12T00:00:00.000Z"},
        "grade": "A",
        "score": 12
      }
    ],
    "name": "Riviera Caterer",
    "restaurant_id": "40356018"
}

 

CRUD 어플리케이션을 위한 모델 클래스 생성

레스토랑 DB는 레스토랑 이름, 아이디, 후기 평점, 주소 등으로 이루어진 데이터입니다. 데이터를 MongoDB 에서 불러와서 사용하기 위해서는 데이터 클래스를 정의해야 합니다. 앞서 제공한 레스토랑 DB의 데이터 구조를 참고합니다. 

  • @Document 는 해당 data class 가 mongodb 의 Document 를 의미한다고 선언하는 역할을 합니다.
  • @Field 는 document 에서 속성을 위한 alias 이름을 정의합니다. 즉, kotlin 속성(property)과 실제 document 내 속성의 이름을 연결하는 역할을 합니다. 즉, val restaurantId 가 레스토랑 DB의 restaurant_id 필드와 일치함을 지시하는 역할을 합니다. 이는 DB 레코드의 필드의 이름과 클래스의 속성 이름이 반드시 일치하지 않기 때문에 필요한 작업입니다. 
@Document("restaurants")
data class Restaurant(
    @Id
    val id: ObjectId = ObjectId(),
    val address: Address = Address(),
    val borough: String = "",
    val cuisine: String = "",
    val grades: List<Grade> = emptyList(),
    val name: String = "",
    @Field("restaurant_id")
    val restaurantId: String = ""
)

data class Address(
    val building: String = "",
    val street: String = "",
    val zipcode: String = "",
    @Field("coord")
    val coordinate: List<Double> = emptyList()
)

data class Grade(
    val date: Date = Date(),
    @Field("grade")
    val rating: String = "",
    val score: Int = 0
)

Repository 클래스 생성

Repo 클래스는 데이터에 엑세스 할 수 있는 모든 메서드를 정의하는 역할을 합니다. Spring Boot는 MongoRepository 인터페이스를 제공합니다. 해당 인터페이스를 상속하고 findBy*, getBy*와 같은 패턴으로 필요한 메소드를 정의할 수 있습니다. MongoRepository 인터페이스를 상속하고 findBy*, getBy*  패턴을 사용하게 되면, 개발자가 실제 코드를 구현하지 않아도 동작하게됩니다. Repo.kt 파일을 생성한 뒤에 아래와 같이 Repo 인터페이스를 정의합니다.

package com.kin.mongoexample

import org.springframework.data.mongodb.repository.MongoRepository

interface Repo : MongoRepository<Restaurant, String> {
    fun findByRestaurantId(restaurantId: String): List<Restaurant>?
}

 

 

컨트롤러(Controller) 클래스 생성

Repo 클래스를 만든 후 이를 호출할 컨트롤러를 생성합니다. 단순한 프로젝트와 달리 큰 프로젝트에서는 어노테이션 기반 종속성을 주입하는 방식이 선호됩니다. @Autowired 어노테이션을 이용하여 MongoRepository 인터페이스를 상속하는 Repo 인터페이스의 인스턴스가 주입되도록 컨트롤러를 정의합니다. 이를 통해 Controller 클래스를 아래와 같이 생성합니다.

  • RequestController 과 RequestMapping 은 라우팅 정보를 제공합니다. 라우팅은 HTTP 요청 경로(호스트/ 뒤에 오는 텍스트)를 다양한 HTTP 메서드에 걸쳐 구현된 클래스에 매핑하는 것입니다. 즉, localhost/restaurants 와 같은 형식으로 해당 컨트롤러에 접근이 가능합니다.
@RestController
@RequestMapping("/restaurants")
class Controller(@Autowired val repo: Repo) {

}

 

전체 레스토랑 수를 출력하는 함수 구현 

  • GET, http[s]://localhost/restaurants 호출을 통해 전체 레스토랑의 수를 출력하는 로직을 구성합니다. 
  • GetMapping 어노테이션은 GET 메소드를 통해 접근을 허용한다는 의미입니다. 
class Controller(@Autowired val repo: Repo) {
	// .. 생략 
    @GetMapping
    fun getCount(): Int {
        return repo.findAll().count()
    }
    // .. 생략
}

 

레스토랑 정보를 출력하는 함수 구현 (Read)

  • restaurant_id 입력값을 통해 레스토랑 정보를 가져오는 로직을 작성할 수 있습니다.
  • http[s]://localhost/restaurants/{id} 와 같이 레스토랑을 구분하는 식별값을 입력받도록 하며 정해진 값을 입력받는다면 레스토랑정보를 출력합니다.
  • @PathVariable 어노테이션을 통해 URL 파라미터와 함수의 인자를 연결할 수 있습니다. 
interface Repo : MongoRepository<Restaurant, String> {
    fun findByRestaurantId(restaurantId: String): Restaurant?
}

@GetMapping("/{id}")
fun getRestaurantById(@PathVariable("id") id: String): Restaurant? {
    return repo.findByRestaurantId(id)
}

 

그러나 위 코드는 문제가 있습니다. 존재하지 않는 레스토랑을 찾을 경우 에러처리를 하지 못합니다. 즉, 아래처럼 존재하지 않는 레스토랑을 찾을 수 없다면 200응답임에도 데이터를 내려주지 않습니다.

GET <http://localhost:8080/restaurants/21>

HTTP/1.1 200 
Content-Length: 0
Date: Tue, 12 Mar 2024 19:30:27 GMT
Keep-Alive: timeout=60
Connection: keep-alive

<Response body is empty>Response code: 200; Time: 57ms (57 ms); Content length: 0 bytes (0 B)

 

따라서, 아래와 같이 추가해줍니다.

  • 레스토랑 정보를 검색하고 존재한다면 ok(=200) 응답과 함께 레스토랑 정보를 출력합니다.
  • 만약, 레스토랑 정보가 발견되지 않는다면 notFound(=404) 응답을 출력합니다.
@GetMapping("/{id}")
fun getRestaurantById(@PathVariable("id") id: String): ResponseEntity<Restaurant> {
    val restaurant = repo.findByRestaurantId(id)
    return if (restaurant != null) ResponseEntity.ok(restaurant) else ResponseEntity
        .notFound().build()
}

 

위 코드 또한 추가적인 개선이 필요합니다. restaurant_id 는 중복될 수 있어 여러 레스토랑 정보가 검색될 경우 에러가 발생합니다. 즉, Repository 인터페이스의 코드의 수정이 필요합니다.

IncorrectResultSizeDataAccessException: <<생략>> returned non unique result] with root cause

 

동일한 레스토랑 정보가 중복해서 검색된다면 대응할 수 있는 방법은 두 가지입니다.

  • 중복된 레스토랑 정보를 모두 보여준다.
  • 중복된 레스토랑 정보 중 하나를 보여준다.

중복된 레스토랑 정보를 모두 보여주는 방향으로 개선 계획을 잡았고 Repo 인터페이스를 수정하겠습니다. 아래는 개선된 Repo 인터페이스입니다. 리턴 타입에 List<Restaurant>? 타입으로 수정을 하였습니다. 

package com.kin.mongoexample

import org.springframework.data.mongodb.repository.MongoRepository

interface Repo : MongoRepository<Restaurant, String> {
    fun findByRestaurantId(restaurantId: String): List<Restaurant>?
}

 

List.isNullOrEmpty 는 검색된 레스토랑이 없을 경우를 판별하는 역할을합니다. Repo 클래스의 findByRestaurantId 메소드가 List<Restaurant>? 타입을 리턴하므로 List 구조에 대한 empty 체크 함수가 필요합니다. 수정된 코드는 아래와 같습니다. 

@GetMapping("/{id}")
fun getRestaurantById(@PathVariable("id") id: String): ResponseEntity<List<Restaurant>> {
    val restaurantList = repo.findByRestaurantId(id)
    return if (!restaurantList.isNullOrEmpty()) ResponseEntity.ok(restaurantList) else ResponseEntity
        .notFound().build()
}

 

그러나, 위 코드도 개선할 부분이 보입니다. 인라인 if-else 문의 특성상 코드 가독성이 떨어집니다. 코틀린에서 제공하는 let 확장함수를 사용하면 가독성 문제를 해결할 수 있습니다. 가독성이 향상된 모습을 볼 수 있습니다. 

@GetMapping("/{id}")
fun getRestaurantById(@PathVariable("id") id: String): ResponseEntity<List<Restaurant>> {
    return repo.findByRestaurantId(id)?.let {
        if (it.isNotEmpty()) {
            ResponseEntity.ok(it)
        } else {
            ResponseEntity.notFound().build()
        }
    } ?: ResponseEntity.notFound().build()
}

 

let 확장함수에대해 추가적인 정보를 알고 싶다면 아래 글을 추천드립니다. 

🔗 2024.03.13 - [프로그래밍/코틀린] - 코틀린, let 확장 함수을 알아보자

 

코틀린, let 확장 함수을 알아보자

안녕하세요 K-인사이트 입니다. 코틀린에서 let 확장함수는 nullable 과 nullsafe 를 체이닝을 통해서 구현할 수 있는 유용한 기능입니다. 인라인 if-else 의 가독성 문제를 해결하며 직관적으로 코드를

k-in.tistory.com

 

위 처럼 개선한 뒤에 앱을 다시 실행시키면 레스토랑 정보가 없을 경우 404를 리턴합니다.

GET <http://localhost:8080/restaurants/21>

HTTP/1.1 404 
Content-Length: 0
Date: Tue, 12 Mar 2024 19:33:29 GMT
Keep-Alive: timeout=60
Connection: keep-alive

<Response body is empty>Response code: 404; Time: 44ms (44 ms); Content length: 0 bytes (0 B)

 

신규 레스토랑을 추가하는 함수 구현 (Create)

새롭게 신규 레스토랑 정보를 추가하는 API 를 만들어 보겠습니다. 앞서 정의한 Repo 인터페이스에서 제공하는 내장 메서드인 insert 를 통해서 구현할 수 있습니다.

@PostMapping
fun postRestaurant(): Restaurant {
    val restaurant = Restaurant().copy(name = "sample", restaurantId = "33332")
    return repo.insert(restaurant)
}

 

레스토랑 정보를 업데이트하는 함수 구현 (Update)

기존 레스토랑 정보를 업데이트하는 API 를 만들어 보겠습니다. Patch 메소드를 사용하므로 PatchMapping 을 사용합니다. id 값으로 관련 레스토랑 정보를 검색하고 이름을 변경할 수 있습니다.

@PatchMapping("/{id}")
fun updateRestaurant(@PathVariable("id") id: String): Restaurant? {
    return repo.findByRestaurantId(restaurantId = id)?.let {
        repo.save(it.copy(name = "Update"))
    }
}

 

레스토랑 정보를 삭제하는 함수 구현 (Delete)

마지막으로 생성하고 수정한 레스토랑 정보를 삭제합니다. Delete 메소드를 사용하므로 DeleteMapping 을 사용합니다. id 값으로 관련 레스토랑 정보를 검색하고 Repository 를 이용해 삭제합니다.

@DeleteMapping("/{id}")
fun deleteRestaurant(@PathVariable("id") id: String) {
    repo.findByRestaurantId(id)?.let {
        repo.delete(it)
    }
}

 

지금까지 레스토랑 리소스에 대한 CRUD(Create, Read, Update, Delete)를 지원하는 RESTful API 를 만들어보았습니다. 

 

Spring Boot MongoDB 테스트

Spring Boot 프로젝트에서 MongoDB 를 데이터베이스로 사용할 경우, @Transactional 과 같은 annotation 을 사용할 수 없습니다. 즉, Data Layer 에 대한 Unit 테스트 시에 쓰레기 데이터가 남게됩니다. 이에, MongoDB 를 사용할 경우 Data Layer 를 위한 Unit 테스트를 구성을 위해 몇가지 팁이 필요합니다.

 

RepoTests 클래스를 생성하고 아래의 코드를 구현합니다. @DataMongoTest 는 Spring Boot 테스트에서 MongoDB 와 관련된 구성을 자동으로 로드하는 역할을 합니다. @DataMongoTest 의 구체적인 역할은 다음과 같습니다. 

  • MongoTemplate, ReactiveMongoTemplate, MongoRepositories 와 같은 MongoDB 와 관련된 Bean 들을 scan 하여 로드합니다. 
  • 테스트 실행 중에 필요한 MongoDB 연결을 설정합니다. 

테스트를 실행하게되면 test collections 에 restaurant 컬렉션이 생성되고 테스트 데이터 들이 주입됩니다. 따라서, 앱이 원래 바라보는 DB가 아니기에 테스트 후에 삭제하는 과정을 구현하여 테스트 후에 쓰레기 데이터가 남지않도록 구현이 가능합니다. 

  • 주입된 데이터는 @AfterAll 을 통해 데이터를 제거할 수 있습니다.
package com.kin.mongoexample

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest
import org.springframework.test.context.junit.jupiter.SpringExtension

@ExtendWith(SpringExtension::class)
@DataMongoTest
open class RepoTests
{

    @Autowired
    lateinit var restaurantRepo: Repo

    private lateinit var mockRestaurant: Restaurant
    private lateinit var mockRestaurantId: String

    @AfterAll
    fun `delete test`() {
        restaurantRepo.deleteAll()
    }

    @Test
    open fun `When findByRestaurantId then return Restaurant`() {
        mockRestaurant = restaurantRepo.save(Restaurant().copy(name = "test restaurant", restaurantId = "12341"))
        mockRestaurantId = mockRestaurant.restaurantId
        val found = restaurantRepo.findByRestaurantId(mockRestaurantId)
        assertThat(found?.firstOrNull()?.restaurantId).isEqualTo(mockRestaurantId)
    }
}

 

실습 코드 

지금까지 배운 실습 코드는 아래의 링크를 통해서 다운받을 수 있습니다. 

🔗 Bitbucket, 실습 프로젝트 다운로드

 

맺음말

이 글에서 아래의 요소들을 학습하였습니다. 

  • Spring Boot 프레임워크와 Spring  프레임워크의 차이점
  • 스프링 부트를 사용해야 하는 이유와 코틀린이 주는 이점
  • 레스토랑 정보를 관리하는 CRUD RESTful API 구현 방법
  • MongoDB 를 사용하는 프로젝트의 Data Layer Unit 테스트를 구현하는 팁

이 정보를 잘 활용하셔서 멋진 어플리케이션을 제작하는데 도움이되길 바랍니다. 

 

이상입니다.

K-인사이트 올림. 

반응형