본문 바로가기
ToyProject

스프링 게시판 만들기 - 7 (2) (동시성 feat.ThreadLocal)

by 완두완두콩 2022. 7. 13.

한동안 손을 떼고 있던 토이프로젝트(게시판 만들기)를 간간히 봐주시는 분들이 계시는데

조회수 관련해서 동시성은 고려하신거냐고 묻는 댓글을 보고

음.. 동시성?.. 바로 이전 학기에 네트워크프로그래밍과 운영체제를 들어서 아 동기화 문제구나 하고 깨닫고

답글을 달아드리려 하는데 명확히 답할수가 없어서(& 잘못된 답변을 달아드려서) 해당 부분에 대해서 정리해보고자 한다.

 

동시성


동시성 문제란 멀티 쓰레드 환경에서 나타나는 문제이다.

예를 들면, 내가 1번 게시글을 조회한다고 할 때 싱글 스레드라면 문제되지 않는다.

하나의 스레드에서 작업이 끝난 후에야 다음 작업이 실행되기 때문에 조회수가 정확하게 계산될 것이다.

 

반면, 멀티 쓰레드 환경이라면 문제가 생길 수 있다.

A 게시글은 100 의 조회수를 갖고 있다고 가정해보자.

 

조회수를 변경하기 위해서는 조회 -> 저장 두가지 동작으로 이루어지는데,

1번 쓰레드에서는 내가 현재 100 조회수를 조회 하고, 저장까지 완료한 다음

다른 2번 쓰레드가 접근해서 조회하면 101 의 조회수를 조회하고, 저장하면 102 로 정상적으로 조회수가 올라가게 된다.

 

하지만 만약, 1번 쓰레드가 100 을 조회하고 저장하는 과정에서 2번 쓰레드가 현재 자원을 조회한다면

100이라는 조회수를 조회하고 1만큼 올려서 저장하게 된다.

 

즉, 두 개의 쓰레드 모두 현재 조회수가 100 인 자원을 갖고 1씩 증가시켰기 때문에 

두번을 조회했음에도 총 조회수는 101이 되게 된다.

 

가변객체 불변객체

 


스프링 개발환경에서는 스프링 컨테이너가 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리하고,

스프링의 기본 세팅은 멀티 쓰레드 환경이다.

 

그렇다면 하나의 객체를 여러 쓰레드가 사용하게 되는거고, 그렇게 되면 앞서 언급한

동시성 문제가 발생하는 것 아닌가? 라는 생각이 든다.

답부터 말하자면 동시성 문제는 작성하는 코드에 따라 생길 수도,

생기지 않을 수도 있다.

 

이를 알기 위해서는 우선 가변객체와 불변객체에 대해 알아야 한다.

 

대표적으로 String, Long 등 Reference type 이 불변클래스에 속하고

int, long 등이 가변클래스 중 하나에 속하게 된다.

 

불변객체는 변경이 불가능하고, 가변적이지 않은 객체를 이야기 하는데 이 뜻에 대해서 설명해보고자 한다.

(변경 불가능, But 재할당 가능)

 

String 을 이용해서 불변 클래스는 어떻게 작동하는지에 대해 간단히

예를 들어보겠다.

 

다음과 같은 코드가 있다고 가정해보자. 

        String d = "dodo";
        d.concat("Kong");

        System.out.println("d = " + d);

당연히 'dodoKong' 이라는 문장이 출력됨을 알 수 있는데, 이게 중요한게 아니고

우리는 concat 이 어떻게 돌아가는지 아는게 중요하다.

 

concat 은 다음과 같이 코드가 작성돼 있다.

중요한 점은 바로 return new String 키워드다.

즉, concat 은 기존의 dodo 라는 문자열에 Kong 이라는 문자를 붙여서 출력하는게 아니라

새로 String 객체를 생성해서 dodoKong 문자열을 return 하는 것이고,

그럼 기존의 100 번지 dodo 스트링 객체는 Garbage Collection 의 대상이 될 것이다.

메모리
100 dodo
200 dodoKong

이렇듯 위에서 d 라는 String 객체는 heap 영역에서 값이 바뀌는게 아니라 가리키고 있는 

메모리 주소가 달라지는 것이다. 즉, 변경은 불가능하지만 재할당을 통해 우리가 원하는

값을 출력하는 것이다.

 

불변객체는 내부적인 상태가 변하지 않으니 여러 스레드에서 동시에 참조해도

동시성 이슈가 발생하지 않는다는 장점이 있다.

불변 객체를 만드는 방법은 생성자로 모든 상태 값을 생성할 때 세팅하고,

객체의 상태를 변화시킬 수 있는 부분을 모두 제거해야 하고(setter 를 만들지 않는 것)

