쓰레드

de_sj_awa·2021년 4월 25일
0
post-custom-banner

1. 쓰레드란 무엇인가?

자바 프로그램을 사용하여 뒤에 클래스 이름을 붙이고, 엔터를 치면 적어도 하나의 JVM이 시작된다. 보통 이렇게 JVM이 시작되면 자바 프로세스(Java Process)가 시작한다. 이 프로세스라는 울타리 안에서 여러 개의 쓰레드라는 것이 아둥바둥 살게 된다. 즉, 하나의 프로세스 내에 여러 개의 쓰레드가 수행된다. 하지만, 거꾸로 여러 프로세스가 공유하는 하나의 쓰레드가 수행되는 일은 절대 없다. 어떤 프로세스 간에 쓰레드가 하나 이상 수행된다.

정리하자면, java 명령어를 사용하여 클래스를 실행시키는 순간 자바 프로세스가 시작되고, main() 메소드가 수행되면서 하나의 쓰레드가 시작되는 것이다. 만약 많은 쓰레드가 필요하다면, 이 main() 메소드에서 쓰레드들을 생성해 주면 된다. 자바를 사용하여 웹을 제공할 때는 Tomcat과 같은 WAS(Web Application Server)를 사용한다. 이 WAS도 똑같이 main() 메소드에서 생성한 쓰레드들이 수행되는 것이다.

그렇다면, 왜 쓰레드라는 것을 만들었을까? 프로세스가 하나 시작하려면 많은 자원(resource)이 필요하다. 만약 하나의 작업을 동시에 수행하려고 할 때 여러 개의 프로세스를 띄워서 실행하려면 각각 메모리를 할당하여 주어야만 한다. JVM은 기본적으로 아무런 옵션 없이 실행하면 OS마다 다르지만, 적어도 32MB ~ 64MB의 물리 메모리를 점유한다. 그에 반해서, 쓰레드를 하나 추가하면 1MB 이내의 메모리를 점유한다. 그래서, 쓰레드를 "경량 프로세스(lightweight process)"라고 부른다.

그리고, 요즘은 PC급의 장비도 두 개 이상의 코어(core)가 달려 있는 멀티 코어 시대다. 대부분의 작업은 단일 쓰레드로 실행하는 것보다는 다중 쓰레드로 실행하는 것이 더 빠른 시간에 결과를 제공해준다. 따라서, 보다 빠른 처리를 할 필요가 있을 때, 쓰레드를 사용하면 보다 빠른 계산을 처리할 수 있다.

2. 쓰레드를 생성하는 방법

쓰레드를 생성하는 방법은 크게 두 가지 방법이 있다. 하나는 Runnable 인터페이스를 사용하는 것이고, 다른 하나는 Thread 클래스를 사용하는 것이다. Thread 클래스는 Runnable 인터페이스를 구현한 클래스이므로, 어떤 것을 적용하느냐의 차이만 있다. Runnable 인터페이스와 Thread 클래스는 모두 java.lang 패키지에 있다. 따라서, 이 인터페이스를 사용할 때에는 별도로 import할 필요가 없다.

  • 쓰레드가 수행되는 우리가 구현하는 메소드는 run() 메소드다.
  • 쓰레드를 시작하는 메소드는 start()이다.

즉, Runnable 인터페이스를 구현하거나 Thread 클래스를 확장할 때에는 run() 메소드를 시작점으로 작성해야만 한다. 그런데, 쓰레드를 시작하는 메소드는 run()이 아닌 start()라는 메소드이다.

그런데 왜 Runnable 인터페이스를 사용하는 것, Thread 클래스를 사용하는 두 가지 방법을 제공하는 것일까? 자바에서는 하나의 클래스만 확장할 수 있다. 만약 어떤 클래스가 어떤 다른 클래스를 extends를 사용해 확장해야 하는 상황인데, 쓰레드로 구현해야 한다. 게다가 그 부모 클래스는 Thread를 확장하지 않았다. 어떻게 해야 할까?

자바에서 Thread 클래스를 확장 받아야만 쓰레드로 구현할 수 있는데, 다중 상속이 불가능하므로 해당 클래스를 쓰레드로 만들 수 있다. 하지만, 인터페이스는 여러 개의 인터페이스를 구현해도 문제가 발생하지 않는다. 따라서, 이러한 경우에는 Runnable 인터페이스를 구현해서 사용하면 된다.

