spring과의 첫 만남으로 spring 공식에서 제공하는 튜토리얼을 따라가보기로 했다. 고맙게도 kotlin으로 시작하는 튜토리얼이 있어 이 순서를 차근히 따라가보았다.
목차 자체는 Tutorial의 목차를 따라가며 알아둬야 할 것들이 정리해본다.
별 문제 없이 시키는대로 하면 된다. 다만 spring initializr로 프로젝트 zip 파일을 만들어 사용하는게 좀 신기했다.
나는 Gradle 파일이 익숙해서 Gradle 빌드를 택했다.
Java type들이 Kotlin에서는 platform type으로 인식되는데 결국 JSR 305 support와 spring annotation을 통해서 별 문제 없이 모두 null safety하게 쓸 수 있다. (? 애매하지만 맞는 이해라고 생각한다.) 아래 코드를 통해 해당 기능을 가능케 할 수 있다.
## build.gradle.kts
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
}
}
3개의 kotlin library가 Spring Boot web application을 위해 필요함.
kotlin-stdlib-jdk8
: Java 8.0을 위한 기능이 추가된 라이브러리kotlin-reflect
: kotlin reflection library(? 뭔지 모른다.)jackson-module-kotlin
: Class들의 serialization을 돕는다.main 함수가 있는 Application 파일의 최초 진입점으로 보이는 class에는 @SpringBootApplication annotation이 붙어야한다.
MVC 패턴에서 C를 맡는 controller로 보인다. 해당 패턴 자체는 따로 더 찾아보는 것으로 하고, 결국 어떤 방식으로든 Model과 View를 이어주는 역할일거라 생각한다.
JUnit은 Spring Boot에서 기본적으로 쓰이는 test library 이다.
이런 방식으로 쓴다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@Test
fun `Assert blog page title, content and status code`() {
val entity = restTemplate.getForEntity<String>("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("<h1>Blog</h1>")
}
}
## src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = perclass
이 한줄을 통해 @BeforeAll 과 @AfterAll annotation을 쓸 수 있고, Test를 좀 더 풍성하게 구성할 수 있게 되는 것 같다.
일반적인 kotlin 지식인 것 같아 패스한다.
개인적으로 tutorial을 하며 핵심이 되는 파트 중 하나라 생각했다. 결국 백엔드의 핵심이 될 수 있는 Entity(Class)와 연관이 되는 파트이기 때문이다.
우선 별다른 문제 없이 사용하려면 해당 코드를 추가하라고 한다.
## build.gradle.kts
plugins {
...
kotlin("plugin.allopen") version "1.6.21"
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.Embeddable")
annotation("javax.persistence.MappedSuperclass")
}
그러고서 Entity를 작성하는 것은 어렵지 않다.
@Entity
class Article(
~~~
~~~
@Id @GeneratedValue var id: Long? = null
)
이 부분을 보며 여러 field를 var로 쓰는 것이 의아했다. 보니 JPA의 경우 immutable class나 data class에 종속된 method들과 같이 쓰기위해 디자인 된게 아니라고 한다. 그렇기에 해당 방식으로 작성한 것이고, 만약 MongoDB, Spring Data JDBC와 같은 Spring Data flavor와 같이 쓴다면 data class (~~ val ~) 이런 식으로 쓰면 될 것이다.
JPA 덕에 class의 id를 맘대로(?)해도 되지만, 이슈가 있어 generated IDs를 쓰는 것이 권장이다.
그 다음으론 Repository가 나왔다. CrudRepository<T, I> 자체가 기본적인 동작을 모두 제공하는 것 같다. 또한 findBy[Field]를 할 경우 해당 Field를 통한 get이 되는 것 같다.
interface ArticleRepository : CrudRepository<Article, Long> {
fun findBySlug(slug: String): Article?
fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}
interface UserRepository : CrudRepository<User, Long> {
fun findByLogin(login: String): User?
}
앞서서 생략했지만 tutorial에 html도 직접 쓰고, 해당 Html을 위한 Controller 또한 작성한다. 하지만 그렇게까지 중요하다고 생각하지는 않는다. 아마 결국 Front는 따로 붙히게 될 것이기에 그렇구나~ 정도만 하고 넘긴다. 다만 data initialization 코드는 중요할 거 같아 적는다.
@Configuration
class BlogConfiguration {
@Bean
fun databaseInitializer(userRepository: UserRepository,
articleRepository: ArticleRepository) = ApplicationRunner {
val smaldini = userRepository.save(User("smaldini", "Stéphane", "Maldini"))
articleRepository.save(Article(
title = "Reactor Bismuth is out",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
articleRepository.save(Article(
title = "Reactor Aluminium has landed",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
}
}
여기서 @Bean에 대한 개념이 없어 간단히 찾아봤는데, spring에서 Spring IoC 컨테이너가 관리하는 자바 객체를 Bean이라고 부른다고 한다. ApplicationRunner로 감싸는 것으로 해당 코드가 실행되는 것 같다.(? 추측)
다음으로 또 다른 핵심 요소인 API를 만들어 노출시키는 방법이다. 생각보다 매우 간단했다. 코드를 보는 것 만으로 일반적인 작성법은 알 수 있다.
@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {
@GetMapping("/")
fun findAll() = repository.findAllByOrderByAddedAtDesc()
@GetMapping("/{slug}")
fun findOne(@PathVariable slug: String) =
repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
}
@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {
@GetMapping("/")
fun findAll() = repository.findAll()
@GetMapping("/{login}")
fun findOne(@PathVariable login: String) =
repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}
HTTP 통신 테스트 코드를 위해서 @WebMvcTest, Mockk, SpringMockK를 이용해서 테스트 코드를 작성한다.
## build.gradle.kts
~~~
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "junit")
exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:3.0.1")
~~~
## src/test/kotlin/com/example/blog/HttpControllersTests.kt
@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {
@MockkBean
private lateinit var userRepository: UserRepository
@MockkBean
private lateinit var articleRepository: ArticleRepository
@Test
fun `List articles`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val spring5Article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
val spring43Article = Article("Spring Framework 4.3 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(spring5Article, spring43Article)
mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[0].slug").value(spring5Article.slug))
.andExpect(jsonPath("\$.[1].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[1].slug").value(spring43Article.slug))
}
@Test
fun `List users`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val smaldini = User("smaldini", "Stéphane", "Maldini")
every { userRepository.findAll() } returns listOf(juergen, smaldini)
mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].login").value(juergen.login))
.andExpect(jsonPath("\$.[1].login").value(smaldini.login))
}
}
Application property를 사용하는 방법이며 @ConfigurationProperties와 @ConstructorBinding을 사용한다. 또 IDE에서 이 custom properties를 인식하게 하려면 spring-boot-configuration-processor
와 함께 kapt를 사용해야 한다.
## build.gradle.kts
plugins {
...
kotlin("kapt") version "1.6.21"
}
dependencies {
...
kapt("org.springframework.boot:spring-boot-configuration-processor")
}
## src/main/kotlin/com/example/blog/BlogProperties.kt
// property를 정의하고
@ConstructorBinding
@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
data class Banner(val title: String? = null, val content: String)
}
## src/main/kotlin/com/example/blog/BlogApplication.kt
// configuration property 쓸거라고 알려주고
@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
// ...
}
## src/main/resources/application.properties
// 값 주입!
blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.
오늘 나왔던 내용들 중 핵심은 다음과 같다.
1. Kotlin + spring boot 프로젝트를 구성하는법
2. JPA를 사용하여 Entity, Repository 구성하기
3. HTTP API를 작성하는 법
4. JUnit5, MockK를 이용하여 테스트 코드 작성하기
이것들을 기억해두면서 앞으로 반복적으로 공부해 나가면 될 것 같다. 또 초반 프로젝트 구성 부분에 키워드로 링크가 많이 걸려있는데, 심심할 때마다 하나씩 보면서 이론을 채워넣으면 될 것 같다.
꽤나 성공적인 첫 대면이었다고 생각한다. 정말로 책의 목차 정도만 펴 본 정도라고 할 수는 있으나, spring framework를 익히는 것은 그렇게 까지 어렵진 않을지도..? 하는 자신감을 가질 수 있어 고무적이다.