728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드 기본 정보

Thread 클래스는 스레드를 생성하고 관리하는 기능을 제공한다.

Thread 클래스가 제공하는 정보들을 확인해보자.

하나는 기본으로 제공되는 main 스레드의 정보를, 하나는 직접 만든 myThread 스레드의 정보를 출력해보자.

 

ThreadInfoMain

package thread.control;

import thread.start.HelloRunnable;

import static util.MyLogger.log;

public class ThreadInfoMain {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        log("mainThread======================================");
        log("mainThread = " + mainThread);
        log("mainThread.threadId(): " + mainThread.threadId());
        log("mainThread.getName(): " + mainThread.getName());
        log("mainThread.getPriority(): " + mainThread.getPriority());
        log("mainThread.isDaemon(): " + mainThread.isDaemon());
        log("mainThread.getThreadGroup(): " + mainThread.getThreadGroup());
        log("mainThread.getState(): " + mainThread.getState());

        Thread myThread = new Thread(new HelloRunnable(), "myThread");
        log("myThread======================================");
        log("myThread = " + myThread);
        log("myThread.threadId(): " + myThread.threadId());
        log("myThread.getName(): " + myThread.getName());
        log("myThread.getPriority(): " + myThread.getPriority());
        log("myThread.isDaemon(): " + myThread.isDaemon());
        log("myThread.getThreadGroup(): " + myThread.getThreadGroup());
        log("myThread.getState(): " + myThread.getState());
    }
}

실행결과

2024-07-18 13:42:57.490 [     main] mainThread======================================
2024-07-18 13:42:57.494 [     main] mainThread = Thread[#2,main,5,main]
2024-07-18 13:42:57.502 [     main] mainThread.threadId(): 2
2024-07-18 13:42:57.502 [     main] mainThread.getName(): main
2024-07-18 13:42:57.505 [     main] mainThread.getPriority(): 5
2024-07-18 13:42:57.505 [     main] mainThread.isDaemon(): false
2024-07-18 13:42:57.505 [     main] mainThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.506 [     main] mainThread.getState(): RUNNABLE
2024-07-18 13:42:57.506 [     main] myThread======================================
2024-07-18 13:42:57.506 [     main] myThread = Thread[#31,myThread,5,main]
2024-07-18 13:42:57.507 [     main] myThread.threadId(): 31
2024-07-18 13:42:57.507 [     main] myThread.getName(): myThread
2024-07-18 13:42:57.507 [     main] myThread.getPriority(): 5
2024-07-18 13:42:57.507 [     main] myThread.isDaemon(): false
2024-07-18 13:42:57.508 [     main] myThread.getThreadGroup(): java.lang.ThreadGroup[name=main,maxpri=10]
2024-07-18 13:42:57.508 [     main] myThread.getState(): NEW

 

1. 스레드 생성

스레드를 생성할 때는 실행할 Runnable 인터페이스의 구현체와, 스레드의 이름을 전달할 수 있다.

Thread myThread = new Thread(new HelloRunnable(), "myThread");

 

2. 스레드 객체 정보

myThread 객체를 문자열로 반환하여 출력한다. Thread 클래스의 toString() 메서드는 스레드 ID, 스레드 이름, 스레드 우선순위, 스레드 그룹을 포함하는 문자열을 반환한다.

log("myThread: " + myThread);
>> Thread[#31,myThread,5,main]

 

3. 스레드 ID

스레드의 고유 식별자를 반환하는 메서드이다. 이 ID는 JVM 내에서 각 스레드에 대해 유일하다. ID는 스레드가 생성될 때 할당되며, 직접 지정할 수 없다. 

log("myThread.threadId() = " + myThread.threadId());

 

4. 스레드 이름

스레드의 이름을 반환하는 메서드이다. 생성자에서 `myThread`라는 이름을 지정했기 때문에, 이 값이 반환된다. 참고로 스레드 ID는 중복되지 않지만, 스레드 이름은 중복될 수 있다.

log("myThread.getName() = " + myThread.getName());

 

5. 스레드 우선순위

스레드의 우선순위를 반환하는 메서드이다. 우선순위는 1 (가장 낮음)에서 10 (가장 높음)까지의 값으로 설정할 수 있으며, 기본값은 5이다. setPriority() 메서드를 사용해서 우선순위를 변경할 수 있다. 우선순위는 스레드 스케쥴러가 어떤 스레드를 우선 실행할지 결정하는데 사용된다. 하지만 실제 실행 순서는 JVM 구현과 운영체제에 따라 달라질 수 있다.

log("myThread.getPriority() = " + myThread.getPriority());

 

6. 스레드 그룹

log("myThread.getThreadGroup() = " + myThread.getThreadGroup());

스레드가 속한 스레드 그룹을 반환하는 메서드이다. 스레드 그룹은 스레드를 그룹화하여 관리할 수 있는 기능을 제공한다. 기본적으로 모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속하게 된다. 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업(예: 일괄 종료, 우선순위 설정 등)을 수행할 수 있다. 

여기서 부모 스레드란, 새로운 스레드를 생성하는 스레드를 말한다. 스레드는 기본적으로 다른 스레드에 의해 생성된다. 이러한 생성 관계에서 새로 생성된 스레드는 생성한 스레드를 부모로 간주한다. 그래서 지금 위 실행결과를 보면 myThreadmain 스레드에 의해 생성되었으니까 main 스레드가 부모 스레드가 되고 그룹도 main이 되는 것이다. 

 

그러나, 이 스레드 그룹은 거의 사용되지 않는다.

 

7. 스레드 상태

log("myThread.getState() = " + myThread.getState());

스레드의 현재 상태를 반환하는 메서드이다. 반환되는 값은 Thread.State 열거형에 정의된 상수 중 하나이다. 주요 상태는 다음과 같다.

  • NEW: 스레드가 아직 시작되지 않은 상태이다.
  • RUNNABLE: 스레드가 실행중이거나, 실행될 준비가 된 상태이다.
  • BLOCKED: 스레드가 동기화 락을 기다리는 상태이다.
  • WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태이다.
  • TIMED_WAITING: 일정 시간 동안 기다리는 상태이다 (예: Thread.sleep())
  • TERMINATED: 스레드가 실행을 마친 상태이다.

 

스레드의 생명 주기

스레드는 생성하고 시작하고, 종료되는 생명주기를 가진다. 스레드의 생명 주기에 대해 자세히 알아보자.

스레드의 상태

  • New: 스레드가 생성되었으나 아직 시작되지 않은 상태
  • Runnable: 스레드가 실행중이거나 실행될 준비가 된 상태
  • Blocked: 스레드가 동기화 락을 기다리는 상태
  • Waiting: 스레드가 무기한으로 다른 스레드의 작업을 기다리는 상태
  • Timed Waiting: 스레드가 일정 시간 동안 다른 스레드의 작업을 기다리는 상태
  • Terminated: 스레드의 실행이 완료된 상태

자바 스레드의 생명 주기는 여러 상태로 나뉘어지며, 각 상태는 스레드가 실행되고 종료되기까지의 과정을 나타낸다. 자바 스레드의 생명 주기를 자세히 알아보자! 

 

1. NEW (새로운 상태)

  • 스레드가 생성되고 아직 시작되지 않은 상태이다.
  • 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태이다.

2. Runnable (실행 가능 상태)

  • 스레드가 실행될 준비가 된 상태이다. 이 상태에서 스레드는 실제로 CPU에서 실행될 수 있다.
  • start() 메서드가 호출되면 스레드는 이 상태로 들어간다.
  • 이 상태는 스레드가 실행될 준비가 되어 있음을 나타내며, 실제로 CPU에서 실행될 수 있는 상태이다. 그러나 Runnable 상태에 있는 모든 스레드가 동시에 실행되는 것은 아니다. 운영체제의 스케쥴러가 각 스레드에 CPU 시간을 할당하여 실행하기 때문에, Runnable 상태에 있는 스레드는 스케쥴러의 실행 대기열에 포함되어 있다가 차례로 CPU에서 실행된다.
  • 참고로 운영체제 스케쥴러의 실행 대기열에 있든, CPU에서 실제 실행되고 있든 모두 RUNNABLE 상태이다. 자바에서 둘을 구분해서 확인할 수는 없다.
  • 보통 `실행 상태`라고 부른다.

3. Blocked (차단 상태)

  • 스레드가 다른 스레드에 의해 동기화 락을 얻기 위해 기다리는 상태이다.
  • 예를 들어, synchronized 블록에 진입하기 위해 락을 얻어야 하는 경우 이 상태에 들어간다.
  • 예) synchronized (lock) { ... } 코드 블록에 진입하려고 할 때, 다른 스레드가 이미 lock의 락을 가지고 있는 경우

4. Waiting (대기 상태)

  • 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태이다.
  • wait(), join() 메서드가 호출될 때 이 상태가 된다.
  • 스레드는 다른 스레드가 notify() 또는 notifyAll() 메서드를 호출하거나, join()이 완료될 때까지 기다린다.
  • 예) object.wait();

5. Timed Waiting (시간 제한 대기 상태)

  • 스레드가 특정 시간 동안 다른 스레드의 작업이 완료되기를 기다리는 상태이다.
  • sleep(long millis), wait(long timeout), join(long millis) 메서드가 호출될 때 이 상태가 된다.
  • 주어진 시간이 경과하거나 다른 스레드가 해당 스레드를 깨우면 이 상태에서 벗어난다.
  • 예) Thread.sleep(1000);

6. Terminated (종료 상태)

  • 스레드의 실행이 완료된 상태이다.
  • 스레드가 정상적으로 종료되거나, 예외가 발생하여 종료된 경우 이 상태로 돌아간다.
  • 스레드는 한 번 종료되면 다시 시작할 수 없다.

자바 스레드의 상태 전이 과정

1. New -> Runnable: start() 메서드를 호출하면 스레드가 Runnable 상태로 전이된다.

2. Runnable -> Blocked/Waiting/Timed Waiting: 스레드가 락을 얻지 못하거나, wait() 또는 sleep() 메서드를 호출할 때 해당 상태로 전이된다. 

3. Blocked/Waiting/Timed Waiting -> Runnable: 스레드가 락을 얻거나, 기다림이 완료되면 다시 Runnable 상태로 돌아간다.

4. Runnable -> Terminated: 스레드의 run() 메서드가 완료되면 스레드는 Terminated 상태가 된다.

 

스레드의 생명 주기 - 코드로 직접 보기

실제로 이 일련의 과정을 코드로 직접 봐보자. 

ThreadStateMain

package thread.control;

import static util.MyLogger.log;

public class ThreadStateMain {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new MyRunnable(), "myThread");
        log("myThread.state1 = " + thread.getState());
        log("myThread.start()");
        thread.start();
        Thread.sleep(1000);
        log("myThread.state3 = " + thread.getState());
        Thread.sleep(4000);
        log("myThread.state5 = " + thread.getState());
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            try {
                log("start");
                log("myThread.state2 = " + Thread.currentThread().getState());

                log("sleep() start");
                Thread.sleep(3000);
                log("sleep() end");
                log("myThread.state4 = " + Thread.currentThread().getState());
                log("end");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
  • 첫번째로 MyRunnable 이라는 Runnable을 구현한 클래스를 만든다. 이 클래스에서는 run()을 재정의하여 스레드가 동작할 행위들을 구현한다. 
  • main()에서는 이 MyRunnable()task로 하는 스레드를 생성한다. 그 스레드의 이름은 `myThread`가 된다.
  • myThread.start()를 하기 전, 현재 myThread의 상태를 찍어본다. (NEW)
  • 그리고 myThread.start()를 호출한다.
  • MyRunnablerun()은 실행된 후 자기 자신의 상태를 찍어본다 (RUNNABLE)
  • 그리고 3초동안의 sleep(3000)을 실행한다. 
  • 이 3초 동안 자는 시간에 myThread의 상태를 확인하기 위해 main()에서 start()를 호출 후 1초 정도의 텀을 두고 myThread 상태를 찍어본다. (TIMED_WAITING)
  • 3초가 지나고 myThreadrun() 안에서 자기 자신의 상태를 찍는다 (RUNNABLE)
  • run() 메서드가 종료된다.
  • main() 에서는 run()이 잘 종료될때까지 잠시 4초 정도 기다리고 다시 이 myThread의 상태를 찍어본다. (TERMINATED)

 

근데, 자꾸 불편한게 있다. Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 같은 체크 예외를 밖으로 던질 수가 없다. 왜 그럴까? 그 이유를 알아보자.

 

재정의 메서드에서의 체크 예외

Runnable 인터페이스의 run() 메서드를 구현할 때 InterruptedException 체크 예외를 밖으로 던질 수 없었다. 왜 그럴까?

Runnable 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Runnable {
    void run();
}

 

자바에서 메서드를 재정의할 때, 재정의 메서드가 지켜야 할 예외와 관련된 규칙이 있다.

  • 체크예외
    • 부모 메서드가 체크 예외를 던지지 않는 경우, 재정의된 자식 메서드도 체크 예외를 던질 수 없다.
    • 자식 메서드는 부모 메서드가 던질 수 있는 체크 예외 또는 그 하위 타입만 던질 수 있다.
  • 언체크(런타임) 예외
    • 예외 처리를 강제하지 않으므로 상관없이 던질 수 있다.

그렇다. Runnable 인터페이스의 run() 메서드는 아무런 체크 예외를 던지지 않는다. 따라서 Runnable 인터페이스의 run() 메서드를 재정의 하는 곳에서는 체크 예외를 밖으로 던질 수 없다.

 

그럼 Runnable을 구현하는 클래스에서 재정의 한 run() 메서드는 아무런 체크 예외도 던질 수 없다는 것을 알았다. 근데 왜 이런 제약을 두지?

부모 클래스의 메서드를 호출하는 클라이언트 코드는 부모 메서드가 던지는 특정 예외만을 처리하도록 작성된다. 자식 클래스가 더 넓은 범위의 예외를 던지면 해당 코드는 모든 예외를 제대로 처리하지 못할 수 있다. 이는 예외 처리의 일관성을 해치고, 예상하지 못한 런타임 오류를 초래할 수 있다. 

 

다음 예시를 보면서 더 이해해보자 (실제 동작할 수 없는 코드이다)

class Parent {
	void method() throws InterruptedException {
        // ...
	} 
}

class Child extends Parent {
	@Override
	void method() throws Exception {
        // ...
	} 
}

public class Test {
    public static void main(String[] args) {
    	Parent p = new Child();
        try {
        	p.method();
        } catch (InterruptedException e) {
        	// InterruptedException 처리
        }
    } 
}
  • new Child();Child 인스턴스를 생성했고 변수 p의 타입은 Parent로 받았다. 
  • p.method()를 호출하면 컴파일러 입장에서는 타입이 Parent 이기 때문에 당연히 부모가 선언한 체크 예외인 InterruptedExceptioncatch에서 잡는다.
  • 그러나, 런타임에서는 p.method()를 호출하는 순간 재정의된 메서드가 있다면 재정의된 Childmethod()가 호출된다. 이게 규칙이다.
  • 만약, 그렇게 해서 정상적으로 끝났으면 상관이 없지만 에러가 발생하면 Exception이 던져지게 된다. 하지만 코드에서는 Exception을 잡을수가 없는 사태가 발생한다. 

재정의 메서드에서 체크 예외 규칙

  • 자식 클래스에 재정의된 메서드는 부모 메서드가 던질 수 있는 체크 예외와 그 예외의 하위 타입만을 던질 수 있다.
  • 원래 메서드가 체크 예외를 던지지 않는 경우, 재정의된 메서드도 체크 예외를 던질 수 없다.

안전한 예외 처리

체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try - catch 블록 내에서 처리하게 된다. 이는 예외 발생 시 예외가 적절히 처리되지 않아서 프로그램이 비정상적으로 종료되는 상황을 방지할 수 있다. 특히, 멀티 스레드 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다. 하지만 이제는 체크 예외를 강제하는 이런 부분들이 구시대적 흐름이고 최근에는 체크 예외보단 언체크 예외를 선호한다. 그리고 Runnable은 Java 1.0에서 만들어진 내용이다. 그리고 이제는 체크 예외도 던질 수 있는 Callable 이라는 인터페이스가 탄생한다. 이는 이후에 다루겠다.

 

그래서 Thread.sleep(int millis) 호출할때마다 try - catch 블록으로 잡는게 귀찮으니 다음과 같이 유틸리티를 만들자. 그리고 이것을 사용하자.

ThreadUtils

package util;

import static util.MyLogger.log;

public abstract class ThreadUtils {

    public static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            log("인터럽트 발생, " + e.getMessage());
            throw new RuntimeException(e);
        }
    }
}

 

join - 시작

