[Java] synchronized 키워드와 Thread의 관계...
개요
안녕하세요. 이번 시간에는 synchronized키워드와 Thread에 대해 알아보겠습니다.
혹시 Thread가 무엇인지 모르시는 분들은 아래 링크를 통해 학습하고 오시는 걸 추천드리겠습니다.
[Java] Thread 클래스와 Runnable 인터페이스 개념 및 사용법
일단 synchronized가 무엇일까요? 아래 단어 뜻을 보면 "동시통합화된"이라고 알 수 있습니다.
Thread는 작업을 독립적으로 실행합니다. 우리가 Thread를 생성하지 않았을 때는 실제로는 싱글 스레드(Java main함수를 실행한 Main Thread) 환경에서 로직이 수행된다고 이해할 수 있습니다. 또한 개발자가 원하는 상황에 Thread가 동작하지 않습니다. (완전한 제어가... 불가능...) 그렇기 때문에 우리는 Thread 상태를 관리하는 메서드를 사용해서 필요에 따라 Thread를 어느 정도 제어는 가능 하지만 완벽히 컨트롤하는 건 어렵다고 볼 수 있습니다.
하지만 우리는 특정 비즈니스 로직에서는 해당 데이터를 동기화시켜야 하는 상황이 발생합니다. 그때 자바에서 사용되는 기술이 synchronized라는 키워드입니다.
종종 Thread Safe 하다. 스레드의 안전하다는 표현을 많이 보셨을 텐데요. 이 안전함을 얻기 위해 사용합니다.
이번 포스팅에 실제 실무에서 Multi Thread 환경에서 개발을 해야 하는 상황에서 기술을 정확히 알지 않고 활용했을 때 문제점을 겪은 상황에 대해서 설명도 같이 함께 드리겠습니다.
최근에도 애를 먹었기 때문에 이번 포스팅에서 해당 내용을 다시 한번 학습하고 정리를 하려고 합니다.
사용법
synchronized 키워드를 사용하는 방법은 다음과 같습니다.
- method에 직접 선언
- synchronized 블록 생성 this 지정
- synchronized 블록 생성 및 다른 Object 지정
예시 상황을 만들기 위해서 가벼운 주제와 간단한 코드를 작성해서 시현하겠습니다.
주제
재고관리 시스템을 구현한다고 가정했을 때 현재 창고에 커피 비품 수량을 확인하고 감소시키는 로직을 만든다고 가정하겠습니다.
기본적으로 커피 클래스를 만들겠습니다.
package org.example;
public class Coffee {
private Long coffeeId;
private String name;
private Integer price;
private Integer quantity;
public Coffee(Long coffeeId, String name, Integer price, Integer quantity) {
this.coffeeId = coffeeId;
this.name = name;
this.price = price;
this.quantity = quantity;
}
public Integer getQuantity() {
return quantity;
}
public void minusCoffeeQuantity(Integer quantity) {
this.quantity -= quantity;
}
}
이제 main() 메서드에서 해당 커피 클래스를 생성하고 Thread를 통해 해당 수량을 감소하는 로직을 만들어보겠습니다.
package org.example;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) throws InterruptedException {
Coffee amerikano = new Coffee(1L, "아메리카노", 1500, 30); // 수량 30개 아메리카노 커피 생성
int numberOfThreads = 5;
ExecutorService service = Executors.newFixedThreadPool(2); //Thread Pool을 사용해서 2개의 Thread 생성
CountDownLatch latch = new CountDownLatch(numberOfThreads); //Thread 등록
for (int i = 0; i < 10; i++) {
service.execute(() -> {
Integer choiceQuantity = 5;
//현재 남아있는 커피 수량 조회
Integer quantity = amerikano.getQuantity();
System.out.println("현재 남아있는 커피 수량 = " + quantity);
if(quantity <= 0) {
throw new RuntimeException("현재 수량이 모두 소진 됐습니다.");
}
//커피 수량 감소
amerikano.minusCoffeeQuantity(choiceQuantity);
Integer minusQuantity = amerikano.getQuantity();
System.out.println("커피 수량 감소 후 커피 수량 = " + minusQuantity);
latch.countDown();
});
}
latch.await();
}
}
위 코드를 보면 Thread Pool이라는 용어가 나오는데 해당 개념음 다른 포스팅에서 언급하도록 하겠습니다.
반복문을 통해 0부터 10까지 스레드는 작업을 진행합니다. 여기서 실제 위 코드를 실행하면 결과는 그때그때 다른 결과를 알 수 있습니다.
위 콘솔 로그를 확인하면 다음과 같은 내용을 알 수 있습니다.
//현재 남아있는 커피 수량 조회
Integer quantity = amerikano.getQuantity();
System.out.println("현재 남아있는 커피 수량 = " + quantity);
if(quantity <= 0) {
throw new RuntimeException("현재 수량이 모두 소진 됐습니다.");
}
위와 같은 방어 로직을 만들었지만 실제 콘솔 로그를 확인하면 현재 남아있는 커피 수량이 -5 상태가 된 걸 확인할 수 있습니다.
이러면... Thread가 더 많으면 당연히 - 되는 커피 재고는 어마어마될 것 같아요... 이럴 때 우리는 데이터 동기화 Thread 끼리 작업 우선권을 줘야 합니다.
synchronized 사용법에 대해 알아보고 위 내용을 수정하도록 하겠습니다.
1. method 선언 방법
method의 선언하는 방식을 메서드 선언 시 synchronized 키워드를 같이 선언해 주면 됩니다.
public synchronized void minusCoffeeQuantity(Integer quantity) {
this.quantity -= quantity;
}
선언하는 방법은 어렵지 않죠? 이렇게 선언하면 메서드 내부 전체 Lock이 발생합니다. Thread가 해당 메서드를 사용하기 위해서는 권한을 얻어서 사용해야 합니다.
2. synchronized 블록 생성 this 지정
메서드 전체 또는 특정 로직 전체를 Lock을 설정하고 싶을 때 특정 구문만 위주로 선언합니다.
public void minusCoffeeQuantity(Integer quantity) {
synchronized (this) {
this.quantity -= quantity;
}
}
또한 this로 지정했다면 해당 synchronized Lock 제어권을 자기 자신이 관리합니다.
3. synchroinized 블록 생성 및 다른 Object 지정
또 다른 방법은 this 직접 선언이 아는 Object 다른 객체를 지정하는 방식입니다.
public class Coffee {
...
private Object object = new Object();
...
public void minusCoffeeQuantity(Integer quantity) {
synchronized (object) {
this.quantity -= quantity;
}
}
이렇게 선언하면 object 클래스가 Lock에 제어권을 관리합니다.
우리는 동기화하는 방법에 대해 알았습니다. 그럼 이제 위와 같이 커피 수량이 마이너스(-)로 떨어지는지 한번 테스트를 해보겠습니다.
값이 마이너스(-)로 떨어지지는 않지만 원하는 로직으로 수행이 되지 않는 것 같습니다.
수량 정보가 순차적으로 감소가 되어야 하는데 수차적으로 감소가 되지 않는 상황입니다... (난 분명히 동기화를 했는데...)
이러한 부분에 있어서 현업에서 많이 실수를 합니다. (저도... 했습니다 ㅠㅁㅠ)
Thrad는 제어하기 어렵다는 걸 학습하면서 많이 느끼셨을 것 같습니다. 처음 작성한 로직을 확인하면 숨은 함정이 있습니다.
그 함정은 현재 커피 수량을 조회 후 수량 정보를 업데이트하는 상황이 함정입니다.
//현재 남아있는 커피 수량 조회
Integer quantity = amerikano.getQuantity();
System.out.println("현재 남아있는 커피 수량 = " + quantity);
if(quantity <= 0) {
throw new RuntimeException("현재 수량이 모두 소진 됐습니다.");
}
//커피 수량 감소
amerikano.minusCoffeeQuantity(choiceQuantity);
Integer minusQuantity = amerikano.getQuantity();
System.out.println("커피 수량 감소 후 커피 수량 = " + minusQuantity);
우리는 synchronized 설정을 update 하는 minusCoffeeQuantity 메서드에만 설정했습니다.
데이터를 조회하는 시점(Integer quantity = amerikano.getQuantity())에서 수량을 감소한 데이터를 가져와야 하는데 그렇지 않기 때문에 위와 같은 상황이 발생합니다.
그럼 어떻게 해야 할까요? 조회하는 시점도 synchronized를 설정하면 해결될까요? 다시 테스트를 해보겠습니다.
public synchronized Integer getQuantity() {
return quantity;
}
public synchronized void minusCoffeeQuantity(Integer quantity) {
this.quantity -= quantity;
}
위 메서드 둘 다 설정을 했습니다. 테스트를 해보겠습니다.
결과가 더 이상해졌습니다... 수량이 마이너스(-)가 다시 보였습니다. 결국 해결되지 않았습니다. 이뮤는 무엇일까요?
우리는 데이터 동기화를 하나의 단위로 만들어서 처리해야 합니다. 이건 DB트랜잭션과 많이 동일합니다.
로직을 다음과 같이 변경하겠습니다. 일단 메서드에 synchronized를 선언한 코드는 지우도록 하겠습니다. 그리고 기존 비즈니스 로직을 변경하도록 하겠습니다.
for (int i = 0; i < 10; i++) {
service.execute(() -> {
Integer choiceQuantity = 5;
synchronized (object) {
//현재 남아있는 커피 수량 조회
Integer quantity = amerikano.getQuantity();
System.out.println("현재 남아있는 커피 수량 = " + quantity);
if (quantity <= 0) {
throw new RuntimeException("현재 수량이 모두 소진 됐습니다.");
}
//커피 수량 감소
amerikano.minusCoffeeQuantity(choiceQuantity);
Integer minusQuantity = amerikano.getQuantity();
System.out.println("커피 수량 감소 후 커피 수량 = " + minusQuantity);
}
latch.countDown();
});
}
데이터 조회와 데이터 수정하는 부분을 하나의 synchronized 블록으로 묶어서 처리했습니다. 그럼 실행 결과를 확인해 보겠습니다.
여러 번 프로그램을 실행해도 다음과 같은 결과가 나오는 걸 확인할 수 있습니다.
하나의 스레드가 작업을 처리할 때 조회와 수정을 하나의 synchronized 블록으로 설정하고 처리했기 때문에 우리가 원하는 결과를 얻을 수 있었습니다.
이번 시간에는 synchronized와 Tread의 대해 알아봤습니다. 꼭 이해가 될 때까지 실습을 해보시는 걸 추천드리겠습니다.