본문 바로가기
실습/리눅스 서버 + 스프링 부트

동시성 제어

by 이민우 2023. 10. 16.
728x90
반응형

옛날에 면접을 보던 중 이런 질문을 받은 적이 있다.

 

"동시에 한 칼럼에 두 명이 접근할 때 동시성 제어를 어떻게 수행하세요?"

 

물론 면접관님께서는 조금 더 구체적인 예시로 질문을 주셨으나, 어찌됐던 질문 속의 속뜻은 위와 같았다.

 

그런데 그 당시 나는 아무런 대답을 하지 못했었다. 동시성 제어가 중요한 건 알고 있었지만, 굳이 그걸 실제로 구현해본 적은 없었기 때문이다.

 

그러다 최근 프로젝트를 진행하며 개발 완료된 모듈에 대한 부하 테스트를 수행하는데, 동시성 제어가 제대로 되어있지 않아 데이터가 제대로 갱신되지 않는 것을 발견했다.

 

이 상황을 면접 전에 겪었더라면 위 질문에 답변을 할 수 있지 않았을까 하는 아쉬움이 남지만, 어찌됐든 지난 일은 지난 일이니 혹시 모를 다음 면접에 대비하고 또 실무에서도 보다 더 동시성 제어를 신경쓰며 개발할 수 있기 위해 동시성 제어 관련 공부를 해볼까 한다.

 

 

동시성 제어

 

은행 계좌에 입금을 한다고 해보자, 잔고가 0원이 계좌에 A와 B가 동시에 각각 100원, 200원을 입금했고, 입금을 하는 로직은 순서대로 아래와 같이 이루어진다.

  1. 계좌 잔고를 조회한다.
  2. 잔고에 입금한 돈을 추가한다.
  3. 갱신된 잔고를 저장한다.

그러면 정상적인 로직이라면 아래와 같이 실행되어 최종적으로 잔고에는 300원이 남아있어야 할 것이다.

잔고를 조회함 돈을 입금함 잔고
0원 100원 100원
100원 200원 300원

 

하지만 동시성 제어가 제대로 되어 있지 않으면 잔고에는 200원이 남아있을 확률이 있다.

잔고를 조회하는 작업이 거의 동시에 이루어지기 때문이다.

잔고를 조회함 돈을 입금함 잔고
0원 100원 100원
0원 200원 200원

 

이를 코드로 구현해보면 아래와 같이 구현할 수 있을 것 같다.

	@Transactional
	public boolean deposit(int userNo, int money) {
		
		log.info("@@ Deposit {} money to {} user", money, userNo);
		
		try {
			AccountDomain newAccount = accountRepo.findById(userNo).orElse(null);
			if(newAccount == null) {
				// 존재하지 않는 사용자 넘버
				log.error("@@ {} user not exists", userNo);
				return false;
			}
			
			// 1. 잔고 조회
			int balance = newAccount.getBalance();
			// 2. 잔고에 입금한 돈 추가
			newAccount.setBalance(balance + money);
			// 3. 저장
			accountRepo.save(newAccount);
		}
		catch(Exception e) {
			log.error("@@ Some error occured : {}", e.getMessage());
			return false;
		}
		
		log.info("@@ Deposiot method successfully finished");
		return true;
	}

 

그러면 이제 테스트를 위한 전체 코드를 작성해보자.

JPA를 사용했으며, 간단하게 도메인, 리포지토리, 서비스만 작성했다.

 

Account.java

package com.mwlee.test.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name="account")
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Account {
	@Id
	@Column(name="user_no")
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private int userNo;
	
	@Column(name="balance")
	private int balance;
}

 

AccountRepository.java

package com.mwlee.test.repository;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import com.mwlee.test.domain.Account;

@Repository
public interface AccountRepository extends CrudRepository<Account, Integer> {

}

 

AccountService.java

