
Spring을 사용하는 개발자들 사이에서 Kotlin을 접목시키는 사례가 많아졌다.
자바보다 간결하고, 안전하며, 생산성이 높았기 때문이었는데, 이처럼 Spring에 Kotlin을 사용하는 방식을 '코프링'이라 부른다.
최근 자바가 21버전을 넘어가면서 많은 기능이 추가되었고, 그 중에는 원래 Kotlin의 고유한 기능이었던 것도 포함되어 있다.
그렇다면 현재 시점에서 코프링은 어떤 면에서 더 이상 유일하지 않고, 어떤 면에서 여전히 이점을 가질까?
Java 21버전 이후 다양한 기능이 추가되면서 자바의 문법이 많이 개선되었고, Kotlin의 여러 기능들을 자바에서도 사용할 수 있어 코드의 편의성 측면에서 Kotlin과 자바와의 격차가 줄어들게 되었다.
이전까지는 Json 형식과 같이 여러 줄에 걸쳐 표현하는 문자열에 대해 1줄씩 일일히 분할하고, 줄내림과 문자열 연결 처리를 해줬어야 했다.
그러나 Java 17버전 이후 텍스트 블록 기능이 제공되어 """를 이용해 여러 줄로 이루어진 문자열을 랩핑하여 표현하는 것이 가능해졌다.
만약 문자열 앞에 공백이 있는 줄이 여러개 있을 경우, 가장 적은 공백을 가진 줄을 기준으로 전체 줄의 공백을 제거한다.
Before
String beforeJson = "{\n" + " \"virsion\": \"Before 17\",\n" + " \"status\": \"불-편\",\n" + "}";After
String afterJson = """ [ 공백 ]{ [ 공백 ] "virsion": "After 17", [ 공백 ] "status": "편-안" [ 공백 ]} """;
.of()를 통해 컬렉션 선언과 동시에 값을 초기화할 수 있게 된지도 꽤 되었고, Java 17버전 이후에는 Stream.toList()를 통한 불변 List를 생성하는 것도 가능해졌다.
Stream.toList()는 Collectors.toList()와는 달리 ArrayList가 아닌 List를 반환하지만, 그렇다고 Collectors.toUnmodifiableList()와 비교해보면 null체크를 하지 않기에 기존 기능과는 별개로 작동하는 것을 알 수 있다.
코드
List<Integer> v9List = List.of(0, 1, 2, 3, 4); List<Integer> v17List = v9List.stream() .filter(value -> value > 3) .toList(); System.out.println("v17List: " + v17List); try { v17List.add(5); System.out.println("v17List는 불변이 아니다."); } catch (UnsupportedOperationException e) { System.out.println("v17List는 불변이다."); } if (v17List == null) System.out.println("v17List는 null이다."); else System.out.println("v17List는 null이 아니다.");결과
v17List: [4] v17List는 불변이다. v17List는 null이 아니다.
여러 버전을 거치며 ->로 조건에 따른 실행을 할 수 있어 break를 사용하지 않아도 되었고, switch문의 case에 null에 대한 경우를 처리할 수 있게 되었으며, 타입 매칭 처리도 간편해져 전반적인 코드가 깔끔해졌다.
생략된 break
String breakSkipExam(String value) { return switch (value) { case "break" -> "skiped"; case "now" -> "inline"; default -> "after v17"; }; }타입 매칭
String typeMatchingExam(Object obj) { return switch (obj) { case Integer i -> String.format("%d", i); case String s -> s; default -> obj.toString(); }; }null 체크
String nullCheckingExam(String value) { return switch (value) { case null -> "이거 null인데요"; default -> "그냥 null체크 가능하다는거 보여주려고 case문이 2개에요"; }; }
값의 집합으로 이루어진 간단한 객체를 만드는 데 특화된 클래스 타입이다.
클래스 자체가 final로 취급되어 다른 클래스를 상속하거나 상속시킬 수 없어 불변데이터를 다루기에 용이하다.
또한 생성자나 getter메서드, equals(), hashCode(), toString() 등의 기능을 자체적으로 제공해줘 DTO 역할을 할 클래스를 개발할때 매번 개발자가 구현해주어야 했던 반복적인 작업을 생략할 수 있는 장점이 있다.
상기한 사항들은 lombok의 어노테이션으로도 가능한 점들이지만, Kotlin의 Data 클래스 역할을 Java에서도 사용할 수 있게 된 점에 의의가 있다.
UserDTO
public record UserDTO(Long id, String name, String email) { }UserService
@Service public class UserService { public UserDTO getUserId(UserDTO userDTO) { Long userId = userDTO.id(); //getter return userId; } public void examMethods(UserDTO userDTO) { System.out.println("Basic print: " + userDTO); UserDTO anotherUser = new UserDTO(userDTO.id(), "Another Name", "another.email@example.com"); System.out.println("Is userDTO equal to anotherUser? " + userDTO.equals(anotherUser)); System.out.println("UserDTO hashCode: " + userDTO.hashCode()); System.out.println("UserDTO toString: " + userDTO.toString()); } }
자바의 코드 편리성만큼은 많이 개선되었지만, Kotlin의 핵심적인 부분들을 대체하지는 못했다.
그리고 그 부분들은 우리가 코프링을 사용하는 이유이기도 하다.
Java는 기본적으로 모든 객체 변수가 nullable한데, 이는 개발자가 별도로 처리하지 않으면 NullPointerException이 발생하기 쉬운 환경이라는 것을 의미한다.
Kotlin에서는 모든 객체 변수를 not-null로 정의하며, 변수 선언 시 Not Nullable, Nullable을 강제로 선언해야 한다.
만약 Nullable한 변수에 대해 null check를 하지 않은 상태에서 사용할 경우, 컴파일 오류가 발생하게 된다.
Spring 환경에서 null 안정성이 드러나는 상황 중 하나는 데이터베이스로부터 값을 가져온 뒤 처리하는 것이다.
일반적으로 데이터베이스 쿼리 결과를 객체로 매핑하고 해당 객체를 사용해야 하는데, Kotlin에서는 여기에 null을 사용하여 누락된 값이나 비어있는 값이 있을 수 있음을 나타낼 수 있다.
null일 경우에 대해 디폴트 값이 필요할 경우 elvis연산자(?:)를 이용하면 되며, 만약 null이 아닐 경우에 대한 동작이 필요하다면 ?. 연산자를 사용한다.
UserEntity
@Entity class UserEntity(val id: Long, val name: String, val email: String?){ }UserService
@Service class UserService(val userRepository: UserRepository) { fun exam(id: Long) { val user = userRepository.findById(id).orElse(null) // Spring Data JPA val userLength = user?.name?.length ?: -1 // ?. 연산자와 ?: 연산자의 사용 // -> user의 name의 length값을 가져온다. // 이 과정에서 null이 나올 경우, userLength의 값은 -1이 된다. println("User name length: $userLength") // null-safe한 출력 } }
Kotlin에서는 이미 정의된 클래스나 인터페이스를 그 클래스의 밖에서 사용할 때, 추가적으로 정의할 수 있는 확장 함수 기능을 제공한다.
작성된 함수는 원래 클래스의 메서드였던 것처럼 일반적인 방법으로 호출 가능하다.
상속받지 않고 추가 메서드를 구현하는 것이지만, 실제로 클래스 내에 메서드가 만들어지는 것은 아니다.
Spring 환경에서 수정할 수 없는 외부 라이브러리의 클래스나 인터페이스에 대한 새 함수를 작성하는 데 활용할 수 있다.
예시
fun UUID.isValidUUID(): Boolean { return try { UUID.fromString(this.toString()) true } catch (ex: IllegalArgumentException) { false } } fun main() { val validUUIDString = "550e8400-e29b-41d4-a716-446655440000" val invalidUUIDString = "not valid uuid" val validUUID = UUID.fromString(validUUIDString) val invalidUUID = UUID.fromString(invalidUUIDString) println(validUUID.isValidUUID()) // true println(invalidUUID.isValidUUID()) // false }
First-class citizen은 변수에 담을 수 있으며, 함수의 인자 및 반환값으로 전달할 수 있는 것을 말한다.
Kotlin에서 메서드는 객체로서 사용할 수 있으므로, First-class citizen으로 취급된다.
그렇기 때문에 함수를 변수에 저장하고, 다른 함수의 인자로 전달하고, 함수의 반환 값으로 사용할 수 있다.
UserDTO
data class UserDTO( val name: String, val email: String, val salary: (Int, Int) -> Int // 함수를 포함한 DTO )UserService
@Service class UserService { fun calculateTotalSalary(userDTO: UserDTO): Int { return userDTO.salary(5, 3) // DTO에 저장된 함수를 호출하여 사용 } }UserController
@RestController class UserController(private val userService: UserService) { @PostMapping("/user") fun createUser(): ResponseEntity<String> { val userDTO = UserDTO("Han", "example@gmail.com") { monthly, bonus -> monthly + bonus } //salary 정의 val result = userService.calculateTotalSalary(userDTO) return ResponseEntity.ok("Result: $result") } }
AOP는 특정 메서드가 실행되기 전/후에 중복되어 실행되어야하는 부분을 재사용할 수 있도록 분리해내는 프로그래밍 방법이다.
Spring에서 제공하는 AOP방식은 클래스 내부 함수 호출에 적용되지 않고, AOP가 적용될 함수임을 명시하기 위한 어노테이션과 Pointcut 표현식 등 구현 과정에 필요한 것이 많아 번거로웠다.
Kotlin 환경에서는 마지막으로 오는 함수 인자를 람다식으로 변환하여 넘기는 후행 람다(Trailing Lambda)를 통해 Spring AOP를 보완할 수 있다고 한다. 함수형 프로그래밍 기법을 통해 내부 함수 호출시 AOP가 동작하도록 손볼 수 있다.
TransactionManager
제네릭 타입을 통한 트랜잭션 관리 클래스
클래스에서 function이라는 인자로 받은 함수의 반환 타입 T(제네릭)를 그대로 반환하여 다양한 타입의 함수들을 처리할 수 있다.
다만 callsInPlace로 function 람다 함수가 단 한 번만 호출되도록 명시한 상태이다.@Component class TransactionManager { @Transactional fun <T> run(function: () -> T): T { contract { callsInPlace(function, kotlin.contracts.InvocationKind.EXACTLY_ONCE) } return function.run() } }TransactionUtils
TransactionManager를 사용하여 트랜잭션을 실행하는 유틸리티 클래스
ransactionManager의 run 메서드를 호출하고, 그 결과를 반환한다.
TransactionUtils의 run 메서드를 객체 인스턴스화 없이도 호출할 수 있도록 TransactionManager를 init 블록에서 초기화하고 companion object로 처리한다.@Component class TransactionUtils ( _transactionManager: TransactionManager, ) { init { transactionManager = _transactionManager } companion object { private lateinit var transactionManager: TransactionManager fun <T> run(function: () -> T): T { return transactionManager.run(function) } } @Component class TxAdvice { @Transactional fun <T> run(function: () -> T): T { return function.run() } } }UserService
@Service class UserService ( val userRepository: UserRepository ) { val logger: Logger = LoggerFactory.getLogger(this::class.java) fun signUp(userInsert: UserInsert) { logger.info("signUp() 시작") logger.info("saveUserData() 시작") this.saveUserData(userInsert.toEntity()) logger.info("saveUserData() 종료") logger.info("signUp() 종료") } private fun saveUserData(user: User) = TransactionUtils.run { //Trailing Lambda로 내부 함수 호출에 트랜잭션 적용 userRepository.save(user) } }
https://discuss.kotlinlang.org/t/kotlin-vs-modern-java/26859
https://tech.kakaopay.com/post/overcome-spring-aop-with-kotlin/#overcome-with-kotlin--spring-context
https://perfectacle.github.io/2023/07/10/java-virtual-thread-vs-kotlin-coroutine