
지난 시간엔, MSA와 Monolithic 의 특징을 알아보고 Toss Bank의 “지금 이자 받기”에 대해 간략하게 말씀드렸습니다. 이번 시간엔 해당 기능이 무엇인지와 어떤 과정을 거쳐서 MSA로 전환되었는지, 좀 더 깊이 있게 탐구합니다. 우선 해당 기능의 기본적인 비즈니스 로직부터 알아보도록 하겠습니다.
(※ 본문에 나오는 Code는 필자의 개인 Code, 의견인 점을 참고해주시기 바랍니다.)
지금 이자받기 Business Logic
지금 이자받기의 작동 방법은 다음과 같습니다. 사용자가 지금 이자받기를 요청하면, 마지막으로 이자를 받은 날과 요청 전날까지의 잔액을 기반으로 상품 약관에 의해 계산이 이루어집니다.
- 이자 금액 : 계좌 잔액 x 상품 금리 x 잔액 유지 일수 / 365 – (이자소득세 14% + 지방소득세 1.4%)
이렇게 국내 소득세법상 소득세를 제외하고 고객 계좌에 입금됩니다.

이제 이 의존성을 분리하는 과정이 필요합니다. Architecture 구성을 알아보겠습니다.
MSA, 어느 정도까지 분리해야 할까?

이미 모놀리식으로 결합된 거대한 시스템을 어느정도로 분리해야 할지 정하는 것이 첫 단추입니다. 이자 지급을 위해 고객 정보 조회, 금리 조회, 이자 회계 처리, 이렇게 3개의 도메인이 필요합니다. 하나의 Transaction으로 처리되고 있었지만, 독립적인 마이크로 서버로 구성하고 API 호출로 통신함으로써 의존성을 낮추게 됩니다.


