관리 메뉴

나만을 위한 블로그

[JAVA] 쓰레드의 동기화 (synchronized) 본문

JAVA

[JAVA] 쓰레드의 동기화 (synchronized)

참깨빵위에참깨빵_ 2020. 10. 25. 03:30
728x90
반응형

요즘 출시되는 컴퓨터는 대부분이 멀티쓰레드를 지원한다. 말 그대로 쓰레드가 여러 개기 때문에 별도의 처리를 하지 않는 이상 종종 여러 쓰레드가 같은 자원에 액세스하려고 시도하고, 그 결과로 내가 생각한 것과는 전혀 다른 결과가 나올 수 있다.

예를 들어서 쓰레드 A, B가 있다고 치고 a라는 객체에 두 쓰레드가 같이 붙어서 작업한다고 가정한다.

쓰레드 A가 a에 붙어서 작업하던 도중 B에게 제어권이 넘어가고 B가 a의 데이터를 변경한 후, 다시 A에게 넘기고 나서 A가 나머지 작업을 이어서 수행하면 분명히 원래 내 의도와 다른 결과가 나올 수도 있다.

 

이런 일이 발생하는 걸 막기 위해선 한 쓰레드가 어떤 작업을 끝내기 전까진 다른 쓰레드에게 방해받지 않게 하는 처리가 필요하다. 자바에선 동기화(synchronized) 블럭을 써서 쓰레드를 생성하고 작업을 동기화할 수 있게 한다. 즉 한 번에 1개의 쓰레드만 붙어서 작업을 수행할 수 있는 것이다.

이 synchronized 키워드가 사용된 부분을 임계 영역, 잠금(lock)이라고 표현한다. 잠금이라 표현하는 이유는 한 쓰레드가 작업 중인 객체를 다른 쓰레드에서 접근 못하게 하기 때문에 lock을 건다고도 표현하기 때문이다.

synchronized 말고 java.util.concurrent.locks나 java.util.concurrent.atomic 패키지에서 동기화를 구현할 수 있는 방법들을 제공하고 있으니 관심있으면 검색해보자.

이 포스팅에선 synchronized 키워드만 다룬다. 참고로 synchronized 키워드는 객체나 메서드 어디든 붙일 수 있다.

아래는 synchronized를 쓰는 것과 쓰지 않는 것을 비교하는 간단한 예제다.

먼저 synchronized를 쓰지 않는 경우다.

 

public class Test
{
    public static void main(String[] args)
    {
        // 5부터 숫자를 1씩 감소시키는 메서드를 호출하기 위해 PrintDemo 객체 생성
        PrintDemo demo = new PrintDemo();

        // 1번 인자로 어떤 쓰레드가 시작되는지 알기 위해 쓰레드 이름을 쓰고
        // 2번 인자로 PrintDemo 객체를 넣는다
        ThreadDemo t1 = new ThreadDemo("1번 쓰레드", demo);
        ThreadDemo t2 = new ThreadDemo("2번 쓰레드", demo);

        // 쓰레드 시작
        t1.start();
        t2.start();

        // 한 쓰레드의 작업이 끝난 다음에 다른 쓰레드가 시작되야 한다
        try
        {
            // 지금 실행중인 쓰레드가 t1이 끝날 때까지 대기한다
            t1.join();
            t2.join();
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
            System.out.println("Interrupted");
        }
    }
}

class PrintDemo
{
    // 5부터 1씩 숫자를 감소시킨다
    public void printCount()
    {
        try
        {
            for (int i = 5; i > 0; i--)
            {
                System.out.println("Counter   ---   "  + i);
            }
        }
        catch (Exception e)
        {
            System.out.println("쓰레드 중단됨");
        }
    }
}

class ThreadDemo extends Thread
{
    private Thread thread;      // 쓰레드를 만들고 시작할 때 사용할 쓰레드 객체
    private String threadName;  // 쓰레드 구분을 위해 쓰레드마다 붙일 이름
    PrintDemo demo;             // printCount() 호출을 위한 객체

    ThreadDemo(String name, PrintDemo pd)
    {
        threadName = name;
        demo = pd;
    }

    @Override
    public void run()
    {
        demo.printCount();
        System.out.println("Thread " + threadName + " 종료됨");
    }

