출처
실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링)
지난 시간에는 코틀린에 익숙해지기 위한 테스트 코드 작성법을 따라 적어봤다. 이제 Java 프로젝트를 Kotlin으로 변경해보는 시간을 가져보자!
먼저 Book class를 Entity로 만들려고 하니 다음과 같이 매개변수가 없는 기본 생성자가 없다고 에러가 난다. 코틀린에서는 기본 생성자를 어떻게 만들어야할까?...
plugins {
...
id 'org.jetbrains.kotlin.plugin.jpa' version '1.6.21'
}
그런거 없다. 우리는 코틀린이니까 다른거다. 위 plugin을 gradle에 추가해준다.
그럼 이제 에러가 사라지며 기본 생성자가 없어도 Entity를 사용할 수 있다.
왼쪽 Java 소스를 오른쪽 Kotlin 소스로 리팩토링 진행하였다.
그 후에 기존 Java 코드의 repository와 service 부분을 코틀린 코드로 대체한 다음 테스트를 돌려보면
public interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByName(String bookName);
}
@Service
public class BookService {
private final BookRepository bookRepository;
private final UserRepository userRepository;
private final UserLoanHistoryRepository userLoanHistoryRepository;
public BookService(
BookRepository bookRepository,
UserRepository userRepository,
UserLoanHistoryRepository userLoanHistoryRepository
) {
this.bookRepository = bookRepository;
this.userRepository = userRepository;
this.userLoanHistoryRepository = userLoanHistoryRepository;
}
@Transactional
public void saveBook(BookRequest request) {
Book newBook = new Book(request.getName(), null);
bookRepository.save(newBook);
}
@Transactional
public void loanBook(BookLoanRequest request) {
Book book = bookRepository.findByName(request.getBookName()).orElseThrow(IllegalArgumentException::new);
if (userLoanHistoryRepository.findByBookNameAndIsReturn(request.getBookName(), false) != null) {
throw new IllegalArgumentException("진작 대출되어 있는 책입니다");
}
User user = userRepository.findByName(request.getUserName()).orElseThrow(IllegalArgumentException::new);
user.loanBook(book);
}
@Transactional
public void returnBook(BookReturnRequest request) {
User user = userRepository.findByName(request.getUserName()).orElseThrow(IllegalArgumentException::new);
user.returnBook(request.getBookName());
}
}
아래 에러가 나온다. 해당 에러도 의존성을 추가하여 해결할 수 있다.
dependencies {
...
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.6.21'
}
다음 의존성을 추가하고 테스트코드를 실행해보자!
테스트도 정상적으로 실행됨을 볼수 있다!
해당 소스를 다음과 같이 먼저 변경했다.
지금까지 작성한 코드들은 모두 Setter가 열려있는 상태이다. 물론 Setter를 private하게 만들어서 외부에서 사용할 수 없도록 막을수도 있지만 그렇게하기엔 코드가 너무 번거롭게 작성된다. Setter를 사용함에 불편함을 느끼는 개발자라면 Setter 대신 다른 이름의 function을 생성하는 방향으로 진행해보자!
class User(
var name: String,
val age: Int?,
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? =null,
) {}
다음 코드를
class User(
var name: String,
val age: Int?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? =null,
) {
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),
}
다음과 같이 작성해도 무관하다.
이렇게 모든 프로퍼티를 생성자에 만들 필요는 없고 설계를 할때 프로퍼티에 대한 명확한 기준과 약속이 있다면 해당 기준과 약속으로 만드는 것이 필요하다!
JPA에서는 data class를 피하는 것이 좋다. 그 이유는 toString(), equals() 등 모두 JPA와 어울리지 않는 function들이다.
plugins {
id "org.jetbrains.kotlin.plugin.allopen" version "1.6.21"
}
// plugins, dependencies와 같은 Level (즉 build.gradle 최상단)
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
JPA의 Lazy Fetching을 위해 사용해야하는 gradle 설정이다.
public interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByName(String bookName);
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByName(String name);
}
public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> {
UserLoanHistory findByBookNameAndIsReturn(String bookName, boolean isReturn);
}
interface BookRepository : JpaRepository<Book, Long> {
fun findByName(bookName: String): Optional<Book>
}
interface UserRepository : JpaRepository<User, Long> {
fun findByName(name: String): Optional<User>
}
interface UserLoanHistoryRepository : JpaRepository<UserLoanHistory, Long> {
fun findByBookNameAndIsReturn(bookName: String, isReturn: Boolean) : UserLoanHistory ?
}
이렇게 리팩토링이 된다. 이후에 기존 Java코드는 모두 삭제해주면 된다!
리팩토링 후 우리가 이전에 리팩토링한 테스트 코드들을 돌려보면 정상 작동하는지 확인할 수 있다!
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public void saveUser(UserCreateRequest request) {
User newUser = new User(request.getName(), request.getAge(), Collections.emptyList(), null);
userRepository.save(newUser);
}
@Transactional(readOnly = true)
public List<UserResponse> getUsers() {
return userRepository.findAll().stream()
.map(UserResponse::new)
.collect(Collectors.toList());
}
@Transactional
public void updateUserName(UserUpdateRequest request) {
User user = userRepository.findById(request.getId()).orElseThrow(IllegalArgumentException::new);
user.updateName(request.getName());
}
@Transactional
public void deleteUser(String name) {
User user = userRepository.findByName(name).orElseThrow(IllegalArgumentException::new);
userRepository.delete(user);
}
}
import com.group.libraryapp.domain.user.User
import com.group.libraryapp.domain.user.UserRepository
import com.group.libraryapp.dto.user.request.UserCreateRequest
import com.group.libraryapp.dto.user.request.UserUpdateRequest
import com.group.libraryapp.dto.user.response.UserResponse
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class UserService (
private val userRepository: UserRepository,
) {
@Transactional
fun saveUser(request: UserCreateRequest){
val newUser = User(request.name, request.age)
userRepository.save(newUser)
}
@Transactional(readOnly = true)
fun getUsers(): List<UserResponse> {
// return userRepository.findAll().map(::UserResponse)
return userRepository.findAll().map {
user -> UserResponse(user)
}
}
@Transactional
fun updateUserName(request: UserUpdateRequest){
val user = userRepository.findById(request.id).orElseThrow(::IllegalArgumentException)
user.updateName(request.name)
}
@Transactional
fun deleteUser(name: String){
val user = userRepository.findByName(name).orElseThrow(::IllegalArgumentException)
userRepository.delete(user)
}
}
코틀린의 경우 @Transactional
어노테이션을 붙이면 다음과 같이 에러표시가 나온다. 그 이유는 @Transactional이 붙은 function은 오버라이드가 될 수 있어야하는데 코틀린은 오버라이드를 허용하기 위해서는 claas와 function에 각 open이라는 예약어가 붙어줘야하기 때문이다.
하지만 작성할 때마다 open을 붙여주는건 엥간히 귀찮은 일이기 때문에 플러그인을 추가하여 해결한다.
그러면 다음과 같이 자동으로 오버라이드를 할수 있는 상태로 만들어주기 때문에 더이상 open을 붙여주지 않아도 된다.
plugins {
...
id 'org.jetbrains.kotlin.plugin.spring' version '1.6.21'
}
그 후 작성한 테스트 코드를 통해 테스트를 진행해보면 정상처리된 것을 확인할 수 있다!
@Service
public class BookService {
private final BookRepository bookRepository;
private final UserRepository userRepository;
private final UserLoanHistoryRepository userLoanHistoryRepository;
public BookService(
BookRepository bookRepository,
UserRepository userRepository,
UserLoanHistoryRepository userLoanHistoryRepository
) {
this.bookRepository = bookRepository;
this.userRepository = userRepository;
this.userLoanHistoryRepository = userLoanHistoryRepository;
}
@Transactional
public void saveBook(BookRequest request) {
Book newBook = new Book(request.getName(), null);
bookRepository.save(newBook);
}
@Transactional
public void loanBook(BookLoanRequest request) {
Book book = bookRepository.findByName(request.getBookName()).orElseThrow(IllegalArgumentException::new);
if (userLoanHistoryRepository.findByBookNameAndIsReturn(request.getBookName(), false) != null) {
throw new IllegalArgumentException("진작 대출되어 있는 책입니다");
}
User user = userRepository.findByName(request.getUserName()).orElseThrow(IllegalArgumentException::new);
user.loanBook(book);
}
@Transactional
public void returnBook(BookReturnRequest request) {
User user = userRepository.findByName(request.getUserName()).orElseThrow(IllegalArgumentException::new);
user.returnBook(request.getBookName());
}
}
import com.group.libraryapp.domain.book.Book
import com.group.libraryapp.domain.book.BookRepository
import com.group.libraryapp.domain.user.UserRepository
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.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class BookService (
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
){
@Transactional
fun saveBook(request: BookRequest){
val book = Book(request.name)
bookRepository.save(book)
}
@Transactional
fun loanBook(request: BookLoanRequest){
val book = bookRepository.findByName(request.bookName).orElseThrow(::IllegalArgumentException)
if(userLoanHistoryRepository.findByBookNameAndIsReturn(request.bookName, false) != null){
throw java.lang.IllegalArgumentException("진작 대출되어 있는 책입니다")
}
val user = userRepository.findByName(request.userName).orElseThrow(::IllegalArgumentException)
user.loanBook(book)
}
@Transactional
fun returnBook(request: BookReturnRequest){
val user = userRepository.findByName(request.userName).orElseThrow(::IllegalArgumentException)
user.returnBook(request.bookName)
}
}
테스트까지 완료!
java 코드를 그대로 실행시키기 위해서 optional을 그대로 유지해뒀었는데 코틀린에서는 optional을 사용해도 상관 없지만 ?
를 통해서 null값을 받아올 수 있기 때문에 굳이 optional을 쓸 필요가 없다. 모두 지워보자
interface UserRepository : JpaRepository<User, Long> {
fun findByName(name: String): Optional<User>
}
기존의 해당 코드를
interface UserRepository : JpaRepository<User, Long> {
fun findByName(name: String): User?
}
interface BookRepository : JpaRepository<Book, Long> {
fun findByName(bookName: String): Book?
}
이렇게 바꿔주면 된다.
그러면 이제 우리가 orElseThrow
로 처리했던 부분이 다음과 같이 에러가 나는데
@Service
class UserService (
private val userRepository: UserRepository,
) {
...
@Transactional
fun deleteUser(name: String){
val user = userRepository.findByName(name)?: throw IllegalArgumentException()
userRepository.delete(user)
}
}
다음과 같이 처리해줄 수 있다.
book 부분도 처리해주자
다음과 같이 기존의 optional 부분을 대체하였다. 그런데 이렇게 보니 계속해서 반복되는 코드를 작성하기 귀찮다
이럴땐 반복되는 코드를 function으로 만들어 내면 되는데
kotlin file
을 생성하자. class가 아닌 file이다.
fun fail(): Nothing {
throw IllegalArgumentException()
}
js를 써보셨다면 js function들만 모아둔 util 파일 같은것을 만들어보거나 사용해본적이 있을것이다. 그런 파일처럼 function만 정의해주자
@Service
@Transactional(readOnly = true)
class BookService (
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
){
...
@Transactional
fun loanBook(request: BookLoanRequest){
val book = bookRepository.findByName(request.bookName)?: fail()
if(userLoanHistoryRepository.findByBookNameAndIsReturn(request.bookName, false) != null){
throw java.lang.IllegalArgumentException("진작 대출되어 있는 책입니다")
}
val user = userRepository.findByName(request.userName)?: fail()
user.loanBook(book)
}
@Transactional
fun returnBook(request: BookReturnRequest){
val user = userRepository.findByName(request.userName)?: fail()
user.returnBook(request.bookName)
}
}
기존의 throw를 일일히 붙여주던 코드에서 fail()
function 하나로 통일해버린 코드를 작성할 수 있었다.
그런데 UserService를 보면 뭔가 옥에티가 보인다. 우리가 Repository에서 직접 지정하여 사용한 findByName의 경우 우리 생각대로 코딩을 할수 있었지만 기존의 JpaRepository에서 제공하는 findById의 경우 Optional로 제공되기 때문에 해당 코드를 그대로 사용할수 밖에 없다. 물론 이렇게 써도 상관은 없지만 그래도 코틀린은 읽기 좋은 코드를 지향하지 않나? 바꿔보자
JetBrain은 Kotlin으로 JpaRepository를 사용할 것을 대비하여 확장 코드를 지언하고 있다.
프로젝트 내에 다음과 같이 파일을 검색해보면
/*
* Copyright 2018-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.repository
/**
* Retrieves an entity by its id.
*
* @param id the entity id.
* @return the entity with the given id or `null` if none found
* @author Sebastien Deleuze
* @since 2.1.4
*/
fun <T, ID> CrudRepository<T, ID>.findByIdOrNull(id: ID): T? = findById(id).orElse(null)
해당 코드를 확인할 수 있는데 기존의 CrudRepository의 코드를 확장하여 작성해둔 코드이다.
해당 코드를 사용하여
다음과 같이 작성해줄 수 있다!
여기서 한단계 더 나아간다면!!!
import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.findByIdOrNull
import java.lang.IllegalArgumentException
fun fail(): Nothing {
throw IllegalArgumentException()
}
fun <T, ID> CrudRepository<T, ID>.findByIdOrThrow(id: ID): T{
return this.findByIdOrNull(id) ?: fail()
}
이전에 생성해두었던 Extention file에 findByIdOrThrow
를 작성하여 반복되는 ?: fail()까지도 사라지게 할 수 있다.
public class BookLoanRequest {
private String userName;
private String bookName;
public BookLoanRequest(String userName, String bookName) {
this.userName = userName;
this.bookName = bookName;
}
public String getUserName() {
return userName;
}
public String getBookName() {
return bookName;
}
}
Dto의 경우 복잡한 코드가 없기 때문에 Intellij의 기능을 이요해보자!
다음과 같이 Convert 기능을 사용해보면
class BookLoanRequest(val userName: String, val bookName: String)
자동으로 변환된것을 확인할 수 있다.
하지만 Integer type의 경우 Integer는 null이 가능한 type이였지만 이렇게 Convert를 진행하면 Int type으로만 변경된다.
class UserCreateRequest(val name: String, val age: Int)
그래서 우리가 직접 Nullable 하게 변경해주어야 한다.
class UserCreateRequest(val name: String, val age: Int?)
그리고 UserResponse의 경우 User class를 매개변수로도 받고 있다. 그래서
data class UserResponse(
val id: Long,
val name: String,
val age: Int?,
) {
companion object{
fun of(user: User): UserResponse{
return UserResponse(
id = user.id!!, // !!는 null이 아님을 단언
name = user.name,
age = user.age
)
}
}
}
다음과 같이 정적 팩토리 메서드를 사용하여 위에와 같이 작성해주고 of 메서드(=function)을 사용하여 객체를 생성하도록 진행할 수 있다.
@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping("/book")
public void saveBook(@RequestBody BookRequest request) {
bookService.saveBook(request);
}
@PostMapping("/book/loan")
public void loanBook(@RequestBody BookLoanRequest request) {
bookService.loanBook(request);
}
@PutMapping("/book/return")
public void returnBook(@RequestBody BookReturnRequest request) {
bookService.returnBook(request);
}
}
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 com.group.libraryapp.service.book.BookService
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
class BookController(
private val bookService: BookService,
) {
@PostMapping("/book")
fun saveBook(@RequestBody request: BookRequest){
bookService.saveBook(request)
}
@PostMapping("/book/loan")
fun loanBook(@RequestBody request: BookLoanRequest){
bookService.loanBook(request)
}
@PostMapping("/book/return")
fun returnBook(@RequestBody request: BookReturnRequest){
bookService.returnBook(request)
}
}
쉽게 리팩토링 할수 있다. 위 예제의 경우 모두 void로 처리하고 있기 때문에 따로 반환값이 없다.
@RestController
class UserController (
private val userService: UserService,
){
@PostMapping("user")
fun saveUser(@RequestBody request: UserCreateRequest){
userService.saveUser(request)
}
@GetMapping("/user")
fun getUser(): List<UserResponse> {
return userService.getUsers()
}
@PutMapping("/user")
fun updateUserName(@RequestBody request: UserUpdateRequest){
userService.updateUserName(request)
}
@DeleteMapping("/user")
fun deleteUser(@RequestParam name: String){ //RequestParam을 사용할 때는 nullable이 가능하지 않기 때문에 ?를 사용하지 않는다.
userService.deleteUser(name)
}
}
java 코드를 까먹고 먼저 지웠다 ㅎㅎ.. 그래도 이제 보면 구조 보일거다. 만약 return 해야하는 값이 있다면 GetMapping과 같이 반환 타입을 지정해주면 된다. 그런데 만약 API를 만들고 있다면 ResponseEntity<>
를 사용해서 반환 상태값까지 지정해주면 되겠지? java에서 사용해본 적 있다면 금방 할수 있을거다.
@SpringBootApplication
public class LibraryAppApplication {
public static void main(String[] args) {
SpringApplication.run(LibraryAppApplication.class, args);
}
}
기존 부트 프로젝트를 만들면 생성되었던 코드를
@SpringBootApplication
class LibraryAppApplication
fun main(args: Array<String>){
runApplication<LibraryAppApplication>(*args)
}
Kotlin에서는 다음과 같이 작성한다. kotlin은 static이 없어 다음과 같이 작성하면 된다고 한다. 잘 보면 class{} 없이 상단에 작성해야한다.
이제 서버를 실행하면 잘 뜨는 것도 확인할 수 있다!
또한 패키지를 확인해보면 java를 지워버렸다. java랑 클래스 명이 겹쳐서 어차피 지우면서 진행해야한다!
처음에 java로 동작하던 서비스들이 잘 작동한다.
다음과 같이 책을 등록하려고 하니 Json parsing에서 에러가 발생했다고 뜬다... 왜? 너 뭐 했냐? 왜 안돼?라고 생각한다면 역시 Java에서 Kotlin으로 넘어온 것에 대한 문제가 발생한것인데... 이것 또한 의존성을 추가함으로 수정할 수 있다.
dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3'
}
json을 파싱해주는 jackson libarary의 의존성을 kotlin에 맞는 것으로 추가해주면 된다.
그럼 정상적으로 등록까지 된다.
이렇게 Java에서는 호환이 되지만 Kotlin에서는 안되는 부분이 가끔씩 나오는 것 같다. 하지만 잘 찾아보면 모두 정상적으로 지원할 수 있도록 새로운 의존성을 추가해주면 해결되는 경우가 많은거 같으니 잘 찾아보면서 해보자!