정리하자면, 쓰레드 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 되고, 그렇지 않은 경우에는 쓰레드 클래스를 사용하는 것이 편하다.

3. 멀티 쓰레드

그런데, 쓰레드라는 것을 start() 메소드를 통해서 시작했다는 것은, 프로세스가 아닌 하나의 쓰레드를 JVM에 추가하여 실행한다는 뜻이다. 이 때, 시작한 start() 메소드가 끝날 때까지 기다리지 않고 그 다음 줄에 있는 thread 객체의 start() 메소드를 실행한다. 이 객체도 마찬가지로, 새로운 쓰레드를 시작하므로 run() 메소드가 종료될 때까지 기다리지 않고, 바로 다음 줄로 넘어간다.

이처럼, 쓰레드를 구현할 때 start() 메소드를 호출하면, 쓰레드 클래스에 있는 run() 메소드의 내용이 끝나든, 끝나지 않든 간에 쓰레드를 시작한 메소드에서는 그 다음 줄에 있는 코드를 실행한다.

그렇다면, 새로 생성한 쓰레드는 언제 끝날까? 바로 run() 메소드가 종료되면 끝난다. 만약 run() 메소드가 끝나지 않으면, 실행한 애플리케이션은 종료되지 않는다.

또한 모든 쓰레드는 이름이 있다. 만약 아무런 이름을 지정하지 않으면, 그 쓰레드의 이름은 "Thread-n"이다. 여기서 n은 쓰레드가 생성된 순서에 따라 증가한다. 그렇지 않고, 쓰레드 이름을 지정한다면, 해당 쓰레드는 별도의 이름을 가지게 된다. 만약 쓰레드 이름이 겹친다고 해도 예외나 에러가 발생하지 않는다.

어떤 쓰레드를 생성할 때 쓰레드를 묶어 놓을 수 있다. 그게 바로 ThreadGroup이다. 이렇게 쓰레드의 그룹을 묶으면 ThreadGroup 클래스에서 제공하는 여러 메소드를 통해서 각종 정보를 얻을 수 있다.

또한 자바 프로세스가 시작되면 실행 데이터 공간(Runtime data area)이 구성된다. 그 중에 하나가 스택이라는 공간이며, 쓰레드가 생성될 때마다 별도의 스택이 할당된다.

4. 데몬 쓰레드

데몬 쓰레드가 아닌 사용자 쓰레드는 JVM이 해당 쓰레드가 끝날 때까지 기다린다고 했다. 즉, 어떤 쓰레드를 데몬으로 지정하면, 그 쓰레드가 수행되고 있든, 수행되지 않고 있든 상관 없이 JVM이 끝날 수 있다. 단, 해당 쓰레드가 시작하기(start() 메소드가 호출되기) 전에 데몬 쓰레드로 지정되어야만 한다. 쓰레드가 시작한 다음에는 데몬으로 지정할 수 없다. 다시 말해서, 데몬 쓰레드는 해당 쓰레드가 종료되지 않아도 다른 실행중인 일반 쓰레드가 없다면 멈춰 버린다.

그렇다면 왜 이런 데몬 쓰레드를 만들었을까?

예를 들어 모니터링하는 쓰레드를 별도로 띄워 모니터링하다가, 주요 쓰레드가 종료되면 관련된 모니터링 쓰레드가 종료되어야 프로세스가 종료될 수 있다. 그런데, 모니터링 쓰레드를 데몬 쓰레드로 만들지 않으면 프로세스가 종료될 수 없게 된다. 이렇게, 부가적인 작업을 수행하는 쓰레드를 선언할 때 데몬 쓰레드를 만든다.

5. synchronized

어떤 클래스나 메소드가 쓰레드에 안전하려면, synchronized를 사용해야만 한다. 여러 쓰레드가 한 객체에 선언된 메소드에 접근하여 데이터를 처리하려고 할 때 동시에 연산을 수행하여 값이 꼬이는 경우가 발생할 수 있다(여기서 객체라는 것은 하나의 클래스에서 생성된 여러 개의 객체가 아니라, 동일한 하나의 객체를 말한다). 단, 메소드에서 인스턴스 변수를 수정하려고 할 때에만 이러한 문제가 생긴다. 매개 변수나 메소드에서만 사용하는 지역변수만 다루는 메소드는 전혀 synchronized로 선언할 필요가 없다.