또한 내부 상태가 변하지 않도록 모든 변수를 final로 선언하면 된다.

 

나는 이런 것들에 대해 별로 신경을 쓰지 않고 작성했는데 왜 문제가 없었을까 라고 생각한다면!..

스프링을 사용하는 개발자들은 아마 대부분 

@Controller, @Service같은 곳에서 사용하는 객체들은 주입을 받아서 사용하고 내부적으로 멤버 변수를 가지고

변화시키지 않기 때문이라고 생각하고 있다!..


+ 해당 부분을 작성하다 보니, 내가 사용하고 있는 Lombok 의 @Builder

Thread-safe 한가 궁금해졌다.

 

여러 자료를 뒤져봤고 결국 @Builder 는 Thread-safe 하다는 것을 깨달았다.

@Builder 는 호출돼서 사용할 때마다 새로 객체를 생성하고, 지역 변수만 사용하기 때문에 문제가 없다!

 

 

Thread-safe


그렇다면 현재 내가 짠 조회수 코드는 과연 Thread-safe 한 것인가?

음.. 사실 정답을 잘 모르겠다. 79번 째의 조회에서 마음이 걸린달까.. @Builder 가 Thread-safe 를 보장해주는 건 알겠는데..

그래서 모르겠다.. 이렇게 짜면 괜찮지 않을까 하고 생각한 방법이 다음과 같이 82행에 때려박는건데..

저렇게 바꾸더라도 어차피 동시에 여러 스레드가 돌아간다고 치면, getter 가 어디에 있든 문제가 되는 것 같아서 

또 다시 자료를 찾아보니.. 아주 예전의 자료지만 getter 를 쓰는 것은

Thread-safe 하지 않다는 정보를 확인했다.

그렇다면 내가 지금까지 쓴 코드는 전부 Thread-safe 하지 않은가?..

라는 물음이 있었으나 우선 제쳐두고..

 

ThreadLocal


그렇다면 불변 객체를 만들지 않고도 Thread-safe 하게 만드는 방법은 무엇인가?!

Synchronized 나 뭐 다른 키워드를 몇개 봤으나 현재 가장 편의성이 좋은 방법은 바로

ThreadLocal 을 사용하는 것 같다.

스레드 로컬의 개념은 위 그림과 같다.

각 스레드 별로 별도의 공간을 따로 할당해주고, 각 스레드는 별도의 내부 저장소만 접근할 수 있다.

즉 -> 각각의 스레드를 위한 공간을 분리해준다는 것!

 

직접 테스트를 해보았는데 생각보다 헤맨 시간이 많았다.

그 이유는 sleep 타임을 제대로 생각 안하고 줘서인데 일단 설명을 해보겠다.

 

 


-Service-

 private ThreadLocal<Long> countVisitStore = new ThreadLocal<>();
    
    @Transactional
    public Long countVisitLogic(Long id) {

        Board board = boardRepository.findById(id).orElseThrow((() ->
                new IllegalStateException("해당 게시글이 존재하지 않습니다.")));

        log.info("저장 : ID={} board.getCountVisit={} ",id, countVisitStore.get());
        countVisitStore.set(board.getCountVisit() + 1L);
        board.updateVisit(countVisitStore.get());
        sleep(100);
        log.info("조회 : countVisitStore={}",countVisitStore.get());
        log.info("카운트 횟수={}", board.getCountVisit());

        countVisitStore.remove();
        return countVisitStore.get();
    }
    
private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

1 행의 ThreadLocal 이 스레드 로컬을 사용하기 위해 사용되는 클래스이다.

다음과 같이 제네릭 타입으로 선언돼있으니, 각자 입맛에 맞게 변경하면 된다.

나의 경우, 조회수를 체크할 것이고 해당 조회수는 Long 타입으로 정의돼있기에 Long 으로 넣었다.

countVisitStore 에 .set() 으로 값을 저장하고, .get() 으로 값을 꺼내온다.

사용에 어려움은 없어서 다행이다.

나는 board.getCountVisit() + 1L 로 현재 게시글의 조회수를 가져오고 +1 증가시켜주는데,

이를 countVisitStore 라는 스레드 로컬에 저장한다.

그 후, update 시키는 로직을 실행시키고 DB 에 반영한다.  마지막으로는 간단히 count 가 올라갔는지 확인을 해본다.

 

