본문 바로가기

JAVA

자바 레벨에서의 동시성

 

사람들은 컴퓨터를 사용하면서 여러 가지를 동시에 할 수 있는 것을 당연하게 생각한다. 예를 들면 인터넷 쇼핑을 하면서 백그라운드에서는 유튜브를 통해 음악을 들을 수도 있고, 카카오톡을 주고 받는 것처럼 말이다. 컴퓨터는 이런저런 일들을 처리해주는 와중에도 사용자가 마우스를 움직이면 즉시 커서를 이동해주고 클릭에도 반응한다. 이를 가능하게 해주는 소프트웨어를 concurrent software라고 한다. 자바는 만들어질 당시부터 기본적으로 동시성을 지원하도록 설계가 되었다. 이와 관련된 개념들을 소개하기 전에 ‘프로세스’와 ‘스레드’를 먼저 간단히 이해해보자.

 

프로세스와 스레드

프로세스

  • 프로그램이 실행되는 데 필요한 모든 것들이 저장되어 있는 자료구조
  • Text, Program Counter and Register, Stack, Data, Heap 구역으로 이루어진다.
  • Heap과 Stack 영역은 가변적으로 변하는 부분이고 나머지는 고정된 영역이다.

스레드

  • CPU 실행의 기본적인 단위
  • 프로세스 내부에서 실질적으로 CPU의 실행을 담당한다
  • 한 프로세스에 하나의 스레드만 실행하는 경우 single-threaded, 여러 개의 스레드를 실행하는 경우 multi-threaded라고 한다.
  • Multi-threaded인 경우 text(code), data, heap 영역은 공통이지만 stack 영역은 각각 가진다.

 

컴퓨터의 운영체제는 여러 개의 프로세스와 여러 개의 스레드를 가지고, 동시성(Concurrency) 혹은 병렬성(Parallelism)을 사용하여 컴퓨터가 여러 개의 일을 동시에 처리할 수 있게 해준다. 이에 관해서는 자바가 아닌 CS적인 내용이라 이 글에서는 이 정도만 알아보도록 하자.

 

자바로 작성된 프로그램을 실행시켜 주는 JVM(Java Virtual Machine)에 일반적으로 컴퓨터에서 한 개의 프로세스로 실행이 된다. 그리고 이 JVM이 동시에 여러 작업을 수행하기 위해 따로 멀티 스레드를 지원한다. 따라서 운영 체제에서의 스레드와 자바에서의 스레드는 엄밀히 말하자면 다르지만, 개념 자체는 비슷하다.

 

 

 

동시성 문제

그렇다면 동시성 문제라는 것은 언제 발생할 수 있을까? 여러 개의 스레드 자체적인 스택도 가지고 있지만 데이터가 저장되는 부분을 공유하고 있다. 이를 공유 자원(shared resource)이라고 한다. 동시성 문제는 여러 개의 스레드가 동시에 같은 공유 자원에 접근할 때 주로 발생한다.

 

정합성이 정말로 중요한 돈과 관련해서 예시를 들어보자. 50명의 회원이 존재하는 모임에서는 모임통장을 이용해서 회비를 관리하고 있다. 이 때 회비납부일에 50명의 회원이 동시에 회비 1000원을 입금하는 상황을 코드로 만들어보았다.

public class Account {

	private int balance;

	public Account() {
		this.balance = 0;
	}

	public void deposit(int amount) {
		balance += amount;
	}

	public int getBalance() {
		return balance;
	}
}

 

@RepeatedTest(100)
void concurrentTest() throws InterruptedException {
	int amountToDeposit = 1000;
	int numberOfMembers = 50;
	ExecutorService executor = Executors.newFixedThreadPool(16);
	CountDownLatch latch = new CountDownLatch(numberOfMembers);

	for (int i = 0; i < numberOfMembers; i++) {
		executor.execute(() -> {
			account.deposit(amountToDeposit);
			latch.countDown();
		});
	}
	latch.await();

	assertEquals(amountToDeposit * numberOfMembers, account.getBalance());
}

 

Account 클래스는 모임의 공동 계좌이다. 현재 잔고가 있고, 입금을 할 수 있는 메서드가 존재한다. 테스트를 통해서 50명의 회원들이 account의 deposit 메서드를 통해서 1000원을 입금하는 상황을 테스트해보았다. 초기에 잔고가 0원이었고 50명이 1000원씩 입금하니 최종 잔고는 50000원이 되어야 한다.

 

여기서 ExecutorService, CountDownLatch 등은 멀티쓰레드 환경으로 테스트를 하기 위한 클래스이다. 또한 멀티쓰레드 환경이라고 하더라도 동시성 문제가 항상 발생하는 것은 아니니 실패하는 경우가 존재하는지 보기 위해 @RepeatedTest를 이용해서 반복해주었다.

 