동시성 제어
잔액 갱신은 앱, 타행, ATM, 자동이체 등 다양한 Channel에 의해 갱신되며 이에 따르는 동시성 제어는 안정성과 직결되는 문제이며, Redis Global Lock만으로 대응하기엔 무리가 있을 수 있다고 합니다.
- Redis Global Lock : 여러 개의 클라이언트가 동시에 접근하고 사용하는 Redis 서버에서 상호 배제를 위해 사용되는 메커니즘입니다. Redis는 싱글 스레드로 동작하므로, 동시에 여러 클라이언트가 데이터를 변경하려고 할 때 문제가 발생할 수 있습니다. 이때 Redis Global Lock을 사용하여 데이터를 보호하고 데이터의 일관성과 안전성을 보장할 수 있습니다.
Redis Global Lock만으로 어려운 이유?
- 성능 저하: 클라이언트가 Lock을 얻기 위해 대기 상태에 머물러야 합니다. 이로 인해 응답 시간이 증가하고 처리량이 감소할 수 있습니다.
- 일관성 문제 : Redis Global Lock은 하나의 Client만이 Lock을 얻을 수 있기 때문에, 다른 Client들은 Lock이 해제될 때까지 대기해야 합니다. 이로 인해 한 Client가 거래 요청 시 다른 Client가 동시에 같은 계좌에 거래를 요청하면 일관성을 유지하기 어렵습니다. (노드 다운, Lock contention으로 인한 Roll back 등)
Redis Global Lock + @Lock
동기화 문제를 해결하기 위해, Redis Global Lock과 JPA의 @Lock을 같이 사용했다고 하는데요, 우선 JPA의 @Lock부터 알아보겠습니다.
- @Lock: 다중 스레드 환경에서 발생할 수 있는 데이터 충돌과 같은 문제를 방지하기 위해 다양한 Locking 기능을 제공합니다.
Optimistic Locking(낙관적 락)
@Entity public class BankAccount { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String accountNumber; private int balance; @Version // Optimistic Locking을 위한 버전 필드 private int version; // getters, setters, constructors, etc. } public interface BankAccountRepository extends JpaRepository<BankAccount, Long> { @Lock(LockModeType.OPTIMISTIC) BankAccount findByAccountNumber(String accountNumber); }
@Version 어노테이션을 사용하여 구현됩니다. 데이터가 수정될 때마다 자동으로 증가하며 조회 시 버전 정보도 함께 가져옵니다.
- @Lock(LockModeType.OPTIMISTIC)로 지정합니다.
- 낙관적 락은 데이터 충돌이 발생하지 않을 것으로 가정하고 작업을 수행합니다.
- 엔티티를 읽어올 때 버전(Version) 정보를 함께 가져옵니다.
- 데이터 수정 시 버전 정보를 사용하여 충돌 여부를 판단하고, 충돌이 발생하면 예외를 발생시키거나 별도의 처리를 수행합니다.
Pessimistic Locking(비관적 락)
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) BankAccount findByAccountNumber(String accountNumber); }
- @Lock(LockModeType.PESSIMISTIC_READ) (공유 락) 또는
- @Lock(LockModeType.PESSIMISTIC_WRITE) (배타 락)으로 지정합니다.
- 비관적 락은 데이터 충돌이 발생할 수 있을 것으로 가정하고 작업을 수행합니다.
- 특정 데이터를 다른 트랜잭션들이 동시에 수정하지 못하도록 Lock을 걸어놓습니다.
- 데이터를 읽을 때는 공유 락(읽기 락)을 사용하고, 수정할 때는 배타 락(쓰기 락)을 사용합니다.
그렇다면, Redis Global Lock과는 어떻게 같이 사용될까요? 공개되진 않았지만, 동일 은행에서 송금(거래)를 시도하는 상황을 가정하여 약식 코드로 알아보겠습니다. (※ 필자의 개인 Code, 의견인 점을 참고해주시기 바랍니다.)
import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; // ... @Service public class BankTransactionService { @Autowired private BankAccountRepository bankAccountRepository; private RedissonClient redissonClient; // Redis 설정과 연결 초기화 @PostConstruct public void init() { Config config = new Config(); config.useSingleServer() .setAddress("redis://localhost:6379"); redissonClient = Redisson.create(config); } // Redis Global Lock을 사용하여 동기화된 메소드 public void performTransaction(String fromAccountNumber, String toAccountNumber, int amount) { String fromLockKey = "account-lock-" + fromAccountNumber; String toLockKey = "account-lock-" + toAccountNumber; RLock fromLock = redissonClient.getLock(fromLockKey); RLock toLock = redissonClient.getLock(toLockKey); try { // Redis Global Lock 획득 (송금을 하는 계좌) fromLock.lock(); // Redis Global Lock 획득 (송금을 받는 계좌) toLock.lock(); // 송금을 하는 계좌 조회 및 잔액 확인 BankAccount fromAccount = bankAccountRepository.findByAccountNumber(fromAccountNumber); if (fromAccount.getBalance() < amount) { throw new InsufficientBalanceException("잔액이 부족합니다."); } // 송금을 받는 계좌 조회 및 잔액 확인 BankAccount toAccount = bankAccountRepository.findByAccountNumber(toAccountNumber); // 송금 fromAccount.setBalance(fromAccount.getBalance() - amount); toAccount.setBalance(toAccount.getBalance() + amount); // JPA의 @Lock을 사용하여 송금을 하는 계좌와 송금을 받는 계좌에 락을 걸기 bankAccountRepository.save(fromAccount); bankAccountRepository.save(toAccount); // JPA의 @Lock을 사용하여 락 해제 (메소드가 끝날 때 자동으로 락이 해제됨) } finally { // Redis Global Lock 해제 (송금을 하는 계좌) fromLock.unlock(); // Redis Global Lock 해제 (송금을 받는 계좌) toLock.unlock(); } } // 기타 비동기 작업이나 동기화가 필요 없는 다른 메소드들 }
위에대한 예시는 2개의 계좌에 대한 Lock을 각각 설정하여 송금 작업을 시도해보았습니다. 송금을 하는 계좌와 송금을 받는 계좌의 잔액을 확인하여 충분한 잔액이 있는지 검사하고, 송금이 완료되면 송금을 하는 계좌와 송금을 받는 계좌를 동기화하여 데이터베이스에 저장합니다.
여러 사용자가 동시에 동일한 계좌로 송금을 시도해도 정성적으로 동기화되며 충돌이 발생하지 않을 것입니다.
Kafka를 활용한 비동기 트랜잭션
기존 Core Banking 시스템에선 1번의 이자를 지급받기 위해 20개의 Table에 80번의 UPDATE, INSERT가 발생했고 평균 응답속도가 300ms로 느린편에 속했다고 합니다. 이에 대한 해결책으로 Kafka를 활용하여, 트랜잭션에서 분리 가능한 테이블들을 분리했다는데요. Kafta에 대한 정보는 첨부된 URL 참고를 권해드리며, 트랜잭션 분리에 대해 알아보겠습니다.
트랜잭션 분리 기준
고객의 잔액과 통장 데이터 관점에서 DB 쓰기 지연이 발생했을 때, 문제가 발생하느냐를 기준으로 두고 반드시 Transaction이 보장되어야 하는 데이터 모델과 즉시 실행될 필요는 없는(세금 처리 Logic 등) 데이터 모델로 분리했다고 합니다.

