본문 바로가기

programming/CS

[Spring] 동시성 문제와 해결방법 알아보기

이번 포스팅에서 알아볼 문제는 동시성 문제입니다. 
오늘은 동시성 문제에 대해 간략하게 설명하고, 자바에서 synchronized를 활용하여 동시성 문제를 해결하는 방식을 알아보고자 합니다.

뒤에서 언급할테지만 이 방식은 단순히 '여러 스레드의 동시 접근을 막는 방식'이기 때문에 프로세스 내에서만 유효하다는 단점이 있다.
즉, 다중 서버(Scale Out)로 운용하는 서비스에서는 synchronized를 사용하더라도 동시성 문제를 해결할 수 없다. 
따라서 데이터베이스에 락을 걸어 동시 접근을 차단하는 방식이 더 적합할 수 있다.
이와 관련해서는 추후 DB Lock 포스팅을 작성하여 정리하고자 한다.

 

동시성 문제

동시성 문제란, 하나의 자원에 여러 스레드가 동시에 접근하여 데이터 정합성이 지켜지지 않은 것을 의미합니다.
 
코드를 작성하며 이해하기 쉽도록 간단한 예시를 하나 들어보겠습니다. (실제로 이런 경우는 거의 없겠지만 ..)

하나의 은행 계좌에 두 개의 스레드가 동시에 접근하여 10,000원씩 출금하는 경우
기대하는 결과 값 = 원래 잔액에 20,000원이 차감된 금액

 

코드 작성하기 - 서비스 로직

이제 예제에 필요한 코드를 작성합니다.
글로만 이해하는 것보다는 코드도 같이 써야 기억에 오래 남아서, 출처 포스팅을 참고하여 예제 코드를 작성했습니다.
 

(1) Bank 도메인

package com.example.demo.bank.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Bank {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true)
    private Long userId;

    private Integer balance;

    @Builder
    public Bank(Long userId) {
        this.userId = userId;
        this.balance = 0;
    }

    public void withdraw(int amount) {
        if (this.balance - amount < 0) {
            throw new RuntimeException();
        }
        this.balance -= amount;
    }

    public void deposit(int amount) {
        this.balance += amount;
    }
}
도메인 코드를 작성하다가 @NoArgsConstructor 어노테이션 사용시 생성자를 protected로 제한하는 이유와 @Builder 어노테이션을 함께 사용하는 방법에 대해 알아보게 되었는데, 이와 관련해서도 따로 포스팅을 업로드할 예정이다.
일단 위 코드처럼 생성자에 직접 @Builder 어노테이션을 지정해주면, 객체 생성에 필요한 값을 제한하여 빌더를 만들 수 있다. 

 
예제에 필요한 프로퍼티와 메서드를 정의했습니다.
 

(2) BankRepository

package com.example.demo.bank.repository;

import com.example.demo.bank.domain.Bank;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.NoSuchElementException;
import java.util.Optional;

@Repository
public interface BankRepository extends JpaRepository<Bank, Long> {
    @Query("SELECT b FROM Bank b WHERE b.userId = :userId")
    Optional<Bank> findByUserId(Long userId);

    // default: interface 에 메서드를 추가 정의할 때 사용
    default Bank findByUserIdOrElseThrow(Long userId) {
        return findByUserId(userId).orElseThrow(NoSuchElementException::new);
    }
}
repository 내에서 예외처리를 하게끔 메서드로 구현

 
예제에 필요한 Bank 관련 DB 작업을 처리하는 BankRepository를 정의했습니다.
 

(3) BankService

package com.example.demo.bank.service;

import com.example.demo.bank.domain.Bank;
import com.example.demo.bank.repository.BankRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class BankService {
    private final BankRepository bankRepository;

    @Transactional
    public void withdraw(Long userId, int amount) {
        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        bank.withdraw(amount);
        bankRepository.saveAndFlush(bank);
    }

    @Transactional(readOnly = true)
    public Integer getBalance(Long userId) {
        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        return bank.getBalance();
    }
}
변경사항을 DB에 즉시 반영하기 위해 saveAndFlush 메서드 사용

saveAndFlush
save 메서드는 영속성 컨텍스트에 등록하는 것이고 saveAndFlush는 영속성 컨텍스트 등록 후 flush도 진행하는 메서드이다. 
영속성 컨텍스트에 등록된 값은 바로 DB에 반영되는 것이 아니라, flush 과정을 거쳐야 DB에 반영된다.

 
예제에 필요한 서비스 로직을 처리하는 BankService를 정의했습니다.
 

코드 실행하기

- 테스트 코드 작성

package com.example.demo.bank.service;

import com.example.demo.bank.domain.Bank;
import com.example.demo.bank.repository.BankRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

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

@SpringBootTest
class BankServiceTest {
    @Autowired
    private BankRepository bankRepository;

    @Autowired
    private BankService bankService;

    private final Long userId = 1L;

