이번 포스팅에서는 동기화 관련 문제를 다루어 보도록 하겠습니다.
우선, 동기화에 대해 알아보고, Spring MVC를 이용시 발생할 수 있는 문제점을 보도록 하겠습니다.
마지막으로, 이를 해결하는 방법도 알아보도록 하겠습니다.
만약! 현재 Spring MVC 프로젝트를 진행중이지만, Thread를 별도로 생성해서 공유 자원에 접근하는 것이 아닌데 왜, 동기화 문제가 발생하는지를 궁금해하신다면, Servlet에 대해 다시 공부를 해보셔야 합니다.
1. 동기화
여러 스레드간 공유되는 자원은 항상 동기화 관련 처리를 해주어야합니다.
자바에서는 객체의 멤버변수
가 동기화 대상 데이터가 될 수 있습니다. JVM에서, 객체는 Heap 영역에 할당되고, 이 Heap 영역을 Thread들이 공유하기 때문입니다.
xpublic class Counter {
private int count;
public void increase() {
this.count ++;
}
public void decrease() {
this.count --;
}
public int getCount() {
return this.count;
}
}
예를들어, 위와 같은 Counter 클래스가 있다고 해보겠습니다.
xxxxxxxxxx
public class Main {
public static void main(String[] args) {
Counter c = new Counter();
new Thread(() -> {
for (int i = 0; i < 200000; i++) {
c.increase();
System.out.println("t 111 : " + c.getCount());
}
}).start();
new Thread(() -> {
for (int i = 0; i < 200000; i++) {
c.decrease();
System.out.println("t 222 : " + c.getCount());
}
}).start();
}
}
이제 위와 같이 2개의 스레드를 이용해서, 각각 c.increase()
와 c.decrease()
를 20만번씩 호출해보도록 하겠습니다. 결과가 어떨것 같으신가요?
0
에서부터 시작했고, +1을 20만번, 그리고 -1을 20만번 호출했으니 0 + 200000 - 200000 = 0
이 결과로 나와야 하지 않을까요?
어? 결과가 2가 출력되었네요?
다시 한번 돌려볼까요? 이번에는 -1이 출력되었습니다. 결과가 계속 바뀌네요?
이유
xxxxxxxxxx
load count;
count +1;
store count;
실제로 java의 ++
나 --
는 원자적으로 실행이 되지 않습니다. 위와 같이 3단계로 나누어 실행이 되는데요. 아래에서 2개의 thread를 이용해 ++를 처리하는 과정을 통해서 문제점을 조금 더 자세히 알아보도록 하겠습니다.
count는 Thread간 공유되는 자원이며, 초기값은 0입니다.
xxxxxxxxxx
// Thread1에 CPU가 할당됨
load count; // Thread1이 count값을 가져옴 (초기값 : 0)
// Thread2에 CPU가 할당됨
Thread1
에게 CPU가 먼저 할당되어, load count
가 실행되어 count값을 가져온 상황에서, Thread2
에게 CPU가 할당이 되었습니다. 지금 thread1이 가지고 있는 count의 값은 0
입니다.
xxxxxxxxxx
load count; // Thread2가 count값을 가져옴
count +1; // Thread2가 count의 값을 1증가 시킴
store count; // Thread2가 count 값을 저장함
Thread2
가 CPU를 점유하고 있는 상태에서, count를 가져와 1을 증가시킨 뒤, 이 값을 저장합니다. 이때 count에 저장된 값은 1
입니다.
xxxxxxxxxx
// Thread1에 CPU가 할당됨
count +1; // 이전에 실행한 명령어의 다음 명령어인 count+1부터 실행을 재게함
store count; // Thread1이 count 값을 저장함
Thread2의 실행이 끝나고, Thread1
에게 다시 CPU가 할당되고, 이때 CPU는 이전에 마지막으로 실행한 명령어의 다음 명령어 부터 실행하게 됩니다.
이때, Thread1
이 가지고 있는 count의 값은 0
입니다. 이 값에 +1
을 합니다.
마지막으로, Thread1
은 count에 1
을 저장합니다.
최종적으로 count에 저장된 값은 1
이 됩니다.
이 때문에, 스레드간에 공유되는 자원은 동기화를 필수적으로 해주어야 합니다.
2. Spring MVC에서의 동기화 문제
이번에는 Spring MVC를 이용할 때의, 동기화 문제를 알아보도록 하겠습니다.
만약! 현재 Spring MVC 프로젝트를 진행중이지만, Thread를 별도로 생성해서 공유 자원에 접근하는 것이 아닌데 왜, 동기화 문제가 발생하는지를 궁금해하신다면, Servlet에 대해 다시 공부를 해보셔야 합니다.
기본적으로 Spring MVC에서 사용자로부터 요청을 받게 되면, Container에서 Thread를 생성
하고, 이에 대한 처리를 한뒤 응답을 주는 구조이기 때문에, 기본적으로 멀티 스레드 환경이라고 생각을 하셔야 합니다.
2.1 시나리오
- 게시판에 글이 하나 존재합니다.
- 게시판의 글 조회시 조회수가 1씩 증가합니다.
- 수백명의 사람들이 동시에 수차례 글을 조회합니다.
- 조회수가 올바르게 올라갔는지 확인합니다.
물론 조회수가 중요한 도메인이 아니라면, 어느정도 조회수 데이터의 일관성이 깨지더라도 크게 지장이 없지만, 유튜브와 같이 조회수가 수입과 직결되는 도메인이라고 가정하고 문제를 해결해 보도록 하겠습니다.
2.2 구현
Entity
xxxxxxxxxx
name = "board") (
public class Board {
private Long boardId;
private String title;
private String content;
private int viewCount;
public Board(String title, String content) {
this.title = title;
this.content = content;
}
public void increaseViewCount() {
this.viewCount ++;
}
}
실제 게시판이라면 성능의 문제로 위와 같이 조회수
필드를 글에 같이 넣지는 않겠지만, 간단한 예제를 위해서, 우선 위와 같이 필드를 넣도록 하겠습니다.
Service
xxxxxxxxxx
public class BoardService {
private final BoardRepository boardRepository;
public Long write(WriteBoardRequest request) {
Board board = new Board(request.getTitle(), request.getContent());
Board savedBoard = boardRepository.save(board);
return savedBoard.getBoardId();
}
public BoardDetail read(Long boardId) {
Board board = boardRepository.findById(boardId)
.get();
board.increaseViewCount();
return new BoardDetail(board.getTitle(), board.getContent(), board.getViewCount());
}
}
Rest Controller
xxxxxxxxxx
public class BoardController {
private final BoardService boardService;
"/write") (
public void write() {
boardService.write(new WriteBoardRequest("hi", "ok"));
}
"/board") (
public BoardDetail read() {
return boardService.read(1l);
}
}
write로 요청시 임의로 글을 하나 작성하도록 하고, board로 요청시 해당글을 조회하도록 하겠습니다.
2.3 테스트
테스트에는 테스트 코드를 사용해보기도 하고, JMeter
를 이용해보기도 할것 입니다.
Junit을 이용한 테스트
우선 코드단에서 테스트를 해보도록 하겠습니다.
xxxxxxxxxx
class BoardServiceTest {
private int THREAD_CNT = 100;
private ExecutorService ex = Executors.newFixedThreadPool(THREAD_CNT);
private CountDownLatch latch = new CountDownLatch(THREAD_CNT);
private BoardService boardService;
private BoardRepository repository;
public void 동시성_테스트() throws Exception {
// given
Long boardId = boardService.write(new WriteBoardRequest("TEST", "TEST"));
// when
for (int i = 0; i < 100; i ++) {
ex.execute(() -> {
boardService.read(boardId);
latch.countDown();
});
}
// then
latch.await();
Board board = repository.findById(boardId).get();
assertThat(board.getViewCount(), is(100));
}
}
Thread 수는 100으로 정하고 이것을 이용해 글 읽기 요청을 동시에 하도록 할 것입니다. (CountDownLatch는 thread가 모두 종료되기를 기다리도록 도와주는 역할을 합니다., thread의 join() 메소드와 비슷한 역할)
xxxxxxxxxx
assertThat(board.getViewCount(), is(100));
100번의 글 읽기 요청을 했으니 조회수는 100이어야 할 것입니다.
역시 동기화 처리를 해주지 않았기 때문에, 올바른 값이 나오지 않습니다.
Jmeter를 이용한 테스트
https://galid1.tistory.com/373 Jmeter 설치
https://galid1.tistory.com/374 Jmeter 간단한 사용법
우선 브라우저에서 /write
로 요청을 보내, 임의의 글을 작성하도록 합니다.
글이 작성되었음을 확인합니다. VIEW_COUNT는 현재 0
입니다.
100개의 스레드로 100번 요청하도록 해보겠습니다. 실행 이후, 조회수는 100 * 100
인 10000
이 되어야겠죠?
??? 결과가 우리가 예상한것과 너무 다르게 나왔습니다. 이 처럼 Spring MVC에서도, 멀티스레딩 환경이기 때문에, 공유자원에 대한 동기화 처리가 필요합니다.
3. 동기화 해결
잠금종류
우선 JPA의 잠금기법을 알아보기전, 잠금의 종류에 대해 알아보도록 하겠습니다.
Optimistic Locking (낙관적 잠금)
이는, 버전
을 이용한 잠금 기법으로, 데이터 갱신시 스레드에 의해 경합이 발생하지 않을거라고 생각을 하고 잠금처리를 하는 기법입니다.
우선 데이터에 version
이라는 개념이 추가되고, 공유 대상의 데이터를 가져올 때 버전을 확인합니다. 이후, 알맞은 처리를 한뒤, 저장시에 자신이 가져온 version
과 같은지를 확인합니다. 이때 version이 다르다면 에러를 발생시킵니다.
거의 충돌 감지? 와 같은 개념인것 같습니다. 이 기법은, 회원정보 수정등과 같이 동시에 많은 처리가 이루어지지 않을거라고 예상되는 곳에서 사용하는것이 좋아보입니다.
Pessmistic Locking(비관적 잠금)
이 방법은, 비관적으로 즉, 무조건 데이터 경합이 발생할 것이라고 생각하고 먼저 데이터에 대한 잠금을 걸도록 하는 잠금 방식입니다.
이 방법은 주문시의 재고량 관리와 같이 많은 사람들이 동시에 변경을 요청하는 작업이 일어나는 곳에 사용해야하는 방법입니다.
3.1 JPA 잠금기법
앞서 말씀드렸듯이, 현재 우리의 서버에서 에러가 발생하는 원인은, 동기화 처리가 되지 않았기 때문입니다. 조금 더 자세히 말씀드리자면, 공유 자원인 Board Entity의 viewCount에 대한 동기화가 되지 않았기 때문입니다.
암시적 잠금
코드로 명시적으로 잠금을 하지 않아도 JPA에서 자동으로 잠금이 발생하는 것을 의미합니다.
Entity에 @Version
이 부여된 필드가 존재하거나, @OptimisticLocking
어노테이션이 부여되어 있다면, 이 Entity를 조회하는 경우 자동으로 각각에 맞는 잠금처리가 이루어 집니다.
명시적 잠금
xxxxxxxxxx
entityManager.find(Order.class, orderId, LockModeType.OPTIMISTIC);
entityManager.createQuery("쿼리").setLockMode(LockModeType.PESSIMISTIC_WRITE);
의도적으로 잠금을 실행하는 것을 명시적잠금
이라고 합니다.
EntityManager를 이용해 Entity조회시 LockMode
를 지정하거나, createQuery의 LockMode를 set하거나, select for update
를 이용해서직접 잠금을 지정할 수 있습니다.
3.2 잠금 기법을 이용해 조회수 동기화 하기
낙관적 잠금을 이용해보자
우선 낙관적 잠금을 이용해서 조회수 데이터의 일관성이 깨지는것을 막아보도록 하겠습니다.
앞서 말씀드렸듯이, 동기화가 필요한 필드에 @Version
을 부여하면, 낙관적 잠금이 암시적으로 설정됩니다.
테스트 실행
낙관적 잠금을 걸고나서, 이와 같은 에러가 발생할 것이라고 예측이 되셨어야 합니다.
낙관적 잠금은, 여러 스레드가 동시에 한 데이터를 변경하려 할때 우선은 데이터를 반환해주고, 이를 저장하는 시점에 다른 스레드에 의한 데이터의 변경이 있으면 이를 덮어 쓰지 못하도록, 에러를 발생시키기 때문입니다.
우리의 테스트는, 100개의 스레드를 이용해 조회수를 동시에 변경하도록 했으니 이때, 낙관적 잠금을 이용한다면, 당연히 변경 오류가 발생할 것 입니다.
비관적 잠금을 이용해보자
public interface BoardRepository extends JpaRepository<Board, Long> {
LockModeType.PESSIMISTIC_READ) (
Optional<Board> findByBoardId(Long boardId);
}
마찬가지로 암시적 잠금 기법을 사용할 것입니다. BoardRepository에, 만약 변경을 위해 글을 조회하는 경우에는, 위의 @Lock(LockModeType.PESSIMISTIC_READ)
가 부여된 find 메소드를 이용하도록 할 것입니다.
BoardService의 read() 메소드중 위의, board를 조회하는 곳에서 비관적 잠금 설정이 된 find 메소드
를 사용하도록 변경합니다.
테스트 실행
성공입니다...
Jmeter 테스트
100개의 스레드로 10번의 요청을 하도록 하겠습니다.
성공입니다 !