    // 쓰레드를 start()하기 전에 처리할 작업이 있어서 start()를 별도로 만든다
    public void start()
    {
        // 이 쓰레드를 시작하면 어떤 쓰레드가 시작됐는지 출력한다
        System.out.println("Starting " + threadName);
        // 이 메서드의 호출 시점에 생성된 쓰레드가 없으면
        if (thread == null)
        {
            // 쓰레드를 만든다
            thread = new Thread(this, threadName);
            // start()를 호출해 쓰레드 작업 시작
            thread.start();
        }
    }
}

내부 클래스 형태로 짜여진 예제다. 먼저 PrintDemo부터 확인하자.

 

class PrintDemo
{
    // 5부터 1씩 숫자를 감소시킨다
    public void printCount()
    {
        try
        {
            for (int i = 5; i > 0; i--)
            {
                System.out.println("Counter   ---   "  + i);
            }
        }
        catch (Exception e)
        {
            System.out.println("쓰레드 중단됨");
        }
    }
}

 

PrintDemo 클래스에는 5부터 1까지 1씩 숫자를 감소시키는 메서드가 있다.

메서드 몸통에는 try-catch 블럭이 있어서 숫자를 바꿔서 출력하는 동안 예외가 발생할 경우 쓰레드가 멈추고 이것을 println()으로 띄운다. 별로 어려운 건 없는 코드다.

 

class ThreadDemo extends Thread
{
    private Thread thread;      // 쓰레드를 만들고 시작할 때 사용할 쓰레드 객체
    private String threadName;  // 쓰레드 구분을 위해 쓰레드마다 붙일 이름
    PrintDemo demo;             // printCount() 호출을 위한 객체

    ThreadDemo(String name, PrintDemo pd)
    {
        threadName = name;
        demo = pd;
    }

    @Override
    public void run()
    {
        demo.printCount();
        System.out.println("Thread " + threadName + " 종료됨");
    }

    // 쓰레드를 start()하기 전에 처리할 작업이 있어서 start()를 별도로 만든다
    public void start()
    {
        System.out.println("Starting " + threadName);
        // 이 메서드의 호출 시점에 생성된 쓰레드가 없으면
        if (thread == null)
        {
            // 쓰레드를 만든다
            thread = new Thread(this, threadName);
            thread.start();
        }
    }
}

Thread를 상속받아 쓰레드의 행동 양식을 정의하는 클래스다.

클래스 안의 필드로 쓰레드 객체, String 변수, PrintDemo의 객체가 정의돼 있고, 생성자로 문자열과 PrintDemo 객체를 받아서 ThreadDemo의 필드에 넣는다.

그리고 쓰레드 생성 시 재정의해야 하는 run() 안에 PrintDemo 클래스의 숫자를 1씩 감소시키는 메서드를 호출했다.

메서드가 끝나면 쓰레드가 종료됐다는 문구가 나올 것이다.

start()도 별도로 만들어서 쓰레드 시작을 알리고, start() 호출 시점에 쓰레드가 없다면 메인쓰레드에서 threadName을 이름으로 갖는 쓰레드를 만든 다음, 이 쓰레드를 start()한다.

쓰레드를 공부했다면 이것 또한 별 어려움 없이 읽을 수 있는 코드다. 마지막으로 메인쓰레드를 보자.

 

public static void main(String[] args)
    {
        // 5부터 숫자를 1씩 감소시키는 메서드를 호출하기 위해 PrintDemo 객체 생성
        PrintDemo demo = new PrintDemo();

        // 1번 인자로 어떤 쓰레드가 시작되는지 알기 위해 쓰레드 이름을 쓰고
        // 2번 인자로 PrintDemo 객체를 넣는다
        ThreadDemo t1 = new ThreadDemo("1번 쓰레드", demo);
        ThreadDemo t2 = new ThreadDemo("2번 쓰레드", demo);

        t1.start();
        t2.start();
        
        try
        {
            // 지금 실행중인 쓰레드(t1)이 끝날 때까지 기다렸다가 t2가 시작된다
            t1.join();
            t2.join();
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
            System.out.println("Interrupted");
        }
    }

메인쓰레드에선 메서드 호출을 위해 PrintDemo 클래스의 객체를 만들고 ThreadDemo의 객체를 2개 만들어 쓰레드를 만든다. 그리고 각각 이름을 붙인 뒤 숫자 감소 메서드가 있는 PrintDemo 객체를 인자로 넣어서 ThreadDemo 클래스 안에 정의된 start()를 호출했다.

주의깊게 볼 부분은 try 블럭 안의 join()이다. join()은 join()을 호출한 쓰레드가 끝날때까지 다른 쓰레드들이 기다렸다가 실행되게 하는 메서드다. 그럼 t1이 끝날때까지 t2는 기다려야 할 것이다.

 

