본문 바로가기
자바

[자바] Thread 와 동시성 문제

by 코딩하는경준 2022. 4. 30.

저번 포스팅에는 Thread 와 Proccess 의 개념에 대해 알아보았는데요.

 

https://coding-jun.tistory.com/6

 

[코딩 지식] Process 와 Thread

프로세스 프로그램(ex. Chrome, KaKaoTalk, IntelliJ, Slack 등등.. )이 동작을하면 프로세스가 되어 메모리에 올라가서 실행이 됩니다. 이때 OS 혹은 다른 프로그램에서 프로세스가 한개가 아닌 여러개가

coding-jun.tistory.com

 

이번에는 저번 포스팅에서 말한것과 같이 자바에서의 Thread 와 동시성 문제를 예시 와 함께 알아보고,
제가 프로젝트를 하면서겪었던 동시성 이슈에 대해 공유해 보려 합니다.

예시로 설명드리겠습니다.

티켓팅 예시

시나리오.

두명의 사용자씩 10개의 티켓을 차례대로 구매를 하고,
500번 작업이 수행되면서,
마지막 티켓의 갯수는 0이 되어야 합니다.

하지만 이상하게도 결과는 0일때도 있고 아닐때도 있습니다.

package ticket;

public class Ticket {

    public static void main(String[] args){
        TicketDashBoard ticketDashBoard = new TicketDashBoard();

        for (int i = 0; i < 500; i++) {
            Thread buyThread1= new Thread(new BuyTicketThread(ticketDashBoard));
            Thread buyThread2 = new Thread(new BuyTicketThread(ticketDashBoard));

            buyThread1.start();
            buyThread2.start();
        }
    }
}

 

Ticket 을 예약하는 메인 클래스

 

package ticket;

public class BuyTicketThread implements Runnable{

    TicketDashBoard ticketDashBoard;

    BuyTicketThread(TicketDashBoard ticketDashBoard){
        this.ticketDashBoard = ticketDashBoard;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            ticketDashBoard.buy();
        }

        ticketDashBoard.currentTicket();
    }
}

 

티켓을 구매하는 동작을 실행한 후 남은 티켓 갯수를 조회하는 스레드 클래스

 

package ticket;

public class TicketDashBoard {

    static long ticket = 10000;

    public void buy() {
        ticket -= 1;
    }

    public void currentTicket(){
        System.out.println("현재 남은 티켓 : " + ticket);
    }
}

 

티켓의 총개수와

티켓을 구매했을때 한개식 줄어드는 buy 메소드와
현재 티켓의 개수를 확인하는 currentTicket 메소드 를 가지는 DashBoard 클래스

 

 

결과

 

매번 실행할때마다 남은 티켓이 0으로 끝나야하는데 계속해서 결과 값이 변했습니다.

이럴 경우 티켓을 판매하는 업체에서는 정해진 수량보다 더 준비해야하는 상황이 발생할 수가 있겠죠?

이런문제가 발생하는 원인은 동시성 문제입니다. ( 혹은 thread-safe 하지 못한 상황입니다. )

여러 스레드가 연산을 하면서 static 으로 선언된 티켓의 개수가 동기화 되지 않아 스레드 별로 다른 값을 가지게되어 연산이 잘못되는 상황입니다.

 

하지만 동시성 문제가 진짜 치명적인 이유는
항상 같은 상황이 아닌, 일어날때도 있고 일어나지 않을 수도 있어서 결과에 일관성이 없습니다.

그렇게되어 이런 문제가 언제, 어떻게 일어날지 아무도 모른다는 것입니다.

 

다음으로 제가 프로젝트를 진행하면서 있었던 동시성 이슈를 순수 자바코드로 구현하여 보여드리고, 해결법을 알려드리겠습니다.

 

자습신청 예시

시나리오.

저는 학교의 기숙사를 총 관리하는 기숙사 관리 프로젝트를 만들어서 배포하고 사용 중에 있습니다.

이 프로젝트의 기능중 하나인 자습신청 부분에서 동시성 문제가 발생했습니다.

저희 학교 기숙사에서 자습을 하려면 매일 8시에 선착순으로 50명만 사용할 수 있도록 자습신청을 받고있습니다.

8시 만 되면 수백건의 요청이 들어오면서 트래픽이 갑작스럽게 늘어납니다.

프로젝트를 배포하고  2주동안 제대로 50명씩 받아지다가
어느날 부터 50명의 제한을 넘어서 51명이 신청되버리는 상황이 발생 했습니다. 

( 이 상황에서 스레드간의 간섭으로 인한 동시성 문제도 있지만
학교 인터넷이 좋지 않아서 요청이 한번에 몇십건씩 보내는 이슈도 있었습니다. )

 

이 상황을 순수 자바 코드로 비슷하게 구현해보겠습니다.

 

package selfStudy;

public class Account {

    private String name;
    private int stunum;

    Account(String name, int stunum){
        this.name = name;
        this.stunum = stunum;
    }

    public String getName() {
        return name;
    }

    public int getStunum() {
        return stunum;
    }
}

 

학생들의 정보를 가지는 Account 객체 입니다.

 

package selfStudy;

public class SelfStudyThread implements Runnable{
    SelfStudyDashBoard selfStudyDashBoard;
    Account account;

    SelfStudyThread(SelfStudyDashBoard selfStudyDashBoard,Account account){
        this.selfStudyDashBoard = selfStudyDashBoard;
        this.account = account;
    }