테스트를 100번 반복했는데 93번은 올바르게 최종 잔고가 50000원이었지만 7개의 테스트에서는 그보다 적은 것을 볼 수 있다. (실패하는 테스트의 개수는 매번 달라진다) 이는 공유 자원인 balance에 여러 쓰레드가 동시에 접근할 때 한 쓰레드가 다른 쓰레드의 접근을 알지 못하기 때문에 발생했다. 이러한 문제는 Race Condition이라고 한다. 물론 이미 이런 상황을 다 예상하고 있는 자바에는 이를 방지하기 위한 방법들이 존재한다

 

 

 

해결 방법

synchronized

public class Account {
	...

	public synchronized void deposit(int amount) {
		balance += amount;
	}

	...
}

Account 클래스의 deposit 메서드를 다음과 같이 수정한 뒤 반복 테스트를 다시 수행해보자.

 

이번에는 몇 번을 수행해도 모든 테스트가 성공하는 것을 볼 수 있을 것이다. synchronized는 해당 메서드를 한 쓰레드가 사용중일 때 다른 쓰레드의 접근을 막음으로써 동기성을 유지시켜준다. 메서드에 사용하는 방식 이외에도 코드 블럭을 사용하여 한 메서드 내부에서도 개발자가 원하는 구역에 대해서만 thread-safe를 보장해줄 수 있다.

 

 

Thread-safe한 객체 사용하기

AtomicInteger

synchronized를 이용해서 락을 획득하는 방법은 상당히 무거운 방식이다. 많은 쓰레드들의 락의 획득 여부를 관리해주어야 하기 때문이다. 그래서 무분별한 synchronized 키워드의 사용은 프로그램의 성능 저하를 발생시킬 수도 있다. AtomicInteger는 int형 값의 변화를 알아서 atomic하게 관리해주는 자료구조이기 때문에 개발자가 필드 값을 변경하는 상황이더라도 동기화를 따로 신경써주지 않아도 된다.

public class Account {
	private final AtomicInteger balance;

		pulblic Account() {
				this.balance = new AtomicInteger(0);
		}

		public void deposit(int amount) {
			balance.addAndGet(amount);
		}

		...
}

 

참고 AtomicInteger 외에도 java에서 제공하는 다양한 자료구조가 존재한다. Hashtable, ConcurrentHashMap 등

 

 

Thread-safe 객체를 포함하는 Race condition

thread-safe 객체를 사용할 때 주의해야 할 부분이 있다. 코드상으로 봤을 때는 멀티쓰레드 상황에서 동기성을 보장해줄 것 같은데 실제로는 그렇지 않은 경우가 있을 수 있다.

List nicknameList = Collections.synchronizedList(new ArrayList<>());

public void addNickname(String nickname) {
		// 닉네임이 중복되지 않으면 map에 저장
	if (!nicknameList.contains(nickname)) {
		nicknameList.add(nickname);
	}
}

 

베타 단계의 게임에서 유저들의 닉네임의 중복을 허락하지 않도록 하기 위해서 위와 같은 로직으로 구현했다고 가정하자. 어떤 스레드에서 nicknameMap을 호출하는 순간에 다른 쓰레드는 nicknameMap에 접근하지 못하도록 객체가 보장해준다. 10명의 유저가 동시에 바다라는 닉네임을 추가하려는 상황을 테스트로 구현해보자.

@RepeatedTest(1000)
void collectionsConcurrencyTest() throws InterruptedException {
	int amountToAdd = 10;
	ExecutorService executor = Executors.newFixedThreadPool(16);
	CountDownLatch latch = new CountDownLatch(amountToAdd);

	for (int i = 0; i < amountToAdd; i++) {
		executor.execute(() -> {
			collections.addNickname("바다");
			latch.countDown();
		});
	}
	latch.await();

	List<String> nicknameList = collections.getNicknameList();
	System.out.println(nicknameList);
	assertEquals(1, nicknameList.size());
}

 

대부분의 테스트에서 nicknameList에 하나의 요소만이 추가되었지만 종종 두 명 이상이 같은 이름으로 추가되어 있는 경우를 볼 수 있다. Thread-safe한 객체를 썼는데도 동기화가 보장이 되지 않은 것이다.

그 이유는 if문 분기 코드와 if문 내부 코드 사이에서 다른 스레드가 리스트를 수정했기 때문이다. 따라서 synchronized 블록을 사용하여 동기화가 필요한 부분은 전체적으로 관리해주거나, 객체에서 제공하는 atomic한 메서드를 사용할 수 있다.

(ex. CocurrentHashMap의 pubIfAbsent)