Engineering
코틀린을 활용한 안전한 효과 처리
alan.an카카오
2024년 6월 20일
원문에서 보기 ↗안녕하세요, 전자문서 서비스의 서버를 개발하고 있는 Alan입니다.
스프링(Spring)으로 서버를 개발하다 보면 데이터베이스를 사용하는 경우가 곧잘 있습니다. 이때 트랜잭션 처리를 직접 구현하기보다는 스프링의 지원을 받아 구현하는 것이 일반적인 과정입니다. 특히, 트랜잭션의 처리 방식을 정확하게 알지 못하더라도 프레임워크가 모든 과정을 대신 처리해 주기 때문에 러닝 커브가 낮다는 것이 큰 장점인데요. 그러다 보니 지나치게 프레임워크에 의존하여 안전하지 않은 코드를 무심코 작성하게 되는 경우가 종종 있는 것 같습니다.
이번 글에서는 독자분들께 통상 효과적인 처리 방식이라 생각하는 스프링 프레임워크를 통한 트랜잭션 처리가 잠재적으로 가져올 수 있는 위험성을 소개하고 살펴보겠습니다. 또한, 이 문제를 해결하기 위한 방법으로 최근 스프링과 조합되어 사용되는 비중이 점차 증가하고 있는 코틀린(Kotlin)을 활용하여 잠재된 위험성을 줄이는 방식 또한 알아보도록 하겠습니다.
프로그램의 효과
현대의 프로그램은 격리된 환경에서 단독으로 실행되는 것이 아니라 사용자의 다양한 요구사항에 맞추어 데이터베이스에서 정보를 읽거나, 다른 프로그램과 HTTP 프로토콜을 사용한 통신으로 데이터를 주고받습니다. 이렇게 프로그램이 외부 세계와 상호 작용하는 것을 통칭 ‘효과’라고 부릅니다.
아래 코드 예시와 같이, 전통적인 프로그램은 외부 세계와 상호 작용을 처리하는 컨텍스트(Context)를 함수에 직접 전달해야 했습니다.
fun saveInDB(database: DatabaseContext, user: User) {
database.begin()
database.saveUser(user)
database.commit()
}
이러한 방식은 함수가 어떤 효과를 수행할 것인지 명시적으로 표현할 수 있지만, 개발자가 모든 과정을 하나하나 처리해야 한다는 단점이 있습니다. 예를 들어 데이터베이스에 데이터를 저장하려면 트랜잭션을 시작하고, 데이터를 저장하고, 트랜잭션을 커밋하는 과정을 모두 직접 처리해야 합니다. 게다가 코드의 실행 과정에서 예외가 발생할 수도 있으므로 이를 대비한 방어 로직 또한 작성해야 합니다.
최근에는 개발자가 이러한 과정을 직접 구현하기보다는 프레임워크에게 처리를 일임하는 것이 각광받고 있습니다. 특히 백엔드 개발에서 주로 사용하는 스프링은 이 효과를 손쉽게 처리할 수 있도록 DI와 AOP를 지원합니다.
아래 예시와 같이, 스프링 프레임워크에선 함수에 @Transactional 어노테이션을 추가하기만 하면 트랜잭션의 모든 과정을 스프링이 대신 처리하도록 구현이 가능합니다. AOP를 통한 효과를 처리하는 방법은 굉장히 유용하기는 하지만, 아쉬운 부분도 있습니다. 바로 모든 클래스와 함수에 어노테이션을 아무런 제약이 없이 설정할 수 있다는 점입니다. 이러한 유연성은 엉뚱한 곳에 효과를 부여하거나 반대로 반드시 필요한 곳에는 효과를 부여하지 못하는 부작용(Side effect)을 가져올 수 있습니다.
@Transactional
fun save(user: User) {
userRepository.save(user)
}
그렇다면 효과를 안전하게 처리하려면 어떻게 해야 할까요? 전통적인 방식에서는 효과 처리에 지나친 자율성이 부여되었기 때문에 작성된 코드의 동작을 예측하기 어렵게 만들었습니다. 따라서, 이러한 생각을 스프링 프레임워크에서도 도입하여 자율성을 제약한다면, 코드의 동작을 보다 예측 가능하도록 변경할 수 있을 것입니다.
지금부터는 코틀린을 활용하여 안전하게 효과를 처리하는 방법을 알아보도록 하겠습니다.
확장 함수로 효과 처리하기
코틀린에서 제약 조건을 설정하는 방법은 바로 확장 함수(Extension function)를 정의하는 것입니다.
확장 함수는 함수의 이름 앞에 수신 객체 타입을 지정하여 정의할 수 있습니다. 확장 함수는 이름처럼 수신 객체 타입에 기능을 추가하는 용도로도 쓰이지만, 반대로 생각하면 확장 함수를 해당 수신 객체가 있을 때만 사용할 수 있도록 제약하는 방식으로도 사용할 수도 있습니다.
interface DatabaseContext {...}
class PrimaryDatabaseContext: DatabaseContext {...}
class SecondaryDatabaseContext: DatabaseContext {...}
fun DatabaseContext.save(user: User) {
// user를 데이터베이스에 저장
}
save 함수에 DatabaseContext라는 수신 객체 타입을 설정하면 컨텍스트 없이는 단독으로는 save 함수를 호출할 수 없습니다.
스프링 코드에 확장 함수 적용하기
이제 코틀린의 확장 함수를 사용해서 스프링 예제 코드를 개선해 보도록 하겠습니다. 코드의 요구사항은 아래와 같습니다.
요구 사항
- 상품을 삭제하는 프로그램을 개발한다.
- 상품을 삭제하는 함수는 반드시 트랜잭션 범위 안에서만 호출해야 한다.
스프링에서는 함수를 트랜잭션 범위 안에서만 호출할 수 있도록 만들고 싶다면 아래와 같이 @Transactional 어노테이션을 추가하고 MANDATORY 옵션을 설정하기만 하면 됩니다.
@Component
class ProductComponent {
@Transactional(propagation = Propagation.MANDATORY)
fun delete(id: Long) {
// 상품을 삭제
}
}
그런데 만약 @Transactional 어노테이션이 누락된 채로 코드가 작성되었다면 어떤 위험이 있을까요? AOP 방식의 트랜잭션은 컴파일 타임에는 호출 관계에 대한 문법 에러를 감지할 방법이 없습니다. 따라서 런타임에 함수를 호출하게 되면 서버에서 에러가 발생하게 됩니다.
그렇다면 이 문제를 해결할 수 있는 근본적인 해결책은 무엇일까요? 가장 좋은 방법은 요구 사항 그대로 트랜잭션이 없는 경우, 컴파일 타임에 에러가 감지되도록 하여 개발자의 실수를 방지하는 것입니다. 이때, 아래 예시와 같이 코틀린을 사용한다면 delete 함수에 트랜잭션을 처리해 줄 특별한 컨텍스트를 지정하여 이 목표를 쉽게 달성할 수 있습니다.
@Component
class ProductComponent {
fun DatabaseContext.delete(id: Long) {
// 상품을 삭제
}
}
@Autowired
lateinit var productComponent: ProductComponent
fun doSomething(id: Long) {
val databaseContext = PrimaryDatabaseContext()
with(databaseContext) {
with(productComponent) {
delete(id)
}
}
}
개선된 코드에서는 delete 함수에 @Transactional을 지정하는 대신에 DatabaseContext를 수신 객체 타입으로 지정하였습니다. 지금부터는 확장 함수의 특성으로 인해 DatabaseContext와 ProductComponent가 모두 주입된 영역에서만 delete 함수를 호출할 수 있습니다. 즉, 잘못된 코드를 억지로 사용하고 싶어도 컴파일 과정에서 문법 에러가 감지되어 컴파일이 실패하게 됩니다. 이는 기존에는 런타임 시점에서야 감지할 수 있었던 에러가 원천적으로 차단될 수 있다는 것을 시사하며, 효과를 안전하게 처리하는 방식에 한 발짝 가까워졌음을 의미합니다.
고차 함수로 효과 함수 전달
코틀린은 함수를 인자로 받거나 리턴할 수 있는 고차 함수를 지원합니다. 코틀린의 고차 함수를 적용한 예시는 아래와 같습니다.
@Component
class ProductComponent {
fun delete(id: Long): DatabaseContext.() -> Unit {
return {
// delete product
}
}
}
@Autowired
lateinit var productComponent: ProductComponent
fun doSomething(id: Long) {
val databaseContext = PrimaryDatabaseContext()
val block = productComponent.delete(id)
with(databaseContext) {
block()
}
}
기존에 살펴보았던 delete 함수는 이제 상품을 직접 삭제하는 것이 아니라, 상품을 삭제하는 함수를 리턴하는 고차 함수로 작성되었습니다. 물론 리턴되는 함수에도 DatabaseContext가 수신 객체 타입으로 지정할 수 있기 때문에 제약 사항도 잘 지켜지고 있습니다.
효과 함수 리턴 방식의 장점은 컴포넌트로부터 효과를 독립적으로 분리할 수 있다는 데 있습니다. 이에 대한 증거로 delete 함수를 아무리 많이 호출하더라도 직접적으로 데이터베이스에는 영향을 줄 수 없다는 점을 들 수 있겠습니다. 즉, delete 함수로부터 전달된 효과 함수를 호출해야 비로소 데이터베이스에 저장된 상품을 삭제하는 효과가 발생합니다.
따라서, 이러한 고차 함수로 효과를 전달하는 방식을 도입하면 효과가 실행될 위치를 보다 유연하게 선택할 수 있습니다.
효과 처리 구현
지금까지 살펴본 방법들을 통해 효과를 안전하게 처리하는 방법에 한 발자국씩 가까워지고 있지만, 아직은 함수를 호출할 수 있는 위치를 제약했을 뿐이며 효과를 직접적으로 처리하는 컨텍스트가 수행하는 일이 없는 상태입니다. 이제 효과를 처리하고 싶다면, 아래 예시와 같이 컨텍스트가 효과를 처리할 수 있도록 함수를 추가해야 합니다.
interface DatabaseContext {
fun transactional(block: DatabaseContext.() -> T): T
}
class PrimaryDatabaseContext : DatabaseContext {
override fun transactional(block: DatabaseContext.() -> T): T {
// begin transaction
val result = block()
// commit
return result
}
}
@Autowired
lateinit var productComponent: ProductComponent
fun doSomething(id: Long) {
val databaseContext = PrimaryDatabaseContext()
val block = productComponent.delete(id)
databaseContext.transactional {
block()
}
}
예제에서는 DatabaseContext에 트랜잭션을 처리할 수 있는 함수 transactional을 추가했으며, 이 transactional 함수 구현부의 주석 부분은 트랜잭션의 처리 과정을 나타내고 있습니다. 지금부터는 이전 예시들의 doSomething 함수에서 사용되었던 with 함수로 컨텍스트를 주입하는 방식이 아닌, transactional 블록 안에서 block 함수를 호출하도록 변경할 수 있습니다.
DSL로 Context 주입하기
DSL로 중복 코드 제거
추가적으로 개선할 부분은 반복되는 코드 작성을 줄이는 일입니다. DatabaseContext를 사용하기 위해서는 PrimaryDatabaseContext와 같은 구현체를 생성하여 주입하는 코드를 매 번 작성해야 합니다. 이와 같이 중복되는 코드는 전용 DSL(Domain Specific Language)을 만들어두면 보다 안전하고 가독성 높은 코드를 작성할 수 있습니다.
fun transactional(block: DatabaseContext.() -> T) {
val databaseContext = PrimaryDatabaseContext()
databaseContext.transactional(block)
}
@Autowired
lateinit var productComponent: ProductComponent
fun doSomething(id: Long) {
val block = productComponent.delete(id)
transactional {
block()
}
}
DSL을 잘 구현하기 위해서는 함수의 기능을 잘 표현하는 이름을 지어주고 기능을 똑같이 구현해 주면 됩니다. 지금부터는 컨텍스트 주입을 위한 코드를 매번 작성할 필요가 없습니다. 단지, 효과를 대신 안전하게 처리해 줄 transactional 함수의 블록 안에 원하는 효과를 전달하기만 하면 됩니다.
Scope를 통한 Context 주입
마지막으로 코드에는 개선할 부분이 한 가지 남아있습니다. transactional 함수는 아직 아무런 제약이 없기 때문에 코드 어디서든지 이 함수를 호출할 수 있습니다. 이를 방지하고 싶다면 transactional 함수 역시 확장 함수로 구현하면 될 것입니다.
interface TransactionalScope
fun TransactionalScope.transactional(
block: DatabaseContext.() -> T
) {
val databaseContext = PrimaryDatabaseContext()
databaseContext.transactional(block)
}
@Service
class ProductService: TransactionalScope {
@Autowired
lateinit var productComponent: ProductComponent
fun doSomething(id: Long) {
val block = productComponent.delete(id)
transactional {
block()
}
}
}
확장 함수 구현을 위해, 위의 코드 예시와 같이 TransactionalScope 인터페이스를 선언하고 이를 transcational 함수의 수신 객체 타입으로 지정했습니다. 이에 따라 TranscationalScope가 지정된 영역에서만 transactional 함수를 호출할 수 있게 되었습니다.
실사용 사례: 코루틴(Coroutine)
이제 앞서 살펴본 예시들이 적용된 공식 라이브러리인 코루틴(Coroutine)을 소개드리며, 프로그램이 안전한 효과를 달성할 수 있도록, 소개드린 전략들이 적용된 곳이 많다는 점을 강조하고자 합니다.
스코프 및 컨텍스트 구조는 코틀린의 비동기 프로그래밍을 지원하는 코루틴 공식 라이브러리에서 쉽게 찾아볼 수 있습니다. 코루틴은 비동기 작업이라는 효과를 안전하게 처리하기 위해, 코루틴을 시작하고 중단하는 함수는 반드시 코루틴 스코프(CoroutineScope) 안에서만 호출하여 사용할 수 있도록 강제합니다.
public fun CoroutineScope.launch(): Job // 코루틴 빌더 함수
public suspend fun delay(timeMillis: Long) // 중단 함수
fun main() = runBlocking { // CoroutineScope를 제공하는 함수
launch { // 새로운 코루틴 시작
delay(1000) // 중단 함수
}
}
코루틴 라이브러리에서 정의한 대표적인 launch 코루틴 빌더와 delay 중단 함수를 살펴보면 수신 객체 타입으로 코루틴 스코프가 설정되어 있거나 이와 동일한 효과를 가져오는 ‘suspend’ 키워드가 함수에 설정된 것을 확인할 수 있습니다. 따라서, launch와 delay는 runBlocking 함수를 통해 제공된 스코프 안에서만 사용할 수 있도록 제한됩니다.
코루틴 스코프에 전달된 비동기 작업(Job)은 코루틴 스코프 안에 자동으로 주입되어 숨겨진 CoroutineContext를 통해 안전하게 처리됩니다. 아래 코드와 같이 CoroutineScope 인터페이스에는 CoroutineContext가 프로퍼티로 정의되어 있으며, 코루틴은 이에 대한 BlockingCoroutine 및 GlobalScope와 같은 다양한 스코프 구현체들을 제공합니다.
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
마치며
본 글에서는 하나하나 단계를 거치며 효과의 위험성을 확인하고, 문제를 해결하기 위해 코틀린을 활용한 안전한 효과 처리 방법에 대해 고민해 보았습니다. 이러한 고민과 과정을 통해, 예측 가능한 영역(Scope)에 컨텍스트(Context)를 주입하여, 요구 사항에 맞는 효과가 안전하게 처리될 수 있도록 구현하는 방법을 소개드릴 수 있었습니다.
보다 안전한 코드를 작성하기를 원하시는 독자분들에게 많은 도움이 되기를 바라며 끝까지 읽어주셔서 감사합니다.
written by Alan. an
edited by June.6