그럼 이 코드가 어떻게 실행될지 생각해보자.

쓰레드 t1이 먼저 시작됐고 t2가 join() 때문에 나중에 시작되니까 t1이 먼저 끝나지 않을까?

실행하면 아래와 같은 결과가 나온다. 실행 횟수는 3번이다.

 

 

아주 개판이다. 스타트는 1번부터 잘 끊었지만 5 4 3 2 1 식으로 숫자가 감소하는 게 보여야 하는데 1번 이미지를 제외하곤 순서가 뒤죽박죽으로 섞였다. 쓰레드 종료도 왜 2번이 먼저 나서서 종료되는 걸까?

이제 synchronized 키워드를 써야 할 때가 왔다. 위에서 설명했듯 synchronized를 객체 또는 메서드 앞에 선언하면 한 쓰레드가 작업을 끝내기 전까진 다른 쓰레드는 간섭이 불가능하다.

 

그럼 synchronized 키워드는 어디에 써야 할까? 먼저 생각해본 다음 아래 코드를 보자.

 

public class Test
{
    public static void main(String[] args)
    {
        // 5부터 숫자를 1씩 감소시키는 메서드를 호출하기 위해 PrintDemo 객체 생성
        PrintDemo demo = new PrintDemo();

        // 1번 인자로 어떤 쓰레드가 시작되는지 알기 위해 쓰레드 이름을 쓰고
        // 2번 인자로 PrintDemo 객체를 넣는다
        ThreadDemo t1 = new ThreadDemo("1번 쓰레드", demo);
        ThreadDemo t2 = new ThreadDemo("2번 쓰레드", demo);

        t1.start();
        t2.start();

        try
        {
            // 지금 실행중인 쓰레드가 t1이 끝날 때까지 대기한다
            t1.join();
            t2.join();
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
            System.out.println("Interrupted");
        }
    }
}

class PrintDemo
{
    // 5부터 1씩 숫자를 감소시킨다
    public void printCount()
    {
        try
        {
            for (int i = 5; i > 0; i--)
            {
                System.out.println("Counter   ---   "  + i);
            }
        }
        catch (Exception e)
        {
            System.out.println("Thread interrupted");
        }
    }
}

class ThreadDemo extends Thread
{
    private Thread thread;      // 쓰레드를 만들고 시작할 때 사용할 쓰레드 객체
    private String threadName;  // 쓰레드 구분을 위해 쓰레드마다 붙일 이름
    PrintDemo demo;             // printCount() 호출을 위한 객체

    ThreadDemo(String name, PrintDemo pd)
    {
        threadName = name;
        demo = pd;
    }

    @Override
    public void run()
    {
        synchronized (demo)
        {
            demo.printCount();
        }
        System.out.println("Thread " + threadName + " 종료됨");
    }

    // 쓰레드를 start()하기 전에 처리할 작업이 있어서 start()를 별도로 만든다
    public void start()
    {
        System.out.println("Starting " + threadName);
        // 이 메서드의 호출 시점에 생성된 쓰레드가 없으면
        if (thread == null)
        {
            // 쓰레드를 만든다
            thread = new Thread(this, threadName);
            thread.start();
        }
    }
}

run() 안에 synchronized 키워드를 쓰고 () 안에 PrintDemo 객체를 넣었다.

이 처리를 함으로써 쓰레드 t1이 PrintDemo 객체에 달라붙어서 작업을 끝내기 전까지, t2는 손가락 빨면서 기다려야만 한다.

그럼 이 키워드를 붙임으로써 내가 원하는대로 작동하는지 확인해보자. 위와 마찬가지로 3번 실행하고 결과를 확인해보자.

 

 

3번 실행했다는 걸 나름 인증하기 위해 캡쳐 이미지의 크기를 각각 다르게 했다.

synchronized 키워드 하나 넣었을 뿐인데 쓰레드 t1이 먼저 종료되고 그 다음 t2가 종료되는 걸 볼 수 있다.

숫자도 5부터 1까지 1씩 감소하는 것이 2번 보인다.

이 코드를 통해서 synchronized 키워드가 어떤 기능을 하는지 확인했다. 그러나 synchronized 키워드를 많이 쓰는 것은 좋지 않다. 성능 개선을 위해 넣은 코드가 역으로 성능 저하를 일으킬 수도 있다.

자신의 코드에서 synchronized가 정말로 필요한 부분이 어디인지 꼼꼼히 고민하고 나서 그곳에만 사용하자.

반응형
Comments