    @BeforeEach
    void setup() {
        bankRepository.saveAndFlush(new Bank(userId));
    }

    @AfterEach
    void reset() {
        bankRepository.deleteAll();
    }

    @Test
    @DisplayName("동시성 테스트")
    void synchronizeTest() throws InterruptedException {
        // given
        int threads = 2;
        ExecutorService executorService = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);

        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        bank.deposit(20000);
        bankRepository.saveAndFlush(bank);

        // when
        for (int i = 0; i < threads; ++i) {
            executorService.submit(() -> {
                bankService.withdraw(userId, 10000);
                latch.countDown();
            });
        }
        latch.await();
        executorService.shutdown();

        // then
        Assertions.assertThat(bankService.getBalance(userId)).isEqualTo(0);
    }
}

 

- 실행 결과

동시성 문제가 발생하여 테스트 통과에 실패함


기대한 결과로는 두 스레드가 각각 10,000원씩(총 20,000원)이 차감되어 0원이 출력되어야 하지만, 실제로는 10,000원만 차감된 것을 확인할 수 있습니다.

 
실제 서비스에서 이러한 문제가 발생한다면 서비스 운영에 치명적이기 때문에, 정합성이 중요한 데이터는 동시성 문제를 해결한 후 운영해야 합니다.
 

동시성 문제 해결하기

동시성 문제의 발생 원인은 다음과 같습니다.

멀티 스레드 환경에서, 하나의 스레드가 특정 자원으로 작업하는 도중에(= 작업을 마치기 전에) 다른 스레드가 해당 자원 값에 접근하면서 정합성이 깨지는 것

 
따라서 이 문제를 해결하려면 하나의 스레드가 작업을 완료할 때까지 다른 스레드가 자원에 접근할 수 없도록 막아야 합니다.
 

Synchronized

자바에서는 멀티 스레드 환경에서의 동시성 문제를 해결하기 위한 방안으로 synchronized 키워드를 제공하는데요, 위의 예시를 그대로 사용한다면 아래와 같이 사용할 수 있습니다. 

package com.example.demo.bank.service;

import com.example.demo.bank.domain.Bank;
import com.example.demo.bank.repository.BankRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class BankService {
    private final BankRepository bankRepository;

    @Transactional
    // synchronized 키워드 적용
    public synchronized void withdraw(Long userId, int amount) {
        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        bank.withdraw(amount);
        bankRepository.saveAndFlush(bank);
    }

    @Transactional(readOnly = true)
    public Integer getBalance(Long userId) {
        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        return bank.getBalance();
    }
}

 
synchronized 키워드를 적용하면 withdraw 메서드에는 여러 스레드가 동시에 접근하지 못할 것입니다.
 

테스트는 통과했지만 과연 문제를 완벽하게 해결 ?

 
테스트 코드를 실행하면 테스트가 통과하는 것을 확인할 수 있습니다.
 

과연 synchronized 키워드 만으로 동시성 문제를 해결할 수 있을까?

[문제점1] @Transactional 어노테이션 사용 불가능

하지만 synchronized 키워드를 사용하여 동시성 문제를 해결하려는 경우에는 @Transactional 어노테이션을 사용할 수 없습니다. 
@Transactional 어노테이션은 프록시 객체를 생성하기 때문인데요, 원인을 구체적으로 이해하기 위해 관련 내용을 정리해보겠습니다.
 
프록시 객체란 원본 객체에 부가적인 기능을 추가한 것으로, 프록시 객체를 사용하면 원본 객체의 코드 수정 없이 부가적인 기능을 제공할 수 있습니다.
따라서 @Transactional 어노테이션 객체를 사용하는 경우, 사용자의 입장에서는 원본 객체를 호출하는 것과 동일하지만 실제로는 프록시 객체를 호출하여 부가적인 기능(트랜잭션)을 제공 받을 수 있는 것입니다.
 
아래 코드는 @Transactional 어노테이션으로 생성된 BankService의 프록시 객체를 간략하게 표현한 것입니다.

class BankServiceProxy {
    private BankService bankService;
    
    @Override
    public void withdraw(Long userId, int amount) {
        try {
            tx.start(); // 트랜잭션 시작
            bankService.withdraw(userId, amount);
        } catch (Exception e) {
            // rollback
        } finally {
            tx.commit(); // 트랜잭션 커밋
        }
    }
}

 
BankService의 decrease 메서드에서 사용했던 synchronized 키워드는 메서드 시그니처가 아니기 때문에, BankService를 확장한 BankServiceProxy 객체에는 해당 키워드가 상속되지 않은 것을 확인할 수 있습니다.

자바 메서드 시그니처는 메서드 명, 파라미터 타입, 파라미터 개수이다. (리턴 타입은 메서드 시그니처에 포함되지 않는다.)

 
따라서 프록시 객체의 withdraw 메서드에는 여러 스레드가 동시에 접근할 수 있기 때문에 동시성 문제가 해결되지 않은 것입니다.
 
 