그렇다면, 도대체 synchronized가 어떤 것이길래 쓰레드 안전과 연관이 있을까? synchronized는 두 가지 방법으로 사용할 수 있다.

  • 메소드 자체를 synchronized로 선언하는 방법(synchronized methods)
  • 다른 하나는 메소드 내의 특정 문장만 synchronized로 감싸는 방법(synchronized statements)

메소드를 synchronized로 선언하려면 메소드 선언문에 synchronized를 넣어주면 된다. 이 한 단어가 있는 것과 없는 것의 차이는 크다. 만약 이 synchronized라는 단어가 메소드 선언부에 있으면, 동일한 객체의 이 메소드에 2개의 쓰레드가 접근하든 100개의 쓰레드가 접근하든 간에 한 순간에는 하나의 쓰레드만 이 메소드를 수행하게 된다.

하지만, 이렇게 하면 성능상 문제점이 발생할 수도 있다. 예를 들어 어떤 클래스에 30줄짜리 메소드가 있다고 가정하자. 그 클래스에도 amount라는 인스턴스 변수가 있고, 30줄짜리 메소드에서 amount라는 변수를 한 줄에서만 다룬다. 만약 해당 메소드 전체를 synchronized로 선언한다면, 나머지 29줄의 처리를 할 때 필요 없는 대기 시간이 발생하게 된다. 이러한 경우에는 메소드 전체를 감싸면 안 되며, amount라는 변수를 처리하는 부분만 synchronized 처리를 해주면 된다.

Object lock = new Object();
public void plus(int value){
    synchronized(this){
        amount += value;
    }
}
public void minus(int value){
    synchronized(lock){
        amount -= value;
    }
}

보통은 잠금 처리를 하기 위한 별도의 객체를 선언하여 사용한다. synchronized를 사용할 때에는 하나의 객체를 사용하여 블록 내의 문장을 하나의 쓰레드만 수행하도록 할 수 있다. 쉽게 생각하자면, 여기서 사용한 lock이라는 객체나, 앞서 사용한 this는 모두 문지기라고 볼 수 있다. 그리고, 그 문지기는 한 명의 쓰레드만 일을 할 수 있도록 허용해 준다. 만약 블록에 들어간 쓰레드가 일을 다 처리하고 나오면, 문지기는 대기하고 있는 다른 쓰레드에게 기회를 준다.

이렇게, synchronized 블록을 사용할 때에는 lock이라는 별도의 객체를 사용할 수 있다. 그런데, 때에 따라서 이러한 객체는 하나의 클래스에서 두 개 이상 만들어 사용할 수도 있다. 만약 클래스에 amount라는 변수 외에 interest라는 변수가 있고, 그 interest라는 변수를 처리할 때에도 여러 쓰레드에서 접근하면 안 되는 경우가 발생할 수 있다. 이럴 때 만약 lock이라는 하나의 잠금용 객체만을 사용하면 amount라는 변수를 처리할 때, interest라는 변수를 처리하려는 부분도 처리를 못하게 된다. 따라서, 두 개의 별도의 lock 객체를 사용하면 보다 효율적인 프로그램이 된다.

또한 synchronized를 사용할 때 잘하는 실수 한 가지가 있다. 메소드를 synchronized 할 때에는 이처럼 같은 객체를 참조할 때에만 유효하다. 또 한가지는 synchronized는 여러 쓰레드에서 하나의 객체에 있는 인스턴스 변수를 동시에 처리할 때 발생할 수 있는 문제를 해결하기 위해 필요한 것이라는 점이다. 즉, 인스턴스 변수가 선언되어 있다고 하더라도, 변수가 선언되어 있는 객체를 다른 쓰레드에서 공유할 일이 전혀 없다면 synchronized를 사용할 이유가 전혀 없다.

StringBuffer와 StringBuilder라는 클래스가 있다. 여기서 StringBuffer는 쓰레드에 안전하고, StringBuilder는 쓰레드에 안전하지 않다. 조금 더 상세하게 이야기하면, StringBuffer는 synchronized 블록으로 주요 데이터 처리 부분을 감싸 두었고, StringBuilder는 synchronized라는 것이 사용되지 않았다. 따라서, StringBuffer는 하나의 문자열 객체를 여러 쓰레드에서 공유해야 하는 경우에만 사용하고, StringBuilder는 여러 쓰레드에서 공유할 일이 없을 때 사용하면 된다.