    @Override
    public void run() {
        selfStudyDashBoard.request(account);
    }
}

 

자습신청 스레드 안에서 실행되는 로직을 담은 SelfStudyThread 클래스 입니다.

 

package selfStudy;

import java.util.ArrayList;
import java.util.List;

public class SelfStudyDashBoard {

    static long count = 50;
    List<Account> selfStudyList = new ArrayList<>();

    public void request(Account account){
        if(count > 0){
            count -=1;
            System.out.println(account.getName() +"님의 자습신청의 자습신청이 완료되었습니다. 남은 자리 : " + count);
            selfStudyList.add(account);
        } else {
            System.out.println("자습 신청 인원이 가득 찼습니다" + account.getStunum());
        }
    }

    public void countSet(){
        System.out.println("총 신청된 인원 : " + selfStudyList.size() + "명");
    }
}

 

자습신청에 관련된 신청, 신청 현황을 가지는 SelfStudyDashBoard 클래스 입니다.

자습을 할 수 있는 자리를 나타내는 count 변수를 static으로 선언하여 스레드간의 값을 공유 할 수 있게 해줍니다.

 

package selfStudy;

import java.util.ArrayList;
import java.util.List;

public class SelfStudy {

    public static void main(String[] args) throws InterruptedException {
        SelfStudyDashBoard selfStudyDashBoard = new SelfStudyDashBoard();
        List<Account> accountList = new ArrayList<>();

        // 학생 51명을 회원 가입
        for (int i = 1; i < 52; i++) {
            accountList.add(new Account("사용자"+i,i));
        }

        // 51번의 자습신청 진행
        // 자습신청의 정원은 50명으로 한정되어있다.
        // 자습신청을 신청에 성공했을때에는 "학생의 이름 + 자습신청이 완료되었습니다"를 출력
        // 자습신청인원이 가득차서 실패했을때에는 "자습신청 인원이 가득 찼습니다 + 신청에 실패한 학생의 번호"를 출력한다.
        for (int i = 0; i < 51; i++) {
            Thread thread = new Thread(new SelfStudyThread(selfStudyDashBoard, accountList.get(i)));

            thread.start();
        }

        Thread.sleep(1000); // 스레드가 완전히 종료될때까지 sleep
        selfStudyDashBoard.countSet(); // 총 신청된 인원을 출력
    }
}

 

실제 로직이 실행되는 SelfStudy 클래스입니다.

51명을 회원가입 시키고 51명을 자습신청을 시켰습니다.

 

결과

결과는 매번 다르게 나왔습니다.
50명이 정상적으로 신청될 때도 있고,
50명보다 더 적은 인원이 신청되거나,
앞서 말한대로 51명으로 초과되어 출력되었습니다.

 

어떻게 해결할까요??

예시도 다 보았으니 이제 한번 이 문제를 해결해보도록 합시다.

 

synchronized 키워드를 사용해봅시다.

synchronized 는 현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근   없도록 막는 개념입니다.

( 이번 포스팅에서는 간단히 이 문제를 해결하기 위해 최소한의 개념만 적겠습니다.
synchronized 와 관련된 정보는 추후에 작성하겠습니다.
)

package ticket;

public class TicketDashBoard {

    static long ticket = 10000;

    public synchronized void buy() {
        ticket -= 1;
    }

    public void currentTicket(){
        System.out.println("현재 남은 티켓 : " + ticket);
    }
}

 

TicketDashBoard 클래스

 

package selfStudy;

import java.util.ArrayList;
import java.util.List;

public class SelfStudyDashBoard {

    static long count = 50;
    List<Account> selfStudyList = new ArrayList<>();

    // 자습 신청
    public synchronized void request(Account account){
        if(count > 0){
            count -=1;
            System.out.println(account.getName() +"님의 자습신청의 자습신청이 완료되었습니다. 남은 자리 : " + count);
            selfStudyList.add(account);
        } else {
            System.out.println("자습 신청 인원이 가득 찼습니다" + account.getStunum());
        }
    }

    public void countSet(){
        System.out.println("총 신청된 인원 : " + selfStudyList.size() + "명");
    }
}

 

SelfStudyDashBoard 클래스

 

결과

synchroinzed 를 적용하고난 뒤 selfStudy
synchronized 를 적용시키고 난 뒤 ticket

synchronized 키워드를 메소드에 적용시킴으로써 스레드간의 동기화를 시켜 thread-safe 하게 되었습니다.

 

Thread 와 관련된 문제는 실무에서도 많은 부분을 차지 한다 하는데요,

프로젝트를 진행하면서 있었던 이슈를 해결하기위해 다른 해결방안( JPA 비관적락, Thread sleep 등등.. ) 들도 찾아봤지만,

 

이번 포스팅에서는 여기까지 하고 마치겠습니다.
( 나중에 프로젝트를 하면서 있었던 이슈를 정리하며 여러가지 해결방안들을 소개해보는 시간을 가져보도록 하겠습니다. )

 

깃허브 blog-code 레포지토리에 여러가지 예시들을 준비해봤으니 한번씩 봐보셔도 될것같습니다.

긴 글 읽어주셔서 감사합니다.

 

Reference

- https://devwithpug.github.io/java/java-thread-safe/

- https://devkingdom.tistory.com/276

댓글