package com.mwlee.test.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.mwlee.test.domain.Account;
import com.mwlee.test.repository.AccountRepository;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class AccountService {
	
	@Autowired AccountRepository accountRepo;
	
	/**
	 * 계좌 수 조회
	 * @return
	 */
	@Transactional(readOnly=true)
	public int count() {
		return (int) accountRepo.count();
	}
	
	/**
	 * 신규 계좌 만들기
	 * @return
	 */
	@Transactional
	public int register() {
		Account account = new Account();
		account.setBalance(0);
		accountRepo.save(account);
		
		return account.getUserNo();
	}
	
	/**
	 * 계좌 잔고 초기화
	 * @param userNo
	 * @return
	 */
	@Transactional
	public boolean resetAccount(int userNo) {
		
		log.info("@@ Reset account of {}", userNo);
		Account account = accountRepo.findById(userNo).orElse(null);
		if(account == null) {
			// 존재하지 않는 사용자 넘버
			log.error("@@ {} user not exists", userNo);
			return false;
		}
		account.setBalance(0);
		accountRepo.save(account);
		
		return true;
		
	}
	
	/** 
	 * 입금
	 * @param userNo
	 * @param money
	 * @return
	 */
	@Transactional
	public boolean deposit(int userNo, int money) {
		
		log.info("@@ Deposit {} money to {} user", money, userNo);
		
		try {
			Account newAccount = accountRepo.findById(userNo).orElse(null);
			if(newAccount == null) {
				// 존재하지 않는 사용자 넘버
				log.error("@@ {} user not exists", userNo);
				return false;
			}
			
			// 1. 잔고 조회
			int balance = newAccount.getBalance();
			// 2. 잔고에 입금한 돈 추가
			newAccount.setBalance(balance + money);
			// 3. 저장
			accountRepo.save(newAccount);
		}
		catch(Exception e) {
			log.error("@@ Some error occured : {}", e.getMessage());
			return false;
		}
		
		log.info("@@ Deposiot method successfully finished");
		return true;
	}
	
	/**
	 * 잔고 조회
	 * @param userNo
	 * @return
	 */
	@Transactional
	public int getBalance(int userNo) {
		
		log.info("@@ Get balance of {} user", userNo);
		Account newAccount = accountRepo.findById(userNo).orElse(null);
		
		try {
			if(newAccount == null) {
				// 존재하지 않는 사용자 넘버
				log.error("@@ {} user not exists", userNo);
				return -1;
			}
			
		}
		catch(Exception e) {
			log.error("@@ Some error occured : {}", e.getMessage());
			return -1;
		}

		log.info("@@ GetBalance method successfully finished");
		return newAccount.getBalance();
		
	}
}

 

이제 아래와 같은 테스트 코드를 작성한다.

package com.mwlee.test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import com.mwlee.test.service.AccountService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
class ConcurtestApplicationTests {

	@Autowired AccountService accountService;
	
	@Test
	void contextLoads() throws Exception {
		
		int threads = 2;
		int userNo = 1;

		ExecutorService exService = Executors.newFixedThreadPool(threads);
		// CountDownLatch를 쓰지 않으면 쓰레드가 끝나기 전에 다음 코드(결과 확인 부분)으로 넘어가 버려서 사용.
		CountDownLatch cdLatch = new CountDownLatch(threads);
		
		// 계좌가 없으면 만들고, 있으면 잔고를 0으로 설정
		log.info("@@ Check if account exists...");
		int count = accountService.count();
		if(count == 0) {
			log.info("@@ Account not exists. register new account...");
			accountService.register();
		}
		else {
			log.info("@@ Account exists. Reset account for test...");
			accountService.resetAccount(userNo);
		}
		
		// 동시성 제어 테스트 진행
		log.info("@@ Start Tests");
		exService.execute(() -> {
			// 100원 저축
			accountService.deposit(userNo, 100);
			cdLatch.countDown();
		});
		exService.execute(() -> {
			// 200원 저축
			accountService.deposit(userNo, 200);
			cdLatch.countDown();
		});
		cdLatch.await();
		
		// 결과 반환 (100, 200, 300 중 어느게 올 지 몰라 Assert는 사용하지 않음.
		int balance = accountService.getBalance(1);
		log.info("@@ Tests done. resunt : {}", balance);
	}

}
CountLatch