앞서 Thread.sleep()을 통해 TIMED_WAITING 상태를 알아보았다. 이번에는 join() 메서드를 통해 WAITING(대기 상태)가 무엇이고 왜 필요한지 알아보자.

 

WAITING - 스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태

 

먼저 스레드로 특정 작업을 수행하는 간단한 예제를 하나 만들어보자.

JoinMainV0

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV0 {
    public static void main(String[] args) {
        log("start");
        Thread thread1 = new Thread(new Job(), "thread-1");
        Thread thread2 = new Thread(new Job(), "thread-2");

        thread1.start();
        thread2.start();

        log("end");
    }

    static class Job implements Runnable {

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            log("작업 완료");
        }
    }
}

실행결과 (스레드의 실행 순서는 보장되지 않기 때문에 실행 결과는 약간 다를 수 있다)

2024-07-18 16:43:04.713 [     main] start
2024-07-18 16:43:04.717 [     main] end
2024-07-18 16:43:04.717 [ thread-1] 작업 시작
2024-07-18 16:43:04.717 [ thread-2] 작업 시작
2024-07-18 16:43:06.719 [ thread-2] 작업 완료
2024-07-18 16:43:06.719 [ thread-1] 작업 완료
  • thread-1, thread-2 모두 main 스레드가 생성하고 start()를 호출해서 실행한다.
  • thread-1, thread-2는 각각 특정 작업을 수행한다. 작업 수행에 약 2초 정도가 걸린다고 가정하기 위해 sleep()을 사용해서 2초간 대기한다. 
  • main 스레드는 thread-1, thread-2를 실행하고 바로 자신의 다음 코드를 실행한다. 여기서 핵심은 main 스레드가 thread-1, thread-2가 끝날때까지 기다리지 않는다는 점이다. main 스레드는 단지 start()를 호출해서 다른 스레드를 실행만 하고 바로 자신의 다음 코드를 실행한다.

그런데 만약 thread-1, thread-2가 종료된 다음에 main 스레드를 가장 마지막에 종료하려면 어떻게 해야할까? 예를 들어 main 스레드가 thread-1, thread-2에 각각 어떤 작업을 지시하고, 그 결과를 받아서 처리하고 싶다면 어떻게 해야할까? 

 

join - 필요한 상황

1 - 100까지 더하는 코드를 생각해보자. 

int sum = 0;
for (int i = 1; i <= 100; i++) {
	sum += i; 
}

이 코드는 스레드를 하나만 사용하기 때문에 CPU 코어도 하나만 사용할 수 있다. CPU 코어를 더 효율적으로 사용하려면 여러 스레드로 나누어 계산하면 된다. 

  • 1 - 50까지 더하기
  • 51 - 100까지 더하기

두 계산 결과를 합치면 된다. main 스레드가 1 - 100까지 더하는 작업을 thread-1, thread-2에 각각 작업을 나누어 지시하면 CPU 코어를 더 효율적으로 활용할 수 있다. CPU 코어가 2개라면 이론적으로 연산 속도가 2배 빨라진다.

  • thread-1: 1 - 50까지 더하기
  • thread-2: 51 - 100까지 더하기

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV1 {
    public static void main(String[] args) {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

SumTask는 계산의 시작값(startValue)과 계산의 마지막 값(endValue)을 가진다. 그리고 계산이 끝나면 그 결과를 result 필드에 담아둔다. main 스레드는 thread-1, thread-2를 만들고 다음과 같이 작업을 할당한다.

  • thread-1: task1 (1 - 50까지 더하기)
  • thread-2: task2 (51 - 100까지 더하기)

thread-1task1 인스턴스의 run()을 실행하고, thread-2task2 인스턴스의 run()을 실행한다. 각각의 스레드는 계산 결과를 result 멤버 변수에 보관한다.

 

run()에서 수행하는 계산이 2초 정도는 걸리는 복잡한 계산이라고 가정하자. 그래서 sleep(2000)으로 설정했다. 여기서는 약 2초 후에 계산이 완료되고 result에 결과가 담긴다.

 

main 스레드는 thread-1, thread-2에 작업을 지시한 다음에 작업의 결과인 task1.result, task2.result를 얻어서 사용한다.

 

실행결과

2024-07-18 16:59:17.656 [     main] start
2024-07-18 16:59:17.660 [ thread-1] 작업 시작
2024-07-18 16:59:17.660 [ thread-2] 작업 시작
2024-07-18 16:59:17.668 [     main] task1.result = 0
2024-07-18 16:59:17.669 [     main] task2.result = 0
2024-07-18 16:59:17.670 [     main] task1 + task2 = 0
2024-07-18 16:59:17.670 [     main] end
2024-07-18 16:59:19.663 [ thread-2] 작업 완료 result = 3775
2024-07-18 16:59:19.663 [ thread-1] 작업 완료 result = 1275

그런데 실행 결과를 보면 기대와 다르게 task1.result, task2.result 모두 0으로 나온다. 그리고 task1 + task2의 결과도 0으로 나온다. 계산이 전혀 진행되지 않았다. 왜 그럴까?

main 스레드는 thread-1, thread-2에 작업을 지시하고, thread-1, thread-2가 계산을 완료하기도 전에 먼저 계산 결과를 조회했다. 참고로 thread-1, thread-2가 계산을 완료하기까지는 2초 정도의 시간이 걸린다. 따라서 결과가 task1 + task2 = 0으로 출력된다.

 

 

이 부분을 메모리 구조로 좀 더 자세히 살펴보자.

  • 프로그램이 처음 시작되면 main 스레드는 thread-1, thread-2를 생성하고 start()로 실행한다.
  • thread-1, thread-2는 각각 자신에게 전달된 SumTask 인스턴스의 run() 메서드를 스택에 올리고 실행한다.
  • thread-1x001 인스턴스의 run() 메서드를 실행한다.
  • thread-2x002 인스턴스의 run() 메서드를 실행한다.

  • main 스레드는 두 스레드를 시작한 다음에 바로 task1.result, task2.result를 통해 인스턴스에 있는 결과값을 조회한다. 참고로 main 스레드가 실행한 start() 메서드는 스레드의 실행이 끝날 때 까지 기다리지 않는다! 다른 스레드를 실행만 해두고 자신의 다음 코드를 실행할 뿐이다.
  • thread-1, thread-2가 계산을 완료해서, result에 연산 결과를 담을 때 까지는 약 2초 정도의 시간이 걸린다. main 스레드는 계산이 끝나기 전에 result의 결과를 조회한 것이다. 따라서 0이 출력된다.

  • 2초가 지난 이후에 thread-1, thread-2는 계산을 완료한다.
  • 이때, main 스레드는 이미 자신의 코드를 모두 실행하고 종료된 상태이다.
  • task1 인스턴스의 result에는 1275가 담겨있고, task2 인스턴스의 result에는 3775가 담겨있다.

여기서 문제의 핵심은 main 스레드가 thread-1, thread-2의 계산이 끝날 때 까지 기다려야 한다는 점이다. 그럼 어떻게 해야 main 스레드가 기다릴 수 있을까? 

 

참고 - this의 비밀

어떤 메서드를 호출하는 것은, 정확히는 특정 스레드가 어떤 메서드를 호출하는 것이다.

스레드는 메서드의 호출을 관리하기 위해 메서드 단위로 스택 프레임을 만들고 해당 스택 프레임을 스택위에 쌓아 올린다.

이때 인스턴스의 메서드를 호출하면, 어떤 인스턴스의 메서드를 호출했는지 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임 내부에 저장해둔다. 이것이 바로 우리가 자주 사용하던 this이다.

 

특정 메서드 안에서 this를 호출하면 바로 스택 프레임 안에 있는 this 값을 불러서 사용하게 된다.

그림을 보면 스택 프레임 안에 있는 this를 확인할 수 있다. 이렇게 this가 있기 때문에 thread-1, thread-2는 자신의 인스턴스를 구분해서 사용할 수 있다. 예를 들어 필드에 접근할 때 this를 생략하면 자동으로 this를 참고해서 필드에 접근한다. 

 

정리하면, 스레드는 자기만의 스택을 가지고 있고, 메서드를 호출한다는 것은 스레드가 어떤 메서드를 호출한다는 것이며 그 메서드를 자신의 스택에 스택 프레임으로 하나씩 쌓아 올린다. 이때, 해당 메서드가 어떤 인스턴스의 메서드인지를 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임에 저장하게 되는데 이것이 this이다.

 

 

각설하고, 이제 과연 main이 어떻게 thread-1, thread-2을 기다리고 계산 결과를 가져올 수 있을까? 

가장 간단하고 단순한 방법은 sleep을 사용하는 것이다.

 

JoinMainV1

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV1 {
    public static void main(String[] args) {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("main 스레드 sleep()");
        ThreadUtils.sleep(3000);
        log("main 스레드 깨어남");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

각 스레드가 작업을 한 2초정도 하니까 3초 정도 main 스레드가 잠을 자고 있다가 일어나면 계산이 다 됐을꺼라고 가정하는 것이다. 즉 무슨 말이냐면, 실제로 스레드의 작업에 대한 정확한 시간은 모두가 모른다는 것이다.

실행결과

2024-07-18 17:21:49.452 [     main] start
2024-07-18 17:21:49.454 [ thread-1] 작업 시작
2024-07-18 17:21:49.454 [     main] main 스레드 sleep()
2024-07-18 17:21:49.454 [ thread-2] 작업 시작
2024-07-18 17:21:51.478 [ thread-2] 작업 완료 result = 3775
2024-07-18 17:21:51.478 [ thread-1] 작업 완료 result = 1275
2024-07-18 17:21:52.460 [     main] main 스레드 깨어남
2024-07-18 17:21:52.462 [     main] task1.result = 1275
2024-07-18 17:21:52.463 [     main] task2.result = 3775
2024-07-18 17:21:52.464 [     main] task1 + task2 = 5050
2024-07-18 17:21:52.464 [     main] end

 

원하는 결과는 나왔지만, 얼마나 자고 있어야 하는지 전혀 모른다. 지금이야 명시적으로 run()안에서 2초의 대기상태라는 명확한 시간이 존재하지만, 실제 서비스라고 생각해보면 절대 알 수 없다.

 

그래서 이럴때 join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있다.

 

join() 사용

다음 코드를 보자. 

JoinMainV2

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV2 {
    public static void main(String[] args) throws InterruptedException {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("join() - main 스레드가 thread1, thread2 종료까지 대기");
        thread1.join();
        thread2.join();
        log("main 스레드 대기 완료");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

 

  • thread1.start(), thread2.start()를 호출한 후 thread1.join(), thread2.join()을 호출한다.
  • thread1.join()을 호출하는 순간 호출한 스레드(main)은 thread1이 작업이 끝날때까지 무기한 기다린다. (WAITING)
  • thread1이 작업이 다 끝나고 다시 RUNNABLE 상태가 된 main은 다음 코드로 간다. 다음 코드가 thread2.join()이므로 또 무기한 기다리는 상태가 된다 (WAITING) 물론, thread1thread2가 소요되는 시간이 거의 비슷하기 때문에 아마 thread1이 작업이 끝남과 동시에 thread2도 작업이 끝날것같다. 그렇게 되면 thread1.join()이 풀림과 동시에 거의 바로 thread2.join()도 풀려서 바로 다음 코드로 진행된다.
  • thread2가 작업이 다 끝나고 다시 mainRUNNABLE 상태가 되며 다음 코드를 진행한다.

실행결과

2024-07-19 09:47:06.821 [     main] start
2024-07-19 09:47:06.826 [ thread-2] 작업 시작
2024-07-19 09:47:06.826 [     main] join() - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:47:06.826 [ thread-1] 작업 시작
2024-07-19 09:47:08.835 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:47:08.835 [ thread-2] 작업 완료 result = 3775
2024-07-19 09:47:08.835 [     main] main 스레드 대기 완료
2024-07-19 09:47:08.836 [     main] task1.result = 1275
2024-07-19 09:47:08.836 [     main] task2.result = 3775
2024-07-19 09:47:08.836 [     main] task1 + task2 = 5050
2024-07-19 09:47:08.836 [     main] end

이렇게 특정 스레드를 `무기한` 기다리는 WAITING 상태로 만드는 방법이 join()이라고 할 수 있다. 하지만 join()의 단점은 정말 무기한 기다리기 때문에, 죽을때까지 기다리기만 할수도 있다. 예외 상황이 발생하면. 그런 단점을 해결하기 위해 특정 시간만큼만 대기할 수 있다. 

 

join(int millis) - 특정 시간 만큼만 대기

JoinMainV3

package thread.control.join;

import util.ThreadUtils;

import static util.MyLogger.log;

public class JoinMainV3 {
    public static void main(String[] args) throws InterruptedException {
        log("start");

        SumTask task1 = new SumTask(1, 50);
        SumTask task2 = new SumTask(51, 100);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        log("join(500) - main 스레드가 thread1, thread2 종료까지 대기");
        thread1.join(500);
        thread2.join(500);
        log("main 스레드 대기 완료");

        log("task1.result = " + task1.result);
        log("task2.result = " + task2.result);

        int sumAll = task1.result + task2.result;
        log("task1 + task2 = " + sumAll);

        log("end");

    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            ThreadUtils.sleep(2000);
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                sum += i;
            }
            result = sum;
            log("작업 완료 result = " + result);
        }
    }
}

이렇게 join(500)으로 시간을 밀리초로 넘기면 이 넘긴 시간만큼만 기다리게 된다. 실행해보자. 아마 각 스레드는 2초정도의 시간이 필요하기 때문에 결과를 가져올 수 없을것이다. 

실행결과

2024-07-19 09:48:40.893 [     main] start
2024-07-19 09:48:40.897 [     main] join(500) - main 스레드가 thread1, thread2 종료까지 대기
2024-07-19 09:48:40.897 [ thread-2] 작업 시작
2024-07-19 09:48:40.897 [ thread-1] 작업 시작
2024-07-19 09:48:41.907 [     main] main 스레드 대기 완료
2024-07-19 09:48:41.914 [     main] task1.result = 0
2024-07-19 09:48:41.914 [     main] task2.result = 0
2024-07-19 09:48:41.914 [     main] task1 + task2 = 0
2024-07-19 09:48:41.915 [     main] end
2024-07-19 09:48:42.901 [ thread-1] 작업 완료 result = 1275
2024-07-19 09:48:42.901 [ thread-2] 작업 완료 result = 3775

 

당연히 이때 main 스레드의 상태는 TIMED_WAITING 상태가 된다. 무기한 기다리는 상태가 아니라 특정 시간만큼만 기다리는 상태이기 때문이다.

 

정리

기본적으로 스레드는 어떤 상태들이 존재하고 생명 주기가 어떻게 되는지 알아보았다. 그리고 join()을 사용해서 다른 스레드의 작업이 끝나는 것을 기다려야 하는 경우 무기한 기다리는 방법과 특정 시간만큼만 기다리는 방법을 알아보았다. 이 Xxx.join()을 마주한 스레드는 Xxx가 작업이 다 끝날때까지 그 코드라인에서 기다리고 다음 라인으로 넘어가지 않는다. 다음 포스팅에서는 좀 더 깊이 있는 스레드 제어와 생명 주기에 대해 알아보자.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드를 시작하기 앞서, 스레드를 제대로 이해하려면 자바 메모리 구조를 확실히 이해하고 있어야 한다.

자바 메모리 구조

  • 메서드 영역: 메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 프로그램의 모든 영역에서 공유한다.
    • 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드와 생성자 코드등 모든 실행 코드가 존재한다.
    • static 영역: static 변수들을 보관한다.
    • 런타임 상수 : 프로그램을 실행하는데 필요한 공통 리터럴 상수를 보관한다.
  • 스택 영역: 자바 실행 시, 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다.
  • 힙 영역: 객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.

스택 영역은 더 정확히는 각 스레드별로 하나의 실행 스택이 생성된다. 따라서 스레드 수 만큼 스택이 생성된다. 지금은 스레드 1개만 사용하므로 스택도 하나이다. 이후 스레드를 추가할 것인데 그러면 스택도 스레드 수만큼 증가한다.

 

스레드 생성

스레드를 직접 만들어보자. 그래서 해당 스레드에서 별도의 로직을 수행해보자.

스레드를 만들때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다.

먼저 Thread 클래스를 상속 받아서 스레드를 생성해보자.

 

스레드 생성 - Thread 상속

자바는 많은 것을 객체로 다룬다. 자바가 예외도 객체로 다루듯 스레드도 객체로 다룬다.

스레드가 필요하면 스레드 객체를 생성해서 사용하면 된다.

 

HelloThread

package thread.start;

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run()");
    }
}
  • Thread 클래스를 상속하고, 스레드가 실행할 코드를 run() 메서드에 재정의한다.
  • Thread.currentThread()를 호출하면 해당 코드를 실행하는 스레드 객체를 조회할 수 있다.
  • Thread.currentThread().getName(): 실행중인 스레드의 이름을 조회한다.

HelloThreadMain

package thread.start;

public class HelloThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
        helloThread.start();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 후");

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}
  • 앞서 만든 HelloThread 객체를 생성하고 start() 메서드를 호출한다.
  • start() 메서드는 스레드를 실행하는 아주 특별한 메서드이다.
  • start()를 호출하면 HelloThread 스레드가 run() 메서드를 실행한다.
주의! run() 메서드가 아니라 반드시 start() 메서드를 호출해야 한다. 그래야 별도의 스레드에서 run() 코드가 실행된다. run()을 직접 호출하면 현재 진행중인 쓰레드가 그냥 run() 메서드를 실행하는 것이다.

 

실행결과

main: main() start
main: start() 호출 전
main: start() 호출 후
main: main() end
Thread-0: run()

실행 결과는 스레드의 실행 순서에 따라 약간 다를 수 있다. 가령 이런 경우도 있고.

main: main() start
main: start() 호출 전
main: start() 호출 후
Thread-0: run()
main: main() end

 

실행 결과 설명 - 1단계

실행 결과를 보면 main() 메서드는 main 이라는 이름의 스레드가 실행하는 것을 확인할 수 있다. 프로세스가 작동하려면 스레드가 최소한 하나는 있어야 한다. 그래야 코드를 실행할 수 있다. 자바는 실행 시점에 main 이라는 이름의 스레드를 만들고 프로그램의 시작점인 main() 메서드를 실행한다.

 

실행 결과 설명 - 2단계

  • HelloThread 스레드 객체를 생성한 다음에 start() 메서드를 호출하면 자바는 HelloThread 스레드를 위한 별도의 스택 공간을 할당한다.
  • 스레드 객체를 생성하고 반드시 start()를 호출해야 스택 공간을 할당받고 스레드가 작동한다.
  • 스레드에 이름을 주지 않으면 자바는 스레드에 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • 새로운 Thread-0 스레드가 사용할 전용 스택 공간이 마련되었다.
  • Thread-0 스레드는 run() 메서드의 스택 프레임을 스택에 올리면서 run() 메서드를 시작한다.

 

 

메서드를 실행하면 스택 위에 스택 프레임이 쌓인다.

  • main 스레드는 main() 메서드 스택 프레임을 스택에 올리면서 시작한다.
  • 직접 만드는 스레드는 run() 메서드 스택 프레임을 스택에 올리면서 시작한다.

실행 결과를 보면 Thread-0 스레드가 run() 메서드를 실행한 것을 확인할 수 있다.

  • main 스레드가 HelloThread 인스턴스를 생성한다. 이때 스레드에 이름을 부여하지 않으면 자바가 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • main 스레드가 start() 메서드를 호출하면, Thread-0 스레드가 시작되면서 Thread-0 스레드가 run() 메서드를 호출한다.
  • 여기서 핵심은 main 스레드가 run() 메서드를 실행하는 게 아니라, Thread-0 스레드가 run() 메서드를 실행한다는 점이다.
  • main 스레드는 단지 start() 메서드를 통해 Thread-0 스레드에게 실행을 지시할 뿐이다. 다시 강조하지만 main 스레드가 run()을 호출하는 게 아니다! main 스레드는 다른 스레드에게 일을 시작하라고 지시만 하고 바로 start() 메서드를 빠져나온다.
  • 이제 main 스레드와 Thread-0 스레드는 동시에 실행된다.
  • main 스레드 입장에서 보면 그림 1, 2, 3번 코드를 멈추지 않고 계속 수행한다. 그리고 run() 메서드는 main이 아닌 별도의 스레드에서 실행된다.

스레드 간 실행 순서는 보장하지 않는다.

스레드는 동시에 수행되기 때문에 스레드 간에 실행 순서는 얼마든지 달라질 수 있다. 따라서 위에서 보여준 실행결과와 같이 다양한 실행 결과가 나올 수 있다. CPU 코어가 2개여서 물리적으로 정말 동시에 실행될 수도 있고, 하나의 CPU 코어에 시간을 나누어 실행될 수도 있다.

그리고 한 스레드가 얼마나 오랜기간 실행되는지도 보장하지 않는다. 한 스레드가 먼저 다 수행된 다음에 다른 스레드가 수행될 수도 있고, 둘이 완전히 번갈아 가면서 수행되는 경우도 있다. 

 

스레드는 순서와 실행 기간을 모두 보장하지 않는다. 이것이 바로 멀티 스레드다!

 

start() vs run()

스레드의 start() 대신에 재정의한 run()을 직접 호출하면 어떻게 될까?

package thread.start;

public class BadThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
        helloThread.run();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 후");

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}

