쓰레드는 상태를 가진다. 쓰레드가 가질 수 있는 상태는 다음과 같다.
위 상태들을 쭉 보다가 TIMED_WAITING 와 WAITING 은 어떻게 다른지 궁금해졌다. 언제 전이되는지, 어떤 상태에서 전이될 수 있는지를 직접 실험해보았다. 그리고 팀에서 관리하는 서비스에서 적용할 만한 사례를 찾아보았다. 천천히 스텝 바이 스텝으로 나아가기로했다. TIMED_WAITING 과 WAITING 상태인 스레드를 직접 눈으로 확인해보는 것이 시작이었다.
다음 코드에서 TIMED_WAITING 으로 전이될 수 있는 포인트가 있다. Thread.sleep(3000) 이다. sleep() 은 스레드를 잠시 멈추게 한다.실행을 일시 중단했다가 얼마의 시간이 지난 후 다시 재실행한다. 이 과정에서 스레드의 상태는 TIMED_WAITING -> RUNNABLE 로 전이된다. sleep() 말고도 TIMED_WAITING 상태에 머물게 하는 두 가지 기능이 더 있다. wait(long timeout), join(long millis) 이다. 상세하게 하는 일은 달라진다. sleep() 과 join() 에 대해서 비교하여 짚어보면, sleep() 은 지정된 시간 동안 멈춰있다가 다시 재실행하고 join() 은 다른 스레드의 종료를 기다렸다 재실행한다. 전자는 단순한 시간의 흐름에 따라 역할이 끝나지만 후자는 특정 스레드의 종료를 엿보는 역할까지 수행한다. 어찌됐든 둘 다 '일정 시간의 경과' 가 필요하다.
일단 대기 상태의 스레드를 RUNNABLE 상태로 전이시키는 또 다른 이벤트가 있다. 자고있는 스레드를 누군가가 깨우면된다. 다른 말로는 interrupted.
sleep() 은 종종 이용하고 있지만 wait(), join() 은 써먹어 본 적이 없었다. 고민하다, 다음 케이스에 활용해볼 수 있을 것 같았다.
구독 데이터는 구독 토큰과 외부 클라이언트가 관리하는 시청권한 아이디로 매핑된다. 근데 매월 정기 결제 시점에 이 아이디가 변경되어있다면, 구독 토큰으로 이전 데이터를 조회할 수 없어 예외가 발생하고 결과적으로 구독 정기결제 이벤트 처리가 실패한다. 여러 요건 상, 해당 플로우에서 시청권한 아이디가 변경되었는지 체크해야했다. 변경되었다면 이전에 적재해둔 매핑 데이터를 재연동하는 프로세스가 필요했다.
그러니까 구독 처리 프로세스에 두 가지 흐름이 존재해야했다. 구독 데이터를 처리하는 메인 플로우와 유저 재연동 플로우. 나는 이 두 플로우가 각각 별개의 흐름이라고 생각했다. 재연동 플로우는 데이터 보정 역할이었기 때문이다. 다른 맥락이기 때문에 메인 플로우와 분리해두고 싶었다. 별개의 플로우라고 하니 이벤트 발행이 생각났다. 근데 이벤트 처리는 알맞지 않다. 이벤트 처리 스레드에서 작업이 완료되지않았을 때, 메인 플로우에서 선 조회한다면 문제는 여전히 발생한다. 아무래도 동기식으로 처리하는 게 마땅해보였다. 그러면서 별개의 처리 과정이라는 것을 드러내고 싶었다. join() 을 이용해보았다. 더 드라마틱하고 멋진 해결법은 여기서 논외로 두었다. 실험을 해보는 것에 집중해보았다.
join() 에 대한 간략한 설명은 다음과 같다.
thread1 이 아직 종료되지 않았다면 main 플로우는 thread1.join() 코드 안에서 더는 진행하지 않고 멈추어 기다린다. 이후에 thread1이 종료되면 main 플로우의 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동한다. thread1 이 종료되는 시점에 thread2 도 거의 같이 종료 되기 때문에 thread2.join() 은 대기하지않고 바로 빠져나온다. WAITING 상태는 이런 것이다. join() 을 호출하는 스레드는 대상 스레드가 TERMINATED 상태가 될 때 까지 대기한다. 대상 스레드가 TERMINATED 상태가 되면 호출 스레드는 다시 RUNNABLE 상태가 되면서 다음 코드를 수행한다.
사실 wait() 도 해당 케이스에서 적용해볼만 했다. wait() 은 재연동 플로우의 상태를 조건을 이용해 세밀하게 제어할 수 있다. 만약에 재연동 플로우에서 조건에 따라 다른 흐름으로 갈 필요가 있다면 wait() + notify() 조합을 이용해볼 수 있다. 난 join() 을 구독 플로우에 끼얹어 보았다. 더 단순했기 때문에.
테스트를 하다보니 여러가지 실험 대상이 늘어났다. 그 과정을 요약하지 않으려고 한다.
위 compensateDisconnectedSaid 메서드는 구독 처리 로직 안에 있다. 해당 메소드는 데이터 불일치를 확인하고 해결하는 DisconnectedSaidCompensator 와 협력한다. DisconnectedSaidCompensator 는 새로운 스레드에서 동작된다. 최대 5초 동안 작업 완료를 대기하다 5초가 지나면 작업이 완료되지 않더라도 메인 플로우의 대기 상태는 종료된다. 재연동이 완료되지않더라도 다음 플로우로 넘어가게 되는거다. 어쩔 수 없다. 재연동에 5초 이상의 시간을 허락할 수 없었다. 다행인 건 해당 구독 처리 프로세스는 비동기적으로 돌아가기 때문에 네트워크 타임아웃에 영향이 없었다.
재연동 핵심 로직을 담고 있는 DisconnectedSaidCompensator 는 다음과 같다.
DisconnectedSaidCompensator는 Runnable 인터페이스를 구현하며, 사용자 ID를 기반으로 클라이언트 API를 호출해 보정 작업을 수행한다. 근데 여기서 문제가 또 있는게, 재연동 후 변경된 유저 정보를 알아야했다. 그래야 뒷처리를 할 수 있었다. join() 의 결과를 어떻게 받아올 수 있을까. future.get() 을 통해 받아올 수 있다.
근데 결과는 null 이었다. future는 비동기성 작업의 결과를 가져오는 동기적 대기 메서드다. 비동기성은 작업 실행 방식(별도 스레드)에서 기인하며, 동기적 대기(get())는 그 결과를 동기적으로 사용할 필요가 있을 때 발생한다고 이해했다.
Future 는 두 가지 주요 유형의 작업을 처리할 수 있다. Callable 인터페이스는 작업을 수행한 후 결과를 반환한다. future.get() 이 해당 값을 반환하는 구조다. 이때 반환 타입은 Callable 이 정의한 제네릭 타입 V 다. Runnable 은 반환값이 없는 작업을 실행한다. 이 경우, Future 는 결과로 null 을 반환한다. 그래서 future.get() 을 호출해도 반환값이 null 인거다. Callable 로 변경해보았다.
넙죽 잘 받아오는 것을 확인할 수 있다. Future 를 사용하기 위해서는 ExecutorService 가 필요하다. ExecutorService는 자바에서 멀티스레딩을 쉽게 관리할 수 있도록 도와주는 스레드 풀 기반의 인터페이스다. 스레드를 직업 만드는 것보다 간편한게, 스레드 사용할 때 자원관리, 스레드 사용 후 자원 정리 같은 잡부역할을 도맡아 한다.
join() 은 단순히 스레드의 종료만 기다리면 되는 경우다. 결과 값이 필요하지 않고 특정 스레드가 끝날 때까지 다음 작업을 진행하지 않아야할 때 사용된다. 내 경우엔 변경된 유저 정보가 필요했으므로 Runnable 을 구현한 Thread.join 은 탈락이다. 그렇다면 Future.get 은 뭘까. 일단 작업의 결과를 리턴한다. 작업의 성공과 실패 또한 확인할 수 있다. 예외 상황 또한 처리할 수 있다. 예외 핸들링은 join() 도 어찌저찌 처리할 수 있긴하다.
> join 예외 catch 하는 방법
완전한 동기로 코드를 다시 변경해보았다.
왜 굳이 자바는 future.get() 을 만들어 별도의 스레드의 값을 받아오도록 만들었을까? 이렇게 return 을 사용하면 되는데. 사실 이번 공부의 시작은 이 물음에서 시작되었다. 완전한 동기와 선언적 동기는 어떤 차이를 만들어 낼까.
*선언적 동기는 내가 지었다. 정의하면 별도 스레드에서 돌아가는 동기성 작업을 의미한다.
별도의 스레드를 만들어 실행한다는 것은 메인 스레드로부터 독립한다는 것을 의미한다. 이게 비동기 작업의 본질인 것 같다. Future.get() 은 비동기 작업의 결과를 기다리는 동작을 수행하지만, 필요한 시점에 결과값을 사용할 수 있다. 이게 lazy evaluation 다. 결과값이 필요하기 전까지는 다른 작업을 병렬적으로 수행한다. 작업의 실행과 결과 수집이 분리되는거다. 거기서 "병렬"이 발생하는 것 같고..
이런 관점이라면 내가 구현한 코드에서 사실 Future 를 제대로 활용했다고 볼 수 없다. 나는 맥락의 분리를 위해 별도의 스레드를 만들었지만, 바로 결과값을 받아 사용하면 효율적인 병렬 사용법이 아니다. 또 결과값이 바로 필요하다면 그건 분리될 수 없는 맥락이라는 결론이나왔다. 결국 코드는 완전한 동기로 변경되었다.
join() 과 wait() 를 적재 적소에 활용해보는데에는 실패했지만 나름 그들과 친해질 수 있는 경험이었다. 사실 join() 과 wait() 을 정말 잘 사용하려면 스레드 풀과 관리에 대해서도 깊게 인지해야한다. 이 영역에 대해서도 파밍중이다. 근데 파고 파도 파밍할 게 계속 나온ㄷ ㅏ,, 다음 파밍 주제는 스레드 풀 관리다.
'생각' 카테고리의 다른 글
java.util.concurrent 안에는 뭐가 들었을까 (1) | 2025.01.04 |
---|---|
동시성 제어의 원초적 접근법, 애플리케이션 락은 유효한가요? (2) | 2024.12.14 |
메인 스레드, 커스텀 스레드, 데몬 스레드 테스트 찍먹일기 : multiThread & concurrency 2 (17) | 2024.10.09 |
<도둑맞은 집중력> 읽다가 멀티 스레드 공부 하게 된 이야기 : multiThread & concurrency 1 (2) | 2024.10.06 |
누가 궁금하다고 한 사람? (0) | 2024.08.22 |