synchronized 키워드와 동시성 처리

백준호·2024년 6월 18일
1

스프링 부트는 내장 톰캣 컨테이너를 지원한다. 톰캣 컨테이너는 작업(요청)이 들어왔을 때 작업 큐에 담아두고 쓰레드에 하나씩 할당하여 요청을 처리한다.

각 작업은 다른 쓰레드에서 처리가 되며, 작업이 완료되면 쓰레드는 유휴 상태로 돌아가 새로운 작업을 받아들인다. 이러한 작동 방식 때문에 스프링 부트에서 내가 의도하지 않은 동작(race condition)을 불러 일으킬 수 있는데, 예시를 통해서 문제 상황과 해결 방법을 알아보자.

Race Condition?

Race Condition(경쟁 상태)란 둘 이상의 프로세스, 쓰레드가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과값이 달라질 수 있는 현상을 말한다. 공유 자원에 차례대로 접근하는 것이 아니라 한 쓰레드가 공유 자원을 사용 중일 때 다른 쓰레드가 또 접근하는 상황이 생긴다면 의도하지 않은 결과를 불러 일으킬 수 있다.

위에서 설명한 것처럼 스프링 부트에서 요청마다 쓰레드가 할당되어 작업을 처리하는데, 이 때 여러 쓰레드가 동일한 자원에 접근하면 경쟁 상태가 발생한다.

예시코드

입금을 하는 예시 코드로 Race Condition 상태를 재현해보자.

데이터베이스

@Component
@Slf4j
public class Database {
    private Map<String, Long> database = new HashMap<>();

    public void add(String key, Long value) {
        sleep(100);

        if (database.containsKey(key)) {
            database.put(key, database.get(key) + value);
        } else {
            database.put(key, value);
        }
    }

    public Long get(String key) {
        if (database.containsKey(key)) {
            return database.get(key);
        } else {
            return 0L;
        }
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 단순하게 조회, 입금을 할 수 있는 데이터베이스이다.

경쟁 상태가 발생하는 서비스 코드

@Service
public class OrderService {
    private Database database;

    public OrderService(Database database) {
        this.database = database;
    }

    public void addOrder(String orderId, Long amount) {
        database.add(orderId, amount);
    }

    public Long getOrderAmount(String orderId) {
        return database.get(orderId);
    }
}
  • 단순하게 입금과 조회를 수행하는 서비스 코드이다.
  • 결론부터 이야기하자면 경쟁 상태가 발생하는 코드이며, 테스트코드를 통해 확인해보자.

경쟁상태 테스트


@SpringBootTest
public class BeforeTest {
    @Autowired
    private OrderService orderService;

    @Test
    void test() throws InterruptedException {
		    // 30개의 쓰레드 풀 생성
        ExecutorService executorService = Executors.newFixedThreadPool(30);
        // 쓰레드 작업 종료를 기다리기 위해 쓰레드 개수만큼의 CountDownLatch 생성
        CountDownLatch countDownLatch = new CountDownLatch(30);
        
        // 작업 시작
        for (int i = 0; i < 30; i++) {
            executorService.submit(() -> {
                orderService.addOrder("order1", 100L);
                countDownLatch.countDown();
            });
        }

        countDownLatch.await();

				// 100을 30번 입금하였으니 3000이 나오는 것이 정상
        assertEquals(3000L, orderService.getOrderAmount("order1"));
    }
}
  • 30번의 동시 입금 요청을 보내는 테스트 코드이다.
  • 100씩 30번 입금하였기 때문에 3000이 최종 데이터베이스에 저장되어 있어야 한다.

예상 결과는 3000이지만 1400이 나온 것을 볼 수 있다. 이유는 경쟁상태이다. 다른 쓰레드가 작업 중인 데이터베이스에 접근하여 값을 가져오고, 그 상태에서 계산하여 틀린 결과가 나오는 것이다.

synchronized?

경쟁 상태를 해결하기 위해 synchronized 키워드를 사용할 수 있다. synchronized 키워드를 메서드에 명시하면 하나의 쓰레드만 접근이 가능하게 해준다.

synchronized 키워드를 사용하면 현재 작업 중인 쓰레드를 제외한 나머지 쓰레드는 공유 자원을 사용하기 위해 기다리며 순차적으로 공유 자원에 접근할 수 있다.

사용 방법은 간단하다.

@Service
public class NewOrderService {
    private Database database;

    public NewOrderService(Database database) {
        this.database = database;
    }

    public synchronized void addOrder(String orderId, Long amount) {
        database.add(orderId, amount);
    }

    public Long getOrderAmount(String orderId) {
        return database.get(orderId);
    }
}

위 코드의 addOrder 메서드와 같이 synchronized 키워드를 붙여주면 된다.

동일한 테스트 진행 시 테스트가 통과되고, 동시성 문제가 해결된 것을 볼 수 있다.

다만 순차적으로 쓰레드가 접근하기 때문에 실행 속도가 상대적으로 느리다. 또한 단일 프로세스 안에서의 공유자원 접근 문제를 해결하는 것이기 때문에 여러 서버를 사용하는 환경에서는 다른 방법으로 경쟁 상태를 해결해야한다.

관련 코드

GitHub - junho100/spring-duplicated-request-prevention: 스프링 부트 단일 서버 동시성 처리 예시코드

관련 자료

스프링부트는 어떻게 다중 유저 요청을 처리할까? (Tomcat9.0 Thread Pool)

LogicBig

[ 운영체제 ] 경쟁상태(Race Condition)와 동기화(Synchronization)의 필요성, 임계 구역(Critical Section)

profile
회고하는 개발자

0개의 댓글

관련 채용 정보