실행결과

main: main() start
main: start() 호출 전
main: run()
main: start() 호출 후
main: main() end

  • 실행 결과를 잘 보면 별도의 스레드가 run()을 실행하는 것이 아니라, main 스레드가 run() 메서드를 호출한 것을 알 수 있다.
  • 자바를 처음 실행하면 main 스레드가 main() 메서드를 호출하면서 시작한다.
  • main 스레드는 HelloThread 인스턴스에 있는 run() 이라는 메서드를 호출한다.
  • main 스레드가 run() 메서드를 실행했기 때문에 main 스레드가 사용하는 스택위에 run() 스택 프레임이 올라간다.

결과적으로 main 스레드에서 모든 것을 처리한 것이 된다.

 

스레드의 start() 메서드는 스레드에 스택 공간을 할당하면서 스레드를 시작하는 아주 특별한 메서드이다. 그리고 해당 스레드에서 run() 메서드를 실행한다. 따라서 main 스레드가 아닌 별도의 스레드에서 재정의한 run() 메서드를 실행하려면, 반드시 start() 메서드를 호출해야 한다.

 

데몬 스레드

스레드는 사용자 스레드데몬 스레드 2가지 종류로 구분할 수 있다.

 

사용자 스레드 (non-daemon 스레드)

  • 프로그램의 주요 작업을 수행한다.
  • 작업이 완료될 때까지 실행된다.
  • 모든 user 스레드가 종료되면 JVM도 종료된다.

데몬 스레드

  • 백그라운드에서 보조적인 작업을 수행한다.
  • 모든 user 스레드가 종료되면 데몬 스레드는 자동으로 종료된다.

JVM은 데몬 스레드의 실행 완료를 기다리지 않고 종료된다. 데몬 스레드가 아닌 모든 스레드가 종료되면, 자바 프로그램도 종료된다.

참고로, 데몬 스레드라는 용어는 사용자에게 직접적으로 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬 스레드, 데몬 프로세스라고 한다. 

 

DaemonThreadMain

package thread.start;

public class DaemonThreadMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main start");

        DaemonThread dt = new DaemonThread();
        dt.setDaemon(true); // 데몬 스레드 여부
        dt.start();

        System.out.println(Thread.currentThread().getName() + ": main end");
    }

    static class DaemonThread extends Thread {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + ": run() start");

            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(Thread.currentThread().getName() + ": run() end");
        }
    }
}
  • 데몬 스레드를 설정하려면 setDaemon(true); 를 호출하면 된다.
  • 데몬 스레드 여부는 start() 실행 전에 결정해야 한다. 이후에는 변경되지 않는다.
  • 기본값은 false

실행결과

main: main start
main: main end
Thread-0: run() start

 

분명 Thread-0은 10초가 지나야 끝나는데 그냥 끝나버렸다. 그 이유는 Thread-0은 데몬 스레드이고, 메인 스레드(사용자 스레드)가 다 끝났기 때문에 자바 프로그램도 종료된 것이다. 만약 데몬 스레드가 아니라면 예상한대로 아래처럼 실행된다.

 

실행결과 (setDaemon(false))

main: main start
main: main end
Thread-0: run() start
Thread-0: run() end

 

스레드 생성 - Runnable

스레드를 만들 때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다고 했다.

이제 Runnable 인터페이스를 구현하는 방식으로 스레드를 생성해보자.

 

Runnable 인터페이스

package java.lang;

@FunctionalInterface
public interface Runnable {
    void run();
}

 

HelloRunnable

package thread.start;

public class HelloRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run() start");
        System.out.println(Thread.currentThread().getName() + ": run() end");
    }
}

 

HelloRunnableMain

package thread.start;

public class HelloRunnableMain {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloRunnable helloRunnable = new HelloRunnable();
        new Thread(helloRunnable).start();

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}

실행결과

main: main() start
main: main() end
Thread-0: run() start
Thread-0: run() end

 

실행 결과는 기존과 같다. 차이가 있다면 스레드와 해당 스레드가 실행할 작업이 서로 분리되어 있다는 점이다.

스레드 객체를 생성할 때, 실행할 작업을 생성자로 전달하면 된다. 위 코드처럼.

 

Thread 상속 vs Runnable 구현

스레드를 사용할 땐 Thread를 상속받는 것보다 Runnable 인터페이스를 구현하는 방식을 사용하자.

두 방식이 서로 장단점이 있지만, 스레드를 생성할 때는 Thread 클래스를 상속받는 방식보다 Runnable 인터페이스를 구현하는 방식이 더 나은 선택이다. 

 

Thread 상속

장점

  • 간단한 구현: Thread 클래스를 상속받아 run() 메서드만 재정의하면 된다.

단점

  • 상속의 제한: 자바는 단일 상속만을 허용하므로 이미 다른 클래스를 상속받고 있는 경우 Thread 클래스를 상속받을 수 없다.
  • 유연성 부족: 인터페이스를 사용하는 방법에 비해 유연성이 떨어진다.

Runnable 구현

장점

  • 상속의 자유로움: Runnable 인터페이스 방식은 다른 클래스를 상속받아도 문제없이 구현할 수 있다.
  • 코드의 분리: 스레드와 실행할 작업을 분리하여 코드의 가독성을 높일 수 있다.
  • 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리를 효율적으로 할 수 있다.

단점

  • 코드가 약간 복잡해질 수 있다. Runnable 객체를 생성하고 이를 Thread에 전달하는 과정이 추가된다.

정리하자면, Runnable 인터페이스를 구현하는 방식을 사용하자. 스레드와 실행할 작업을 명확히 분리하고, 인터페이스를 사용하므로 Thread 클래스를 직접 상속받는 방식보다 더 유연하고 유지보수 하기 쉬운 코드를 만들 수 있다.

 

로거 만들기

현재 어떤 스레드가 코드를 실행하는지 출력하기 위해 다음과 같이 긴 코드를 작성하는게 이젠 귀찮다.

System.out.println(Thread.currentThread().getName() + ": main() start");

 

이런식으로 실행하면 현재 시간, 스레드 이름, 출력 내용등이 한번에 나올 수 있으면 좋을것같다.

log("Hello World");
log(1234);

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

MyLogger

package util;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public abstract class MyLogger {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    public static void log(Object obj) {
        String localDateTime = LocalDateTime.now().format(formatter);
        System.out.printf("%s [%9s] %s\n", localDateTime, Thread.currentThread().getName(), obj);
    }
}
  • 다른곳에서 new로 생성하지 못하도록 abstract 클래스를 만들었다.
  • 현재 시간을 원하는 포맷으로 출력하기 위해 DateTimeFormatter를 사용한다.
  • printf에서 %s는 문자열을 뜻한다. 인자를 순서대로 사용한다.
  • 마지막 출력할 객체는 문자열이 아니라 Object 타입인데, %s를 사용하면 toString()을 사용해서 문자열로 변환 후 출력한다. 이렇게 Object 타입을 사용하면 문자열 뿐만 아니라 객체도 출력할 수 있어서 이렇게 했다.
  • %9s는 다음과 같이 문자를 출력할 때 9칸을 확보한다는 뜻이다. 9칸이 차지 않으면 왼쪽에 그 만큼 비워둔다. 이 기능은 단순히 출력시 정렬을 깔끔히 하기위해 사용한다.
  • [     main]: 앞에 5칸 공백
  • [ Thread-0]: 앞에 1칸 공백

 

MyLoggerMain

package util;

import static util.MyLogger.*;

public class MyLoggerMain {
    public static void main(String[] args) {
        log("Hello World");
        log(1234);
    }
}

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

이제 이 로거를 사용해서 마음껏 스레드 공부를 해보자!

 

여러 스레드 만들기

많은 스레드를 만들어보고 실행해보자.

ManyThreadMainV1

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV1 {
    public static void main(String[] args) {

        log("main() start");

        HelloRunnable helloRunnable = new HelloRunnable();

        Thread thread1 = new Thread(helloRunnable);
        thread1.start();

        Thread thread2 = new Thread(helloRunnable);
        thread2.start();

        Thread thread3 = new Thread(helloRunnable);
        thread3.start();

        log("main() end");
    }
}

실행결과

2024-07-18 10:07:46.166 [     main] main() start
2024-07-18 10:07:46.171 [     main] main() end
2024-07-18 10:07:46.171 [ Thread-1] run() start
2024-07-18 10:07:46.171 [ Thread-2] run() start
2024-07-18 10:07:46.171 [ Thread-0] run() start
2024-07-18 10:07:46.171 [ Thread-1] run() end
2024-07-18 10:07:46.171 [ Thread-2] run() end
2024-07-18 10:07:46.172 [ Thread-0] run() end

 

이 예시는 단순 3개의 스레드를 만들고 실행한 결과이다.

  • 스레드 3개를 생성할 때 모두 같은 HelloRunnable 인스턴스를 스레드의 실행 작업으로 전달했다.
  • Thread-0, Thread-1, Thread-2 모두 HelloRunnable 인스턴스에 있는 run() 메서드를 실행한다.

 

스레드 100개도 생성해보자.

ManyThreadMainV2

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV2 {
    public static void main(String[] args) {

        log("main() start");

        HelloRunnable helloRunnable = new HelloRunnable();
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(helloRunnable);
            thread.start();
        }

        log("main() end");
    }
}

실행결과

2024-07-18 10:10:01.192 [     main] main() start
2024-07-18 10:10:01.195 [ Thread-2] run() start
2024-07-18 10:10:01.195 [ Thread-5] run() start
2024-07-18 10:10:01.196 [ Thread-5] run() end
2024-07-18 10:10:01.196 [Thread-20] run() start
2024-07-18 10:10:01.195 [ Thread-4] run() start
2024-07-18 10:10:01.197 [Thread-29] run() start
2024-07-18 10:10:01.195 [ Thread-8] run() start
...
2024-07-18 10:10:01.218 [Thread-98] run() end

 

실행 결과는 그때마다 다르고 스레드의 실행 순서는 보장되지 않는다!

 

Runnable을 만드는 다양한 방법

중첩 클래스 사용하면 Runnable을 더 편리하게 만들 수 있다.

InnerRunnableMainV1

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV1 {
    public static void main(String[] args) {
        log("main() start");
        MyRunnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
        t1.start();
        log("main() end");
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            log("run() start");
            log("run() end");
        }
    }
}

실행결과 (실행 결과는 모두 같다)

2024-07-18 10:47:29.014 [     main] main() start
2024-07-18 10:47:29.017 [     main] main() end
2024-07-18 10:47:29.017 [ Thread-0] run() start
2024-07-18 10:47:29.018 [ Thread-0] run() end

 

익명 내부 클래스를 사용하면 더 편리하게 만들수 있다.

InnerRunnableMainV2

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV2 {
    public static void main(String[] args) {
        log("main() start");

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                log("run() start");
                log("run() end");
            }
        });
        t1.start();
        log("main() end");
    }
}

 

람다를 사용하면 더더 편리하게 만들수도 있다.

InnerRunnableMainV3

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV3 {
    public static void main(String[] args) {
        log("main() start");

        Thread t1 = new Thread(() -> {
            log("run() start");
            log("run() end");
        });
        t1.start();
        log("main() end");
    }
}

 

 

정리

이렇게 여러 방법으로 Runnable을 만드는 방법을 알아보았다. 스레드를 어떻게 만들고 뭐로 만드는게 더 효율적인지를 배워본것이다.

다음 포스팅부턴 더 깊은 내용을 알아볼 예정이다.

728x90
반응형
LIST
728x90
반응형
SMALL

ObjectMapper는 정말 많이 사용되는데, 이 ObjectMapper를 사용할 때 writeValueAsString(Object value) 같은 경우에 IOException을 던지기 때문에 클라이언트 코드에서 처리하기 여간 귀찮은 게 아니다.

 

그래서 IOException 받아서 내가 만든 커스텀 에러 객체 (예를 들면, MapperToJsonException 이런 클래스? 아니면 그냥 RuntimeException)를 던져서 예외 공통 처리를 하는 방식으로 모듈화하면 개발 생산성이 높아지는 느낌이 든다.

 

