오늘은 코드 개선을 위해 고군분투한 내용을 적어보겠습니다!
개선하고자 하는 서비스는 정기 결제 서비스입니다. 물론 말은 결제지만, 정확히는 자동이체가 맞습니다. 사용자가 정해둔 일자에 희망하는 금액을 A계좌에서 출금하고 B계좌로 송금하는 시스템입니다. 기존의 시스템을 간단히 도식화 해보겠습니다.

정해진 일자, 시간에 JENKINS에서 PaymentController로 요청을 보냅니다. 그리고 요청을 PaymentService로 전달합니다.
@RestController
class PaymentController(
private val paymentService: PaymentService
) {
@PostMapping("/v1/recurring-payment")
fun payRegularly() {
paymentService.payRegularly()
}
}
Service에서는 그 날의 대상들을 조회합니다. 그리고 해당 대상들을 순회하며 차례로 출금과 송금을 진행합니다. 이 과정들을 중간중간 DB에 기록하고 있습니다. 또, 출금과 송금 과정에서 외부 솔루션 호출하게 됩니다.
@Service
class PaymentService(
private val karinaPaymentProcessor: KarinaPaymentSolutionProcessor,
private val winterPaymentProcessor: WinterPaymentSolutionProcessor,
private val paymentRepository: PaymentRepository
) {
fun payRegularly() {
val targets = paymentRepository.findTodayRegularPaymentTargets(PaymentSolutionType.KARINA)
val withdrawalResult = karinaPaymentProcessor.withdraw(targets)
paymentRepository.save(withdrawalResult.successTargets)
karinaPaymentProcessor.transfer(withdrawalResult.successTargets)
.run { paymentRepository.save(this.successTargets) }
}
}
당시 사용하던 솔루션은 단 하나였는데요, 이 솔루션을 카리나라고 해보겠습니다. 그리고 카리나가 제공하는 출금과 송금 기능은 KarinaPaymentProcessor에 구현되어있습니다.
@Component
class KarinaPaymentSolutionProcessor {
fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult {
// 카리나 출금 호출
println("카리나 출금을 호출하셨습니다!")
return WithdrawalResult(targets)
}
fun transfer(targets: List<RegularPaymentTarget>): TransferResult {
// 카리나 송금 호출
println("카리나 송금을 호출하셨습니다!")
return TransferResult(targets)
}
}
지금과 같은 구조에서 신규 솔루션을 추가하게 되었습니다. 기존 카리나 솔루션의 수수료가 너무 부담이 되는 상황이었고, 윈터 솔루션을 추가하게 되었습니다.
솔루션을 추가하면서 두 개의 문제가 있었습니다. 첫 번째는 기존 시스템에 대한 파악이 부족했다는 점입니다. 인수인계 문서나 주석이 전혀 없어서 히스토리 파악이 불가했고, 각종 축약어가 사용되면서 의미 파악이 어려운 상황이었습니다. 두 번째는 개발에 투입되는 리소스가 극히 제한적이었다는 점입니다. 자금 이체 개발 쪽을 혼자 담당하고 있다 보니 혼자 모든 개발을 진행해야 했습니다.
짧은 기간 속에서 두 가지 문제를 고려하다보니, 결국 별도의 API를 열어 기존 로직과 무관하게 새로 개발했습니다.

사실 두 개의 프로세스는 결제 솔루션별 전처리 및 후처리 과정에서 약간의 차이가 있을 뿐, 근본적으로는 동일합니다. 고객의 통장에서 돈을 출금하고 다른 계좌로 이체해주는 시스템입니다. 그럼에도 불구하고 아예 상관이 없는 로직처럼 별개의 프로세스로 구성되어 있습니다. 공통 로직도 다수 존재하지만, 아예 별개의 프로세스인 것처럼 구성되어 있습니다.
@RestController
class PaymentController(
private val paymentService: PaymentService
) {
@PostMapping("/v1/recurring-payment")
fun payRegularly() {
paymentService.payRegularly()
}
@PostMapping("/v1/recurring-payment/winter")
fun payRegularlyWinter() {
paymentService.payRegularlyWinter()
}
}
@Service
class PaymentService(
private val karinaPaymentProcessor: KarinaPaymentSolutionProcessor,
private val winterPaymentProcessor: WinterPaymentSolutionProcessor,
private val paymentRepository: PaymentRepository
) {
fun payRegularly() {
val targets = paymentRepository.findTodayRegularPaymentTargets(PaymentSolutionType.KARINA)
val withdrawalResult = karinaPaymentProcessor.withdraw(targets)
paymentRepository.save(withdrawalResult.successTargets)
karinaPaymentProcessor.transfer(withdrawalResult.successTargets)
.run { paymentRepository.save(this.successTargets) }
}
fun payRegularlyWinter() {
val targets = paymentRepository.findTodayRegularPaymentTargets(PaymentSolutionType.WINTER)
val withdrawalResult = winterPaymentProcessor.withdraw(targets)
paymentRepository.save(withdrawalResult.successTargets)
winterPaymentProcessor.transfer(withdrawalResult.successTargets)
.run { paymentRepository.save(this.successTargets) }
}
}
짜면서도 마음에 들지 않는 구석이 한 두 곳이 아니었지만, 일정에 맞추어 개발을 마무리하고 리팩토링을 시작했습니다.
하나의 서버 개발을 혼자 도맡아 하다보니 리팩토링에 많은 시간을 쏟을 수 없는 상황이었습니다. 그래서 최소한의 기준을 잡아 선택적으로 진행하고자 했습니다.
- 솔루션이 추가 또는 변경될 때 대응할 수 있도록 한다.
- 로직 파악이 쉽게 될 수 있도록 한다.
- 공통로직은 묶을 수 있게 한다.
위와 같은 기준에서, 개선 포인트로 잡은 곳은 PaymentService와 PaymentSolution입니다. 솔루션의 추가 또는 변경, 비즈니스 로직 변경 시에 가장 많이 보게 될 코드이기 때문입니다. 그리고 입출금 결과를 저장하는 중복 코드들도 해당 부분에 몰려 있기 때문입니다.