6. ThreadGroup

ThreadGroup은 쓰레드의 관리를 용이하게 하기 위한 클래스이다. 하나의 애플리케이션에는 여러 종류의 쓰레드가 있을 수 있으며, 만약 ThreadGroup 클래스가 없으면 용도가 다른 여러 쓰레드를 관리하기 어려울 것이다. 쓰레드 그룹은 기본적으로 운영체제의 폴더처럼 뻗어나가는 트리(tree) 구조를 가진다. 즉, 하나의 그룹이 다른 그룹에 속할 수도 있고, 그 아래에 또 다른 그룹을 포함할 수도 있다.

ThreadGroup 클래스에서 제공하는 주요 메소드를 살펴보자.

리턴 타입 메소드 이름 및 매개 변수 설명
int activeCount 실행중인 쓰레드의 개수를 리턴한다.
int activeGroupCount 실행중인 쓰레드 그룹의 개수를 리턴한다.
int enumerate(Thread[] list) 현재 쓰레드 그룹에 있는 모든 쓰레드를 매개 변수로 넘어온 쓰레드 배열에 담는다.
int enumerate(Thread[] list, boolean recurse) 현재 쓰레드 그룹에 있는 모든 쓰레드를 매개 변수로 넘어온 쓰레드 배열에 담는다. 두 번째 매개 변수가 true이면 하위에 있는 쓰레드 그룹에 있는 쓰레드 목록도 포함된다.
int enumerate(ThreadGroup[] list) 현재 쓰레드 그룹에 있는 모든 쓰레드 그룹을 매개 변수로 넘어온 쓰레드 그룹 배열에 담는다.
int enumerate(ThreadGroup[] list, boolean recurse) 현재 쓰레드 그룹에 있는 모든 쓰레드 그룹을 매개 변수로 넘어온 쓰레드 그룹 배열에 담는다. 두 번째 매개 변수가 true이면 하위에 있는 쓰레드 그룹 목록도 포함된다.
String getName() 쓰레드 그룹의 이름을 리턴한다.
ThreadGroup getParent() 부모 쓰레드 그룹을 리턴한다.
void list() 쓰레드 그룹의 상세 정보를 출력한다.
void setDaemon(boolean daemon) 지금 쓰레드 그룹에 속한 쓰레드들을 데몬으로 지정한다.
여기서 enumerate()라는 메소드가 있다. 이 메소드는 해당 쓰레드 그룹에 포함된 쓰레드나 쓰레드 그룹을 매개 변수로 넘어온 배열에 담는다. 이 메소드의 리턴값은 배열에 저장된 쓰레드의 개수다. 따라서, 쓰레드 그룹에 있는 모든 쓰레드의 객체를 제대로 담으려면 activeCount() 메소드를 통해서 현재 실행중인 쓰레드의 개수를 정확히 파악한 후, 그 개수만큼의 배열을 생성하면 된다.

이렇게 쓰레드 그룹을 사용하면 쓰레드를 보다 체계적으로 관리할 수 있다.

7. ThreadLocal

여러 쓰레드에서 데이터를 공유할 때 발생하는 문제를 해결하기 위해서, synchronized라는 구문을 사용했다. 만약 쓰레드 별로 서로 다른 값을 처리해야 할 필요가 있을 때는 ThreadLocal이라는 것을 사용하면 된다.

예제 코드

package threadlocal;

import java.util.Random;

public class ThreadLocalSample {
    private final static ThreadLocal<Integer> local = new ThreadLocal<>();
    private static Random random;
    static{
        random = new Random();
    }
    public static Integer generateNumeber(){
        int value = random.nextInt(45);
        local.set(value);
        return value;
    }
    public static Integer get(){
        return local.get();
    }
    public static void remove(){
        local.remove();
    }
}

이 코드에는 어떤 synchronized 메소드나 블록이 존재하지 않는다.

다음과 같이 위의 ThreadLocalSample 클래스를 사용하는 LocalUserThread를 만들자.

package threadlocal;

public class LocalUserThread extends Thread{