물론, 이게 정답은 아니지만 워낙 ObjectMapper는 많이 사용되고 그 중에서도 객체를 Json으로 변환하는 작업은 더더욱 많이 사용되고 그때마다 예외 처리하기가 너무너무 귀찮다. 그래서 난 아예 빈으로 등록할 때 설정 작업을 해주었다.

 

CommonConfiguration

package kr.osci.kapproval.com.config;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;

@Configuration
public class CommonConfiguration {

    @Bean
    public ObjectMapper objectMapper() {
        return new CustomObjectMapper();
    }

    public static class CustomObjectMapper extends ObjectMapper {

        @Override
        public String writeValueAsString(Object value) {
            try {
                return super.writeValueAsString(value);
            } catch (IOException e) {
                throw new RuntimeException("value: " + value + " 를 JSON으로 직렬화 중 오류가 발생했습니다. ", e);
            }
        }
    }
}

 

이렇게 빈으로 등록해서 어디선가 ObjectMapper를 주입받아 사용할 일이 있다면 편리하게 사용중이다.

만약, 그 외 설정이 필요한 경우가 있을 수 있는데 그럴때도 난 이렇게 사용하고 설정을 더 추가해준다 아래처럼.

 

CommonConfiguration

package kr.osci.kapproval.com.config;
import com.sun.xml.internal.ws.developer.SerializationFeature;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.PropertyNamingStrategy;
import org.codehaus.jackson.map.SerializationConfig;
import org.codehaus.jackson.map.annotate.JsonSerialize;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CommonConfiguration {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new CustomObjectMapper();

        mapper.configure(SerializationConfig.Feature.WRITE_ENUMS_USING_TO_STRING, true);
        mapper.setSerializationInclusion(JsonSerialize.Inclusion.NON_NULL);
        
        return mapper;
    }

    public static class CustomObjectMapper extends ObjectMapper {
        
        @Override
        public String writeValueAsString(Object value) {
            try {
                return super.writeValueAsString(value);
            } catch (IOException e) {
                throw new RuntimeException("value: " + value + " 를 JSON으로 직렬화 중 오류가 발생했습니다. ", e);
            }
        }
    }
}

 

여튼 이렇게 잘 사용중이다! 내가 안 까먹기 위해 작성했다. 

728x90
반응형
LIST
728x90
반응형
SMALL

당연하게도 JIRA Plugin을 개발할 땐 나만의 JQL Function도 만들 수 있다.

"JQL Function이 뭔가요? JQL 그냥 이런거 아닌가요? `issuekey = TEST-1`".

 

JQL Function은 사용자들이 많이 사용하는 JQL을 아예 함수로 만들어 버린 것들을 말한다. 대표적인 JQL Function은 이런것들이 있다.

  • currentUser()
  • endOfDay()
  • ...

이런게 바로 JQL Function이다. 바로 한번 만들어보자!

참고 자료:

 

Adding a JQL function to Jira

Adding a JQL function to Jira Applicable:Jira 7.0.0 and later.Level of experience:Intermediate. You should have completed at least one beginner tutorial before working through this tutorial. See the list of developer tutorials.Time estimate:It should take

developer.atlassian.com

 

JQL Function 클래스

우선, JQL Function을 만드려면 AbstractJqlFunction이라는 클래스를 상속받아야 한다.

 

그리고 이 클래스는 다음 5가지를 오버라이딩 해야 한다.

  • getFunctionName() : JQL Function 이름을 지정한다.
  • validate() : 유효성 검사 메서드. 
  • getValues() : 실제 JQL Function으로부터 가져올 데이터를 쿼리하는 메서드
  • getMinimumNumberOfExpectedArguments() : 최소한으로 필요한 Arguments 개수
  • getDataType() : 데이터 타입

KapprovalApprovalFunction

package kr.osci.kapproval.com.jira.jql;

import com.atlassian.jira.JiraDataType;
import com.atlassian.jira.JiraDataTypes;
import com.atlassian.jira.jql.operand.QueryLiteral;
import com.atlassian.jira.jql.query.QueryCreationContext;
import com.atlassian.jira.plugin.jql.function.AbstractJqlFunction;
import com.atlassian.jira.plugin.jql.function.JqlFunction;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.util.MessageSet;
import com.atlassian.query.clause.TerminalClause;
import com.atlassian.query.operand.FunctionOperand;
import kr.osci.kapproval.admin.service.CustomJiraService;
import kr.osci.kapproval.user.service.KapprovalApprovalService;
import lombok.RequiredArgsConstructor;

import javax.annotation.Nonnull;
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
public class KapprovalApprovalFunction extends AbstractJqlFunction implements JqlFunction {

    private final KapprovalApprovalService approvalService;

    private final CustomJiraService jiraService;

    @Nonnull
    @Override
    public String getFunctionName() {
        return "kapprovalApproval";
    }

    @Nonnull
    @Override
    public MessageSet validate(ApplicationUser applicationUser,
                               @Nonnull FunctionOperand functionOperand,
                               @Nonnull TerminalClause terminalClause) {
        return validateNumberOfArgs(functionOperand, 0);
    }

    /**
     * 이슈들을 쿼리하는 메서드
     * @param queryCreationContext
     * @param functionOperand
     * @param terminalClause
     * @return
     */
    @Nonnull
    @Override
    public List<QueryLiteral> getValues(@Nonnull QueryCreationContext queryCreationContext,
                                        @Nonnull FunctionOperand functionOperand,
                                        @Nonnull TerminalClause terminalClause) {
        return approvalService
                .getApprovalIssueKeyByApproverId(queryCreationContext.getApplicationUser().getId())
                .stream()
                .map(issueId -> new QueryLiteral(functionOperand, jiraService.getIssueKey(issueId)))
                .collect(Collectors.toList());
    }

    @Override
    public int getMinimumNumberOfExpectedArguments() {
        return 0;
    }

    @Nonnull
    @Override
    public JiraDataType getDataType() {
        return JiraDataTypes.ISSUE;
    }
}

 

위 코드를 보자. 가장 중요한 건 당연하겠지만 데이터를 쿼리하는 메서드인 getValues()이다. 비즈니스 로직에 따라 이 메서드 안에서 내가 이 JQL Function이 어떤 이슈들을 보여줄건지 정해야 한다. 그리고 반환 타입은 List<QueryLiteral>이다. 

 

QueryLiteral의 첫번째 인자는 파라미터로 받는 FunctionOperand 객체를 넘겨주면 된다. 두번째 객체는 String 타입 또는 Long 타입의 가져올 데이터의 값이다. 만약 getDataType()이 반환하는 값이 JiraDataTypes.ISSUE로 되어 있다면 이슈의 키를 반환하면 된다.

 

그래서 위 코드를 보면 결국 반환은 가져온 모든 QueryLiteral 객체 데이터를 리스트로 넘긴다.

이 메서드의 내부 로직은 본인이 원하는 이슈들을 가져오게끔 작성하면 된다. 예를 들어, 이슈의 상태가 `Closed` 상태인 모든 이슈라던가, Assignee가 `XXX`인 사람의 모든 이슈 등 원하는 이슈 쿼리를 하면 된다.

 

JQL Function 리소스 등록

이제 익숙해질때도 됐다. 플러그인의 모든 리소스는 다 Add-on Descriptor(atlassian-plugin.xml)에 등록해야 한다.

그래서 다음과 같이 등록해보자.

<jql-function name="K-Approval Approval" i18n-name-key="jql.approval" key="jqlKapprovalApproval" class="kr.osci.kapproval.com.jira.jql.KapprovalApprovalFunction">
    <fname>Approval</fname>
    <description key="KapprovalApprovalDescription">Approval function</description>
    <list>true</list>
</jql-function>

jql-function 태그의 class attribute를 보자. 이 attribute에 방금 위에서 만든 클래스의 타입(패키지 + 클래스)을 적어주면 된다.

이 부분이 제일 중요하고 나머지는 메타데이터에 가깝다.

 

fname 태그는 그냥 Function 이름이라고 생각하면 된다. 그리고 list 태그는 이 JQL Function의 결과가 단일 이슈인지 리스트인지를 알려준다. 이렇게 등록을 하면 끝난다. 실제로 이 JQL Function이 잘 보일것이다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

이제 JIRA 관리자든 일반 사용자든 접근 가능한 상단 네비게이션 화면에 플러그인 관련 링크를 노출하는 방법을 소개한다.

화면으로 보면 이해가 좀 더 빠를 것 같다. 바로 이 부분.

 

 

여기에 플러그인 관련 링크를 노출시키고 빠른 접근성을 확보하기 위해 해야하는 절차들을 소개한다.

 

Web Section과 Web Item

이것 역시 Web Section과 Web Item과 연관이 있다.

그리고 아무튼 JIRA Plugin 개발은 무조건 다 Add-on Descriptor (atlassian-plugin.xml)에 리소스를 등록해야 한다.

 

그래서 우선 첫번째 단계는 아래와 같이 Web Item을 등록하는 것이다.

<web-item key="topNavKapproval" name="Link on My Links Main Section" section="system.top.navigation.bar" weight="150">
    <condition class="com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition" />
    <label key="K-Approval"></label>
    <link linkId="topNavKapproval"></link>
</web-item>

<web-section name="K-Approval Request Approval Section" i18n-name-key="section.kapproval.request.approval.name" key="kapprovalRequestSection" location="topNavKapproval" weight="10">
    <label key="section.kapproval.request.approval.label"></label>
</web-section>

 

Web Itemsection attribute는 `system.top.navigation.bar`라는 미리 정해져 있는 값이다.

그리고 지금껏 보지 못했던 내용이 하나 추가됐는데 바로 `condition` 태그이다. 이 컨디션 태그는 뭐냐면 어떤 특정 조건에 만족할 경우에만 이 상단 네비게이션 바에 버튼(링크)가 보여지게 하는 방법이다. 

 

그리고 이 컨디션은 또한 클래스가 이미 다 정해져있다. 그 중 하나가 바로 `com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition`이다.

클래스 이름만 봐도 유저가 로그인 된 상태인 조건이라고 짐작할 수 있다. 이 조건은 어떻게 알까? 다음 링크를 참고하자. 

 

Web Item conditions

Web Item conditions Web Item Conditions Conditions control whether a given web item will be displayed. com.atlassian.fisheye.plugin.web.conditions.HasCrucible This condition measures whether the product runs with a Crucible license. com.atlassian.fisheye.p

developer.atlassian.com

 

참고로 컨디션을 사용하려면 그냥 태그만 추가해서는 안된다. 이 컨디션의 클래스는 말 그대로 클래스를 가져오는 것이고 클래스를 가져온다는 것은 모듈(라이브러리)가 프로젝트에 포함된 상태여야 한다. 그리고 그건 프로젝트의 pom.xml 파일에서 추가한다.

 

pom.xml

<build>
    <plugins>
        <plugin>
            <groupId>com.atlassian.maven.plugins</groupId>
            <artifactId>jira-maven-plugin</artifactId>
            <version>${amps.version}</version>
            <extensions>true</extensions>
            <configuration>
                <productVersion>${jira.version}</productVersion>
                <productDataVersion>${jira.version}</productDataVersion>
                <enableQuickReload>true</enableQuickReload>

                <instructions>
                    <Atlassian-Plugin-Key>${atlassian.plugin.key}</Atlassian-Plugin-Key>

                    <!-- Add package to export here -->

                    <!-- Add package import here -->
                    <Import-Package>
                        com.atlassian.jira.plugin.webfragment.conditions,
                        org.eclipse.gemini.blueprint.*;resolution:="optional",
                        *
                    </Import-Package>

                    <!-- Ensure plugin is spring powered -->
                    <Spring-Context>*</Spring-Context>
                </instructions>
                <log4jProperties>src/main/resources/log4j.properties</log4jProperties>
            </configuration>
        </plugin>
        ...
    </plugins>
</build>

 

build 태그 안에 plugins 태그 안에 plugin`com.atlassian.maven.plugins` 안에 자세히 보면 <Import-Package> 라는 태그가 있다. 이 안에 `com.atlassian.jira.plugin.webfragment.conditions`를 추가해줘야 한다. 그러면 아래 라이브러리 안에 구현된 컨디션들을 확인해 볼 수 있을 것이다.

 

 

다시 돌아와서, 이제 컨디션까지 확인을 해봤고 label은 말 그대로 화면에 보여지는 Label을 의미한다.

그리고 그 다음에 Web Section이 나오는데 Web Section은 그 상단 네비게이션 바에서 버튼을 클릭하면 하단에 나오는 드롭다운 메뉴에서 섹션을 구분할 수가 있다. 그 구분 섹션을 의미한다. 다음 사진을 보자.

이렇게 빨간 박스로 구분된 섹션들을 말한다. 그리고 이 location 중요한데, 이 location은 상단 네비게이션 바 Itemkey가 되어야 한다. 그래야 그 상단 네비게이션 바에 섹션들이 들어가겠지?라는 합리적인 발상이 가능해진다. 그리고 label 태그는 i18n 리소스를 사용했다. 

 

그 다음, 섹션이 있으면 그 섹션에 각 링크들이 달릴것이다. 그래서 그 링크(버튼)들을 만들어야 한다.

<web-item key="menuKapprovalRequestApproval" i18n-name-key="menu.kapproval.request.approval.name" name="kapprovalRequestApproval" section="topNavKapproval/kapprovalRequestSection" weight="10">
    <label key="menu.kapproval.request.approval.label" ></label>
    <link linkId="menuKapprovalRequestApproval">/issues/?jql=issuekey in kapprovalRequestApproval()</link>
</web-item>

Web Item은 방금 본 Web Section 하단에 달릴 링크(버튼)이다. 

여기서도 역시 중요한건 section이다. 여기에 보면 `topNavKapproval/kapprovalRequestSection`으로 되어 있는데 `topNavKapproval`은 상단 네비게이션 바를 가리키는 Web ItemKey이다. 그리고 `kapprovalRequestSection`은 그 상단 네비게이션 바에 달리는 섹션의 Key이다.

 

전체 코드로 보면 아래와 같다.

<web-item key="topNavKapproval" name="Link on My Links Main Section" section="system.top.navigation.bar" weight="150">
    <condition class="com.atlassian.jira.plugin.webfragment.conditions.UserLoggedInCondition" />
    <label key="K-Approval"></label>
    <link linkId="topNavKapproval"></link>
</web-item>

<web-section name="K-Approval Request Approval Section" i18n-name-key="section.kapproval.request.approval.name" key="kapprovalRequestSection" location="topNavKapproval" weight="10">
    <label key="section.kapproval.request.approval.label"></label>
</web-section>

<web-item key="menuKapprovalRequestApproval" i18n-name-key="menu.kapproval.request.approval.name" name="kapprovalRequestApproval" section="topNavKapproval/kapprovalRequestSection" weight="10">
    <label key="menu.kapproval.request.approval.label" ></label>
    <link linkId="menuKapprovalRequestApproval">/issues/?jql=issuekey in kapprovalRequestApproval()</link>
</web-item>

 

그리고, 그 실질적으로 클릭 가능한 버튼인 `menuKapprovalRequestApproval`이라는 키를 가진 Web Item은 의미있는 링크를 가지고 있다. 그 링크는 특정 JQL을 실행하는 링크이고 이 JQL 함수는 내가 따로 만든 JQL이다. 이건 이후에 설명하겠다. 아무튼 이 버튼을 클릭하면? 지정된 JQL을 실행하는 화면으로 이동한다.

 

그래서 아래 사진이 완성된 화면이다. 

 

이렇게 상단 네비게이션 바에 플러그인의 링크를 노출시킬 수 있다. 그리고 그 각 버튼은 의미있는 화면으로의 이동이 되면 된다. 나같은 경우는 내가 만든 커스텀 JQL 함수를 실행하는 화면으로 이동시키게 만든 것이고 말 나온김에 다음 포스팅은 커스텀 JQL 함수를 만드는 방법을 포스팅 해보겠다.

 

참고로, 섹션을 또 추가하고 싶으면 위 방식대로 Web Section 추가하고 그 섹션 하위에 Web Item들을 위 방식 그대로 똑같이 추가해주면 된다.
728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

멀티 스레드에 대해 공부할 시간이 왔다. 멀티 스레드에 대해 제대로 이해하려면 먼저 멀티 태스킹프로세스 같은 운영체제의 기본 개념들에 대해서 알아야 한다. 여기서는 멀티 스레드를 이해하기 위한 목적으로 최대한 단순하게 핵심 내용만 알아보겠다.

 

단일 프로그램 실행