CountLatch 미사용 시 쓰레드가 완료되지 않아도 메인 스레드가 실행된다.

CountLatch 생성 시 카운트를 정해놓고, countDown() 메서드 실행 시 카운트가 하나씩 차감되며, await 사용 시 카운트가 0이 될 떄까지 대기하고 0이 되면 이후 스레드(메인 스레드)를 실행한다.

 

위 코드를 돌려보면 아래와 같은 결과가 출력됨을 확인할 수 있다. (스레드 순서가 그 때마다 다르기에 서로 다른 결과가 출력된다.)

로그를 자세히 보면 Deposit 메소드가 거의 동시에 실행되었고, 비슷한 타이밍에 잔고가 조회되었다. 이로 인해 최종적으로 300원이 아닌 100원 혹은 200원이 잔고에 적립되었다.

 

테스트용 프로그램이 아니라 실제 프로그램을 이렇게 했더라면 나중에 엄청난 문책을 받았을 지 모륵겠다.

 

 

동시성 제어 방법

그러면 동시성 제어를 어떻게 해야 할까?

대표적인 방법으로는 @Lock을 사용하는 방법이 있다.

Lock

Lock이란 DB 동시 접근에서 무결성과 일관성을 지키기 위한 방법이다.

정말 말 그대로 DB를 잠시 잠그는 행위로 볼 수 있는데, 작업 시 DB를 잠금으로써 다른 접근을 제한하고, 작업 완료 시 잠금을 해제함으로써 다른 접근을 허용하는 행위이다.

 

JPA에서 Lock은 org.springframework.data.jpa.repository.Lock 의 어노테이션을 사용할 수 있으며, Lock 타입은 javax.persistence.LockModeType에서 가져와 사용할 수 있다.

 

LockModeType에서 사용 가능한 Lock 종류는 아래와 같다. 즉, 낙관적 잠금과 비관적 잠금을 설정할 수 있다.

  • NONE : 별도의 옵션을 사용하지 않으나, Entity에 @Version 존재 시 낙관적 잠금이 적용됨.
  • OPTIMISTIC : 버전 속성을 포함하는 모든 엔티티에 대한 낙관적 읽기 잠금
  • OPTIMISTIC_FORCE_INCREMENT : 낙관적 잠금을 사용하며 버전 정보를 강제로 증가
  • PESSIMISTIC_FORCE_INCREMENT : 배타적 잠금으로 데이터 읽기, 업데이트, 삭제 방지 및 버전 정보 증가
  • PESSIMISTIC_READ : 공유 잠금으로 데이터의 업데이트와 삭제 방지
  • PESSIMISTIC_WRITE : 배타적 잠금으로 데이터 읽기, 업데이트, 삭제 방지
  • READ : OPTIMISTIC과 유사
  • WRITE : OPTIMISTIC_FORCE_INCREMENT와 유사

*https://www.baeldung.com/jpa-pessimistic-locking

*https://www.baeldung.com/jpa-optimistic-locking

 

낙관적 잠금은 말 그대로 자원 경쟁을 낙관적으로 바라본다. 풀어 말하면 다중 트랜잭션이 데이터를 동시에 수정하지 않는다고 가정한다. 고로 데이터를 읽을 때가 아닌 갱신할 때만 Lock을 설정하며, 트랜잭션에 의해 잘못된 갱신을 자동으로 방지해주지는 않는다. 그렇기에 데이터를 수정하는 시점에서 앞서 읽은 데이터가 다른 사용자에 의해 변경되었는지를 검사해야 한다.

 