    @Override
    public void run() {
        int value = ThreadLocalSample.generateNumeber();
        System.out.println(this.getName()+" LocalUserThread value="+value);
        
        OtherLogic otherLogic = new OtherLogic();
        otherLogic.printMyNumber();
        ThreadLocalSample.remove();
    }
}

여기서 호출한 OtherLogic 클래스는 다음과 같다.

package threadlocal;

public class OtherLogic {
    public void printMyNumber(){
        System.out.println(Thread.currentThread().getName() + " OtherLogic value=" + ThreadLocalSample.get());
    }
}

OtherLogic 클래스에서는 현재 쓰레드의 이름과 ThreadLocalSample 클래스의 get() 메소드를 이용하여 값을 읽는다. ThreadLocal을 사용하는 가장 큰 장점이 바로 여기에 있다. 만약 임의로 만든 값을 ThreadLocal을 사용하지 않고 OtherLogic 클래스에서 사용하려면 어떻게 해야 할까? ThreadLocal을 사용하지 않는다면, printNumber() 메소드를 호출할 때 매개 변수로 넘겼을 것이다. 이렇게 사용해도 된다. 그런데, OtherLogic에서 호출하는 다른 메소드에서 이 값을 사용하려면, 그 메소드에도 이 값을 또 매개 변수로 전달해 줘야만 할 것이다. 또 다른 방법은 LocalUserThread 클래스에서 인스턴스 변수를 선언하여 사용하는 방법이 있다. 하지만, 이 방법 또한 매우 구현이 복잡해질 수 있다.

3개의 쓰레드 객체를 생성하여 시작한 결과는 다음과 같다.

Thread-1 LocalUserThread value=33
Thread-1 OtherLogic value=33
Thread-2 LocalUserThread value=23
Thread-2 OtherLogic value=23
Thread-3 LocalUserThread value=5
Thread-3 otherLogic value=5

LocalUserThread에서 출력한 값과 OhterLogic에서 출력한 값은 동일하다. 또한 그 값은 각 쓰레드 별로 절대로 공유하지 않는다.

ThreadLocal에 대해 정리하면 다음과 같다.

  • ThreadLocal에 저장된 값은 해당 쓰레드에서 고유하게 사용할 수 있다.
  • ThreadLocal 클래스의 변수는 private static final로 선언한다.
  • ThreadLocal 클래스에 선언되어 있는 메소드는 set(), get(), initialValue()가 있다.
  • 사용이 끝난 후에는 remove() 메소드를 호출해 주는 습관을 가져야만 한다.

그렇다면, 왜 마지막에 remove() 메소드를 호출해 줘야 할까? 이 예제에서 사용한 쓰레드들은 한 번 생성되고 수행이 끝나면 사라진다. 하지만, 웹 기반의 애플리케이션에서는 쓰레드를 재사용하기 위해서 쓰레드 풀(ThreadPool)이라는 것을 사용한다. 이 쓰레드 풀을 사용하면, 쓰레드가 시작된 후에 그냥 끝나는 것이 아니기 때문에 remove() 메소드를 사용하여 값을 지워줘야지만 해당 쓰레드를 다음에 사용할 때 쓰레기 값이 들어있지 않게 된다.

8. 자바의 Volatile

Volatile이라는 자바의 예약어가 있다. 이 예약어는 변수 선언시에만 사용된다.

예제 코드

package Volatile;

public class VolatileSample extends Thread{
    private double instanceVariable = 0;
    
    void setDouble(double value){
        this.instanceVariable = value;
    }
    public void run(){
        while(instanceVariable == 0){
            System.out.println(instanceVariable);
        }
    }
}

실행하는 코드는 다음과 같다.

package Volatile;

public class RunVolatile {
    public static void main(String args[]){
        RunVolatile sample = new RunVolatile();
        sample.runVolatileSample();
    }
    public void runVolatileSample(){
        VolatileSample sample = new VolatileSample();
        sample.start();
        try{
            Thread.sleep(1000);
        }catch(Exception e){
            e.printStackTrace();
        }
        System.out.println("Sleep ended !!!");
        sample.setDouble(-1);
        System.out.println("Set Value is completed !!!");
    }
}

실행 결과는 다음과 같다.

Sleep ended !!!
Set value is completed !!!