솔루션 확장에 대응하는 방법으로 인터페이스와 스프링의 컬렉션을 활용한 DI를 적용하기로 했습니다. 스프링에서는 Collection 형태의 DI를 지원하는데요, Baeldung에도 해당 내용이 소개되어 있습니다. List, Map 형태로 DI를 받을 수 있는데, 이를 활용하여 기존 PaymentProcessor를 개선해보겠습니다.
두 개의 SolutionProcessor는 출금과 송금 기능을 제공하므로, 하나의 인터페이스를 구현한 형태로 바꿀 수 있습니다. 논리가 반대로 되긴 했습니다. 저희가 사용하는 PaymentSolution은 출금과 송금 기능을 제공해야 하므로, 두 기능을 제공하는게 맞는 접근법이겠죠.
interface PaymentSolutionProcessor {
fun solutionType(): PaymentSolutionType
fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult
fun transfer(targets: List<RegularPaymentTarget>): TransferResult
}
@Component
class WinterPaymentSolutionProcessor : PaymentSolutionProcessor {
override fun solutionType() = PaymentSolutionType.WINTER
override fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult {
// ...
}
override fun transfer(targets: List<RegularPaymentTarget>): TransferResult {
// ...
}
}
@Component
class KarinaPaymentSolutionProcessor : PaymentSolutionProcessor {
override fun solutionType() = PaymentSolutionType.KARINA
override fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult {
// ...
}
override fun transfer(targets: List<RegularPaymentTarget>): TransferResult {
// ...
}
}
이렇게 구현된 SolutionProcessor를 가지고 PaymentService를 개선해보겠습니다.
@Service
class PaymentService(
private val withdrawalSolutionProcessor: List<PaymentSolutionProcessor>,
private val transferSolutionProcessor: KarinaPaymentSolutionProcessor,
private val paymentRepository: PaymentRepository
) {
fun payRegularly(solutionType: PaymentSolutionType) {
val targets = paymentRepository.findTodayRegularPaymentTargets(solutionType)
val withdrawalResult = withdrawalSolutionProcessor.single { it.solutionType == solutionType }
.withdraw(targets)
.also { paymentRepository.save(it.successTargets) }
transferSolutionProcessor.transfer(withdrawalResult.successTargets)
.also { paymentRepository.save(it.successTargets) }
}
}
PaymentService가 기존에 비해 굉장히 짧아졌습니다. 파라미터로 넘겨받은 솔루션 타입을 가지고 리스트를 순회하며 일치하는 구현체를 찾습니다. 그리고 해당 구현체로 withdraw 메서드를 호출합니다. 이렇게 구현함으로써, 솔루션별로 분리되었던 메서드가 하나로 합쳐졌습니다. 나중에 누군가 자동이체를 처리하는 로직을 확인하고자 한다면, 해당 메서드 하나만 확인하면 되는 것이죠.
이렇게 하면 새롭게 솔루션이 추가되더라도 PaymentSolutionType에 타입을 추가하고, PaymentSolutionProcessor 구현체만 추가하면 PaymentService에서 코드를 변경할 일은 없습니다.