만약, 프로그램을 2개 이상 동시에 실행한다고 가정해보자. 예를 들어서 음악 프로그램을 통해 음악을 들으면서, 동시에 워드 프로그램을 통해 문서를 작성하는 것이다. 여기서는 연산을 처리할 수 있는 CPU 코어가 1개만 있다고 가정하겠다.

 

 

프로그램 A의 코드 1 실행 시작

 

 

 

프로그램 A의 코드 2 실행 중

 

 

 

프로그램 A의 실행 완료

 

 

프로그램 B의 실행 시작

 

 

프로그램 A 완료 후 프로그램 B 완료

 

  • 프로그램 실행이란 프로그램을 구성하는 코드를 순서대로 CPU에서 연산(실행)하는 일이다.
  • 여기서 CPU 코어는 하나로 가정하므로, 한 번에 하나의 프로그램 코드만 실행할 수 있다.
  • 이때, 하나의 프로그램 안에 있는 코드를 모두 실행한 후에야 다른 프로그램의 코드를 실행할 수 있다면? 예를 들어 음악 프로그램이 끝난 후에야 워드 프로그램을 실행할 수 있다면 컴퓨터 사용자는 매우 답답할 것이다.
  • 실제로 초창기의 컴퓨터는 이처럼 한 번에 하나의 프로그램만 실행했다.
  • 이를 해결하기 위해 하나의 CPU 코어로 여러 프로그램을 동시에 실행하는 멀티 태스킹 기술이 등장했다.

멀티 태스킹

순서대로 촬영한 연속된 사진을 빠르게 교차해서 보여줄 경우 사람은 이를 움직이는 영상으로 인지한다. 애니메이션이 바로 이 원리이다.

현대의 CPU는 초당 수십억번 이상의 연산을 수행한다. 쉽게 이야기해서 초당 수십억 장의 사진이 빠르게 교차되는 것이다.

 

만약, CPU가 매우 빠르게 두 프로그램의 코드를 번갈아 수행한다면, 사람이 느낄 때 두 프로그램이 동시에 실행되는 것처럼 느낄 것이다. (대략 0.01초 단위로 돌아가며 실행한다)

 

 

프로그램 A의 코드 1 수행

 

 

프로그램 B의 코드 1 수행

 

(이 연산이 0.01초만에 빠르게 프로그램A와 B를 한번씩 실행한다고 생각해보자. 사람은 느낄 수 없다)

 

 

프로그램 수행 완료

이 방식은 CPU 코어가 프로그램 A의 코드를 0.01초 정도 수행하다가 잠시 멈추고, 프로그램 B의 코드를 0.01초 정도 수행한다. 그리고 다시 프로그램 A의 이전에 실행중인 코드로 돌아가서 0.01초 정도 코드를 수행하는 방식으로 반복 동작한다.

 

이렇게 각 프로그램의 실행 시간을 분할해서 마치 동시에 실행되는 것처럼 하는 기법을 시분할(Time Sharing) 기법이라고 한다.

이런 방식을 사용하면 CPU 코어가 하나만 있어도 여러 프로그램이 동시에 실행되는 것처럼 느낄 수 있다.

 

이렇게 하나의 컴퓨터 시스템이 동시에 여러 작업을 수행하는 능력을 멀티태스킹이라 한다.

참고로, CPU에 어떤 프로그램이 얼마만큼 실행될지는 운영체제가 결정하는데 이것을 스케쥴링이라고 한다. 이때 단순히 시간으로만 작업을 분할하지는 않고, CPU를 최대한 활용할 수 있는 다양한 우선순위와 최적화 기법을 사용한다. 우리는 운영체제가 스케쥴링을 수행하고, CPU를 최대한 사용하면서 작업이 골고루 수행될 수 있게 최적화한다는 정도로 이해하면 충분하다. 

 

멀티 프로세싱

CPU 코어가 둘 이상이면 어떻게 될까? 여기서 프로그램은 A, B, C 세가지이고 CPU 코어는 2개이다.

참고로, CPU 안에는 실제 연산을 처리할 수 있는 코어라는 것이 있다. 과거에는 하나의 CPU 안에 보통 하나의 코어만 들어있었다. 그래서 CPU와 코어를 따로 분리해서 이야기하지 않았다. 최근에는 하나의 CPU 안에 보통 2개 이상의 코어가 들어있다.

 

 

프로그램 A, 프로그램 B 실행

 

 

프로그램 A, 프로그램 C 실행

 

  • CPU 코어가 2개이므로 물리적으로 동시에 2개의 프로그램을 처리할 수 있다. 
  • 위 그림에서는 먼저 A와 B를 수행하고 그 다음 B를 잠시 멈추고 C를 수행한다. 

그러니까 쉽게 말해, 한 개가 3개를 처리하는 것보다 두 개가 3개를 처리하면 더 빨리 일이 끝난다.

멀티 프로세싱은 컴퓨터 시스템에서 둘 이상의 프로세서(CPU 코어)를 사용하여 여러 작업을 동시에 처리하는 기술을 의미한다. 멀티 프로세싱 시스템은 하나의 CPU 코어만을 사용하는 시스템보다 동시에 더 많은 작업을 처리할 수 있다.

 

멀티 프로세싱과 멀티 태스킹

멀티 프로세싱은 하드웨어 장비의 관점(둘 이상의 CPU 코어)이고, 멀티 태스킹은 운영체제 소프트웨어의 관점(하나의 CPU 코어라고 할지라도 여러개의 소프트웨어의 연산을 수행)이다.

 

멀티 프로세싱

  • 여러 CPU(여러 CPU 코어)를 사용하여 동시에 여러 작업을 수행하는 것을 의미한다.
  • 하드웨어 기반으로 성능을 향상시킨다.
  • 예) 다중 코어 프로세서를 사용하는 현대 컴퓨터 시스템

멀티 태스킹

  • 단일 CPU(단일 CPU 코어)가 여러 작업을 동시에 수행하는 것처럼 보이게 하는 것을 의미한다.
  • 소프트웨어 기반으로 CPU 시간을 분할하여 각 작업에 할당한다.

 

프로세스와 스레드

프로세스

  • 프로그램은 실제 실행하기 전까지는 단순한 파일에 불과하다.
  • 프로그램을 실행하면 프로세스가 만들어지고 프로그램이 실행된다.
  • 이렇게 운영체제 안에서 실행중인 프로그램을 프로세스라고 한다.
  • 프로세스는 실행 중인 프로그램의 인스턴스이다.

프로세스는 실행 중인 프로그램의 인스턴스이다. 각 프로세스는 독립적인 메모리 공간을 갖고 있으며, 운영체제에서 별도의 작업 단위로 분리해서 관리된다. (위 그림에서 프로세스A, 프로세스B는 분리된 상태)

 

각 프로세스는 별도의 메모리 공간을 갖고 있기 때문에 서로 간섭하지 않는다. 그리고 프로세스가 서로의 메모리에 직접 접근할 수 없다. 프로세스는 이렇듯 서로 격리되어 관리되기 때문에, 하나의 프로세스가 충돌해도 다른 프로세스에는 영향을 미치지 않는다. 쉽게 이야기해서 특정 프로세스(프로그램)에 심각한 문제가 발생하면 해당 프로세스만 종료되고, 다른 프로세스에 영향을 주지 않는다.

 

프로세스의 메모리 구성

  • 코드 섹션: 실행할 프로그램의 코드가 저장되는 부분
  • 데이터 섹션: 전역 변수 및 정적 변수가 저장되는 부분(그림에서 기타에 포함)
  • 힙 (Heap): 동적으로 할당되는 메모리 영역
  • 스택 (Stack): 메서드(함수) 호출 시 생성되는 지역 변수와 반환 주소가 저장되는 영역 (스레드에 포함)

스레드

프로세스는 하나 이상의 스레드를 반드시 포함한다.

스레드는 프로세스 내에서 실행되는 작업의 단위이다. 한 프로세스 내에서 여러 스레드가 존재할 수 있으며, 이들은 프로세스가 제공하는 동일한 메모리 공간을 공유한다. 스레드는 프로세스보다 단순하므로 생성 및 관리가 단순하고 가볍다.

 

스레드의 메모리 구성

  • 공유 메모리: 같은 프로세스의 코드 섹션, 데이터 섹션, 힙(메모리)은 프로세스 안의 모든 스레드가 공유한다.
  • 개별 스택: 각 스레드는 자신의 스택을 가지고 있다.

프로그램이 실행된다는 것은 어떤 의미일까?

프로그램을 실행하면 운영체제는 먼저 디스크에 있는 파일 덩어리인 프로그램을 메모리로 불러오면서 프로세스를 만든다. 그럼 만들어진 프로세스를 어떻게 실행할까? 프로그램이 실행된다는 것은 사실 프로세스 안에 있는 코드가 한 줄씩 실행되는 것이다. 코드는 보통 main()부터 시작해서 하나씩 순서대로 내려가면서 실행된다.

 

생각해보면 어떤 무언가가 코드를 하나씩 순서대로 실행하기 때문에 프로그램이 작동하고 계산도 하고 출력도 할 수 있다. 이 코드를 하나씩 실행하면서 내려가는 것의 정체가 무엇일까? 바로 스레드. 프로세스의 코드를 실행하는 흐름을 스레드라고 한다.

 

스레드는 프로세스 내에서 실행되는 작업의 단위이다. 한 프로세스 내에 하나의 스레드가 존재할 수 있고, 한 프로세스 내에 여러 스레드가 존재할 수도 있다. 그리고 스레드는 프로세스가 제공하는 동일한 메모리 공간을 공유한다.

 

  • 단일 스레드: 한 프로세스 내에 하나의 스레드만 존재
  • 멀티 스레드: 한 프로세스 내에 여러 스레드가 존재

하나의 프로세스 안에는 최소 하나의 스레드가 존재한다. 그래야 프로그램이 실행될 수 있다. 정리하면 프로세스는 실행 환경과 자원을 제공하는 컨테이너 역할을 하고, 스레드는 CPU를 사용해서 코드를 하나하나 실행한다.

 

멀티 스레드가 필요한 이유

"하나의 프로그램도 그 안에서 동시에 여러 작업이 필요하다."

  • 워드 프로그램으로 문서를 편집하면서, 문서가 자동으로 저장되고 맞춤법 검사도 동시에 수행된다.
  • 유튜브는 영상을 보는 동안, 댓글도 달 수 있다.

운영체제 관점에서 보면 다음과 같이 구분할 수 있다.

  • 워드 프로그램 - 프로세스 A
    • 스레드 1: 문서 편집
    • 스레드 2: 자동 저장
    • 스레드 3: 맞춤법 검사
  • 유튜브 - 프로세스 B
    • 스레드 1: 영상 재생
    • 스레드 2: 댓글

스레드와 스케쥴링

앞서 멀티 태스킹에서 설명한 운영체제의 스케쥴링 과정을 더 자세히 알아보자.

CPU 코어는 한개이고, 프로세스는 2개이다. 프로세스 A는 스레드 1개, 프로세스 B는 스레드 2개가 있다.

프로세스는 실행 환경과 자원을 제공하는 컨테이너 역할을 하고, 실제 CPU를 사용해서 코드를 하나하나 실행하는 것은 스레드이다. 

 

 

프로세스 A에 있는 스레드 A1을 실행한다.

 

 

프로세스 A에 있는 스레드 A1의 실행을 잠시 멈추고, 프로세스 B에 있는 스레드 B1을 실행한다.

 

 

프로세스 B에 있는 스레드 B1의 실행을 잠시 멈추고 같은 프로세스의 스레드 B2를 실행한다.

다시 첫번째 과정으로 돌아가고 이 과정을 반복한다.

 

단일 코어 스케쥴링

 

스레드 A1, 스레드 B1, 스레드 B2가 스케쥴링 큐에 대기한다.

 

 

 

 

운영체제는 스레드 A1을 큐에서 꺼내고 CPU를 통해 실행한다. 이때, 스레드 A1이 프로그램의 코드를 수행하고, CPU를 통한 연산도 일어난다.

 

 

운영체제는 스레드 A1을 잠시 멈추고, 스케쥴링 큐에 다시 넣는다.

 

 

운영체제는 스레드 B1을 큐에서 꺼내고 CPU를 통해 실행한다.

이 과정을 반복해서 수행한다. 

 

멀티 코어 스케쥴링

CPU 코어가 2개 이상이면, 한번에 더 많은 스레드를 물리적으로 진짜 동시에 실행할 수 있다.

 

 

스레드 A1, 스레드 B1, 스레드 B2가 스케쥴링 큐에 대기한다.

 

 

 

스레드 A1, 스레드 B1을 병렬로 실행한다. 스레드 B2는 스케쥴링 큐에 대기한다.

 

 

스레드 A1의 수행을 잠시 멈추고, 스레드 A1을 스케쥴링 큐에 다시 넣는다.

 

 

스케쥴링 큐에 대기중인 스레드 B1을 CPU 코어 1에서 실행한다. 물론 조금 있다가 CPU 코어 2에서 실행중인 스레드 B2도 수행을 멈추고, 스레드 스케쥴링에 큐에 있는 다른 스레드가 실행될 것이다.

 

이 과정을 반복해서 수행한다.

 

프로세스, 스레드와 스케쥴링 정리

멀티 태스킹과 스케쥴링

  • 멀티 태스킹이란 동시에 여러 작업을 수행하는 것을 말한다. 이를 위해 운영체제는 스케쥴링이라는 기법을 사용한다. 스케쥴링은 CPU 시간을 여러 작업에 나누어 배분하는 방법이다.
  • 위 그림에서 스케쥴링 큐에 여러 스레드의 작업이 있고 그게 번갈아가면서 CPU 코어1, 코어2에서 실행된다. 그리고 하나의 CPU 코어가 한 작업을 전부 끝내고 다음 작업을 하는게 아니라 번갈아가면서 조금씩 조금씩 해나간다. 

 

프로세스와 스레드

  • 프로세스는 실행중인 프로그램의 인스턴스이다. 각 프로세스는 독립적인 메모리 공간을 가지며, 운영체제에서 독립된 실행 단위로 취급된다.
  • 스레드는 프로세스 내에서 실행되는 작은 단위이다. 여러 스레드는 하나의 프로세스 내에서 자원을 공유하며, 프로세스의 코드, 데이터, 시스템 자원등을 공유한다. 실제로 CPU에 의해 실행되는 단위는 스레드이다.

 

프로세스의 역할

  • 프로세스는 실행 환경을 제공한다. 여기에는 메모리 공간, 파일 핸들, 시스템 자원(네트워크 연결)등이 포함된다. 이는 프로세스가 컨테이너 역할을 한다는 의미이다.
  • 프로세스 자체는 운영체제의 스케쥴러에 의해 직접 실행되지 않으며, 프로세스 내의 스레드가 실행된다. 참고로 1개의 프로세스 안에 하나의 스레드만 실행되는 경우도 있고, 1개의 프로세스 안에 여러 스레드가 실행되는 경우도 있다. 

컨텍스트 스위칭

멀티 태스킹이 반드시 효율적인 것만은 아니다.

비유를 하자면 사람이 프로그램 A를 개발하고 있는데 갑자기 기획자가 프로그램 B를 수정해달라고 한다. 프로그램 A의 개발을 멈추고, 프로그램 B를 수정한다고 하면 어찌저찌 수정을 다 하고 다시 프로그램 A를 개발하기 위해 돌아가는데 어디까지 개발을 했는지, 선언한 변수들에 들어가는 값들이 뭐였는지 등등 다시 기억해내기 쉽지 않다. 차라리 프로그램 A를 다 개발하고 끝난 후에 프로그램 B를 수정하는 게 전체 시간으로 보면 더 효율적일 수 있다.

 

컴퓨터의 멀티 태스킹

운영체제의 멀티 태스킹을 생각해보자. CPU 코어는 하나만 있다고 가정하자.

스레드 A, 스레드 B가 있다. 운영체제는 먼저 스레드 A를 실행한다. 멀티 태스킹을 해야하기 때문에 스레드 A를 계속 실행할 수 없다. 스레드 A를 잠시 멈추고, 스레드 B를 실행한다. 이후에 스레드 A로 그냥 돌아갈 수 없다. CPU에서 스레드를 실행하는데, 스레드의 A의 코드가 어디까지 수행되었는지 위치를 찾아야 한다. 그리고 계산하던 변수들의 값을 CPU에 다시 불러들여야 한다. 따라서 스레드 A를 멈추는 시점에 CPU에서 사용하던 이런 값들을 메모리에 저장해두어야 한다. 그리고 이후에 스레드 A를 다시 실행할 때 이 값들을 CPU에 다시 불러와야 한다.

 

이런 과정을 컨텍스트 스위칭이라고 한다.