세금 Kafka 토픽에 메시지를 Produce하고, 비동기 처리 서버가 Consume을 수행 후 세금 DB에 저장하도록 구현된 것입니다. 추가로 Kafka 메시지 장애에 대한 대응으로 DLQ(Dead Letter Queue)를 이용하여 세금 DB에 대한 트랜잭션을 안정적으로 보장할 수 있도록 하고 중복 업데이트 방지로 API 멱등성도 확보된 상태입니다.
DB 쓰기 지연
DB에 Data를 저장하는 작업이 완료되기까지 걸리는 시간을 의미합니다. 데이터 쓰기 작업 속도에 따라 DB 성능에 영향을 미칠 수 있습니다.
DLQ(Dead Letter Queue)
처리 중에 발생한 오류로 인해 정상적으로 처리되지 않은 메시지를 보관하는 Queue입니다. 약식 코드로 예를 들어보겠습니다. (※ 필자의 개인 Code, 의견인 점을 참고해주시기 바랍니다.)
import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @Component public class KafkaConsumerListener { private final KafkaTemplate<String, String> kafkaTemplate; public KafkaConsumerListener(KafkaTemplate<String, String> kafkaTemplate) { this.kafkaTemplate = kafkaTemplate; } @KafkaListener(topics = "input_topic", groupId = "my_group_id") public void consumeFromTopic(ConsumerRecord<String, String> record) { try { // 메시지 처리 로직 // 세금DB에 대한 트랜잭션 처리 후, 에러가 발생 시 예외 처리. processMessage(record.value()); // 처리에 성공하면 컨슈머의 오프셋을 커밋 } catch (Exception e) { // 처리에 실패한 경우, 에러가 발생한 메시지를 DLQ 토픽으로 전송 kafkaTemplate.send("dlq_topic", record.value()); System.err.println("Failed to process message: " + record.value() + ". Error: " + e.getMessage()); } } private void processMessage(String message) { // 메시지 처리 로직 throw new RuntimeException("Error occurred during processing."); } }
API 멱등성
동일한 요청을 여러 번 수행해도 결과가 동일하게 유지되는 것입니다. 즉, 같은 Request를 여러 번 보내더라도 시스템의 상태는 변하지 않아야 합니다. 재시도나 중복 요청 시에도 안전한 데이터 처리를 보장하고, 시스템의 안정성을 높이는 중요한 개념입니다.
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service public class TaxService { private final JdbcTemplate jdbcTemplate; @Autowired public TaxService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void updateTaxInDB(String requestId, int taxAmount) { // 요청 ID 기반으로 DB에서 해당 요청의 처리 상태를 확인. boolean requestExists = checkIfRequestExists(requestId); // 해당 요청이 이미 처리되었는지 확인. if (!requestExists) { // 요청이 처음 들어왔다면 세금DB UPDATE updateTaxAmountInDB(taxAmount); // 요청 처리를 표시하기 위해 요청 ID를 DB에 저장합니다. saveRequestInDB(requestId); } else { // 이미 해당 요청을 처리한 경우, 중복으로 처리하지 않고 무시합니다. System.out.println("Request with ID " + requestId + " has already been processed."); } } private boolean checkIfRequestExists(String requestId) { // 요청 ID 기반으로 DB에서 해당 요청 처리 상태 조회 // DB에서 요청을 조회하는 로직이 들어가야 합니다. // 요청이 존재하는지 여부 반환 return false; // 예시로 항상 false 반환하는 가정 } private void updateTaxAmountInDB(int taxAmount) { // 세금 DB 업데이트 작업 수행. // DB에 세금 정보를 업데이트하는 로직이 들어가야 합니다. } private void saveRequestInDB(String requestId) { // 요청 ID를 DB에 저장하여 중복 처리 방지. // 요청 ID를 DB에 저장하는 로직이 들어가야 합니다. } }
이렇게, 트랜잭션을 안정적으로 분리함으로써, 80회의 DML에서 30회의 세금 DML을 제외시킬 수 있었다고 합니다.
캐싱 전략
마지막으로 알아볼 항목은, 성능 향상을 위한 캐싱 전략입니다. 기존 Core Banking에서의 이자 계산은 일자별 거래 내역을 조회하여 연산하는 방식으로 구현되어 있었습니다. 고객이 거래를 해온 모든 일자의 거래 내역을 참조하여 이자와 세금을 계산하는 구조로 막대한 비용이 들 수밖에 없는구조였습니다.
그러나 고객은 하루 1번밖에 이자를 못 받기 때문에, Redis를 통해 1번의 DB/IO를 발생시킬 수 있을 것으로 판단하여 Redis를 사용했다고 합니다.
- Redis는 인메모리 데이터 스토어로, 빠른 IO가 가능하여 DB 부하를 줄이고 Application 성능을 향상시킬 수 있습니다.
고객이 하루의 처음, 접근할 때 이자 예상 조희를 실행하며, 계산 결과를 Redis에 캐싱하면, 재 접근 시 만료일이 지날때 까지 캐싱된 결과를 그대로 가져오게됩니다. 여기서 만료일자는 정각까지로 두어야 한다는 점을 놓치면 안 될것 같네요.
마치며
이번 시간엔 MSA 전환 시, 기능 고도화를 위해 사용한 전략과 기술들을 알아보았습니다. 약식으로 예시 코드도 작성하여 좀 더 깊게 이해할 수 있었는데요. 만약, 직접 전환 과정에 투입될 기회가 있었다면, 기억에 남을 좋은 성장 회고가 될수 있었을 것 같습니다.

답글 남기기