엥 근데 위에서 테스트 통과 했잖아?

 
프록시 객체가 아닌 실제 bankService 객체의 메서드에는 synchronized 가 적용되기 때문에, 스레드가 적을수록 프록시 객체로 인한 동시성 문제 발생을 발견하기 어렵습니다. (앞선 예제에서는 스레드가 두 개 뿐)
 
그래서 synchronized 키워드를 사용한 코드는 유지한 채로, 스레드를 100개로 늘려보도록 하겠습니다.
이 테스트 코드는 10,000원이 있는 계좌에 100개의 스레드가 100원씩 출금합니다.
그렇다면 결과는 10,000 - (100 * 100) = 0원이 되어야겠죠?

package com.example.demo.bank.service;

import com.example.demo.bank.domain.Bank;
import com.example.demo.bank.repository.BankRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

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

@SpringBootTest
class BankServiceTest {
    @Autowired
    private BankRepository bankRepository;

    @Autowired
    private BankService bankService;

    private final Long userId = 1L;

    @BeforeEach
    void setup() {
        bankRepository.saveAndFlush(new Bank(userId));
    }

    @AfterEach
    void reset() {
        bankRepository.deleteAll();
    }

    @Test
    @DisplayName("동시성 테스트")
    void synchronizeTest() throws InterruptedException {
        // given
        int threads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);

        Bank bank = bankRepository.findByUserIdOrElseThrow(userId);
        bank.deposit(10000);
        bankRepository.saveAndFlush(bank);

        // when
        for (int i = 0; i < threads; ++i) {
            executorService.submit(() -> {
                bankService.withdraw(userId, 100);
                latch.countDown();
            });
        }
        latch.await();
        executorService.shutdown();

        // then
        Assertions.assertThat(bankService.getBalance(userId)).isEqualTo(0);
    }
}

 

동시성 문제로 한 스레드의 실행 결과가 반영되지 않았음


결국 synchronized 키워드를 적용시키려면 프록시 객체를 사용할 수 없습니다. 즉 @Transactional 어노테이션을 사용할 수 없습니다. 
하지만 동시성 문제를 해결하기 위해 해당 어노테이션을 삭제하는 것이 맞을까요?
 
@Transactional 어노테이션은 트랜잭션 단위를 나타낼 때 사용합니다. 트랜잭션이란 하나의 쪼갤 수 없는 작업 단위를 나타낸 것으로, 앞서 살펴본 예제에서는 "(1)은행 계좌 금액을 읽어오기 (2)원하는 만큼 금액 차감하기 (3)변경사항 반영하기"가 하나의 트랜잭션이 됩니다.

트랜잭션은 아래 네 가지의 특성(ACID)을 갖는다.
Atomic - 원자성
Consistency - 일관성
Isolation - 고립성
Durability - 영속성
트랜잭션에 대한 자세한 내용은 이번 포스팅에서 다루지 않음

 
즉, 트랜잭션에는 하나의 연산만 포함되는 것이 아닙니다. 만약 단순한 연산의 경우에는 어노테이션을 뺄 수도 있겠지만, 대부분 복잡한 연산이 하나의 트랜잭션으로 관리되어야 하는 경우가 더 많기 때문에 현실적으로 불가능하다고 생각합니다. 
 
이러한 단점(= @Transactional 어노테이션 사용 불가)을 보완하기 위한 방법으로는 자바의 Lock을 활용할 수 있지만, 바로 이어서 설명할 문제 때문에 이 방식 또한 근본적인 해결책이 되지 못합니다.
 

[문제점2] Scale Out 환경에서는 동시성 문제를 해결할 수 없다.

synchronized 키워드와 Lock 모두 단일 프로세스 내의 여러 스레드 사이에서 발생하는 동시성 문제를 해결하는 것이기 때문에, 분산 서버로 운영하는 경우에는 근본적인 해결책이 될 수 없습니다. 

서버는 하드웨어이므로 한정적인 자원을 가지고 있다. 따라서 서비스를 배포해서 운영할 때 이용자가 증가한다면 서버에 부하가 발생하는데, 이를 해결하기 위해 Scale Up 방식 혹은 Scale Out 방식을 활용하여 사용자 수를 늘릴 수 있다.

 
결국 분산 서버 환경도 고려했을 때, 스레드간에 락을 거는 것이 아니라 데이터베이스에 락을 거는 것이 안전하다고 볼 수 있습니다. 
데이터베이스 락은 다음 포스팅에서 트랜잭션 격리 수준과 같이 정리해보고, 스프링에서 데이터베이스 락을 적용해보겠습니다. 
 
 
 


참고

- 인프런 질의응답 (프록시 객체가 생성될 때 synchronized 없이 메서드가 생성되는 것이 맞을까요?)
https://www.inflearn.com/questions/907953/comment/267025
 
- [Spring] 동시성 문제 해결방법 (1) - synchronized, Lock 사용
https://ttl-blog.tistory.com/1567