컨텍스트 스위칭 과정에서 이전에 실행 중인 값을 메모리에 잠깐 저장하고, 이후에 다시 실행하는 시점에 저장한 값을 CPU에 다시 불러와야 한다. 결과적으로 컨텍스트 스위칭 과정에는 약간의 비용이 발생한다. 

 

멀티 스레드는 대부분 효율적이지만, 컨텍스트 스위칭 과정이 필요하므로 항상 효율적인 것은 아니다.

예를 들어 1 - 10000까지 더해야 한다고 가정해보자. 이 문제는 둘로 나눌 수 있다. 

  • 스레드 1: 1 - 5000 까지 더함
  • 스레드 2: 5001 - 10000 까지 더함
  • 마지막에 스레드 1의 결과와 스레드 2의 결과를 더함

CPU 코어가 2개

CPU 코어가 2개 있다면 스레드 1, 스레드 2로 나누어서 멀티 스레드로 병렬 처리하는게 효율적이다. 모든 CPU를 사용하므로 연산을 2배 빠르게 처리할 수 있다. (코어 1개당 스레드 1개로 남김없이 CPU를 사용)

 

CPU 코어가 1개

CPU 코어가 1개가 있는데, 스레드를 2개로 만들어서 연산하면 중간중간 컨텍스트 스위칭 비용이 발생한다. 운영체제 스케쥴링 방식에 따라 다르겠지만, 스레드 1을 1 - 1000 까지 연산한 상태에서 잠시 멈추고 스레드 2를 5001 - 6001 까지 연산하는 식으로 반복할 수 있다. 이때 CPU는 스레드 1을 잠시 멈추고 다시 실행할 때 어디까지 연산했는지 알아야 하고, 그 값을 다시 CPU에 불러와야 한다. 결과적으로 이렇게 반복할 때 마다 컨텍스트 스위칭 비용이 든다. 연산 시간 + 컨텍스트 스위칭 시간이 곧 전체 비용이다. 

 

이런 경우 단일 스레드로 1 - 10000 까지 더하는 것이 컨텍스트 스위칭 비용 없이 연산 시간만 사용하기 때문에 더 효율적이다.

물론, 예를 이렇게 들었지만 실제로 컨텍스트 스위칭에 걸리는 시간은 아주 짧다. 그러나 스레드가 매우 많다면 이 비용이 커질 수 있다.

 

 

실무 이야기

CPU 4개, 스레드 2개

스레드의 숫자가 너무 적으면 모든 CPU를 100% 다 활용할 수 없지만, 스레드가 몇 개 없으므로 컨텍스트 스위칭 비용이 줄어든다.

 

CPU 4개, 스레드 100개

스레드의 숫자가 너무 많으면 CPU를 100% 다 활용할 수 있지만 컨텍스트 스위칭 비용이 늘어난다.

 

CPU 4개, 스레드 4개

스레드의 숫자를 CPU의 숫자와 맞춘다면 CPU를 100% 활용할 수 있고, 컨텍스트 스위칭 비용도 자주 발생하지 않기 때문에 최적의 상태가 된다. 이상적으로는 CPU 코어 수 + 1개 정도로 스레드를 맞추면 특정 스레드가 잠시 대기할 때 남은 스레드를 활용할 수 있다.

 

CPU 바운드 작업 VS I/O 바운드 작업

각각의 스레드가 하는 작업은 크게 2가지로 구분할 수 있다.

  • CPU 바운드 작업
    • CPU의 연산 능력을 많이 요구하는 작업을 의미한다.
    • 이러한 작업은 주로 계산, 데이터 처리, 알고리즘 실행 등 CPU의 처리 속도가 작업 완료 시간을 결정하는 경우다.
    • 예시: 복잡한 수학 연산, 데이터 분석, 비디오 인코딩 등
  • I/O 바운드 작업
    • 디스크, 네트워크, 파일 시스템등과 같은 입출력(I/O) 작업을 많이 요구하는 작업을 의미한다.
    • 이러한 작업은 I/O 작업이 완료될 때까지 대기 시간이 많이 발생하며, CPU는 상대적으로 유휴(대기)상태에 있는 경우가 많다. 쉽게 이야기해서 스레드가 CPU를 사용하지 않고 I/O 작업이 완료될 때까지 대기한다.
    • 예시: 데이터베이스 쿼리 처리, 파일 읽기/쓰기, 네트워크 통신, 사용자 입력 처리 등

웹 애플리케이션 서버

분야마다 다르겠지만 실무에서는 CPU 바운드 작업보다는 I/O 바운드 작업이 많다.

예를 들어 백엔드 개발자의 경우 주로 웹 애플리케이션 서버를 개발하는데 스레드가 1 -10000 까지 더하는 CPU의 연산이 필요한 작업보다는 대부분 사용자의 입력을 기다리거나, 데이터베이스를 호출하고 그 결과를 기다리는 등 기다리는 일이 더 많다. 쉽게 이야기해서 스레드가 CPU를 많이 사용하지 않는 I/O 바운드 작업이 많다는 뜻이다.

 

일반적인 자바 웹 애플리케이션 서버의 경우, 사용자의 요청 하나를 처리하는데 1개의 스레드가 필요하다. 사용자 4명이 동시에 요청하면 4개의 스레드가 작동하는 것이다. 그래야 4명의 사용자의 요청을 동시에 처리할 수 있다.

사용자의 요청을 하나 처리하는데 스레드는 CPU 1% 정도를 사용하고, 대부분 데이터베이스 서버에 어떤 결과를 조회하면서 기다린다고 가정하자. 이때는 스레드는 CPU를 거의 사용하지 않고 대기한다. 바로 I/O 바운드 작업이 많다는 것이다.

 

이 경우 CPU 코어가 4개 있다고 해서 스레드 숫자도 CPU 코어에 맞추어 4개로 설정하면 안된다! 그러면 동시에 4명의 사용자 요청만 처리할 수 있다. 이때 CPU는 단순하게 계산해서 4% 정도만 사용할 것이다. 결국 사용자는 동시에 4명밖에 못받지만 CPU는 4%만을 사용하며 CPU가 놀고 있는 사태, 낭비되는 사태가 발생할 수 있다. 

 

사용자의 요청 하나를 처리하는데 CPU를 1%만 사용한다면, 단순하게 생각해도 100개의 스레드를 만들 수 있다. 이렇게 하면 동시에 100명의 사용자 요청을 받을 수 있다. 물론 실무에서는 성능 테스트를 통해서 최적의 스레드 숫자를 찾는것이 이상적이다.

 

결국 스레드 숫자만 늘리면 되는데 이런 부분을 잘 이해하지 못해서 서버 장비에 문제가 있다고 생각하고 2배 더 좋은 장비로 구매하는 사태가 발생하기도 한다! 이렇게 되면 CPU는 4%의 절반인 2%만 사용하고(장비가 2배 더 좋아졌으니) 사용자는 여전히 동시에 4명밖에 받지 못하는 사태가 벌어진다. 

 

정리하면 스레드의 숫자는 CPU-바운드 작업이 많은가, 아니면 I/O 바운드 작업이 많은가에 따라 다르게 설정해야 한다.

  • CPU 바운드 작업이 많다면: CPU 코어 수 + 1개
    • CPU를 거의 100% 사용하는 작업이므로, 스레드를 CPU 숫자에 최적화
  • I/O 바운드 작업이 많다면: CPU 코어수보다 많은 스레드를 생성, CPU를 최대한 사용할 수 있는 숫자까지 스레드 생성
    • CPU를 많이 사용하지 않으므로 성능 테스트를 통해 CPU를 최대한 활용하는 숫자까지 스레드 생성
    • 단 너무 많은 스레드를 생성하면 컨텍스트 스위칭 비용도 함께 증가 - 적절한 성능 테스트 필요

 

그래서 결론은, 적절한 스레드 숫자를 성능 테스트와 함께 내가 구축하고 있는 서버가 어떤 작업을 더 많이 하는가?를 고민해서 스레드 수를 만들어내야 한다.

728x90
반응형
LIST
728x90
반응형
SMALL

거의 모든 애플리케이션은 데이터가 필요하다. 데이터를 저장하고, 읽고, 쓰는 작업이 안들어가는 애플리케이션은 없을것이다.

JIRA DC 플러그인도 마찬가지로 데이터가 필요한데 이 데이터를 다루기 위해 JIRA 플러그인에서는 AO(ActiveObjects)를 공식적으로 사용한다.

 

그래서 이 AO를 사용하는 방법을 알아본다. 우선, 라이브러리를 내려받아야 한다.

<dependency>
    <groupId>com.atlassian.activeobjects</groupId>
    <artifactId>activeobjects-plugin</artifactId>
    <version>${ao.version}</version>
    <scope>provided</scope>
</dependency>

 

내가 지정한 ao.version은 다음과 같다.

<properties>
    ...
    <ao.version>6.0.0-m03</ao.version>
    ...
</properties>

 

이렇게 라이브러리를 내려받았으면 이제 Entity를 만들어야 한다. 이 AO는 방식이 좀 신기하게 되어있다.

 

KapprovalUserEntity

package kr.osci.kapproval.com.entity;

import net.java.ao.Entity;
import net.java.ao.schema.Indexed;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.StringLength;
import net.java.ao.schema.Table;

@Table("KAPP_USER")
public interface KapprovalUserEntity extends Entity {

      @NotNull
      @Indexed
      Long getUserId();
      void setUserId(Long userId);

      Integer getOrgId();
      void setOrgId(Integer orgId);

      Integer getPositionId();
      void setPositionId(Integer positionId);

      @StringLength(1)
      String getSuperUserYn();
      void setSuperUserYn(String superUserYn);

}

우선 인터페이스를 하나 만들고 net.java.ao.Entity를 상속받아야 한다. 그리고 각 엔티티에 필요한 필드들은 Getter, Setter를 만듦으로써 생성된다. 그리고 Primary Keynet.java.ao.Entity 안으로 들어가보면 Primary Key가 이미 선언이 되어있다. 

 

net.java.ao.Entity

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package net.java.ao;

import net.java.ao.schema.AutoIncrement;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.PrimaryKey;

public interface Entity extends RawEntity<Integer> {
    @AutoIncrement
    @NotNull
    @PrimaryKey("ID")
    int getID();
}

그래서 기본키는 따로 만들지 않아도 된다. Entity를 상속받기 때문에!

엔티티끼리 Relationship도 생성할 수 있다. 그 부분은 이 공식 문서를 참조하자.

 

Developing your plugin with Active Objects

Last updated Jul 12, 2024

developer.atlassian.com

저기서 @Table 애노테이션은 이 엔티티에 대한 테이블명을 명시해주는 방법이다.

저렇게 @Table("KAPP_USER")로 애노테이션을 달면 데이터베이스에서 테이블 명은 이렇게 된다.

AO_28BE2D_KAPP_USER

그리고 @Table 애노테이션으로 테이블 명을 명시하지 않으면 테이블 명은 기본이 클래스명을 따라간다.

 

그래서 이렇게 테이블을 만들면, 이 AO 인터페이스로 만든 테이블을 atlassian-plugin.xml 파일에 등록해야 한다.

<ao key="ao-module">
    <description>The module configuring the Active Objects service used by this plugin</description>
    ...
    <entity>kr.osci.kapproval.com.entity.KapprovalUserEntity</entity>
    ...
</ao>

이렇게 등록을 하고 나서 다음 명령어로 서버를 실행해보자.

atlas-run

 

띄워진 서버에서 알려준대로 `localhost:2990/jira`로 접속해보면 띄워진 지라 서버가 보여질텐데 거기에 DbConsole 버튼을 클릭해보자.

그럼 기본으로 연동된 H2 데이터베이스의 콘솔이 노출된다.

테이블 목록을 쭉 보면 내가 등록한 테이블이 보여진다.

그럼 테이블은 정상적으로 만들어졌으니 이제 CRUD에 대한 작업을 해보자.

Create

기본적으로 ORM이면 CRUD에 대한 메서드가 이미 있다. 솔직히 AO는 너무 불편하고 부실하지만, 이게 Atlassian에서 제공하는 ORM이기 때문에 사용해야 한다. 다른 ORM을 사용할 수 있는지 계속해서 알아보는 중인데 쉽지 않다. 새로운 사실을 알게 되면 업데이트 해야겠다! 

 

우선 Create 작업이 필요한 서비스 내에서 ActiveObjects 객체를 주입받자.

KapprovalSettingsServiceImpl

package kr.osci.kapproval.admin.service.impl;

import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import kr.osci.kapproval.admin.service.KapprovalSettingsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class KapprovalSettingsServiceImpl implements KapprovalSettingsService {

    @ComponentImport
    private final ActiveObjects activeObjects;

}

 

그리고 이 activeObjects가 가지고 있는 메서드를 쭉 살펴보면 다음과 같이 create()가 있다.

첫번째 메서드 타입을 선택하면 된다. DBParam 객체를 받아 생성하는 메서드.