동시 업데이트가 없는 경우 빠른 조회와 업데이트를 보장할 수 있으나, 여러 트랜잭션 이용시 다른 트랜잭션의 작업이 거부되어 오류 처리 혹은 재시도 로직이 필요할 수 있다.

 

그에 반해 비관적 잠금은 말 그대로 자원 경쟁을 비관적으로 바라보기에, 다중 트랜잭션이 데이터를 동시에 수정할 것이라고 가정한다. 고로 데이터를 읽는 시점부터 Lock을 걸고, 조회 및 갱신이 완료될 때까지 Lock을 유지한다. 트랜잭션의 동시 접근을 확실하게 방지할 수 있으나, 로직 진행간 다른 트랜잭션의 접근을 막기에 동시성이 떨어져 성능이 떨어지고, 자원을 점유한 채 서로의 자원을 요청하는 데드락 현상의 발생 가능성이 존재한다.

 

위 경우에는 select > update 순이기에 비관적 잠금을 사용하는 것이 맞겠으나, 어차피 테스트용이므로 그냥 둘 다 써보자.

참고로 비즈니스 로직 전체에 대한 Lock을 걸기 위해 Repository가 아닌 Service에 @Lock을 걸었다.

 

우선 기존의 @Transactional을 @Lock으로 대체하고, 메소드 앞에 synchronized를 붙여준다.

우선 비관적 락이다. 테스트를 수행해본다.

메소드가 동시에 실행되지 않았고, 300원이 잔고에 정상적으로 남아있다.

 

다음으로 낙관적 락도 사용해보자.

*원래 낙관적 락 시 엔티티에 @Version을 설정해야 하나, lock을 Service에 걸어서인지 그냥 해도 된다.

비즈니스 로직 내에 select 뿐 아니라 update도 존재해, 메소드가 동시에 실행되지 않고 순차적으로 실행되어 300원이 정상 입금되었다.

 

추가하자면 Transational의 Serializable을 사용하면 동일한 결과를 얻을 수 있다.

 

또한 큐를 직접 구현해서 사용자의 요청을 순서대로 큐에 넣고 하나씩 빼서 진행함으로써 동시성을 제어할 수도 있는데, 이 방법은 직접 구현을 할 수 있음으로써 유연하게 백엔드의 처리 능력을 확장/축소할 수 있다. 다만 설계 및 구현이 복잡해질 수 있고, 큐에 메시지가 머무는 동안 지연 시간이 생겨 실시간 처리에 적합하지 않을 수 있다.

 

추가)

어찌됐던 Lock이나 Transaction을 사용하면 효율성은 어쩔 수 없이 떨어지게 되어있다.
이 부분에 대해서 사수이신 과장님께 의견을 구했고, 다음 방법으로 어느정도 효율성을 다시 잡을 수 있다는 답변을 얻었다.

- Replica DB를 이용할 것.
사실 실제 DB가 단일DB로 구성되는 경우는 크게 없다. 진짜 딱히 쓰잘데기 없는 프로그램이 아닌 이상 백업 및 장애 시 복구를 위해서라도 하나보다 많은 DB가 존재한다. 그래서 그 중 하나를 READ 전용으로 사용하는 것이다. 그러면 UPDATE 하는 동안에 READ는 해당 DB에서 수행함으로써 최소한 조회에서의 성능은 잡을 수 있다.

다만 이 경우 메인 DB와 주기적인 동기화가 필요해서 일부 지연 시간이 발생할 수 있으므로 실시간 데이터를 대상으로는 무리가 있다.

 

728x90
반응형

'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글

[Spring Webflux] MariaDB CRUD API  (2) 2023.12.03
Spring MVC vs Spring Webflux  (0) 2023.10.30
[Apache Server] 리버스 프록시_CentOS, Ubuntu  (1) 2023.09.28
Spring Batch  (0) 2023.07.19
OSIV  (0) 2023.07.14