-Test-

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class BoardControllerTest {
 
    @Autowired
    BoardService boardService;
 
    @Test
    public void ThreadTest () throws Exception{
        // given
        log.info("main start");
        Runnable count1 = () -> boardService.countVisitLogic(1L);
        Runnable count2 = () -> boardService.countVisitLogic(1L);
        // when
        Thread threadA = new Thread(count1);
        threadA.setName("thread-A");
 
        Thread threadB = new Thread(count2);
        threadB.setName("thread-B");
        // then
        threadA.start(); // thread-A 비지니스 로직 실행
        sleep(100);
        threadB.start(); // thread-B 비지니스 로직 실행
 
        sleep(3000); // 메인 쓰레드 종료 대기
 
 
        Board board1 = boardService.findById(1L);
        Long countV1 = board1.getCountVisit();
        log.info("조회 : 카운트={}",countV1);
        log.info("main exit");
     }
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
 
cs

 

대망의 테스트코드!..

Runnable 은 스레드 관련 인터페이스고, Thread는 스레드 관련 클래스로 알고 있다.

간단히 설명하면, count1과 count2 쓰레드에 내가 실행하고자 하는 로직(조회수 증가)를 넣고, 

해당 쓰레드의 객체를 생성해서 이름을 설정해준 뒤, 각각의 쓰레드를 실행시켜 주는 것이다.

 

boardService.coutnVisitLogic(1L) 을 통해 1번 게시글을 이용해서 테스트를 진행하기로 했다.

각각의 A , B 스레드에 로직을 넣고, A.start(), B.start() 하면 다음과 같이!

각각의 스레드 별로 동작해서 조회수가 두번 올라가게 된다!

 

그렇다면 만약, 여기서 sleep() 시간을 주지 않거나, Service 에서 sleep() 시간을

A 스레드 실행 후 대기 되는 시간보다 더 길게 주면

당연한 결과겠지만 스레드 간 실행 시간의 차이를 주지 않았기 때문에 서로 동시에 돌아가게 되고

같은 값을 조회 -> 저장 하기 때문에 카운트 횟수가 2로 1만큼만 증가하게 된다.


바뀐 코드가 정상적으로 돌아가는지 확인 했으니,

그럼 기존의 코드는 과연 동시성을 만족하고 있었는지 궁금해서 테스트 해보았다.

 

  @Test
    public void BeforeCountLogic () throws Exception{
        // given
        log.info("main start");
        Board board = boardService.findById(1L);
        BoardDto boardDto = BoardDto.builder()
                .countVisit(board.getCountVisit()+1L)
                .build();
        // when
        Runnable test1 = () -> boardService.updateVisit(board.getId(), boardDto);
        Runnable test2 = () -> boardService.updateVisit(board.getId(), boardDto);
        // then
        Thread threadA = new Thread(test1);
        threadA.setName("thread-A");

        Thread threadB = new Thread(test2);
        threadB.setName("thread-B");
        // then
        threadA.start(); // thread-A 비지니스 로직 실행
        sleep(200);
        threadB.start(); // thread-B 비지니스 로직 실행

        sleep(3000); // 메인 쓰레드 종료 대기
        log.info("카운트={}",board.getCountVisit());
     }

음.. 동시성 만족이 되지 않는다!..

지금까지 내가 짠 코드는 모두 동시성을 만족하는 코드가 아니였구나 하는 깨달음을 얻었다..

당연히 Thread-safe 하다고 무의식중에 생각했었는데 음.. 띠용 했달까..

final 을 통해 불변객체로 설정한 것도 아니고, ThreadLocal 로 코드를 짠것도 아니기에 

당연한 결과겠지만은.. 

 

결론


동시성을 만족하고자 하는 객체는 불변 객체로 작성하거나

ThreadLocal 을 이용해서 동시성을 보장해주자!

 

사담


ThreadLocal 을 사용하면 동시성 문제도 해결 되는구나! 하는 결론과 함께!..

매번 이렇게 sleep() 을 통해 시간 간격을 주면 사용량이 많은 서비스의 경우에는 서비스가 너무

느려지지 않을까 하는 생각이 있다.

내가 작성한 조회수 같은 부분은 동시성을 굳이 줄 필요는 없을 것 같고.. 결제 같은 중요한 부분에 따로 주어야하나?..

아니면 애초에 설계부터 stateless(무상태)로 유지하게 해서 작성하나?..

매번 ThreadLocal 을 사용할 수는 없을 것 같기에 결제 관련이나 그런 중요한 부분에는 final 키워드를 사용해서

애초에 불변객체로 만들어서 작성하면 되겠지? 라는 생각..

 

 

다음편! (소셜 로그인 구현)

 

스프링 게시판 만들기 - 8 (1) (소셜 로그인 [구글,네이버,카카오,페이스북] )

이번에는 최근 여러 웹사이트에서 흔히 볼 수 있는 소셜 로그인을 구현해 볼까 한다. 게시판 기능을 좀 더 많이 구현해보고 이쁘게 꾸미고, 코드 리팩토링도 해보고.. 하려고 했는데 현재 진행중

dodokong.tistory.com

 

댓글