KapprovalSettingsEntity entity = activeObjects.create(KapprovalSettingsEntity.class
                    ,new DBParam("LINE_TYPE", "1")
                    ,new DBParam("PASSWORD_USE_YN", "Y")

이런식으로 작성하면 된다. 보면 2번째 파라미터의 타입이 DBParam... 이기 때문에 계속해서 추가적으로 넣어주는게 가능하다.

그리고 DBParam()에는 컬럼명과 값이 들어간다. 여기서 컬럼명이 어떻게 저렇게 되냐? 만약 아래와 같은 엔티티가 있다면,

@Table("KAPP_USER")
public interface KapprovalUserEntity extends Entity {

      @NotNull
      @Indexed
      Long getUserId();
      void setUserId(Long userId);

}

각 대문자 사이에 `_`가 들어간다고 보면 된다. `getUserId` -> `USER_ID`

그래서 각 컬럼에 원하는 값을 DBParam 객체로 하나씩 넣어주면 된다. 물론 위에 오버로딩된 메서드 시그니쳐를 보면 알 수 있듯 Map을 사용하거나 MapList를 사용해도 된다. 

 

Read

한개의 레코드를 찾을땐 get() 메서드를 사용하면 된다.

KapprovalOrgEntity kapprovalOrgEntity = activeObjects.get(KapprovalOrgEntity.class,id);

 

두번째 인자엔 해당 엔티티에 대한 PK를 집어넣으면 된다. 그리고 이 get()메서드도 여러 PK를 넣으면 그 PK에 해당하는 모든 레코드를 가져온다. 다음 사진을 참고해보자. 넣은 PK만큼 배열로 리턴하고 있는것을 확인할 수 있다.

 

그리고 여러개의 레코드를 찾을땐 위 사진과 같이 get()을 사용해도 되지만, 일반적으로 find()를 사용한다.

다음 코드를 보자.

KapprovalUserEntity[] arrKapprovalUserEntity =
                activeObjects.find(KapprovalUserEntity.class, "USER_ID = ? ", jiraUser.getId());

첫번째 인자는 엔티티 타입이다. 어떤 엔티티로부터 조회할건지에 대한 정보.

두번째 인자는 특정 조건이다. 위 코드와 같이 특정 컬럼(USER_ID)값에 대한 조건을 건다.

세번째 인자는 두번째 인자에서 사용되는 Variable에 대한 값이다.

 

다른 방식을 사용할수도 있다. 아예 Query 라는 객체가 있는데, 생김새가 QueryDSL과 유사하게 생겼다.

activeObjects.find(KapprovalApprovalPathMappingEntity.class,
                Query
                        .select()
                        .where("APPROVAL_PATH_ID = ? AND PROJECT_ID = ? AND ISSUE_TYPE_ID = ? AND APPLY_STATUS_ID = ? AND PROCESS_ACTION_ID = ? AND REJECTED_ACTION_ID = ?",
                                approvalPathId,
                                projectId,
                                issueTypeId,
                                applyStatusId,
                                processActionId,
                                rejectedActionId))

그래서 조금 더 복잡한 쿼리에 대해서 아예 객체로 쿼리를 만들어낸다. 

어떤 방법을 사용해도 상관없다. 그리고 어떠한 조건도 넣지 않는다면 그냥 `SELECT * FROM TABLE_NAME`이랑 똑같다.

activeObjects.find(KapprovalApprovalPathMappingEntity.class)
참고로, 아주 많은 양의 데이터를 읽어 들일땐 find(), get() 말고 stream()을 사용하라고 나와있다. 일단 아주 많은 양의 데이터를 읽는다는 것은 업데이트와 거리가 멀다. 정말 읽어들이기 위한 (Read-Only) 데이터를 찾는 경우가 대다수이다. 이럴땐 Stream API를 사용하라고 공식 문서에 표기되어 있다. 

 

만약, 간단한 쿼리가 아닌 복잡한 쿼리가 필요하다면, findWithSQL()을 사용하자.

String sql = "SELECT id, name, value FROM my_table WHERE column1 = ? AND column2 = ?";
Object[] params = new Object[] {value1, value2};
String keyField = "id"; // 쿼리 결과의 'id' 컬럼을 키로 사용

MyEntity[] results = ao.findWithSQL(MyEntity.class, keyField, sql, params);
return results;

여기서 keyField라는 파라미터가 사용되는데 이는 SELECT 절에 사용되는 컬럼 중 임의의 것을 사용해도 되지만 일반적으로 그리고 권장되는 것은 PK를 사용하는 것이다. 이 keyField를 통해 AO가 쿼리로부터 받아오는 결과를 적절하게 매핑할 수 있기 때문에 사용한다고 한다. 그리고 keyField로 사용되는 값은 반드시 SELECT절로 받아오는 컬럼 중 하나여야 한다.

Update

업데이트는 읽어서 쓴다. 즉, 먼저 업데이트 할 객체를 가져와서 그 객체를 변경한 후에 저장하는 방식으로 수행한다.

예를 들어서 이렇게 사용하면 된다.

KapprovalPositionEntity entity = activeObjects.get(KapprovalPositionEntity.class, id);

entity.setXxx(...);

entity.save();

읽고, 변경하고, 저장한다. 오히려 이 방식이 더 좋을수도 있다. 딱 필요한 것만 업데이트 친 후에 변경하기 때문에.

JPA를 써봤던 사람들은 변경감지 개념과 유사하다고 볼 수 있다. 물론 save()라는 메서드를 따로 호출하지 않아도 영속시켰다면 저절로 변경 감지가 일어나서 쿼리가 날라가지만! 

 

Delete

이제 AO는 Delete가 불편하다. 왜냐하면, AO는 Cascade를 지원하지 않는다.

그에 대한 내용은 다음 문서에 있다.

 

Best practices for developing with Active Objects

Best practices for developing with Active Objects This page contains some guidelines for best practices when developing a plugin that uses the Atlassian Active Objects (AO) plugin to store and retrieve data. The information takes the form of a quick refere

developer.atlassian.com

그래서 연관 레코드가 있다면 적절한 순서에 맞춰 먼저 그들을 삭제해 준 후에 원하는 레코드를 삭제해야 한다.

메서드는 간단하다.

activeObjects.delete(Your_Entity);

이는 단일 객체를 삭제하는 방식인데 여러 객체를 삭제해야 한다면 또는 복잡한 쿼리가 필요하다면 deleteWithSQL()을 사용하자.

activeObjects.deleteWithSQL(YourEntity.class, "SPECIFIC_COLUMN_ID = ?", id);

 

 

참고 자료

다른 데이터베이스로 연결하는 방법에 대한 참조 3개:

https://community.developer.atlassian.com/t/how-to-config-atlassian-sdks-jira-to-use-custom-jdbc-database-instead-of-h2/25457/2

 

How to config Atlassian-SDK's Jira to use custom JDBC database instead of H2

Hello @hy.duc.nguyen, The way I did it is to use maven-amps-plugin in my POM and declaring the datasource there. In a nutshell, I did it like this (for mySql but should be similar with Postgre) <plugin> <groupId>com.atlassian.maven.plugins</groupId> <artif

community.developer.atlassian.com

https://developer.atlassian.com/server/framework/atlassian-sdk/declaring-jndi-datasources-in-amps/#declaring-jndi-datasources-in-amps

 

Declaring JNDI datasources in AMPS

Last updated Jul 12, 2024

developer.atlassian.com

https://bitbucket.org/aragot/amps-examples/src/master/start-jira-with-datasource/

 

Bitbucket

 

bitbucket.org

 

728x90
반응형
LIST
728x90
반응형
SMALL

JIRA 플러그인을 개발할 땐 플러그인이 허용하는 범위 내에서 새로운 섹션을 추가할 수 있다.

예를 들어, JIRA 관리자라면 볼 수 있는 화면인 이 화면을 보자.

이 화면에 Manage apps 탭을 선택하면 보이는 좌측 사이드바에 플러그인의 메뉴들을 등록할 수 있다.

사진에 보이는것처럼 K-APPROVAL도 만들고 있는 플러그인의 메뉴이다.

 

Web Section과 Web Item

이걸 어떻게 하는지 하나씩 알아보자. 방법은 크게 2가지가 있다.

  • 배치 스크립트 실행
  • 직접 등록

우선 배치 스크립트를 실행하는건 다음 명령어를 실행하는 것이다.

atlas-create-jira-plugin-module

이 명령어를 프로젝트의 루트 경로, 다른 말로 프로젝트의 pom.xml 파일이 존재하는 경로에서 실행하면 이런 화면이 보일것이다.

이게 커스텀하고 새로 만들어낼 수 있는 여러 모듈들이다. 그 중 Web Section이 보일것이다. 30번. 

이걸 선택해서 진행하면 되는데, 나는 직접 등록하는 방식으로 Admin 화면에 메뉴를 새로 만드는 걸 작성하고자 한다. 이젠 직접 등록하는 방식이 더 편하기도 하고. 

 

그러기 위해선 일단 atlassian-plugin.xml 파일에 아래와 같이 작성을 하나 해준다.

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

web-section 태그에서 가장 중요한 attribute는 location이다. 이 location은 아무값이나 적을 수 있는게 아니고 지정된 값만을 적을 수 있다. 그리고 그 지정된 값들 중에 관리자 화면에 있는 Manage Apps에 메뉴로 등록할 수 있는 위치는 `admin_plugins_menu`이다.

 

이걸 어떻게 알았냐면 공식 문서를 진짜 열심히 찾다보면 겨우 찾을 수 있다. 찾기가 굉장히 어렵다.

 

Administration UI locations

The section attribute of this web item module (for versions of Jira prior to 4.4) defines which section of the Administration area the web item will appear in.

developer.atlassian.com

위 링크에 들어가면 관리자 화면의 locations 정보를 알 수 있다. 

 

다시 돌아와서, web-section 태그에서 key, name, location attritbutes를 작성했는데 key, name은 원하는 값을 적으면 된다. 그렇지만 Unique해야 한다. 이 key를 통해서 섹션 아래 여러 아이템(메뉴버튼)들을 등록할 수 있기 때문에.

 

그리고 label은 보여지는 값을 의미한다. 그러니까 아래 사진에 빨간 박스.

그리고 여기선, 국제화를 위해 i18n 리소스를 사용했다. menu.admin.section 이라는 키는 i18n 리소스 파일에서 등록한 값이다.

kapproval.properties

menu.admin.section=K-Approval
...

kapproval_ko.properties

menu.admin.section=k-결재

 

여기까지 하면 web-section에 대한 내용은 다 한 것이다. 이제 그 섹션아래 여러 버튼이 존재한다. 아래 사진을 봐보자.

섹션 아래 여러 버튼들이 있다. 이것들은 web-item이라는 리소스이다. 이것들도 다 atlassian-plugin.xml에 등록해줘야 한다.

그래서 다음과 같이 작성해준다.

atlassian-plugin.xml

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

여기서 key, name attributes는 역시 원하는 값을 그대로 넣어주면 된다. 중요한 것은 section attribute.

보면 `admin_plugins_menu/adminPluginsMenuSection`이라고 되어 있다. 

admin_plugins_menu 이 부분은 정해져 있는 값이다. 위에서도 다룬 바 있다. 그리고 adminPluginsMenuSection 이 값은 위에 만든 web-section 태그의 key 값이다. 그래서 합쳐지면 관리자 화면의 내가 만든 섹션 하단에 아이템을 넣겠다는 의미가 된다.

 

그리고 여기서도 label은 역시 i18n 리소스를 사용한것이다.

link 태그는 이제 이 버튼(아이템)을 클릭하면 보여질 화면에 대한 path라고 생각하면 된다. 그리고 이건 서블릿을 등록하고 서블릿을 코드를 직접 만들어야 한다. 그래서 일단은 link 태그를 위처럼 작성해두자. 아 물론 만약 버튼을 클릭한게 구글 브라우저를 띄우는 거라면 그냥 아래처럼 하면 된다.

<link>https://www.google.com</link>

아무튼 결국 이 link 태그는 버튼을 클릭하면 이동하는 링크이다.

 

그럼 아래가 위에서 다룬 전체 코드이다.

atlassian-plugin.xml

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

여기까지 한다고 끝난건 아니다. 이제 서블릿을 등록하고 서블릿 코드를 작성해야 한다. 

 

Web Item에 대한 Servlet

https://developer.atlassian.com/server/framework/atlassian-sdk/creating-an-admin-configuration-form/#render-the-form-with-the-atlassian-template-renderer

 

Creating an admin configuration form

Namespace your keys! Because this is global application configuration data, it is important that you do some kind of namespacing with your keys. Whether you use your plugin key, a class name or something else entirely is up to you. Just make sure it is uni

developer.atlassian.com

 

서블릿을 등록하려면 먼저 서블릿 클래스를 만들어야 한다.

나도 2024년에 서블릿을 직접 사용해서 MVC 구조를 만들줄은 몰랐는데, Atlassian JIRA DC 플러그인을 만들려면 이게 정해진 규칙이니 따라야 했다. 우선 의존성을 내려받아야 한다. 

 

pom.xml

<dependency>
    <groupId>com.atlassian.templaterenderer</groupId>
    <artifactId>atlassian-template-renderer-api</artifactId>
    <version>1.1.1</version>
    <scope>provided</scope>
</dependency>

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>servlet-api</artifactId>
    <version>2.3</version>
    <scope>provided</scope>
</dependency>

두 가지가 필요한데 나는 뷰 템플릿을 사용한다. JIRA Plugin 개발에서 공식문서에 나와있는 뷰 템플릿은 Velocity인데, 다른게 가능한지는 모르겠다. 크게 다른게 없기 때문에 그냥 Velocity를 사용해도 무방하다고 생각한다. 그리고 이 템플릿을 렌더링 하는 Atlassian의 라이브러리인 ATR(Atlassian Template Renderer)를 사용한다.

 

그리고 Servlet을 사용하려면 관련 의존성을 내려 받아야 하기 때문에 두가지 dependencies를 추가한다.

 

이제 Servlet 클래스를 하나 만들어보자.

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class ApprovalManagementServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {}
}

우선 HttpServlet을 상속받아야 한다. 서블릿을 사용할거라면!

 

첫번째로 할 일은 doGet()을 오버라이딩 하는 것이다. 뭐 GET인 이유는 데이터를 전송하는 과정이 아니라 그저 관리자 화면의 특정 메뉴를 클릭하면 보여지는 화면을 구현할것이기 때문에.

 

그리고, 이게 관리자 화면이다 보니까 지금 요청한 사람이 실제로 관리자인지 아닌지 검증이 필요하다.

그리고 Atlassian에서 제공하는 방법 중 편리하게 사용할 수 있는 WebSudoManager라는게 있다.

 

Adding WebSudo support to your plugin

Adding WebSudo support to your plugin Support for Secure Administrator Sessions (also called websudo) was added in Confluence 3.3 and Jira 4.3. When an administrator who is logged into Confluence or Jira attempts to access an administration function, they

developer.atlassian.com

WebSudoManager가 뭘 해주는지 위 링크에서 자세하게 볼 수 있고 간단하게 설명해서 현재 요청한 사람이 관리자인지 아닌지 체크해서 화면을 보여주거나 로그인 화면으로 이동시키거나 둘 중 하나를 해주는 서비스라고 생각하면 된다.

 

그래서 다음과 같이 WebSudoManager를 필드로 선언해준다.

@ComponentImport private final WebSudoManager webSudo;

참고로 이 친구의 패키지는 다음과 같다.

com.atlassian.sal.api.websudo.WebSudoManager;

그리고 이 라이브러리는 기본으로 이 라이브러리가 내포하고 있다. 그리고 이 라이브러리 역시 기본으로 설정되어 있다.

<dependency>
    <groupId>com.atlassian.jira</groupId>
    <artifactId>jira-api</artifactId>
    <version>${jira.version}</version>
    <scope>provided</scope>
</dependency>

 

그리고 뷰 템플릿을 렌더링 할 것이기 때문에 TemplateRenderer 클래스 역시 주입받아야 한다.

@ComponentImport private final TemplateRenderer renderer;

 

그리고 이전 포스팅에서 스프링 스캐너를 사용해서 애노테이션 기반의 주입을 사용하게 설정했었다. 그래서 @ComponentImport 애노테이션을 사용해서 필요한 모듈을 이렇게 쉽게 주입받을 수가 있다. 이 @ComponentImport는 주입받는 모든것들에 다 적용하는건 아니고 Atlassian 패키지 하위에 있는 모듈들을 주입받을때만 사용하면 된다. 이 말은 이후에 좀 더 잘 이해하게 된다.

 

그래서 가장 먼저 실행할 코드는 다음과 같다.

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.websudo.WebSudoManager;
import com.atlassian.templaterenderer.TemplateRenderer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class ApprovalManagementServlet extends HttpServlet {

    @ComponentImport private final TemplateRenderer renderer;

    @ComponentImport private final WebSudoManager webSudo;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

        webSudo.willExecuteWebSudoRequest(req);

    }
}

스프링에 익숙한 사람들은 생성자 주입을 통해 주입받는다는 것을 알 것이다. 그리고 Lombok을 사용하면 @RequiredArgsConstructor 애노테이션으로 편리하게 생성자를 만들 수 있다. 그래서 위 코드가 그 방식을 따르고 있다. 

 

그리고 doGet() 안에서 WebSudoManagerwillExecuteWebSudoRequest(req); 를 호출해주면 현재 요청을 관리자 권한을 가진 유저가 요청한것인지 확인해준다. 그 이후에 코드가 실제 이 서블릿을 호출할 때 실행될 코드가 된다. 특별히 뭐 다른게 필요없다면 바로 뷰 템플릿을 렌더링하면 된다.

renderer.render("templates/admin/approvals.vm", resp.getWriter());

이 코드가 이제 뷰 템플릿을 렌더링하는 코드이다. 뷰 템플릿의 경로는 classpath(`src/main/resources`) 아래 templates에 넣어두면 된다. 이건 스프링 부트랑 다른게 없기 때문에 쉽게 와 닿았다.

 

근데 뷰 템플릿을 사용하는 이유는 동적으로 뷰를 렌더링하기 위함이다. 그렇다는 것은 데이터가 동적으로 변경되고 필요한 데이터가 그때 그때 달라질텐데 그럴때 데이터를 담아 전송하는 방법은 간단하게 Map으로 Key/Value 자료구조를 넘기면 된다.

Map<String, Object> context = new HashMap<>();
context.put("approvalPaths", "pathA");

 

이렇게 만든 context를 다음과 같이 넘긴다.

renderer.render("templates/admin/approval.vm", context, resp.getWriter());

 

render() 메서드는 2가지가 있다. Context를 넘기는 경우와 넘기지 않는 경우. 필요에 따라 넘기거나 넘기지 않거나 알아서 선택하면 된다. 

 

그리고 보통은 비즈니스 로직을 처리하기 위해 필요한 서비스를 주입받는다. 지금까지 작성한 내용은 쉽게 설명하기 위해 그런 내용을 다 뺐지만, 아마 대부분은 특정 서비스를 주입받아 사용할 것이다.

 

임의의 서비스가 아래처럼 있다고 가정해보자. 

public interface ApprovalManagementService {...}
@Slf4j
@Service
@RequiredArgsConstructor
public class ApprovalManagementServiceImpl implements ApprovalManagementService {...}

스프링 스캐너를 사용하고 있기 때문에 @Service 애노테이션으로 간단하게 이 서비스를 스캔할 수 있다.

그리고 주입받는것도 역시 스프링이랑 동일하게 이렇게 주입받으면 된다.

 

ApprovalManagementServlet

package kr.osci.kapproval.admin.servlet;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.websudo.WebSudoManager;
import com.atlassian.templaterenderer.TemplateRenderer;
import kr.osci.kapproval.admin.service.ApprovalManagementService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
public class ApprovalManagementServlet extends HttpServlet {

    private final ApprovalManagementService approvalManagementService;

    @ComponentImport private final TemplateRenderer renderer;

    @ComponentImport private final WebSudoManager webSudo;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

        webSudo.willExecuteWebSudoRequest(req);
		
        ...
        
        renderer.render("templates/admin/approval.vm", context, resp.getWriter());
    }
}

그리고 이 서블릿을 역시나 마찬가지로 리소스로 등록해야 한다.

atlassian-plugin.xml

<servlet key="approvalManagementServlet" class="kr.osci.kapproval.admin.servlet.ApprovalManagementServlet">
    <url-pattern>/kapproval/admin/approval</url-pattern>