리팩토링 이후 또다른 문제가 생겨나기 시작했습니다. 아래 서술한 내용은 실제 프로덕션 코드에 반영되지 않았지만, 어떤 식으로 개선할 수 있었을까 고민한 내용입니다.
서비스가 성장하면서 다양한 종류의 결제를 지원해야 했고, 여러 기능들이 붙으면서 PaymentService가 비대해졌습니다. 또한 자동이체 시스템도 솔루션별로 요구사항이 달라지면서 상이한 로직이 추가돼야 하는 상황이 됐습니다. 카리나 솔루션 이용객의 경우 조건을 충족하지 못하면 실행이 안 되어야 한다든가, 윈터 솔루션은 자동이체 대가로 지불해야 하는 금액을 할인해주는 요구사항이 추가되기도 했습니다.
앞서 진행한 개선은 당시 상황과 요구사항의 추가 및 변경들을 유연하게 반영하기는 어려운 구조였습니다. PaymentService에서 솔루션별 요구사항을 반영하기 위해선 결국 분기를 통해 처리가 필요하기 때문입니다.
@Service
class PaymentService(
private val withdrawalSolutionProcessor: List<PaymentSolutionProcessor>,
private val transferSolutionProcessor: KarinaPaymentSolutionProcessor,
private val paymentRepository: PaymentRepository,
) {
fun payRegularly(solutionType: PaymentSolutionType) {
val targets = paymentRepository.findTodayRegularPaymentTargets(solutionType)
if (solutionType == KARINA) {
filterKarinaSolutionTargets(targets)
}
// ...
if (solutionType == WINTER) {
updateTransferAmount(targets)
}
// ...
}
private fun filterKarinaSolutionTargets(
targets: List<RegularPaymentTarget>
): List<RegularPaymentTarget> {
return targets.filter { it.accountNumber.endsWith("01") }
}
private fun updateTransferAmount(targets: List<RegularPaymentTarget>) {
targets.forEach { it.updateTransferAmount(0.9) }
}
}
요구사항이 추가되고 변경됨에 따라 점차 자동이체 로직에 if문이 늘어나기 시작했습니다. 마침 비대해진 PaymentService를 분리할 겸 템플릿 메서드 패턴을 활용하여 개선해보고자 합니다.
@Service
abstract class RegularPaymentService {
abstract val solutionType: PaymentSolutionType
fun pay() {
val targets = getTodayRegularPaymentTargets()
val withdrawalResult = withdraw(targets)
transfer(withdrawalResult.successTargets)
}
abstract fun getTodayRegularPaymentTargets(): List<RegularPaymentTarget>
abstract fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult
abstract fun transfer(successTargets: List<RegularPaymentTarget>): TransferResult
}
class KarinaRegularPaymentService(
private val karinaPaymentProcessor: KarinaPaymentSolutionProcessor,
private val paymentRepository: PaymentRepository,
) : RegularPaymentService() {
override val solutionType: PaymentSolutionType = KARINA
override fun getTodayRegularPaymentTargets(): List<RegularPaymentTarget> {
return paymentRepository.findTodayRegularPaymentTargets(solutionType)
.filter { it.accountNumber.endsWith("01") }
}
override fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult {
return karinaPaymentProcessor.withdraw(targets)
}
override fun transfer(successTargets: List<RegularPaymentTarget>): TransferResult {
return karinaPaymentProcessor.transfer(successTargets)
}
}
class WinterRegularPaymentService(
private val paymentRepository: PaymentRepository,
private val winterPaymentProcessor: WinterPaymentSolutionProcessor,
private val transferPaymentProcessor: KarinaPaymentSolutionProcessor,
) : RegularPaymentService() {
override val solutionType: PaymentSolutionType = PaymentSolutionType.WINTER
override fun getTodayRegularPaymentTargets(): List<RegularPaymentTarget> {
return paymentRepository.findTodayRegularPaymentTargets(solutionType)
}
override fun withdraw(targets: List<RegularPaymentTarget>): WithdrawalResult {
return winterPaymentProcessor.withdraw(targets)
}
override fun transfer(successTargets: List<RegularPaymentTarget>): TransferResult {
successTargets.map { it.updateTransferAmount(0.9) }
return transferPaymentProcessor.transfer(successTargets)
}
}
템플릿 메서드 패턴을 적용함으로써 얻을 수 있는 이점은, 솔루션별로 상이한 로직을 깔끔하게 처리할 수 있다는 점입니다. 템플릿 메서드 패턴을 도입하기 전에는 if문을 통해 분기를 작성해야 합니다. 템플릿 메서드 패턴을 적용하면 구현체에서 메서드 구현을 달리하면 됩니다. 별도의 분기문이 없으므로 훨씬 더 깔끔한 코드를 구현할 수 있습니다.

두 가지 포인트에서 정말 좋은 경험이었습니다.
하나는 공부한 내용을 실무에서 적용할 수 있었다는 점입니다. 디자인 패턴의 경우 구조를 잡는 과정이나 리팩토링을 할 때가 아니면 적용할 기회를 얻기 힘듭니다. 그래서 이번 기회에 템플릿 메서드 패턴을 적용하며, 공부한 것을 활용했다는 사실이 굉장히 좋았습니다.
다른 하나는, 상대적으로 좋지 못한 환경에서도 노력을 통해 얻어갈 것이 있다는 점에서 좋았습니다. 좋은 코드 베이스와 좋은 사람들이 있는 환경은 아니지만, 더 나은 코드를 위해 고민했고 또 개선했다는 것이 뿌듯했습니다.
추후에 누군가 회사에 합류하여 이 코드를 읽었을 때 조금이나마 편안하게 읽는다면, 목적한 바를 모두 이룬 것이 아닐까 싶습니다.