출처
실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링)
앞으로 해당 시리즈에 관련된 소스와 글은 위 링크가 출처임을 밝힙니다.
이전부터 Kotlin에 관심이 있었는데 막상 시작하기엔 디자인 패턴도 공부해야하고 알고리즘도 공부해야하고 스프링도 공부해야하고 개발자가 된 이후에 왜이리 공부할게 많은지 모르겠다... 또 최근에 이직을 해서 그런가 정신이 없어서 공부도 손에 잘 안잡히던 와중 인프런에 실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링) 강의가 개설되어 할인중인것을 보고 아무 생각없이 일단 결제해놨었다.
공부를 시작하자니 일단 내가 재밌을거 같은거 부터 시작하자는 마음이 커서 앞서 공부하던 디자인 패턴은 잠시 멈춰두고 코프링을 먼저 공부해보려고 한다.
...
plugins {
...
id 'org.jetbrains.kotlin.jvm' version '1.6.21'
}
dependencies {
...
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
}
compileKotlin{
kotlinOptions {
jvmTarget = "11"
}
}
compileTestKotlin{
kotlinOptions {
jvmTarget = "11"
}
}
기존 java project에서 다음과 같이 kotlin 설정을 추가해주었다.
해당 설정이 kotlin이면 무조건 설정되는 것인지 아니면 해당 프로젝트를 위해 설정된 것인지는 아직 모르겠다!
기존 java 프로젝트에서는 /src/main/java
디렉토리 내에 소스들을 정리해두었을 것이다.
하지만 이제는 우리는 kotlin 프로젝트를 관리할 것이기 때문에 src/main/kotlin
디렉토리를 추가하여 소스를 관리해보자.
main 디렉토리에 새로운 디렉터리 추가를 눌렀을 때 gradle 설정이 정상적으로 처리되었다면 다음과 같이 폴더를 선택할 수 있는 옵션이 나온다. 없다면 kotlin 이름으로 만들면 된다.
그리고 다음과 같이 main과 test에 각각 추가해주면 된다. 만약 여기서 intellij가 파란색 폴더로 표시해주지 않는다면 디렉토리를 우클릭 > Mark Directory as > Source Set Root
옵션을 클릭해주면 된다.
그 후에는 내부 패키지 형태를 동일하게 가져가기 위해 다음과 같이 추가해주자
테스트 쪽도 똑같이 추가해주면 된다.
data class Calculator (private var number:Int) {
fun add(operand: Int){
this.number += operand
}
fun minus(operand: Int){
this.number -= operand
}
fun multiply(operand: Int){
this.number *= operand
}
fun divide(operand: Int){
if(operand == 0){
throw IllegalArgumentException("0으로 나눌 수 없습니다.");
}
this.number /= operand
}
}
다음과 같이 코틀린으로 계산기 프로그램을 간단하게 작성했다. 여기서 java와 다른점을 몇개 찝어보고 넘어가자. 왜냐면 나는 코틀린 초보니까^^
fun
이라는 예약어를 통해 function을 정의한다.throw new Exception()
과 같은 형식으로 선언하는 것에 비해 코틀린은 new
를 제외하고 사용한다.;
을 사용하지 않는다.data class는 기존 class에서 boilerplate code인 toString(), equals(), hashCode(), copy() 등 반복되는 코드를 줄여주기 위한 class로 class 앞에 data만 붙여주면 자동으로 해당 fun를 가진 class로 정의된다.
fun main() {
val calculatorTest = CalculatorTest()
calculatorTest.addTest()
}
class CalculatorTest{
fun addTest(){
val calculator = Calculator(5)
calculator.add(2) // 파라미터 값을 3으로 고치면 정상실행!
val expectedCalculator = Calculator(8)
if(calculator != expectedCalculator) throw IllegalArgumentException("예상 값이 다릅니다!")
}
}
main function을 가지는 다음 Test 코드를 작성해보았다. Junit은 아직 사용하지 않는 코드로 실행해보자!
다음과 같이 java와 동일한 에러코드의 로그를 확인할 수 있다. 물론 저기에서 add(3)으로 고쳐주면 정상 실행된다.
해당 부분은 kotlin을 이해하기 위한 부분이므로 이미 코틀린 문법에 익숙한 분이면 굳이 따라하지 않으셔도 됩니다!
class Calculator (var number:Int) {
fun add(operand: Int){
this.number += operand
}
fun minus(operand: Int){
this.number -= operand
}
fun multiply(operand: Int){
this.number *= operand
}
fun divide(operand: Int){
if(operand == 0){
throw IllegalArgumentException("0으로 나눌 수 없습니다.");
}
this.number /= operand
}
}
앞선 코드와 조금 다르게 data 대신 생성자 매개변수에 private을 지워서 사용했다. 참고로 kotlin은 default 값이 public이다.
fun main() {
val calculatorTest = CalculatorTest()
calculatorTest.addTest()
}
class CalculatorTest{
fun addTest(){
val calculator = Calculator(5)
calculator.add(2)
val expectedCalculator = Calculator(8)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
}
그럼 다음과 같이 테스트 코드의 확인도 다음과 같이 다시 작성할 수 있다.
하지만 우리는 여기서 불편함을 느껴야한다. 왜냐? 필드값 public으로 set 행동을 쉽게할 수 있게 되기 때문에 값의 변경이 너무 쉽게 되기 때문이다. 그럼 get만 열어주는 방식으로 다시 짜보자.
class Calculator (private var _number:Int) {
val number: Int
get() = this._number
fun add(operand: Int){
this._number += operand
}
fun minus(operand: Int){
this._number -= operand
}
fun multiply(operand: Int){
this._number *= operand
}
fun divide(operand: Int){
if(operand == 0){
throw IllegalArgumentException("0으로 나눌 수 없습니다.");
}
this._number /= operand
}
}
기존 class 코드의 생성자 부분을 다시 private으로 막아주고 public으로 열린 number 필드를 선언해준다. 그리고 get() 부분을 다음과 같이 선언해주면 외부에서도 접근이 가능한 number가 새롭게 정의되고 set은 막혀있게 된다.
fun main() {
val calculatorTest = CalculatorTest()
calculatorTest.addTest()
}
class CalculatorTest{
fun addTest(){
val calculator = Calculator(5)
calculator.add(2)
val expectedCalculator = Calculator(8)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
}
테스트 코드 쪽은 다음과 같이 number에 그대로 접근할 수 있게 된다.
강의의 강사님인 최태현 강사님께서는 다음과 같이 setter를 막아두는 코드가 좋지만 kotlin이 java보다 더 깔끔하고 가독성 좋은 코드를 지향하고 있는 만큼 위 코드와 같이 어려운 코드가 추가되어지는 것 보단 개발자들끼리 setter를 쓰지 않기로 약속을 하고 public으로 열어둔 코드를 작성하는 방법도 있다고 알려주셨다.
자신 있으면 그렇게 하자.
나머지 테스트도 작성해보자.
fun main() {
val calculatorTest = CalculatorTest()
calculatorTest.addTest()
calculatorTest.minusTest()
calculatorTest.multiplyTest()
calculatorTest.divideTest()
calculatorTest.divideExceptionTest()
}
class CalculatorTest{
fun addTest(){
val calculator = Calculator(5)
calculator.add(3)
val expectedCalculator = Calculator(8)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
fun minusTest(){
val calculator = Calculator(5)
calculator.minus(3)
val expectedCalculator = Calculator(2)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
fun multiplyTest(){
val calculator = Calculator(5)
calculator.multiply(3)
val expectedCalculator = Calculator(15)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
fun divideTest(){
val calculator = Calculator(5)
calculator.divide(2)
val expectedCalculator = Calculator(2)
if(calculator.number != expectedCalculator.number) throw IllegalArgumentException("예상 값이 다릅니다!")
}
fun divideExceptionTest(){
val calculator = Calculator(5)
try{
calculator.divide(0)
}catch (e: IllegalArgumentException){
if(e.message != "0으로 나눌 수 없습니다.") throw IllegalStateException("예상치 못한 메세지 발생")
return
}catch (e: Exception){
throw IllegalStateException("예상치 못한 예외 발생")
}
throw IllegalStateException("예외가 발생하지 않았습니다.")
}
}
main에서 모두 코드가 정상 실행된다면 정상적으로 진행된 것이다.
이미 테스트 코드를 경험해본 분이라면 왜 이렇게 작성할까? 라는 생각이 들었을거다 왜냐면 우리에겐 Java쪽에서부터 사용하던 Junit이 있으니까 ㅎㅎ... 이제 테스트 코드를 Junit5를 사용하여 다시 작성해보자!
class JunitTest {
companion object{
@BeforeAll
@JvmStatic
fun beforeAll(){
println("***** 모두 시작 *****")
}
@AfterAll
@JvmStatic
fun afterAll(){
println("***** 모두 종료 *****")
}
}
@BeforeEach
fun beforeEach(){
println("----- 시작 -----")
}
@AfterEach
fun afterEach(){
println("----- 종료 -----")
}
@Test
fun test1(){
println("테스트1")
}
@Test
fun test2(){
println("테스트2")
}
}
기존에 테스트 코드를 작성하신 분들은 금방 이해하겠지만 kotlin이 조금 다른 부분이 있어서 적어놨다. 대부분 똑같이 작성하면 되지만 @BeforAll
@AfterAll
부분이 기존 java 코드와는 조금 달라서 작성해두었다. 두 어노테이션이 붙은 테스트는 java에서는 static으로 관리되어야 했었는데 코틀린에서는 static 대신 companion object
를 사용한다고 한다. static과 비슷한 개념이지만 조금 다른 개념도 있으니 모르면 찾아보자! 그리고 @JvmStatic
을 사용하여 더 java의 static과 동일하게 사용할 수 있도록 옵션을 주어야한다.
테스트 결과는 다음과 같이 나오면 정상이다!
프로젝트를 진행하던 도중
println()
이 intellij에서 읽지 못하는 오류가 있었다. 이때 해결방법으로File > Invalidate Caches / Restart
를 눌러서 프로젝트를 다시 시작하면 잘 된다.
이제 이전에 작성했던 테스트를 Junit5를 통하여 다시 작성해보자!
그전에 Assertions에서 사용할 단언문들을 살짝 정리해보자.
assertThat(expect).isEqualsTo(actual)
assertThat(expect).isTrue
assertThat(expect).isFalse
위 단언문을 이용하여 예상값이 실제 값과 맞는지 확인해볼 수 있다.
val people = listOf(Person("A"), Person("B"))
assertThat(people).hasSize(2)
assertThat(people).extracting("name").containsExactlyInAnyOrder("A","B") //순서 상관없이 체크
assertThat(people).extracting("name").containsExactly("A","B") // 순서대로 체크
위 단언문을 이용하여 list 값들을 체크할 수 있다.
assertThrows<IllegalArgumentException> {
function1()
}
val message = assertThrows<IllegalArgumentException> {
function1()
}.message
assertThat(message).isEqualsTo("잘못된 값이 들어왔습니다.")
function1() 함수를 실행했을 때 발생하는 Exception은 다음과 같이 체크할 수 있다.
message를 사용하여 예외의 메세지값도 가져올 수 있다.
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.lang.IllegalArgumentException
class CalculatorJunitTest {
@Test
fun addTest(){
val calculator = Calculator(5)
calculator.add(3)
Assertions.assertThat(calculator.number).isEqualTo(8)
}
@Test
fun minusTest(){
val calculator = Calculator(5)
calculator.minus(3)
Assertions.assertThat(calculator.number).isEqualTo(2)
}
@Test
fun multiplyTest(){
val calculator = Calculator(5)
calculator.multiply(3)
Assertions.assertThat(calculator.number).isEqualTo(15)
}
@Test
fun divideTest(){
val calculator = Calculator(5)
calculator.divide(2)
Assertions.assertThat(calculator.number).isEqualTo(2)
}
@Test
fun divideExceptionTest(){
//given
val calculator = Calculator(5)
//when & then
val message = assertThrows<IllegalArgumentException> {
calculator.divide(0)
}.message
Assertions.assertThat(message).isEqualTo("0으로 나눌 수 없습니다.")
}
}
다음과 같이 모두 작성하면 된다.
@SpringBootTest
class UserServiceTest(
@Autowired private val userRepository: UserRepository,
@Autowired private val userService: UserService
) {
}
Service 계층을 테스트하기 위해 다음과 같이 의존성을 주입받는 class를 만들었다. 여기서 @Autowired
가 중복되어지는 해당 부분은
@SpringBootTest
class UserServiceTest @Autowired constructor(
private val userRepository: UserRepository,
private val userService: UserService
) {
}
다음과 같이 수정해서 사용할수 있다.
@SpringBootTest
class UserServiceTest @Autowired constructor(
private val userRepository: UserRepository,
private val userService: UserService
) {
@Test
fun saveUserTest(){
//given
val request = UserCreateRequest("juno", null)
//when
userService.saveUser(request)
//then
val results = userRepository.findAll()
Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("juno")
}
}
테스트 코드를 마저 작성한다면 다음과 같이 작성해볼 수 있다.
@SpringBootTest
class UserServiceTest @Autowired constructor(
private val userRepository: UserRepository,
private val userService: UserService
) {
@AfterEach
fun clean(){
userRepository.deleteAll()
}
@Test
fun saveUserTest(){
//given
val request = UserCreateRequest("juno", 10)
//when
userService.saveUser(request)
//then
val results = userRepository.findAll()
Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("juno")
}
@Test
fun getUsersTest(){
//given
userRepository.saveAll(listOf(
User("A",20),
User("B",21)
))
//when
val results = userService.getUsers()
//then
Assertions.assertThat(results).hasSize(2)
Assertions.assertThat(results).extracting("name").containsExactlyInAnyOrder("A","B")
Assertions.assertThat(results).extracting("age").containsExactlyInAnyOrder(20,21)
}
@Test
fun updateUserNameTest(){
//given
val savedUser = userRepository.save(User("A", 20))
val request = UserUpdateRequest(savedUser.id, "B")
//when
userService.updateUserName(request)
//then
val result = userRepository.findAll()[0]
Assertions.assertThat(result.name).isEqualTo("B")
}
@Test
fun deleteUserTest(){
//given
userRepository.save(User("A",20))
//when
userService.deleteUser("A")
//then
Assertions.assertThat(userRepository.findAll()).isEmpty()
}
}
모든 테스트를 다 작성하면 다음과 같이 작성해볼 수 있다.
import com.group.libraryapp.domain.book.Book
import com.group.libraryapp.domain.book.BookRepository
import com.group.libraryapp.domain.user.User
import com.group.libraryapp.domain.user.UserRepository
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistory
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistoryRepository
import com.group.libraryapp.dto.book.request.BookLoanRequest
import com.group.libraryapp.dto.book.request.BookRequest
import com.group.libraryapp.dto.book.request.BookReturnRequest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.lang.IllegalArgumentException
@SpringBootTest
class BookServiceTest @Autowired constructor(
private val bookService: BookService,
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
){
@AfterEach
fun clean(){
bookRepository.deleteAll()
userRepository.deleteAll()
}
@Test
@DisplayName("책 등록 정상 동작")
fun saveBook(){
//given
val request = BookRequest("이상한 나라의 엘리스")
//when
bookService.saveBook(request)
//then
val books = bookRepository.findAll()
assertThat(books).hasSize(1)
assertThat((books[0].name)).isEqualTo("이상한 나라의 엘리스")
}
@Test
@DisplayName("책 대출이 정상 동작")
fun loanBookTest(){
//given
val book = bookRepository.save(Book("이상한 나라의 엘리스"))
val user = userRepository.save(User("juno", 20))
val request = BookLoanRequest("juno", "이상한 나라의 엘리스")
//when
bookService.loanBook(request)
//then
val results = userLoanHistoryRepository.findAll()
assertThat(results).hasSize(1)
assertThat(results[0].bookName).isEqualTo("이상한 나라의 엘리스")
assertThat((results[0].user.id)).isEqualTo(user.id)
assertThat((results[0].isReturn)).isFalse
}
@Test
@DisplayName("책이 이미 대출되어 있다면, 신규 대출이 실패한다.")
fun loanBookFailTest(){
//given
val book = bookRepository.save(Book("이상한 나라의 엘리스"))
val user = userRepository.save(User("juno", 20))
//이미 대출한 데이터 입력
userLoanHistoryRepository.save(UserLoanHistory(user, "이상한 나라의 엘리스", false))
val request = BookLoanRequest("juno", "이상한 나라의 엘리스")
//when & then
assertThrows<IllegalArgumentException> {
bookService.loanBook(request)
}.apply {
assertThat(message).isEqualTo("진작 대출되어 있는 책입니다")
}
}
@Test
@DisplayName("책 반납이 정상 동작한다")
fun returnBookTest(){
//given
val book = bookRepository.save(Book("이상한 나라의 엘리스"))
val user = userRepository.save(User("juno", 20))
//이미 대출한 데이터 입력
userLoanHistoryRepository.save(UserLoanHistory(user, "이상한 나라의 엘리스", false))
val request = BookReturnRequest(user.name, book.name)
//when
bookService.returnBook(request)
//then
val results = userLoanHistoryRepository.findAll()
assertThat(results).hasSize(1)
assertThat(results[0].isReturn).isTrue
}
}
이미 테스트 코드를 잘 작성하는 부들이면 참고할 필요가 없지만 코틀린을 사용하여 코드를 어떻게 짜는지 더 확인하고 싶은 사람들을 위해 작성해두었다.
또한 예외를 테스트하는 코드를 보면 .apply
부분이 있는데 해당 부분은 코틀린의 스코프 함수이다.