</servlet>

리소스로 등록할 때 url-pattern 이라는 태그가 있는데 이 태그는 이 서블릿을 호출하는 URL이라고 생각하면 된다.

이 URL이 위에서 등록했던 web-item 태그의 link 경로가 된다.

근데 서블릿은 기본 context path가 존재한다. 그 기본 context path가 `/plugins/servlet`이 된다.

그래서 web-itemlink가 이렇게 등록되는 것이다.

<web-section key="adminPluginsMenuSection" name="k-approval admin plugins menu section" location="admin_plugins_menu" >
    <label key="menu.admin.section"/>
</web-section>

<web-item key="approvalManagement" name="k-approval management" section="admin_plugins_menu/adminPluginsMenuSection" >
    <label key="menu.admin.section.approval"/>
    <link>/plugins/servlet/kapproval/admin/approval</link>
</web-item>

 

이런식으로 서블릿을 만들었으면 서블릿이 렌더링 할 뷰 템플릿이 있어야 한다. renderer.render()에서 파라미터로 작성한 경로 그대로 뷰 템플릿을 Velocity로 만들어서 사용하면 된다. 

src/main/resources/templates/admin/approval.vm

<html>
  <head>
    <title>My Admin</title>
  </head>
  <body>
    <form id="admin">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name">
      </div>
      <div>
        <label for="age">Age</label>
        <input type="text" id="age" name="age">
      </div>
      <div>
        <input type="submit" value="Save">
      </div>
    </form>
  </body>
</html>

 

더해서, 이 뷰 템플릿에서 사용하고자 하는 자원이 있을 수 있다. 예를 들면 JS, CSS 파일 또는 JIRA Plugin 개발하면서 아주 많이 자주 사용되는 AUI 리소스들. 그런것들을 뷰 템플릿에서 사용하고 싶으면 이 또한 atlassian-plugin.xml 파일에 웹 리소스를 등록해서 그 리소스를 뷰 템플릿에서 가져와야 한다. 

atlassian-plugin.xml

<!-- admin approval management 화면 resources -->
<web-resource key="admin-approval-resources" name="admin approval resource">
    <!-- 화면 모듈 include -->
    <resource type="download" name="approval.css" location="/css/admin/approval.css"/>
    <resource type="download" name="approval.js" location="/js/admin/approval.js"/>

    <transformation extension="js">
        <transformer key="jsI18n"/>
    </transformation>

    <context>kapproval</context>
</web-resource>

이렇게 웹 리소스를 등록한다. 이 경우에는 CSS, JS 파일이 필요한것으로 보인다. 그리고 JS에서도 사용할 i18n 리소스까지 이렇게 웹 리소스를 등록을 한 상태에서 뷰 템플릿에서 가장 상단에 아래 코드를 추가한다.

$webResourceManager.requireResource("<atlassian-plugin-key>:<web-resource-key>")

 

`atlassian-plugin-key`atlassian-plugin.xml 파일에 가장 상단에 있는 key attribute 값을 말한다.

`web-resource-key`는 바로 위에 작성한 web-resource 태그의 key attribute값을 말한다.

 

`atlassian-plugin-key`atlassian-plugin.xml 파일에 가장 최상단에 보이는 atlassian-plugin 태그의 key

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
        ...
</atlassian-plugin>

 

그리고 저 리소스를 추가한 최종 뷰 템플릿 코드는 이렇게 생겼다.

$webResourceManager.requireResource("com.atlassian.tutorial:admin-approval-resources")

<html>
  <head>
    <title>My Admin</title>
  </head>
  <body>
    <form id="admin">
      <div>
        <label for="name">Name</label>
        <input type="text" id="name" name="name">
      </div>
      <div>
        <label for="age">Age</label>
        <input type="text" id="age" name="age">
      </div>
      <div>
        <input type="submit" value="Save">
      </div>
    </form>
  </body>
</html>

 

정리

이게 큰 그림에서의 설정, 리소스 등록, 서비스를 주입하는 방법이다. 이제 그 내부의 비즈니스 로직은 요구사항에 맞게 원하는대로 구현하면 된다. 관리자 화면의 섹션 생성, 섹션 하단에 메뉴 등록, 메뉴에서 보여지는 화면을 호출하는 서블릿, 서블릿에서 주입하는 서비스까지 전부 알아보았다. 서비스 중에선 atlassian 모듈 하위의 서비스인 경우 @ComponentImport 애노테이션을 붙여서 임포트하는 것까지. 

 

다음엔 DB를 연동하고 데이터를 읽고 쓰는 방법까지 알아보자!

728x90
반응형
LIST
728x90
반응형
SMALL

Jira DC 플러그인 개발을 하면서 프레임워크같은데 프레임워크가 아닌것 같은 느낌을 받았다. 무슨 말이냐면, 개발자에게 강제하는 부분이 있는가하면 또 자유로운 부분이 있다. 예를 들면, 스프링 스캐너를 사용하여 스프링의 애노테이션을 사용하려면 사용할수가 있다. 그래서 더 모호하고 어렵다. 어떤게 좋은 코드가 될 지 한눈에 들어오지 않는다. 

 

그래서 나는 스프링의 애노테이션과 JAX-RS를 병행하는 방법을 택했다.

이 Jira DC Plugin은 기본이 JAX-RS이다. JAX-RS는 Java For XML Restful Web Service의 약자로 자바의 또 하나의 웹 애플리케이션 개발 방법이다. 그래서 깊이 있게 알아보기 전 프로젝트 구조를 먼저 살펴보자. 

 

프로젝트 구조

.
├── LICENSE
├── README
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── atlassian
    │   │           └── tutorial
    │   │               └── myPlugin
    │   │                   ├── api
    │   │                   │   └── MyPluginComponent.java
    │   │                   └── impl
    │   │                       └── MyPluginComponentImpl.java
    │   └── resources
    │       ├── META-INF
    │       │   └── spring
    │       │       └── plugin-context.xml
    │       ├── atlassian-plugin.xml
    │       ├── css
    │       │   └── myPlugin.css
    │       ├── images
    │       │   ├── pluginIcon.png
    │       │   └── pluginLogo.png
    │       ├── myPlugin.properties
    │       └── js
    │           └── myPlugin.js
    └── test
        ├── java
        │   ├── it
        │   │   └── com
        │   │       └── atlassian
        │   │           └── tutorial
        │   │               └── myPlugin
        │   │                   └── MyComponentWiredTest.java
        │   └── ut
        │       └── com
        │           └── atlassian
        │               └── tutorial
        │                   └── myPlugin
        │                       └── MyComponentUnitTest.java
        └── resources
            └── atlassian-plugin.xml

 

스프링과 유사한 구조를 가지고 있다. src/main/java, src/main/resources, src/test/java, src/test/resources.

 

Add-on Descriptor (atlassian-plugin.xml)

근데, 리소스를 추가하고 설정하는 방법이 XML을 사용한다.

 

그리고 그 파일은 src/main/resources 안에 있는 atlassian-plugin.xml 파일이다. 

이 파일이 어떻게보면 가장 중요한 파일이라고 볼 수 있다.

 

atlassian-plugin.xml

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
        <param name="plugin-icon">images/pluginIcon.png</param>
        <param name="plugin-logo">images/pluginLogo.png</param>
    </plugin-info>
    <!-- add our i18n resource -->
    <resource type="i18n" name="i18n" location="myPlugin"/>
    <!-- add our web resources -->
    <web-resource key="myPlugin-resources" name="myPlugin Web Resources">
        <dependency>com.atlassian.auiplugin:ajs</dependency>
        <resource type="download" name="myPlugin.css" location="/css/myPlugin.css"/>
        <resource type="download" name="myPlugin.js" location="/js/myPlugin.js"/>
        <resource type="download" name="images/" location="/images"/>
        <context>myPlugin</context>
    </web-resource>
</atlassian-plugin>

 

만약 내가 커스텀 JQL 함수를 만들기 위해 플러그인을 개발한다 하면 이 파일에 리소스를 등록해야 한다.

만약 내가 워크플로우의 컨디션을 커스텀하기 위해 플러그인을 개발한다 하면 이 파일에 리소스를 등록해야 한다.

어떤 특정 리소스를 만들던 이 파일에 그 리소스를 등록해야 한다.

 

이후에 리소스를 개발하면서 하나씩 알아보자.

우선, 가장 먼저 나는 스프링 스캐너를 사용하기 위한 설정을 했다. Atlassian Plugin은 또 atlassian-spring-scanner 라는 것을 사용한다. 그래서 pom.xml에 이런 설정을 해줘야 한다.

 

버전

<properties>
	...
    <atlassian.spring.scanner.version>2.2.4</atlassian.spring.scanner.version>
	...
</properties>

 

의존성

<dependency>
    <groupId>com.atlassian.plugin</groupId>
    <artifactId>atlassian-spring-scanner-annotation</artifactId>
    <version>${atlassian.spring.scanner.version}</version>
    <scope>provided</scope>
</dependency>

 

빌드 플러그인

<build>
    <plugins>
		...
        <plugin>
            <groupId>com.atlassian.plugin</groupId>
            <artifactId>atlassian-spring-scanner-maven-plugin</artifactId>
            <version>${atlassian.spring.scanner.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>atlassian-spring-scanner</goal>
                    </goals>
                    <phase>process-classes</phase>
                </execution>
            </executions>
            <configuration>
                <verbose>false</verbose>
            </configuration>
        </plugin>
    </plugins>
</build>

 

버전은 2.x 를 사용한다. 3.x 버전도 있는것 같은데 거의 관련 내용이나 문서가 없어서 시도하지 않기로 했다.

pom.xml 파일은 이렇게 설정을 하면 되고, 이게 끝이 아니다. 

 

`src/main/resources/META-INF/spring/plugin-context.xml` 파일에서 스프링 설정을 해줘야 한다.

plugin-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:atlassian-scanner="http://www.atlassian.com/schema/atlassian-scanner/2"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
        http://www.atlassian.com/schema/atlassian-scanner/2
        http://www.atlassian.com/schema/atlassian-scanner/2/atlassian-scanner.xsd">
    <atlassian-scanner:scan-indexes/>
</beans>

 

이렇게 설정을 해주면, atlassian-spring-scanner를 사용할 수 있다! 그 말은 애노테이션으로 자동 스캔이나 주입이 가능해진다는 말이다. 

Jira Version, AMPS Version

아래 명령어를 사용해서 스켈레톤 프로젝트를 만들면 자동으로 JIRA, AMPS 버전을 선정해준다.

atlas-create-jira-plugin

 

버전을 확인하는 방법은 pom.xml 파일에 있다.

<properties>
    <jira.version>9.12.2</jira.version>
    <amps.version>8.14.3</amps.version>
	...
</properties>

 

버전은 원하는 버전으로 변경할 수도 있다.

 

i18n

특히나 Atlassian 제품은 국제적으로 사용하는 제품이기 때문에 국제화가 거의 필수다. 그래서 i18n 리소스가 반드시 필요하다. 플러그인을 좀 더 완성도 높게 하려면.

 

그리고 이 파일은 `src/main/resources` 안에 작성하면 된다. 나 같은 경우는 간단하게 영어, 한국어에 대한 리소스를 만들었다.

 

kapproval.properties
kapproval_ko.properties

 

이렇게 리소스를 만들면 역시 이것도 atlassian-plugin.xml 파일에 등록해야 한다.

atlassian-plugin.xml



<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
	...
    <!-- add our i18n resource -->
    <resource type="i18n" name="i18n" location="kapproval"/>
    ...
</atlassian-plugin>

locationBundle의 이름을 적으면 된다.

간단하게 구조에 대해 이해해 보았다. 이제 Part.3에서 리소스를 하나씩 만들면서 어떤 설정이 필요하고 어떤 작업을 해줘야 하는지 하나씩 알아보자. 

 

728x90
반응형
LIST
728x90
반응형
SMALL

Atlassian Developer로서, 플러그인을 개발하고 있다. Data Center(또는 Server)버전과 Cloud 개발 방식이 살짝 다르다.

그리고 결정적으로 문서가 최신화가 되어있지 않은 경우가 많아서 삽질 과정을 적어두려고 한다.

 

환경 설정

우선 DC는 가장 먼저 Atlassian Plugin SDK가 필요하다. 나는 MacOS 유저이기 때문에 간단하게 Homebrew로 다운받았다.

Atlassian Plugin SDK는 Atlassian 플러그인을 개발할 때 필요한 여러 모듈들이 들어있다. 

brew tap atlassian/tap
brew install atlassian/tap/atlassian-plugin-sdk

 

두 줄의 명령어를 한번씩 실행하자. 실행하면 Atlassian Plugin SDK를 내려받는다.

정상적으로 내려받았으면 다음 명령어를 통해 제대로 설치가 됐는지 확인해보자.

atlas-version

 

잘 설치가 됐다면 아래 사진과 유사한 결과를 보게된다.

 

그리고 JDK 8이 필요하다. 8 이상이어도 되는지는 모르겠다. 공식 문서는 정확히 8로 명시하고 있어서 나도 공식 문서를 따르기로 했다.

JDK 8 설치 과정은 따로 작성하지는 않는다.

 

그리고 나서, 다음 명령어로 기본 틀을 갖춘 프로젝트 하나를 생성한다. 

atlas-create-jira-plugin

그럼 배치 파일 하나가 실행되는데, pom.xml 파일에 작성될 groupId, ArtifactId 등등의 정보를 작성한다. 참고로 이 Jira 플러그인을 개발할 땐 기본이 Maven을 사용한다. Gradle을 사용할 수 있는지는 모르겠다. 난 Maven도 불편함이 없기 때문에 그대로 Maven을 사용하기로 했다.

Define value for groupId: : com.atlassian.tutorial
Define value for artifactId: : myPlugin
Define value for version: 1.0.0-SNAPSHOT: : 1.0.0-SNAPSHOT
Define value for package: com.atlassian.tutorial: : com.atlassian.tutorial.myPlugin

 

참고로, 이 명령어를 실행중에 OSGi 활성화를 묻는 상황이 온다. 나는 No로 설정했다. 사용하지 않아도 상관이 없기 때문에.

프로젝트가 만들어지면 본인이 사용하는 IDE로 프로젝트를 연다.

 

가장 먼저 해야할 건 Maven Path 설정이다. 위에서 설치한 Atlassian Plugin SDK 경로를 넣어줘야 한다.

Settings > Build, Execution, Deployment > Build Tools > Maven에 보면 Maven home path 설정이 있다. 이 부분을 위에서 확인한 Atlassian Plugin SDK 경로로 설정한다. 그리고 JDK8이 잘 설정됐는지도 확인!

Project Structure > Project Settings > Project > SDK 확인

여기까지 확인을 했으면 프로젝트를 진행하기 위해 기본적으로 설정할 것들을 다 했다.

 

Debug 설정

개발하면서 디버깅은 필수 요소이다. Atlassian Plugin SDK는 디버깅을 위한 명령어가 따로 존재하고, 디버깅 설정도 필요하다.

우선 IntelliJ IDE에서 상단에 Run / Debug Configuration을 설정해야 한다. 아래 사진을 보자. 

JIRA Dev 라는 이름은 각자 다를 수 있다. Edit Configurations 버튼을 클릭해보자.

그럼 아래 사진과 같은 창이 하나 보인다.

 

여기서 왼쪽 상단에 + 버튼을 클릭한다. 그리고 `JVM`을 검색해보자. 검색하면 아래와 같이 `Remote JVM Debug`가 나온다.

이걸 선택해보자. 다음 화면이 보인다. 여기서 이름 정도만 수정해주면 된다. 기본으로 Host(localhost)Port(5005)가 적절하게 설정되어 있다. 그리고 Use module classpath에 만든 프로젝트가 잘 설정되어 있으면 된다.

 

이제 만약, 디버깅을 하고 싶으면 아래 명령어를 먼저 실행하자.

atlas-debug

 

그럼 프로젝트가 실행이 된다. 기본적으로 내장 톰캣을 사용하여 서버 하나가 띄워진다.

시스템 콘솔에는 다음 라인이 시간이 조금 지난 후 찍힌다.

[INFO] jira started successfully in 89s at http://localhost:2990/jira

이런 로그가 찍히면 이제 디버깅을 할 수 있다. 아래 사진처럼 디버그 버튼을 클릭하면 된다.

그럼 아래 사진과 같이 연결되었다고 보여진다.

이제 디버깅하고 싶은 특정 라인을 BreakPoint를 찍어서 확인해보면 된다!

이 정도로 환경 설정은 얼추 갖춰졌다. 이제 Part.2에서 프로젝트 구조에 대해 알아보는 시간을 가져보자!

 

728x90
반응형
LIST

+ Recent posts