그냥 보면 정상적인 것 같지만, sample이라는 쓰레드가 종료되지 않는다. Ctrl+c 버튼을 눌러 수행중인 프로그램을 중단하자.

코드만 보면 전혀 문제가 없는 프로그램이고, sample.setDouble() 메소드를 수행하면 당연히 instanceVariable 값이 -1이 되기 때문에 프로그램이 끝나야 한다. 하지만 실제로 instanceVariable 값은 바뀌지 않고 while 문이 계속 수행된다.

이 문제의 원인은 "CPU 캐시(cache)" 때문이다. 이 예제에서 동일한 sample이라는 VolatileSample 클래스의 객체에 있는 변수의 값을 바꿨다. 그런데, 이처럼 각 쓰레드에서 수행되는 변수의 값을 반복적으로 참조하게 될 때에는 사용하는 장비의 "메인 메모리"에 저장되지 않고 "CPU 캐시"에 저장되고 참조된다. 그래서, run() 메소드의 while문에서는 "CPU 캐시"에 저장되어 있는 instanceVariable만 참조하게 된다.

이 상황에서 다른 쓰레드에서 setDouble() 메소드를 사용해 값을 -1로 변경하면, 서로 다른 "CPU 캐시"에 있는 instanceVariable 값이 바뀐다. 따라서, while문에서 참조하는 "CPU 캐시"에 있는 값은 바뀌지 않으므로 계속 while문이 끝나지 않고 지속되는 것이다. 이 문제를 해결하려면 instanceVariable 변수 선언 시 volatile이라고 써주면 된다.

다음과 같이 VolatileSample 클래스의 instanceVariable 선언문을 바꾸자.

private volatile double instanceVariable=0;

이렇게 변경한 후 저장 및 컴파일을 다시하고 실행해보자. 그러면 다음과 같은 내용이 출력된다.

Sleep Ended !!!
Set value is completed !!!
-1.0

-1.0이라는 결과만 출력되는 것이 아니라 실행한 프로그램도 종료된다.

즉, 쓰레드에 선언된 인스턴스 변수를 선언할 일이 있을 때, 이처럼 volatile로 선언하면 해당 변수 값이 바뀌면 "내가 갖고 있는 volatile 변수가 바뀌었는데, 너도 이거 쓰니까 값을 바꿔"라고 이야기 해준다. 그러므로, 같은 객체에 있는 변수는 모든 쓰레드가 같은 값을 바라보게 된다.

하지만, 모든 변수에 이렇게 volatile이라고 써 줄 필요는 없다. 게다가 volatile을 남발하면 성능상으로 저하가 발생한다. 모든 쓰레드의 인스턴스 변수에 이렇게 volatile이라고 적어주지 않았다고 데이터가 꼬이는 일이 발생하는 것이 아니다. 이 예제에서 처럼 volatile을 처리하지 않았을 때 문제가 발생한 이유는 JIT 컴파일러가 최적화 작업(Optimization)을 수행하기 때문이다. 쓰레드가 보다 빠르게 수행할 수 있도록 instanceVariable을 캐시에 두고 최적화가 되어서 이런일이 발생한 것이다. 즉, 최적화가 돠지 않아 캐시간에 데이터가 서로 다른 값을 보지 않으면 volatile을 사용할 필요가 없다.

다음과 같이 VolatileSample 클래스를 변경해보자.

package Volatile;

public class VolatileSample extends Thread{
    private volatile double instanceVariable = 0;

    void setDouble(double value){
        this.instanceVariable = value;
    }
    public void run(){
        try{
            while(instanceVariable == 0){
                Thread.sleep(1);
            }
        }catch(Exception e){

        }
        System.out.println(instanceVariable);
    }
}

while문 안에 Thread.sleep(1)을 지정하여 1 밀리초만 대기하도록 코드를 변경하였다. 이렇게 한 후 RunThread 클래스를 실행해보자. instanceVariable을 volatile로 선언하지 않아도 쓰레드가 멈추는 것을 확인할 수 있을 것이다. 다시 이야기해서, 반드시 volatile을 모든 코드에 추가할 필요는 없으며, 데이터가 문제가 있을 때에만 추가해서 사용하면 된다.

참고

  • 자바의 신
profile
이것저것 관심많은 개발자.
post-custom-banner

0개의 댓글