요약: 코틀린 함수형 프로그래밍에 매료된 서버 개발자 스노우는 실무 적용을 위해 여러 시행착오의 길을 거쳤습니다. 실무에 적용하기 어려운 추상적 이론 대신, 간단한 기법에서 시작해 점차 고도화된 방식을 도입하며 많은 인사이트를 얻었습니다. 이 글은 함수형 프로그래밍의 기본 원칙부터 고차함수 활용, 패턴 추상화, 그리고 Kotlin Arrow 라이브러리의 실제 적용 사례를 통해 실무에서 적용할 수 있는 여러 길을 제시합니다.
💡 리뷰어 한줄평
uni.code 함수형 프로그래밍에 대한 스노우의 열정이 매우 잘 보이는 글입니다. 기초 예제부터 실무에서 사용하고 있는 예제까지 순서대로 잘 나열되어있어 따라 하기에도 부담 없을 것 같네요!
larry.charry 서비스에 함수형 프로그래밍을 도입하고 싶다면 이 글을 추천드려요. 코드 구조를 완전히 전환하지 않아도 작은 부분부터 적용할 수 있는 유용한 방법들을 소개해주는 글입니다.
시작하며
안녕하세요. 해외결제서비스의 서버를 개발하고 있는 스노우입니다. 저는 자바에서의 제약이 큰 함수형 프로그래밍만으로도 큰 만족감을 느꼈습니다. 그러다 코틀린을 통해 더욱 확장된 함수형 프로그래밍의 다양한 장점을 활용해 보며 매료되었습니다.
팀원들과 ‘코틀린을 다루는 기술’ 책으로 함께 스터디를 하면서 함수형 프로그래밍을 적용해 보려 노력했지만, 추상적인 이론에 가까워 실무에 적용이 쉽지 않았습니다. 또한 타 분야의 함수형 프로그래밍도 참고해보려 했으나 코틀린 백엔드에 적절한 사례를 쉽게 찾지 못했습니다.
함수형 프로그래밍에 대한 이해를 높이고 실무에 적용하기 위해 개인적인 학습은 물론, 팀원들과도 3회 이상의 스터디를 진행했습니다. 이번 글에서는 수개월의 시행착오 과정에서 얻은 함수형 프로그래밍의 장점을 활용하는 실용적인 사례들을 소개하고, 코틀린 백엔드에서 이를 적용하는 길을 제시해 보려 합니다. 코틀린에 함수형 프로그래밍을 적용하고는 싶은데, 실무에 적용할 만한 마땅한 사례를 찾지 못한 분들께 이 글을 추천합니다.
쉽게 따라 할 수 있는 베스트 프랙티스의 부재
함수형 프로그래밍을 배우면서 가장 힘들었던 부분은 쉽게 믿고 따라갈 만한 사례가 부족하다는 점이었습니다. 책이나 인터넷에서 함수형 프로그래밍을 검색하면 펑터, 어플리캐티브, 모노이드, 모나드 등 지나치게 복잡한 개념과 무엇인가 심오해 보이는 코드 예제들이 많이 나옵니다. 이러한 개념을 이해하지 못하면 함수형 프로그래밍을 제대로 사용하지 못한다고 이야기하며, 모나드에 대한 환상 같은 이야기들이 등장합니다. 하지만 이러한 어려운 개념들은 실무에서 필요한 문제를 해결하는 데 반드시 필요한 것은 아닙니다.
실무에서는 부수 효과가 필요한 경우도 많고, 너무 어려운 코딩은 결국 대중화되기 어려운 면이 있습니다. 심지어 내가 혼자 모나드를 잘 활용한다고 해도 함께 일하는 팀원들이 이해하고 수정하지 못하면 쓸모가 없습니다. 따라서 이 글에서는 쉽게 믿고 따라갈 만한 함수형 프로그래밍의 사례를 소개하려 합니다.
순서대로 따라 하기 좋은 4가지 함수형 프로그래밍 실무 활용 전략
함수형 프로그래밍을 실무에 적용하기 위해서는 어떤 전략이 필요할까요?
여러 차례 스터디하고 실무에 적용해 보면서 부딪쳐본 경험 상 처음부터 복잡한 기술보다는 간단하면서도 효과적인 방법을 먼저 익히고, 이를 바탕으로 점차 복잡한 기법으로 학습을 진행하는 것이 개인적으로 효과적이었습니다.
아래에는 구체적인 기법들을 네 가지로 분류하여 난이도가 쉬운 것에서 어려운 것으로 높여가면서 설명드리겠습니다. 하나씩 순서대로 따라 하며 실무에 적용해 보시는 걸 추천합니다.
1. 함수형의 기본적인 사상 따르기(불변성, 순수함수 등)
개인적으로 가장 중요하게 생각하는 부분은 함수형 프로그래밍의 기본적인 사상을 따르는 것입니다. 함수형 프로그래밍은 다음과 같은 개념들을 중요하게 생각합니다:
- 참조 투명성: 외부의 값에 영향받지 않으며 동일한 입력에 항상 동일한 결과를 반환하는 성질입니다.
- 불변성: 변수의 값을 한 번 설정한 후에는 변경할 수 없다는 개념입니다. 자바에서는
final
키워드, 코틀린에서는val
키워드로 불변성을 구현할 수 있습니다.- 순수 함수: 함수의 결과가 오직 입력에만 의존하고, 함수 호출이 부작용(side effect)을 일으키지 않는 함수를 의미합니다.
이러한 개념들을 잘 지키면서 코드를 작성하면 코드의 안정성, 테스트 용이성, 병렬 처리 용이성 등의 장점을 얻을 수 있습니다.
하지만 실무에서는 이와 같은 개념을 철저하게 지키기가 어렵습니다. 오히려 반대되는 개념인 참조 불투명성이 많이 발생하게 됩니다. 참조 불투명성은 동일한 입력에 다른 결과를 반환하는 성질을 의미하며, 일반적으로 요청 외의 다른 외부 시스템이나 변수에 의해 영향을 받기 때문에 발생합니다.
위 그림과 같이 데이터베이스, 전역 변수, 캐싱 데이터 등 입력이 아닌 외부 데이터에 의존하지 않는 경우 참조 투명한 함수라고 부를 수 있으며, 반대로 다양한 외부 데이터에 의존하는 경우 참조 불투명하다고 할 수 있습니다.
위 그림은 참조 투명하고 참조 불투명한 함수의 특징을 보여줍니다. ‘1’이라는 동일한 입력에 대해 참조 투명한 함수는 항상 동일한 성공이라는 결과를 줍니다. 하지만 참조 불투명한 함수는 동일한 입력이 전달되더라도 입력되는 외부 데이터에 의해 결과가 실패로 변경되는 경우도 발생할 수 있습니다.
또, 실무에서는 불변성을 지키지 못하는 경우(예: 계정의 상태)가 무수히 많습니다. 시스템이 발전할수록 필연적으로 내외부 시스템에 의존하기 때문에, 참조 투명성을 지키기 어려운 경우가 많습니다. 불변성과 참조 투명성을 지키지 않는 코드가 만들어지는 경우가 많고, 너무 당연하게 여겨지기 때문에 별다른 문제를 느끼지 못할 수 있습니다.
아래는 일반적으로 실무에서도 볼 수 있는 샘플 코드입니다. 이 코드의 안 좋은 점과 그로 인해 발생할 수 있는 문제를 한번 떠올려보세요.
@RestController
class PayController(
private val payService: PayService,
) {
@PostMapping("/pay")
fun pay(
@RequestBody request: PayRequest,
): PayResult = payService.pay(request)
}
@Service
class PayService(
private val exchangeRateService: ExchangeRateService,
) {
fun pay(request: PayRequest): PayResult {
val exchangeRate = exchangeRateService.getExchangeRate(request.currency)
changeToKRW(request, exchangeRate)
reduceDiscount(request)
return doPay(request)
}
private fun changeToKRW(request: PayRequest, exchangeRate: BigDecimal) {
if (request.currency != Currency.KRW) {
request.currency = Currency.KRW
request.amount = request.amount * exchangeRate
}
}
private fun reduceDiscount(request: PayRequest) {
if (request.amount.toInt() >= 10000) {
request.amount = (request.amount.toInt() - 1000).toBigDecimal()
}
}
private fun doPay(request: PayRequest): PayResult {
// 실제 결제 로직 구현
return PayResult(success = true, message = "Payment successful")
}
}
data class PayRequest(
var currency: Currency,
var amount: BigDecimal,
)
data class PayResult(
val success: Boolean,
val message: String
)
위 코드는 엄밀하게 보자면 참조 투명하지 않고, 불변성을 지키지 않는 코드입니다. 이 코드에서 어떤 문제가 발생할 수 있고 어떻게 개선하면 좋은지 살펴보겠습니다.
현재 이 코드는 이상이 없지만, 코드가 발전하고 다양한 기능이 추가되면서 문제가 발생할 수 있습니다.
예시로 결제에 복합 결제 수단이 포함되는 케이스를 살펴보겠습니다. 기존에 내가 갖고 있는 돈으로 결제하던 것은 MONEY
라는 결제수단이라고 부르고,
신규로 POINT
라는 결제 수단이 추가되면서 아래와 같이 정책을 추가합니다.
[추가 정책]
- 결제 금액 중 50%: MONEY 결제
- 나머지 결제 금액 50%: POINT 결제
이제는 이 정책을 반영할 수 있는 코드로 수정해 보겠습니다. 물론 일반적으로 배웠던 참조 불투명하고 변수를 수정하는 코드로 수정을 진행합니다.
@RestController
class PayController(
private val payService: PayService,
) {
@PostMapping("/pay")
fun pay(
@RequestBody request: PayRequest,
): PayResult {
// 결제 금액 중 50%: MONEY 결제
var result = payService.payMoney(request)
// 머니 결제가 성공한 경우
if (result.success) {
// 나머지 결제 금액 50%: POINT 결제
result = payService.payPoint(request)
}
return result
}
}
@Service
class PayService(
private val exchangeRateService: ExchangeRateService,
) {
fun payMoney(request: PayRequest): PayResult {
val exchangeRate = exchangeRateService.getExchangeRate(request.currency)
changeToKRW(request, exchangeRate)
reduceDiscount(request)
// 전체 금액 중 절반만 결제
request.amount = request.amount.div(2.toBigDecimal())
return doPay(request, PayMethod.MONEY)
}
fun payPoint(request: PayRequest): PayResult {
val exchangeRate = exchangeRateService.getExchangeRate_NewVersion(request.currency)
changeToKRW(request, exchangeRate)
reduceDiscount(request)
return doPay(request, PayMethod.POINT)
}
private fun changeToKRW(request: PayRequest, exchangeRate: BigDecimal) {
if (request.currency != Currency.KRW) {
request.currency = Currency.KRW
request.amount = request.amount * exchangeRate
}
}
private fun reduceDiscount(request: PayRequest) {
if (request.amount.toInt() >= 10000) {
request.amount = (request.amount.toInt() - 1000).toBigDecimal()
}
}
private fun doPay(request: PayRequest, payMethod: PayMethod): PayResult {
val exchangeRate = exchangeRateService.getExchangeRate_NewVersion(request.currency)
changeToKRW(request, exchangeRate)
// 실제 결제 로직 구현
return PayResult(success = true, message = "Payment successful")
}
}
위 코드는 포인트 결제가 추가되는 케이스를 약간의 오류와 함께 보여줍니다. 첫 결제가 성공하면 포인트 결제도 진행하고, 첫 결제가 실패하면 포인트 결제는 진행하지 않는 방식으로 구성했습니다. 이와 같은 경우 결제 금액이 21,000원을 초과하면 어떨까요?
첫 번째 결제는 21,000원에서 1,000원 할인 후 2로 나누어서 10,000원 결제가 됩니다. 두 번째 포인트 결제는 10,000원에서 1,000원을 할인하여 9,000원이 결제됩니다. 만일 할인이 전체 결제에 한 번만 되기를 원하는 상황이었다면 이 결제 금액은 잘못된 것입니다. 그렇다면 이 문제의 근본적인 원인은 무엇일까요? 개발자의 실수일까요?
불변성을 지키지 않는 코드
저는 약간 억지스러울 수 있는 이 문제가 바로 불변성을 지키지 않을 때 발생할 수 있는 문제를 단편적으로 보여준다고 생각합니다.
PayRequest.amount
금액은 최초에는 통화에 따른 금액이었으며, 그 이후 KRW
로 변환된 금액이 됩니다.
또 할인이 적용되고, pay()
와 payPoint()
메서드에서 절반씩 결제하기 위해 절반으로 나누어집니다.
이러한 과정 속에서 금액이 계속 바뀌지만 해당 금액은 어떤 사유로 어떻게 바뀌었는지에 대한 히스토리가 전혀 남지 않습니다.
그래서 테스트를 했을 때에도 이 금액이 할인 적용으로 변경된 것인지,
두 개의 결제 수단으로 반반 나눠 결제하기 위해 분리되어 변경된 것인지, 할인이 두 번 적용된 것인지 등 결정된 금액을 정확하게 확인하기가 어렵습니다.
요컨대 다른 연유로 금액이 변경되었어도 최종 금액이 동일하여 테스트를 통과하는 경우도 발생할 수 있는 것입니다.
// 최초 금액이 10,000원으로 동일하고 결과도 4,000원으로 동일하지만 계산 과정이 다른 경우
(10,000-1,000-1,000)/2=4,000
10,000/2-1,000=4,000
참조 투명성을 지키지 않는 코드
두 번째로는 참조 투명성을 지키지 않는 코드입니다.
pay()
, payPoint()
, doPay()
메서드는 어떤 currency
가 들어올지 모르기 때문에 ExchangeRateService
에 의존하고 있습니다.
여러 메서드에 이러한 참조 투명하지 않은 코드가 대량으로 존재하고 서로에게 의존하는 경우가 많습니다.
더군다나 어떤 부분은 exchangeRateService.getExchangeRate
를 활용하고
다른 부분에서는 exchangeRateService.getExchangeRate_NewVersion
메서드를 활용하는 혼란스러운 모습을 보여주기도 합니다.
참조 투명하지 않은 코드가 또 참조 투명하지 않은 코드를 의존하고, 변경 가능한 값들이 지속적으로 값을 바꾸는 상황들이 복합적으로 발생한다면 코드를 분석하기도 어려울 뿐 아니라 수정하거나 테스트할 때에도 자신감을 하락시키는 요인이 됩니다. 그렇다면 이러한 불변성과 참조 투명하지 않은 코드를 어떻게 개선할 수 있을까요?
@RestController
class PayController(
private val payService: PayService,
) {
@PostMapping("/pay")
fun pay(
@RequestBody request: PayRequest,
): PayResult = payService.compositePay(request)
}
@Service
class PayService(
private val exchangeRateService: ExchangeRateService,
) {
fun compositePay(request: PayRequest): PayResult {
val krwPayParams =
request.toKrwPayParams(exchangeRateService.getExchangeRate_NewVersion(request.currency))
val payResult = doPay(krwPayParams.payMoneyAmount, PayMethod.MONEY)
return when (payResult.success) {
true -> doPay(krwPayParams.payPointAmount, PayMethod.POINT)
false -> PayResult(success = false, message = "Payment failed")
}
}
private fun doPay(payAmount: Int, payMethod: PayMethod): PayResult {
// 실제 결제 로직 구현
return PayResult(success = true, message = "Payment successful")
}
}
data class KrwPayParams(
val totalAmount: Int,
val payMoneyAmount: Int,
val payPointAmount: Int,
val discountAmount: Int,
)
data class PayRequest(
val currency: Currency,
val amount: BigDecimal,
) {
fun toKrwPayParams(exchangeRate: BigDecimal): KrwPayParams {
val totalAmount =
if (currency == Currency.KRW) amount.toInt()
else (amount * exchangeRate).toInt()
val discountAmount = if (totalAmount >= 10000) 1000 else 0
val halfPayAmount = (totalAmount - discountAmount) / 2
return KrwPayParams(
totalAmount = totalAmount,
payMoneyAmount = halfPayAmount,
payPointAmount = halfPayAmount,
discountAmount = discountAmount,
)
}
}
data class PayResult(
val success: Boolean,
val message: String,
)
[개선된 코드 개요]
PayRequest
의currency
와amount
를val
로 변경하여 불변성 지키기PayRequest
에toKrwPayParams()
메서드를 추가하여KrwPayParams
로 금액별 계산하여 저장 후 처리하는 방식으로 수정compositePay()
메서드를 추가하여pay()
와payPoint()
를 합쳐서 한 번에 처리
불변성을 지키는 코드
변화된 코드를 살펴보시면 PayRequest
내 var
로 선언되어 있던 currency
와 amount
를 val
로 변경했으며,
변화된 금액을 계산하여 요청 초기에 KrwPayParams
로 변환하는 방식으로 변경했습니다.
이렇게 변경된 코드는 금액의 모든 상세를 언제든 정확하게 살펴볼 수 있으며,
toKrwPayParams()
메서드만 테스트해 보면 요청에 따라 정확하게 할인의 적용과 분할 금액이 지정되는지 명확하게 확인해 볼 수 있게 됩니다.
모든 변화되는 값들을 이와 같이 분할하여 저장할 수 있는 것은 아니지만 곰곰이 생각해 보면 이러한 방식으로 데이터를 분리하여 저장할 수 있고, 이렇게 개선되었을 때 하나의 데이터를 수차례 변화시키는 것보다 훨씬 명확하게 데이터를 관리할 수 있어서 코드가 단순화됩니다. 테스트 코드를 짤 때도 해당 부분만 단위 테스트로 만들 수 있으므로 요구사항을 명확하게 반영할 수 있습니다.
참조 불투명의 최소화
또한 KRW
통화로 결제를 해야만 하기 때문에, 이곳저곳에 산재되어 있는 exchangeRateService.getExchangeRate
와 exchangeRateService.getExchangeRate_NewVersion
메서드 호출 부분을 getExchangeRate_NewVersion
으로 통일했습니다.
그리고 결제 초기에만 의존하도록 참조 불투명함을 최소화했습니다.
실무에서는 여러 내외부 시스템이나 데이터베이스 등에 의존하여 실시간 데이터를 활용하다 보니, 완벽한 참조 투명성을 확보하기 어려운 경우가 많습니다.
환율도 계속 변화하는 환율 데이터가 필요하기 때문에 참조 불투명할 수밖에 없는 부분이지요.
하지만 이러한 참조 불투명한 부분은 최소화하고 가능한 한 곳에서만 의존하도록 하는 것이 좋습니다.
불변성과 참조 투명성의 장점
불변성을 지키는 코드를 만들고, 참조 불투명한 코드를 한 곳으로 모아서 되도록 많은 코드들이 참조 투명하게 된다면, 코드 상의 비율로 봤을 때 많은 코드들이 순수한 함수에 가까운 함수가 될 수 있습니다. 따라서 해당 코드의 비중이 높아질수록 데이터의 명확성, 테스트의 용이성, 코드의 안정성 등의 장점을 얻을 수 있으며, 이후 발생하는 시스템의 고도화나 기능이 복잡해지는 변화도 거뜬히 받아들일 수 있는 튼튼한 코드가 될 수 있습니다. 마치 레고 블록 하나하나가 정밀하고 튼튼하게 만들어져 있어야, 수만 개의 블록으로 크고 복잡한 모양을 만들더라도 형태를 정확하게 유지할 수 있는 것과 유사합니다.
이처럼 최소 단위인 객체들이 불변하고 함수들이 최대한 참조 투명하게 된다면, 시스템이 크고 복잡해지더라도 테스트와 코드에서의 복잡도가 낮아져서 버그가 숨어들 곳이 줄어들게 됩니다. 반대로 최소 단위인 객체나 함수가 불변하지 않은 것들이 많고 참조도 불투명한 것들이 많다면, 시스템이 커질수록 버그가 숨어들기 쉽고, 테스트의 복잡도도 높아지게 됩니다.
불변한 코드는 여러 스레드가 동시에 접근해도 값이 변화하지 않으므로 안전하게 사용할 수 있는 장점이 있습니다. 저는 애초에 이와 같은 전역적인 데이터를 사용하지 않는 편이긴 합니다. 하지만 전역적인 데이터가 필요한 경우에는 불변성을 지키는 것이 더더욱 중요하다고 생각합니다.
2. 고차함수를 활용한 간단하고 유용한 활용
고차함수는 함수형 프로그래밍에서 많이 사용되는 기법 중 하나입니다. 기존 함수를 감싸서 기능을 추가하거나 변경하는 함수를 의미합니다. 별도로 기존 함수의 기능 변경 없이, 기존 함수 기능을 확장하거나 변경할 수 있습니다.
각 시스템에서 자주 발생하는 비슷한 패턴들이 있으면, 아래와 같은 방식으로 고차함수를 활용하여 코드를 간결하게 만들 수 있습니다.
예외처리 고차함수
간단하게는 예외를 무시하는 고차함수입니다. 예외가 발생하면 null
을 반환하고, 예외가 발생하지 않으면 정상적인 결과를 반환합니다.
조금 더 기능을 추가하면 예외 시 기본 값을 반환하는 방식으로도 변경할 수 있습니다.
fun sample() {
val result = try {
block()
} catch (e: Exception) {
println("Exception occurred: ${e.message}")
null
}
val result2 = try {
block()
} catch (e: Exception) {
println("Exception occurred: ${e.message}")
defaultValue
}
}
// 예외를 무시하고 실행하는 함수
fun <T> runWithoutException(block: () -> T?): T? {
return try {
block()
} catch (e: Exception) {
println("Exception occurred: ${e.message}")
null
}
}
// 기본값을 반환하는 함수
fun <T> runWithDefaultValue(defaultValue: T, block: () -> T?): T {
return runWithoutException(block) ?: defaultValue
}
// 예제 사용법
fun main() {
// 예외를 무시하고 실행
val result1 = runWithoutException {
10 / 0
}
println("Result without exception: $result1")
// 기본값을 반환
val result2 = runWithDefaultValue(0) {
10 / 0
}
println("Result with default value: $result2")
}
재시도 함수
재시도 함수도 이와 같은 반복되는 패턴으로 만들 수 있습니다. 예외가 발생하면 재시도를 하고, 재시도 횟수를 초과하면 예외를 발생시키는 방식으로 만들어볼 수 있습니다. 특히 외부 API를 요청하거나 결과를 폴링하여 조회하는 경우 등 다시 시도했을 때 성공으로 결과가 바뀔 수 있는 경우에 유용합니다.
fun main() {
val maxRetries = 3
var retryCount = 0
while (retryCount < maxRetries) {
try {
// 재시도할 로직을 작성
val result = block()
// 성공하면 결과를 반환, 루프를 종료
println("Operation succeeded with result: $result")
break
} catch (e: Exception) {
// 예외 발생 시 재시도 수행 횟수 증가
retryCount++
println("Retry attempt $retryCount failed: ${e.message}")
if (retryCount >= maxRetries) {
// 최대 재시도 횟수 초과 시 예외 발생
throw RuntimeException("Max retries reached: $maxRetries")
}
}
}
}
일반적으로 코딩에서 직접 재시도를 구현하면 위처럼 재시도 횟수를 저장해야 하며, 어느 부분을 재시도하는지 확인하기가 다소 어렵습니다.
또한 비슷하게 재시도를 수행해야 하는 부분이 발생하더라도 동일한 코드를 계속 반복적으로 작성해야 하는 불편함이 있습니다.
스프링에 있는 spring-retry
라이브러리를 활용한다면 재시도 로직을 쉽게 구현할 수 있습니다.
하지만 스프링에 의존해야 하며, 정확한 사용 방식을 익혀야만 하는 제한점이 있습니다.
이런 부분을 코틀린 고차함수로 만들어보면 어떨까요?
// 재시도 로직을 추가하는 고차함수
fun <T> retry(maxRetries: Int, block: () -> T): T {
fun attempt(retryCount: Int): T {
try {
return block()
} catch (e: Exception) {
if (retryCount < maxRetries) {
println("Retry attempt $retryCount failed: ${e.message}")
return attempt(retryCount + 1)
} else {
throw RuntimeException("Max retries reached: $maxRetries")
}
}
}
return attempt(0)
}
// 예제 사용법
fun main() {
// 재시도 로직
val result4 = retry(3) {
val random = (0..1).random()
if (random == 0) throw Exception("Random failure")
10 + 5
}
println("Result with retry: $result4")
}
위 예시 코드는 재시도 로직을 추가하는 고차함수와 해당 함수를 활용하는 예시 코드입니다. 이처럼 재시도 로직을 고차함수로 만들어 사용하면 재시도 로직을 쉽게 재사용할 수 있으며, 직접 만들어 사용하기 때문에 원하는 방식으로 동작시킬 수 있습니다. 또한 직접 만든 로직이기 때문에 동작 원리를 명확히 알 수 있고, 필요한 경우에는 쉽게 수정할 수 있습니다.
배치 고차함수
배치 고차함수는 여러 작업을 동시에 실행하는 함수입니다. 여러 작업을 동시에 실행하고 모든 작업이 완료되면 결과를 반환하는 방식으로 만들 수 있습니다. 일반적으로는 컬렉션에 있는 데이터를 순환하면서 처리하기 위해 forEach 등을 활용할 수도 있겠지만, 각 동작이 복잡하고, 특히 예외가 발생할 경우 실패로 처리하고 싶거나 응답에서 요청과 결과를 함께 보고 싶은 경우에는 아래와 같이 배치를 위한 고차함수를 만들어볼 수도 있습니다.
fun <T> batchTemplate(
// 대량 작업을 수행할 아이템 리스트
itemList: List<T>,
// 각 아이템을 처리하는 로직
process: (T) -> Unit,
): BatchResponse = BatchResponse(
itemList.map { itemId ->
try {
BatchItemResponse(
ApiResultType.from(Result.success(process(itemId))),
itemId.toString(),
)
} catch (e: Exception) {
// 예외가 발생하면 실패 결과 반환
BatchItemResponse.ofFailure(itemId.toString())
}
},
)
data class BatchResponse(
val results: List<BatchItemResponse>,
)
data class BatchItemResponse(
val resultType: ApiResultType,
val key: String,
) {
companion object {
fun ofFailure(key: String) = BatchItemResponse(
ApiResultType.from(Result.failure<Unit>(Exception())),
key,
)
}
}
enum class ApiResultType {
SUCCESS,
FAILURE,
UNKNOWN,
;
companion object {
fun from(result: Result<Any>) =
when (result.isSuccess) {
true -> SUCCESS
false -> FAILURE
}
}
}
// 예제 사용법
fun main() {
// 배치에 사용될 데이터
val inputs = listOf(1, 2, 3, 4, 5)
// 배치로 적용할 로직
fun process(input: Int) {
println("process $input")
val output = input * 2
// 랜덤하게 예외 발생
if (output % 3 == 0) {
throw Exception("error")
}
}
// 배치 실행
val response = batchTemplate(inputs, ::process)
println(response)
}
위 코드는 배치 고차 샘플 코드와 사용법 예시입니다.
각 아이템을 처리하는 로직을 process 함수로 받아서 각 아이템을 순환하면서 처리하고 그 결과 각각을 BachItemResponse
에 담은 후 BatchResponse
로 반환하는 방식입니다.
데이터와 로직을 명확하게 확인할 수 있으며, 결과에서 각 데이터와 해당 결과를 볼 수 있어서 디버깅이나 테스트가 용이해집니다.
(위에서는 단순한 예시를 보였으나 코루틴 등을 적용하면 비동기로 처리할 수도 있습니다.)
고차함수를 적용하여 반복되는 부분들을 간결하게 만드는 것은 어찌 보면 별 것 아닌 것처럼 보일 수 있지만, 추상화되어 재활용되는 부분들은 많은 재활용과 테스트를 통해 더 탄탄해집니다. 불변성과 참조 투명성 등을 지키는 순수 함수를 통해 코드의 가장 작은 조각에서 안정성을 올릴 수 있었다면, 고차함수를 통해서는 그보다 조금 더 큰 단위에서 안정성과 간결성을 높일 수 있습니다.
3. 작은 패턴의 발견과 추상화
2번에서 이야기드린 고차함수도 이와 같은 형식으로 어떤 패턴을 찾고, 그걸 공통화해서 간단히 사용하는 것입니다. 객체 지향에서는 GOF(Gang of Four)의 디자인 패턴으로 매우 유명한 여러 가지 패턴들이 있고, 그걸 저도 많이 학습했었는데요. 함수형 프로그래밍에서는 디자인 패턴을 함수형 방식으로 재설계할 수 있을뿐더러, 더 작은 크기 혹은 데이터 관점에서의 패턴들을 구조화하여 재활용하는 데 장점을 보여줍니다.
객체 지향에서 디자인 패턴을 적용하는 것은 매우 큰 범위의 변화를 필요로 하는 경우가 많습니다. 하지만 경험상 함수형 프로그래밍으로 작은 패턴 단위로 추상화를 하면, 불필요한 부분까지 패턴화 하여 구현하지 않는 문제가 발생하거나 재사용성이 떨어지는 문제를 해결할 수 있었습니다.
데이터 패턴
개발을 하다 보면 비슷한 데이터 구조를 계속해서 사용하게 됩니다. 이러한 데이터 구조를 패턴화 하여 재사용할 수 있습니다.
특히 무언가의 성공/실패 결과를 담는 xxxResult
와 같은 데이터 구조는 매우 많이 사용하는 패턴입니다.
동일한 콘텍스트가 필요한 데이터들이 각자의 방식으로 데이터를 담는 경우,
반복적인 코드 개발이 필요한 경우가 많이 발생하고 유지보수성이 낮아지게 됩니다.
데이터가 있거나 없거나 하는 경우 null
을 활용할 수도 있지만, Optional
을 활용할 수도 있고 결과가 정상이냐 비정상이냐 하는 단순한 데이터 패턴이 필요할 수 있습니다.
이러한 반복되는 데이터 패턴을 추상화한다면, 손쉽게 기본적인 데이터를 포장해서 사용할 수 있고 해당 데이터의 특성에 맞는 다양한 기능들을 활용할 수도 있습니다.
data class BalanceResult(
val balance: Int,
val userStatus: UserStatus,
// 성공 실패를 나타내는 방법 1: Boolean, 에러 메시지로 표현
val success: Boolean,
val errorMessage: String
)
data class CardPaymentResult(
val payAmount: Int,
val cardCorp: String,
val approvedAt: Boolean,
// 성공 실패를 나타내는 방법 2: 상태값으로 표현, 에러 메시지로 표현
val statusCode: StatusCode,
val errorMsg: String
)
data class MoneyPaymentResult(
val amount: Int,
val bankName: String,
val approvedAt: Boolean,
// 성공 실패를 나타내는 방법 3: 상태값, 에러 코드, 에러 메시지로 표현
val resultCode: ResultCode,
val errorCode: ErrorCode,
val errorMsg: String
)
위와 같이 결과에 대한 데이터가 비슷하게 반복될 때, 아래와 같은 Result 클래스를 만들어서 코드를 간소화하고 기타 다양한 기능도 활용할 수 있습니다.
sealed class SampleResult<out A> {
abstract fun <B> map(f: (A) -> B): SampleResult<B>
abstract fun <B> flatMap(f: (A) -> SampleResult<B>): SampleResult<B>
abstract fun <B> recover(recoverFunc: (ResultCode) -> SampleResult<B>): SampleResult<B>
fun getOrElse(defaultValue: @UnsafeVariance A): A = when (this) {
is Success -> value
is Failure -> defaultValue
}
companion object {
operator fun <A> invoke(value: A) = Success(value)
fun failure(resultCode: ResultCode) = Failure(resultCode)
fun <A> catch(block: () -> A): SampleResult<A> = try {
Success(block())
} catch (e: Exception) {
Failure(e.toResultCode())
}
}
data class Success<out A>(val value: A) : SampleResult<A>() {
override fun <B> map(f: (A) -> B): SampleResult<B> = Success(f(value))
override fun <B> flatMap(f: (A) -> SampleResult<B>): SampleResult<B> = f(value)
override fun <B> recover(recoverFunc: (ResultCode) -> SampleResult<B>): SampleResult<B> = this
override fun toString(): String = "SUCCESS"
}
data class Failure(val resultCode: ResultCode) : SampleResult<Nothing>() {
override fun <B> map(f: (Nothing) -> B): SampleResult<B> = this
override fun <B> flatMap(f: (A) -> SampleResult<B>): SampleResult<B> = this
override fun <B> recover(recoverFunc: (ResultCode) -> SampleResult<B>): SampleResult<B> = recoverFunc(resultCode)
override fun toString(): String = "FAILURE: ${resultCode.errorCode}"
}
}
data class ResultCode(val errorCode: ErrorCode, val message: String) {
companion object {
fun create(errorCode: ErrorCode, message: String) = ResultCode(errorCode, message)
}
}
위 샘플 코드에서는 map
, getOrElse
와 같은 기본적인 데이터를 다루는 함수도 제공하고 있으며, recover
라는 실패 시 복구하는 기능도 제공합니다.
코틀린 내에도 Result
라는 클래스가 있지만, 제가 생각했던 것과 많이 다르게 동작하고 불필요한 기능이 너무 많다고 느껴서 직접 간단하게 만들어서 활용해 봤습니다.
recover
하는 상황이 자주 필요하다 보니 이런 기능도 넣어서 더욱 편리하게 활용할 수 있었습니다.
그럼 이러한 SampleResult
를 활용하여 결제를 진행하는 코드를 작성해 보겠습니다.
data class BalanceDTO(
val balance: Int,
val bankName: String,
val userStatus: UserStatus
)
data class CardPayment(
val payAmount: Int,
val cardCorp: String,
val approvedAt: Boolean
)
data class MoneyPayment(
val amount: Int,
val bankName: String,
val approvedAt: Boolean
)
// 결제 요청에 사용되는 DTO
data class PayRequest(
val amount: Int,
val bankName: String,
val cardCorp: String
)
fun getBalanceFromDB(bankName: String): SampleResult<BalanceDTO> =
Result.catch { balanceRepository.getBalance(bankName) }
fun getBalanceFromBankAdapter(bankName: String): BalanceDTO = bankAdapter.getBalance(bankName)
fun cardPayment(amount: Int, cardCorp: String): SampleResult<CardPayment> =
Result.catch { cardAdapter.payment(amount, cardCorp) }
fun payMoney(balance: Int, amount: Int, bankName: String): SampleResult<MoneyPayment> {
if (balanceDTO.balance < payRequest.amount) {
return SampleResult.Failure(ResultCode(BALANCE_NOT_ENOUGH, "Insufficient balance"))
}
val bankPaymentResult = bankAdapter.payment(amount, bankName)
return when (bankPaymentResult.success) {
true -> SampleResult.Success(MoneyPayment(amount, bankName, true))
false -> SampleResult.Failure(ResultCode(BANK_PAYMENT_FAILURE, "Bank payment failed"))
}
}
fun main() {
val payRequest = PayRequest(1000, "KAKAO BANK", "SS CARD")
// DB에서 잔액 조회 후 실패하면 BankAdapter를 통해 잔액 조회
val balanceResult = getBalanceFromDB(payRequest.bankName).recover {
getBalanceFromBankAdapter(payRequest.bankName)
}
val moneyPaymentResult = balanceResult.flatMap { balanceDTO ->
payMoney(balanceDTO.balance, payRequest.amount, payRequest.bankName)
}.recover { cardPayment(payRequest.amount, payRequest.cardCorp) }
// when 표현식을 사용하여 성공과 실패를 분기 처리
when (moneyPaymentResult) {
is Success -> println("결제가 성공했어요: ${moneyPaymentResult.value}")
is Failure -> println("결제가 실패했어요: ${moneyPaymentResult.resultCode.message}")
}
}
위 코드 중 main()
함수를 보면 전체적인 로직을 살펴볼 수 있는데, 우선 잔액을 DB에서 조회해 보고 실패하면 은행에서 조회하는 방식으로 잔액을 가져옵니다.
그 후에 잔액을 가져오는데 성공하면 머니로 결제를 시도해 보고, 실패하면 카드로 결제를 다시 시도하도록 되어 있습니다.
말로 설명하더라도 꽤 긴 이러한 조건부 로직을 if/else
를 활용할 때보다 간결하고 명확하게 보여줄 수 있는 장점이 있습니다.
타 프로그래밍 기법에서도 데이터의 구조나 동작에서 반복되는 부분들을 추상화하는 방식이 많습니다. 함수형 프로그래밍의 추상화는 조금 더 간결성이 높고, 추상화된 상태의 데이터 처리 기법에 대해 일종의 표준화가 되어 있습니다. 그래서 한 가지 데이터 패턴에 대해 잘 이해한다면 다른 데이터 패턴도 빠르게 익힐 수 있고, 하나를 알면 열을 알 수 있는 장점이 있었습니다.
4. 이미 잘 만들어져 있는 라이브러리(Kotlin Arrow) 활용
이미 잘 만들어져 있는 라이브러리를 처음부터 쓰면 되지 않나?라고 반문하실 수 있습니다. 저도 처음엔 그렇게 생각했습니다. 하지만 처음부터 너무 많은 기능과 복잡함이 있는 라이브러리를 이해하며 사용하기가 쉽지 않았습니다. 오히려 내가 이해하고 딱 필요한 기능만 만들어서 사용하는 편이 더 유용하고 내 프로젝트에 어울리는 경우가 많았습니다.
그런데 딱 필요한 것들만 만들어서 사용하다 보니, 어느 순간 라이브러리의 기능을 대부분 가져오게 되고 그러다 보니 라이브러리를 사용하는 것이 더 편리하고 유용하다는 것을 깨달았습니다. 또 우리 프로젝트에서만 필요한 기능들은 확장 함수를 활용하여 만들면 되니, 부족한 부분이 있어도 크게 문제가 되지 않았습니다.
Arrow 라이브러리와 Either 타입
코틀린에는 Arrow
라는 함수형 프로그래밍을 지원하기 위한 라이브러리가 있습니다.
Arrow
는 코틀린에서 함수형 프로그래밍을 더 적극적으로 적용할 수 있도록 돕는 라이브러리로, 복잡한 비즈니스 로직을 간결하고 안전하게 표현할 수 있게 도와줍니다.
이 라이브러리는 Option
, Either
, Try
등과 같은 타입을 통해 null 처리와 오류 관리를 더욱 효과적으로 수행합니다.
또한, Arrow
는 코루틴과 잘 통합되어 비동기 작업을 함수형 방식으로 수행할 수 있도록 지원하므로 함수형 프로그래밍과 코루틴, 안정적인 프로그래밍에 관심이 많으시다면 한 번쯤 관심을 가져보셔도 좋습니다.
저는 이 라이브러리 중 Either
라는 타입이 매우 유용하다고 생각했습니다.
Either
는 성공과 실패를 나타내는 데 유용한 타입으로, 함수형 프로그래밍 언어에서 오랜 시간 동안 사용되어 온 개념입니다.
자바에서도 Vavr 라이브러리를 통해 Either
를 사용할 수 있지만, 코틀린과 Arrow 라이브러리를 활용하면 Either
의 사용성을 크게 높일 수 있습니다.
Either
는 왼쪽(Left)과 오른쪽(Right)으로 나뉘어 있으며, 보통 왼쪽은 실패, 오른쪽은 성공을 나타냅니다.
하지만 우리가 현실 코드에서 개발을 하다 보면 꼭 성공/실패로 나눌 수 없는 상황들이 자주 발생합니다.
Arrow의 Either
는 코틀린의 강력한 타입 시스템과 함께 사용하여, 이러한 복잡한 상황을 더 쉽게 다룰 수 있게 해 줍니다.
예를 들면 코틀린의 inline
함수와 reified
타입 매개변수를 통해 Either
의 생성과 사용이 더욱 간결합니다.
map
, flatMap
등의 기본적인 연산자 활용도 편리하지만, 코틀린의 확장 함수를 활용하여 내가 원하는 기능을 마음대로 추가할 수 있는 부분이 특히 마음에 들었습니다.
그래서 저는 직접 제작한 Result
를 활용했던 부분들을 Arrow의 Either
로 변경해 봤습니다.
Either
를 통해 상황에 따라 왼쪽에는 간단히 에러 데이터만 담을 수도 있었고,
어떤 경우에는 에러는 아니지만 다른 처리가 필요한 데이터를 담도록 하는 경우를 활용할 수 있었습니다.
이러한 장점 덕분에 예외 상황에서 더욱 확장성 있게 처리할 수 있었습니다.
Either를 활용한 결제 프로세스 개선 사례
Either
의 실제 활용 사례를 결제를 진행하며 상태를 관리하고 상태에 따른 처리를 진행하는 코드로 보여드리겠습니다.
/**
* 특정 상태일 경우에만 다음 프로세스를 진행하도록 하는 확장 함수
*
* T: 전달받는 데이터의 결제 상태
* R: 반환할 데이터의 결제 상태
*/
inline fun <reified T : PayStatus, R : PayStatus> Either<Throwable, PayStatus>.next(
action: (T) -> Either<Throwable, R>
): Either<Throwable, PayStatus> {
return flatMap {
when (it) {
is T -> action(it)
else -> it.right()
}
}
}
sealed class PayStatus(val description: String) {
data class PayParamValidated(val payParams: PayParams) : PayStatus("파라미터 검증 완료")
data class PayReqSaved(val paymentTxId: EntityId) : PayStatus("요청 저장 완료")
data class PayVerified(val paymentTxId: EntityId, val verifyResultList: List<VerifyResult>) : PayStatus("검증 처리 완료")
data class PayMethodPaid(val paymentTxId: EntityId, val paymentResourceInfo: PaymentResourceInfo) : PayStatus("결제 프로세스 종료")
}
우선은 위와 같은 Either
의 확장 함수와 PayStatus
를 만들었습니다.
PayStatus
는 결제의 상태를 나타내는 데이터 클래스로 파라미터 검증 완료, 요청 저장 완료, 검증 처리 완료, 결제 완료 등의 상태를 나타내는 데이터 클래스입니다.
Either
의 next
함수는 T
와 R
타입이 PayStatus
의 서브 클래스인 경우에만 다음 프로세스를 진행하도록 하는 확장 함수입니다.
특히 T
타입은 reified
키워드를 사용하여 런타임에 타입을 알 수 있도록 하였습니다.
따라서 정확히 지정된 타입인 경우에는 action
이라는 함수를 호출하고, 그렇지 않으면 현재 상태를 그대로 반환하도록 했습니다.
결제의 전체 플로우를 파라미터 검증 -> 요청 저장 -> 검증 처리 -> 결제 프로세스 종료 4단계로 나누고, 이전 상태가 정상적으로 끝나면 다음 프로세스를 진행한다고 생각했습니다. 기존 코드도 크게 보면 이와 같은 흐름이었지만, 중간에 실패하는 경우 바로 종료 단계로 가기 때문에 전체 흐름을 파악하기가 어려웠습니다.
그래서 Either
를 활용해 전체 흐름을 한눈에 보고 항상 같은 흐름으로 동작하는 것처럼 마지막 단계까지 진행되는 코드를 수월하게 볼 수 있도록 했습니다.
위 케이스를 결제 프로세스 샘플 코드로 보여드리겠습니다.
fun payFlow(payParams: PayParams): PayResultCompositeDto {
return payParamsValidator.validate(payParams)
.next<PayParamValidated, PayReqSaved> {
payService.createPaymentTx(it.payParams)
}.next<PayReqSaved, PayVerified> {
abnormalDetector.verify(it)
}.next<PayVerified, PayMethodPaid> {
resourceTxHandler.pay(it)
}
// 결과 처리
.fold(::handleFailure, ::handleSuccess)
}
private fun handleFailure(throwable: Throwable): Nothing {
paymentStatusService.saveAsFail(throwable)
throw throwable.throwable
}
private fun handleSuccess(payStatus: PayStatus): PayResultCompositeDto {
paymentStatusService.saveAsSuccess(it.paymentTxId, it.paymentResourceInfo)
return payService.getPayResultCompositeDto(payStatus.paymentTxId)
}
위 코드는 결제 처리 흐름을 따라 다양한 단계에서 결제 상태를 검증하고 처리하는 로직을 구현하고 있습니다.
각 단계에서는 특정 상태를 확인하고, 해당 상태에 맞는 액션을 수행합니다.
Either
타입을 사용하여 각 단계에서 성공하거나 실패한 결과를 처리하며,
마지막에 fold
메서드를 사용하여 최종 결과를 반환합니다.
이전에 if/else
를 활용할 때에는 전체 플로우 중 중간에 예외가 발생하면 거기서 바로 결제가 완료되는 경우도 있었으며, 각 단계에서 직접 다음 단계를 호출하는 방식이었습니다.
그렇기 때문에 전체 프로세스를 한눈에 보기 어려웠습니다.
그리고 각 단계마다 다음 단계를 호출하는 방식이 제각각이거나 내부 호출이 깊게 이어지다가 다음 단계를 호출하는 케이스도 있어서 정확한 프로세스 확인이 어려웠습니다.
Either
와 결제 상태를 활용하여 전체 프로세스를 한눈에 볼 수 있도록 코드를 작성하니, 깊은 부분에서 다음 단계를 호출하는 케이스가 사라졌습니다.
마치며
객체지향 등 기존의 프로그래밍 패러다임들도 사람에 따라 다양한 관점과 구현 방법들이 펼쳐져왔습니다. 그에 비해 함수형 프로그래밍은 제가 열심히 찾아봤던 방법들은 너무 난해하거나 현실과 동떨어진, 현재 있는 코드들을 모두 바꿔야 하는 극단적인 방법들이 많았습니다. 하지만 중간중간 발견한 매력적인 장점들 때문에 포기하기가 아쉬웠습니다.
그래서 차근차근 함수형 프로그래밍의 기초적인 장점 활용을 시작으로 조금씩 더 다양한 기법들을 활용하기 시작했습니다. 간단한 고차함수부터 직접 만들어서 사용하는 타입, Arrow 라이브러리 타입까지 다양한 방식으로 함수형 프로그래밍을 활용해 보았습니다. 처음에는 책에 나오는 난해한 방식으로도 코딩을 하다 보니 주변 동료들도 어려워했고, 저도 시간이 지난 후 제 코드를 봤을 때 이해하기가 쉽지 않았습니다. 난이도에 비해 얻어지는 것이 큰지 적은지, 똑같은 코드라도 어떻게 하면 더 이해하기 쉽고 수정하기 쉬울지 등을 고민했습니다. 또한 팀원들과 함수형 프로그래밍 스터디를 3번 이상 진행하며 함수형 프로그래밍에 대한 이해를 높이고 실무에 적용하는 방안에 대해 고민해 볼 수 있었습니다.
이 글에 그동안 고군분투했던 함수형 프로그래밍의 다양한 기법들을 정리해 봤습니다. 위 네 가지 기법이 함수형 프로그래밍을 처음 접하는 분들에게는 조금 어려울 수도 있습니다. 특히 1번 함수형 프로그래밍의 기본적인 사상 따르기 부분은 학교나 기초적인 개발 언어 책에 나오는 샘플과 다소 상이한 이야기로 낯설 수 있지만, 많은 장점이 있는 부분이니 한 번쯤 실무에서 적용해 보면 좋을 것 같습니다.
그리고 그게 잘 활용되고 마음에 드신다면 2번, 3번, 4번 순서로 다양한 기법을 활용해 보신다면 함수형 프로그래밍의 매력에 빠져보실 수 있을 듯합니다. 위에서 말한 대로 저의 방식은 여러 함수형 프로그래밍의 실전 적용에 대한 일부 방법일 뿐이며, 더 다양한 방법들이 있을 수 있습니다. 다른 분들도 이 글에서 영감을 받고 더 좋은 방법들을 많이 공유해 주셔서 더 즐거운 프로그래밍을 할 수 있길 기대하겠습니다.
참고 자료
- 코틀린을 다루는 기술(Kotlin in Action)
- Kotlin Arrow