출처: https://www.youtube.com/live/QxxG66eQoTc?feature=share&t=3652
많은 사람들이 Virtual Thread가 코루틴과 같은 역할을 할 수 있을지 궁금해하는 것 같았고, 나 또한 코루틴을 잘 모르는 입장에서 ‘결국 코루틴은 사장되는 게 아닐까?’란 생각이 들었다.
그러던 중 Coroutines and Loom behind the scenes by Roman Elizarov라는 영상을 보게 되어서 이해한 내용을 겸사겸사 정리해보았다. (나중에 회사에서 Virtual Thread 적용 할 때 고민해야할 부분도 함께 적어두기 위해서도 있지만…)
우선 해당 포스트에서는 Virtual Thread에 대한 기본적인 내용들은 알고 있다는 전제 하에 작성했다.
가상 쓰레드는 요청 당 쓰레드 하나를 만드는 모델(Spring WebMVC 같은)에 적합하고, 이미 존재하는 코드를 재사용할 수 있다는 장점이 있다.
코루틴은 높은 동시성(동시에 여러 요청을 보내서 응답을 받은 후 머지를 한다던지)을 요구하거나 Event 기반 시스템(유저의 이벤트를 받아서 UI를 제어해야한다던지)이나 Hierarchy가 있는 작업(Strucured Concurrency)을 처리하거나 취소해야하는 경우에 적합하다.
우선 Virtual Thread는 기존에 존재하던 java.lang.Thread API의 최소한의 변경만을 목표로 하고, 서버 사이드에서 1 Request에 1 Thread를 사용하는 모델(Spring WebMVC 같은)에서 최적화가 잘 되는 걸 목표로 삼고 있다.
그래서 이렇게 전통적인 방식으로 코드를 짜게 되면 요청이 여러번 들어왔을 때 OS의 Thread와 매칭되는 실제 쓰레드가 생성되고, Blocking I/O를 만나는 순간 쓰레드가 블락되고, 다른 쓰레드로 CPU의 제어권이 넘어가면서 컨텍스트 스위칭이 발생한다.
Virtual Thread 방식은 OS의 Thread와 매칭되는 실제 쓰레드는 조금만(컨텍스트 스위칭 비용을 줄이기 위해 기본적으로 CPU 코어 갯수 정도)만 만들고, 필요할 때마다 가상 쓰레드를 만든다.
그리고 가상 쓰레드 내에서도 Blocking I/O를 만나는 순간 쓰레드가 블락되지만, 가상 쓰레드만 블락이 되고 실제 쓰레드는 블락되지 않는다.
그리고 다른 가상 쓰레드로 컨텍스트 스위칭을 하는데 이 때 실제 쓰레드 전환이 일어나는 게 아니기 때문에 컨텍스트 스위칭 비용이 매우 적다.
하지만 가상 쓰레드에도 다음과 같은 두 가지 상황에서 Carrier Thread가 블락당할 수 있다.
Instead, avoid frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guard potentially long I/O operations to use java.util.concurrent.locks.ReentrantLock instead.
There is no need to replace synchronized blocks and methods that are used infrequently (e.g., only performed at startup) or that guard in-memory operations.
…
In a future release we may be able to remove the first limitation above, namely pinning inside synchronized. The second limitation is required for proper interaction with native code.
https://openjdk.org/jeps/444#Executing-virtual-threads
이렇게 쓰레드가 핀(가상 쓰레드가 고정돼서 다른 가상 쓰레드로 전환되지 않는 현상) 되지 않으려면 synchronized 대신 ReentrantLock의 사용을 권장하고 있고,
아주 가끔 호출되는(초기 설정 같은) 경우나 아주 짧은 시간동안만 점유하는 경우(메모리 내의 변수만 조작한다던지)에는 굳이 synchronized를 바꿀 필요가 없다고 한다.
만약 본인이 사용하는 라이브러리/프레임워크에서도 synchronized를 어디서 어떻게 사용하는지 보고 문제가 없는지 확인도 해야한다.
PostgreSQL JDBC Driver 같은 경우에도 synchronized를 ReentrantLock으로 바꾸는 작업을 하고 있다.
그리고 이런 제약들이 추후 수정된다는데 언제 수정될 지도 모르고, LTS가 아닌 이상 회사에서 해당 버전을 쓰다가 안정성에 문제가 생겨도 서포트 기간이 끝나면 업데이트가 올라오지 않을 수도 있다보니 우선은 조심해서 써야한다.
코루틴은 기존에 존재하는 다양한 비동기 API들(Java NIO, 다양한 Future 구현체 등등)을 래핑해서 사용하기 쉽게 만들고, 다른 라이브러리들에 의존성을 가지지 않으며, async/await를 사용하는 케이스나 generator 블럭(아마 둘 다 ECMAScript에 있는 내용을 얘기하는 것 같음)를 커버하는 것을 목표로 하고 있다.
그러다보니 가상 쓰레드는 Thread에, Coroutine은 비동기에 더욱 집중하고 있으며 가상 쓰레드는 서버 사이드에서 외부 네트워크를 콜 하는 경우 등등에 적합한 반면, 코루틴은 다양한 동시성 이슈(UI에 다양한 애니메이션 이벤트가 돌아가야하는 경우, 서버 사이드에서 다른 서비스에 동시에 요청을 보내서 응답을 머지해야하는 경우 등등)를 다뤄야하는 복잡한 케이스에 적합하다고 한다.
virtual thread 같은 경우에는 중간에 Blocking I/O를 만나더라도 다른 가상 쓰레드로 스위칭 될 거기 때문에 그냥 동기 방식으로 코드 짜듯이 순차적으로 짜면 된다.
반면 코루틴은 이벤트를 핸들링하는 방식으로 코드를 많이 짜기 때문에 상태를 업데이트 하는 부분에 Blocking I/O를 넣어선 안 된다.
그러면 다음 이벤트를 핸들링 할 수 없기 때문에 UI가 멈추는 현상이 발생하거나 서버 사이드에서 다른 서비스에 요청을 보내서 응답을 머지하는 경우에도 블락이 발생한다면 다른 요청을 보내지 못하게 되거나 할 수도 있다.
그리고 코틀린은 suspend function과 일반 function을 구분하는 게 매우 중요하다고 하는데 아마 suspend function으로 진입하는 순간 다른 코루틴 스코프에게 제어권을 양보하기 때문에 로컬에서 아주 짧은 시간 동안 처리하고 끝낼 수 있음에도 불구하고, 다시 제어권을 획득할 때까지 기다려야하기 때문에 처리 속도가 느려질 수 있는 이유 때문이 아닐까 싶다.
그리고 코루틴은 자식/부모 코루틴 스코프와 같이 구조화된 코루틴을 사용할 수도 있고, 특정 코루틴 스코프의 실행을 취소 시켜버리거나 에러를 핸들링하기 쉽다는 점도 존재한다.
물론 가상 쓰레드도 incubating 단계이긴 하지만 JEP 428: Structured Concurrency에서 위에서 언급한 코루틴의 에러 핸들링이나 취소 기능을 지원하려고 하고 있다.
가상 쓰레드는 Blocking I/O가 발생하여 yield 메서드가 실행되는 순간 가상 쓰레드가 들고있던 정보들(로컬 변수, 쓰레드 로컬 변수, 콜스택 정보 등등)을 힙메모리에 올리는 과정이 진행된다.
그리고 나서 다른 가상 쓰레드가 Carrier Thread를 점유하게 되는데 실제 쓰레드는 스위칭 된 게 아니기 때문에 우리가 알고 있는 컨텍스트 스위칭에 비해서는 쓰레드 스위칭 비용이 훨씬 싸다.
그렇기 때문에 가상 쓰레드에서는 yield 하는 비용이 가장 크다.
그런 반면 코루틴에서는 yield 할 때 비용이 발생하는 게 아니라 다음 suspend function을 만났을 때 현재 메서드의 상태를 힙에 저장하기 때문에 이 때 비용이 가장 크다.
그러한 이유 때문에 suspend function과 일반 function을 잘 구분해서 작성해야한다.
메모리는 코루틴이 더 적게 먹긴 하는데 가상 쓰레드가 100만개라고 가정했을 때 2.6GB 정도 차지하는데 요즘 어지간해서 JVM에 힙메모리는 4GB 이상은 주지 않나(케바케긴 하겠지만) 싶고, 사실 단일 서버가 100만개의 요청을 동시에 받을 일도 없고 하다보니 메모리는 크게 신경쓰지 않아도 될 것 같다.
그리고 가상 쓰레드가 느린 이유를 아래 3가지로 설명하고 있다.
코루틴과 가상 쓰레드의 CPU 리소스 사용률을 비교하려고 하는데 가상 쓰레드에 마땅히 비교할 때 사용할만한 API가 없다고 한다.
아마 가상 쓰레드는 쓰레드에 포커스를 맞췄기 때문에 저렇게 명시적으로 특정 가상 쓰레드를 yield만 하는 API는 딱히 없는 것 같다.
그럴 필요도 없이 쭉 실행하면 돼서 그런 게 아닐까 싶다.
이쯤에서 궁금해지는 게 코루틴은 저렇게 명시적으로 다른 코루틴 스코프에게 제어권을 양보하는 yield 메서드가 있고, 일반적인 멀티쓰레드 모델에서는 OS의 쓰레드가 사용되니까 OS에 의해 쓰레드가 스케쥴링이 될텐데 그럼 가상 쓰레드는 어떻게 스케쥴링이 될까? 궁금하기도 했다.
Blocking I/O를 만나면 yield 메서드가 실행되어 다른 쓰레드에게 제어권을 양보할텐데 만약 CPU 집약적인 작업을 많이 해서 오랫동안 yield 메서드가 실행되지 않고 있는 가상 쓰레드가 있다면 어떻게 될까??
The scheduler does not currently implement time sharing for virtual threads. Time sharing is the forceful preemption of a thread that has consumed an allotted quantity of CPU time.
While time sharing can be effective at reducing the latency of some tasks when there are a relatively small number of platform threads and CPU utilization is at 100%,
it is not clear that time sharing would be as effective with a million virtual threads.
https://openjdk.org/jeps/444#Scheduling-virtual-threads
아직 Time Sharing 방식으로 가상 쓰레드를 스케쥴링을 하지 않고 있다고 한다.
따라서 CPU 집약적인 작업을 오래하는 가상 쓰레드가 있다면 그 가상 쓰레드는 계속 CPU를 점유하게 될 것이다.
How that capability will be exposed to the schedulers is TBD, and will likely not make it to the first Preview.
https://cr.openjdk.org/~rpressler/loom/loom/sol1_part2.html#forced-preemption
하지만 추후 개선될 예정이라고 하니 그 전까지는 조심조심 하면서 사용해야하고, 혹시 CPU 집약적인 작업이 많은 어플리케이션이라면 가상 쓰레드를 꼭 써야하는지 고민 후 적용해봐야할 것 같다. (아니면 부분적으로만 적용한다던지)
가상 쓰레드는 built-in으로 Async I/O(NIO 같은)을 지원하는데 반해 코루틴은 특정 I/O 라이브러리(프레임워크)에 종속적이지 않고, 여러 I/O 기술을 범용적으로 다룰 수 있다.
그러다보니 가상 쓰레드는 Blocking I/O를 쓰는 코드에서 적합하고, 코루틴은 I/O를 많이 쓰면서 높은 처리량을 내야하는 비동기 코드에 적합하다고 한다.
그리고 코루틴이 특정 I/O 기술에 종속적인 게 아니다보니 어떤 Async I/O 프레임워크를 쓰는지에 따라서 성능도 달라질 수 있다고 한다.
가상 쓰레드의 마지막 목표는 기존에 있던 도구들(힙메모리 분석 도구, IDE의 디버깅 툴 등등)을 그대로 이용하는 것이다.
따라서 IntelliJ IDEA에서도 가상 쓰레드를 디버깅 할 수 있는데 아직은 가상 쓰레드가 Preview 버전이라 좀 더 작업할 것들이 남아있다고 한다.
그리고 코루틴은 기존에 없던 것이다 보니 도구가 새롭게 나왔는데, JetBrains에서 코틀린도 만들고, IntelliJ IDEA도 만들었으니 IntelliJ를 사용하면 코루틴도 디버깅이 가능하다.
그럼 가상 쓰레드와 코루틴은 언제 함께 사용하는 게 좋을까??
코루틴의 단점은 보완하면서 가상 쓰레드의 장점을 사용할 수 있을 때 함께 사용하면 좋다고 한다. (그 반대 케이스인 가상 쓰레드의 단점을 코루틴이 보완하는 케이스도 궁금했는데 영상에는 나오지 않는다.)
코루틴 안에서 복잡한 레거시 로직을 호출해야하는데 이 안에 Blocking I/O가 있다고 하면 suspend function이 적절한 타이밍에 yield를 하지 않기 때문에 Dispatchers.IO를 사용해서 코루틴을 별도 쓰레드에서 실행시키면 된다.
Coroutine의 IO Dispatcher와 Default Dispatcher 의 사용 시 차이에 Dispatchers.IO에 대해 간단히 설명하고 있다.
하지만 Dispatchers.IO 같은 경우에는 실제 쓰레드를 생성하기 때문에 메모리를 더 많이 쓴다는 단점이 존재한다.
이럴 때 Blocking 로직을 가상 쓰레드에서 실행시키면 메모리를 절약할 수 있다는 장점이 존재한다.
그럼에도 불구하고 무조건 가상 쓰레드가 좋은 건 아니고 트레이드 오프가 존재한다.
Dispatchers.IO 같은 경우에는 Blocking 전/후에 실행되는 쓰레드가 같기 때문에 불필요하게 컨텍스트 스위칭이 일어나지 않는다는 장점이 존재하고, 반면 메모리를 더 사용한다는 단점이 있다.
가상 쓰레드의 경우에는 메모리를 덜 사용한다는 장점이 있지만, 쓰레드가 다르기 때문에 물리적인 쓰레드의 스위칭까지는 아니지만 가상 쓰레드끼리는 스위칭이 발생한다는 단점이 존재한다.
물론 둘의 단점이 각각 미비할 수 있기 때문에 상황에 따라서 적절히 사용하면 된다.
물론 나중에 가상 쓰레드가 정식으로 나오면 Dispatchers.IO가 가상 쓰레드를 사용하도록 바뀌고, 가상 쓰레드끼리도 스위칭이 발생하지 않을 수 있게 서로의 장점만 취하는 방향으로 개발한다고 한다.
근데 가상 쓰레드 사용하는데 Blocking I/O를 만나서 yield가 되면서 힙메모리에 기존 가상 쓰레드의 컨텍스트 정보들을 올려뒀다가 다시 실행될 때는 그 정보들을 다시 가져오느라 비용이 발생할텐데 어떻게 이런 비용을 줄일 수 있을지 궁금하다.
가상 쓰레드와 코루틴은 서로 컨셉부터 다르고 장단점도 명확한 것 같다.
다만 나는 아직까지는 높은 동시성 처리/처리량을 요구하는 시스템이라기 보다는 돈이 오고가는 도메인을 다루다보니 안정성을 더 중점적으로 다루다보니 코루틴의 장점이 크게 와닿지 않았다.
그리고 코루틴은 어쨋든 코루틴 스코프 안에서 실행이 돼야하고, 이게 일반 function에서 실행시킬 것인지, suspend function에서 실행시킬 것인지, suspend function 안에 Blocking I/O가 있는 건 아닌지 등등 고민을 많이 해서 코드를 짜야한다는 단점이 존재하는 것 같다.
가상 쓰레드로도 몸빵이 안 쳐지는 높은 처리량을 요구하는 서버라면 차라리 서버를 더 늘리는 게 값 싼 것 같다.
아직까지는 사람이 제일 비싼 자원인 것 같고, 사람이 이해하기 쉽고 관리하기 편한 코드를 짜는 게 더 중요한 것 같다.
다만 가상 쓰레드도 위에 얘기한 것처럼 synchronized 블럭에 진입하면 해당 가상 쓰레드가 핀 된다던지, Structured Concurreny가 Preview 단계라던지 하는 등의 문제 때문에 필요에 따라서는 코루틴과 함께 사용할 수도 있을 것 같다.
Spring Web MVC와 같이 전형적인 1 Request per 1 Thread 모델의 한계(쓰레드 자체가 많은 메모리를 소비하고, 컨텍스트 스위칭에 따른 불필요한 시간 소요 등등)를 극복하기 위해
Spring Webflux(가 의존하는 Netty)에서는 코어 갯수 * 2개만의 쓰레드를 만듦으로 인해 그 한계를 극복하였지만 하나의 요청을 하나의 쓰레드가 온전히 처리하는 것이 아니기 때문에 스택트레이스를 봐도 파편화된 정보가 남아 트러블 슈팅에 문제가 있었고,
Mono나 Flux와 같은 Publisher 타입으로 값을 감싸야 하기 때문에 코드가 매우 보기 힘들고, 어디서 쓰레드를 블락하는 코드를 호출하는 건 아닌지 항상 불안에 떨었어야했다.
그러다보니 Webflux가 더 고성능을 보장하더라도 유지보수하기가 힘들고 러닝커브 또한 존재하기 때문에 어지간한 경우가 아니면 Web MVC로 프로젝트를 만들었다.
사실 프로덕션에서 RDBMS를 안 쓰는 곳이 거의 없는데 R2DBC를 사용하기에는 너무 불안정해 보이기도 했고, 그리고 TPS가 안 나오면 대부분 스케일 아웃하는 형태로 해결을 많이 했다.
서버보다는 사람이 가장 비싼 자원이라고 생각되기에 유지보수 측면으로만 생각하다보니 Webflux는 거의 사용한 적이 없는 것 같다.
결정적으로 Web MVC(Spring Boot를 사용한다면 톰캣의 최대 쓰레드인 200개)만으로도 부족함이 없는 서비스도 많았고, 단일 서버가 아닌 이중화 등등으로 인해 서버가 다중으로 뜨기에 Webflux를 써야할 만큼의 처리를 단일서버에서 하지 않는 경우가 대다수였다.
그럼에도 불구하고 Virtual Threads는 어떠한 문제를 해결해주는 것인지, Spring과 함께 사용하면 어떤 시너지를 낼 수 있을지 궁금해서 살짝만 훑어보았다.
graph TD; subgraph OS A[OS Scheduler] -- schedule --> B[OS Thread 1]; A[OS Scheduler] -- schedule --> C[OS Thread 2]; end subgraph JVM B --> D[Platform Thread 1]; C --> E[Platform Thread 2]; end
우리가 일반적으로 자바에서 쓰레드라고 부르는 것은 OS에서 생성한 쓰레드를 래핑해서 JVM에서 사용하기 쉽게 만든 Platform Threads라는 것을 말한다.
OS에 의해 스케쥴링 되기 때문에 쓰레드 간 전환을 위해 컨텍스트 스위칭이 발생하기도 하고, Platform Thread 하나를 생성하는 것은 OS에도 쓰레드를 하나 생성하는 것이기 때문에 일반적인 객체 생성과는 비교 불가능할 정도로 느리기 때문에 기본적으로 쓰레드 풀이라는 것을 만들고 거기에 미리 쓰레드들을 생성해두게 된다.
그러다보면 쓰레드 풀을 또 관리해야하는데 많은 양의 쓰레드를 만들다보면 또 메모리를 너무 많이 사용해서 문제가 발생하기도 한다.
graph TD; subgraph OS A[OS Scheduler] -- schedule --> B[OS Thread 1]; A[OS Scheduler] -- schedule --> C[OS Thread 2]; end subgraph JVM B --> D[ForkJoinPool]; C --> D[ForkJoinPool]; D -- schedule --> E["Carrier Thread 1 (Worker Thread 1)"]; D -- schedule --> F["Carrier Thread 2 (Worker Thread 2)"]; E --> G[Queue 1]; F --> H[Queue 2]; G -- schedule --> I["Virtual Thread 1 (Task 1)"]; G -- schedule --> J["Virtual Thread 2 (Task 2)"]; H -- schedule --> K["Virtual Thread 3 (Task 3)"]; end
그에 반해 Virtual Threads는 OS의 Thread와 1:1로 대응되지 않는다.
OS와 1:1로 대응되던 Platform Threads는 Carrier Threads라고 부른다.
Carrier Thread는 ForkJoinPool 안에 Worker Thread로 생성이 되어 스케쥴링이 되고, 각 Worker Thread들은 Queue를 가지고 있어서 Task를 스케쥴링하는데 Virtual Thread 자체(좀 더 정확히는 Virtual Thread의 runContinuation 메서드를 실행하는 Runnable 타입)가 Task가 되어서 Queue에 들어가게 된다.
initially, carrier threads for virtual threads are threads in a ForkJoinPool that operates in FIFO mode. The size of this pool defaults to the number of available processors.
https://www.infoq.com/articles/java-virtual-threads
Queue 안에 있는 Virtual Thread 쓰레드들은 FIFO 방식으로 스케쥴링 되고, ForkJoinPool 안의 Worker Thread인 Carrier Thread는 기본적으로 CPU 코어 갯수(정확히는 available processor)만큼의 Carrier Threads를 생성한다. (아마 컨텍스트 스위칭 비용 때문이 아닐까 싶다.)
We dont have to guess how much stack space a thread might need, or make a one-size-fits-all estimate for all threads;
the memory footprint for a virtual thread starts out at only a few hundred bytes, and is expanded and shrunk automatically as the call stack expands and shrinks.
https://www.infoq.com/articles/java-virtual-threads
그리고 Virtual Threads는 OS의 쓰레드와 대응되는 개념도 아니고, JVM에서 직접 쓰레드를 생성하기 때문에 생성 비용(용량/시간 등등의 측면에서)이 비싸지도 않고, 크기가 자동으로 조절되기 때문에 쓰레드 풀처럼 갯수를 관리할 필요도 없다.
The difference with virtual threads is that, due to them being under the control of the JVM, the thread stack is stored in the heap memory and not in the stack.
This means that allocating the thread stack for an awakened virtual thread becomes much cheaper.
https://theboreddev.com/understanding-java-virtual-threads/
JVM에서 쓰레드를 스케쥴링 해야하기 때문에 stack이 아닌 Heap 메모리 영역에 쓰레드의 스택이 저장되고 관리되기 때문에 우리가 알고있는 컨텍스트 스위칭에 대한 비용이 OS 레벨이 아닌 JVM 레벨에서 끝나기 때문에 훨씬 값싼 것이다.
The operating system schedules OS threads, and thus platform threads, but virtual threads are scheduled by the JDK.
The JDK does so indirectly by assigning virtual threads to platform threads in a process called mounting.
The JDK unassigns the platform threads later; this is called unmounting.
…
To implement this process, the JDK uses a dedicated ForkJoinPool in first-in-first-out (FIFO) mode as a virtual thread scheduler.
(Note: This is distinct from the common pool used by parallel streams.)
…
The JDK could mount a virtual thread by copying all its frames from heap to stack.
When the virtual thread is unmounted, most frames are left on the heap and copied lazily as needed.
https://blogs.oracle.com/javamagazine/post/java-loom-virtual-threads-platform-threads
Carrier Threads(Platform Threads)와 달리 Virtual Threads는 JDK에 의해 스케쥴링 되는데 ForkJoinPool을 이용하여 구현하였다. (parallel streams에서 사용하는 common pool과는 별개의 ForkJoinPool이라고 함.)
Virtual Threads 내에서는 CPU를 사용하지 않는 블로킹 메서드(네트워크 I/O, 파일 I/O 등등)를 만나게 되면 stack frames를 Heap 메모리에 저장(복사) 해놓는데 이 과정을 unmounting이라고 부른다.
그리고 블로킹 메서드가 종료되면 Heap 메모리에 저장된 stack frames를 다시 Virtual Threads로 불러오는데 이 과정을 mounting이라고 부른다.
이렇게 JVM 내에서 Virtual Threads 간 컨텍스트 스위칭이 이루어지기 때문에 Platform Threads를 사용할 때 비해서 컨텍스트 스위칭 비용이 매우 싸고, 스택트레이스를 찍었을 때 유실없이 모든 정보를 남길 수 있는 것이다.
코드를 통해 어떻게 Virtual Threads가 스케쥴링 되는지 좀 더 자세히 보자.
1 | public class VirtualThreadExample { |
Virtual Thread로 실행하는 블럭 내에 브레이크 포인트를 걸고 디버그 모드로 실행해서 확인해보면 Carrier Thread가 뭔지 볼 수 있다.
Virtual Threads는 Platform Threads와 달리 더 많은 상태들을 가지고 있다.
1 | /* |
무조건 cpu 코어 갯수만큼 생기는 건 아니고, Virtual Threads 갯수가 적다면 더 적은 Carrier Threads(ForkJoinPool Worker 쓰레드)가 생성된다.
그리고 실행 결과를 보면 100번의 Thread.sleep(1초)가 발생했고, 실제로 OS Thread와 매칭되는 Carrier Thread는 10개 밖에 사용하지 않았는데 1초 만에 모든 연산이 종료된 걸 볼 수 있다.
동일하게 10개의 Platform Threads를 사용하면 10초가 걸린다. (Executors.newFixedThreadPool(10)
)
Virtual Thread를 사용하면 100%는 아니지만 기존 코드의 변경없이 Virtual Thread를 사용할 수 있다고 한다.
어떻게 그게 가능한지 살펴보자.
VirtualThread의 부모 타입인 BaseVirtualThread가 Thread 클래스를 상속받았기 때문에 기존 Thread 구현의 변경 없이 Virtual Threads를 사용할 수 있다.
실제로 Thread.sleep(long millis) 메서드를 보면 아래와 같이 구현돼있다.
1 | public static void sleep(long millis) throws InterruptedException { |
내부에서 VirtualThread인지 아닌지 판단하고 있다.
그리고 VirtualThread라면 sleepNanos(long nanos) 메서드를 호출하고 있다.
1 | final class VirtualThread extends BaseVirtualThread { |
sleepNanos -> doSleepNanos -> parkNanos -> yieldContinuation 메서드를 순차적으로 호출하게 되는데
yieldContinuation 메서드 안에서 unmount 메서드를 호출해서 virtual thread를 block 시키고, 지정한 시간이 지나면 다시 mount 메서드를 호출해서 block 된 virtual thread를 깨워서 해당 지점부터 다시 task를 진행하도록 한다.
1 | /** |
unmount 메서드에서는 Virtual Thread의 Carrier Thread(ForkJoinPool Worker 쓰레드)의 currentThread(VirtualThread)를 Carrier Thread 그 자체로 할당해버림으로써 VirtualThread는 할당된 Carrier Thread가 없기 때문에 남은 연산을 수행하지 못하게 만들어버리고(blocking),
mount 메서드에서는 Virtual Thread의 Carrier Thread(ForkJoinPool Worker 쓰레드)의 currentThread(ForkJoinPool Worker 쓰레드)를 다시 자기 자신인 Virtual Thread 그 자체로 할당해버림으로써 VirtualThread의 남은 연산을 다시 Carrier Thread에서 수행하게 끔 만든다.
이렇게 되면 실제로 OS Thread와 매칭되는 Carrier Thread 그 자체가 블로킹 된 건 아니기 때문에 OS 레벨에서는 컨텍스트 스위칭이 발생하지 않고, JVM 레벨에서만 Carrier Thread에 다른 Virtual Thread를 할당하는 컨텍스트 스위칭만 발생하게 된다.
기존 코드와의 호환성을 위해 JEP 353 (Reimplement the legacy Socket API), JEP 373 (Reimplement the legacy DatagramSocket API)에서 Socket API들을 재구현함으로써 코드의 변경없이 Virtual Thread를 사용할 수 있도록 하였다.
JDK 11로 실행시킨 Apache HTTP Client 4.5의 stacktrace
1 | at java.base/java.net.SocketInputStream.socketRead0(Native Method) |
JDK 19로 실행시킨 Apache HTTP Client 4.5의 stacktrace
1 | at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:313) |
둘의 가장 큰 차이는 JDK 19에서는 NioSocket을 사용한다는 것이다.
기본적으로 SocketImpl의 구현체로 NioSocketImpl을 사용하는 건 JDK 13부터 변경된 사항이지만, JDK 19부터 Virtual Threads의 지원을 위해 내부에서 VirtualThread의 park 메서드를 호출하고 있다.
1 | public final class NioSocketImpl extends SocketImpl implements PlatformSocketImpl { |
1 | public abstract class Poller { |
1 | public class LockSupport { |
코드의 변경이 없기 때문에 마치 Thread(Carrier Threads/Platform Threads)가 블로킹 될 것처럼 보이지만, 내부를 들여다보면 Virtual Threads만 unmounting 되는 걸 볼 수 있다.
따라서 Carrier Threads에서는 블로킹 없이 다른 연산들을 처리할 수 있게 된다.
스프링 블로그 포스트에서 가장 최신 버전을 기준으로 설명하기 때문에 Spring Boot 3.0.1(Spring 6.0.3)을 기준으로 설명한다.
Spring Boot Starter Web을 사용하게 되면 Embedded Tomcat을 사용하게 되므로 아래와 같이 Virtual Threads를 사용하도록 설정해주면 된다.
1 |
|
쓰레드 풀의 갯수를 신경 쓸 필요 없는 것도 엄청난 장점같다.
그리고 혹시나 MDC도 정상적으로 동작하는지 궁금했다.
Webflux에서는 Context라는 것에 넣어야해서 매우 번거로웠던 기억이 있는데 한 번 MDC에 값을 넣는 인터셉터를 설정해보자.
1 |
|
우리가 위에서 protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor())
로 세팅을 해놨기 때문에 요청을 받아들이는 부분부터 Virtual Threads를 사용하기 때문에 당연히 Interceptor에서도 Virtual Threads를 사용하게 된다.
동일한 Virtual Thread이기 때문에 ThreadLocal 값인 MDC의 값이 그대로 유지되는 걸 볼 수 있다.
1 | 2022-12-29T05:35:27.085+09:00 INFO 12707 --- [ ] c.e.p.controller.MdcController : MDC.a: a |
참고로 Virtual Thread이기 때문에 쓰레드의 이름은 찍히지 않는다.
그리고 아래와 같이 @Async 어노테이션에서 사용할 쓰레드 풀에도 Virtual Thread를 사용할 수 있다.
1 |
|
@Async 어노테이션을 사용했을 때는 MDC가 어떻게 동작하는지 한 번 살펴보자.
Virtual Thread가 달라졌기 때문에 ThreadLocal 값인 MDC의 값은 유지되지 않는 걸 볼 수 있다.
1 | 2022-12-29T05:55:02.257+09:00 INFO 14591 --- [ ] c.e.p.controller.VirtualThreadService : MDC.a: null |
이는 Platform Threads를 사용했을 때도 마찬가지이므로 MDCTaskDecorator 같은 걸 만들면 쉽게 해결할 수 있다.
1 | public class MdcTaskDecorator implements TaskDecorator { |
1 |
|
코틀린 최신 버전인 1.8.0에서 JDK 19를 지원하긴 하지만, JDK 19에서도 Preview Features인 Virtual Threads를 코틀린으로 한 번 더 컴파일까지 거쳐야하면 의도대로 동작한다는 보장이 없어서 아직은 시기상조인 것 같다.
아무리 Virtual Threads가 기존 코드와의 호환성을 최대한 지켰다고는 하지만 100% 호환성을 가진 건 아니다.
코드가 돌아가는 측면에서는 호환성을 지켰을지 몰라도 아래 주의사항 같은 걸 지키지 않으면 시스템에 심각한 성능저하를 유발할 수도 있다.
Coming to Java 19: Virtual threads and platform threads의 마지막 부분인 Three pieces of practical advice 파트를 눈여겨보면 된다.
Virtual Threads를 풀링하지 말고 Semaphore를 사용해라.
Virtual Threads는 생성 비용도 굉장히 싸기 때문에 굳이 풀링할 필요가 없고, 기존 방식대로 풀링하게 되면 Virtual Threads가 아닌 Platform Threads를 아마 사용하게 되는 것 같다.
따라서 만약 풀링해야하는 일이 있다면 Semaphore 방식을 권장하고 있다.
synchronized 키워드를 사용해서 Carrier Thread까지 Blocking 되는 현상(이걸 보고 Thread가 Pinning 됐다고 말하는 듯)을 피해라
Fortunately, future work may make synchronization nonpinning.
다만 추후에는 synchronized 키워드를 사용해도 쓰레드가 pinning 되는 현상은 사라질 듯 하다.
기존의 비동기 모델이 거의 끝판왕이라고 생각하고 성능을 얻으려면 코드의 퀄리티는 포기해야할 줄 알았는데 이렇게 내가 생각한 한계를 깨부시는 아키텍처가 나오는 걸 보면 정말 흥미롭다.
얼른 Virtual Threads를 실무에서 사용할 날이 왔으면 좋겠는데 JEP 428: Structured Concurrency, JEP 429: Scoped Values까지 완료시키려면 한참 멀었나… 싶다.
다음 LTS인 JDK 21(2023/09월에 릴리즈)에 포함되면 참 좋을 것만 같다.
해당 포스트는 아래 링크들을 참고하여 짜집기한 글이므로 보다 자세한 정보들은 아래 링크를 참고하는 걸 추천한다.
⏳maxLifetime
This property controls the maximum lifetime of a connection in the pool.
An in-use connection will never be retired, only when it is closed will it then be removed.
On a connection-by-connection basis, minor negative attenuation is applied to avoid mass-extinction in the pool.
We strongly recommend setting this value, and it should be several seconds shorter than any database or infrastructure imposed connection time limit.
A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the idleTimeout setting.
The minimum allowed value is 30000ms (30 seconds). Default: 1800000 (30 minutes)
커넥션 풀에서 idle 커넥션이 최대 얼마동안 생존할 수 있냐는 설정이다. (단위는 ms, 기본값은 30분, 최소값은 30초, 0으로 설정하면 무제한)
반환될 때마다 idle time은 다시 0으로 초기화 될테니 트래픽이 많이 들어와서 커넥션이 계속 사용되는 서비스라면 이 설정에 의해 커넥션이 종료될 일은 적을 것이다.
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (maxLifeTime: 30000ms(30s)) participant C1 as Connection 1 (current idle time: 20s) participant C2 as Connection 2 (current idle time: 15s) Server ->> Hikari: getConnection() Hikari ->> C1: getConnection() C1 ->> Hikari: Connection 1 Hikari ->> Server: Connection 1 Server ->> Hikari: releaseConnection(Connection 1) Hikari ->> C1: release (reset idle time to 0s)
히카리 CP에서 커넥션을 가져오고 반납하는 과정을 대략적으로 그려보면 위와 같을 것이다.
커넥션 풀에 있는 idle 커넥션을 가져와서 사용하고 반납할 때는 idle time을 다시 0으로 초기화해서 반납하는 것이다.
위와 같은 상황에서 15초가 지났다고 할 때 어떻게 될까…??
sequenceDiagram autonumber participant Hikari as HikariCP (maxLifeTime: 30000ms(30s)) participant C2 as Connection 2 (current idle time: 30s) participant C1 as Connection 1 (current idle time: 15s) Hikari ->> C2: close() C2 ->> Hikari: remove from Connection Pool
HikariCP에서는 내부적으로 maxLifeTime에 도달한 idle conenection을 종료하고 커넥션 풀에서 제거하는 스케쥴러가 돌고 있다.
그리고 커넥션들이 동시에 종료돼서 성능 상 이슈를 유발하는 것을 방지하고자 각 커넥션 사이에 ms 단위의 차이를 두고 순차적으로 종료시키고 있다.
자세한 내용은 다음 블로그에 나와있다.
HikariCP는 test-while-idle과 같은 커넥션 갱신 기능이 없을까?
maxLifeTime을 지나치게 길게 설정했거나 아무런 설정도 하지 않았을 경우에 가끔 아래와 같은 warn 로그를 보게 된다. (주로 트래픽이 없는 어드민 류의 서버에서 종종 발생했던 거 같다.)
1 | hikari-pool - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@1e2db70 (No operations allowed after connection closed.). |
위 로그는 DB에 설정한 wait_timeout(DBMS마다 파라미터 이름은 다를 수 있다.) 보다 maxLifeTime을 길게 줬을 경우 발생할 수 있다. (여기서는 MySQL을 사용한다고 가정하고 설명한다.)
MySQL의 경우 아래와 같이 쿼리를 날려서 wait_timeout을 확인해볼 수 있다. (기본값은 28800(s)로 8시간이다.)
1 | show global variables like 'wait_timeout' |
MySQL의 wait_timeout 기본값은 8시간이고, maxLifeTime의 기본값은 30분이라서 발생하지 않을텐데 MySQL의 wait_timeout이 maxLifeTime 보다 짧을 때 어떻게 동작하는지 알아보자.
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (maxLifeTime: 600000ms(10m)) participant C1 as Connection 1 (current idle time: 5m) participant C2 as Connection 2 (current idle time: 4m) participant MySQL as MySQL (wait_timeout: 300s(5m)) MySQL ->> C1: close Server ->> Hikari: getConnection() Hikari ->> C1: getConnection() C1 ->> Hikari: failed to getConnection (already closed connection) Hikari ->> C1: remove from connection pool Hikari ->> C2: getConnection() C2 ->> Hikari: Connection 2 Hikari ->> Server: Connection 2
MySQL에서는 wait_timeout에 도달한 idle connection을 끊어버린다.
하지만 MySQL은 해당 커넥션을 어떤 어플리케이션에서 사용하는지 모르니 해당 어플리케이션에도 커넥션을 사용하지 말라는 패킷을 보낼 수 없다.
따라서 우리가 짠 어플리케이션에는 HikariCP에 아직도 종료된 커넥션이 남아있는 것이다.
이 때 우리가 해당 커넥션을 풀에서 꺼내려고 하면 warn 로그가 뜨는 것이다.
하지만 warn 로그이기 때문에 유효한 connection을 제대로 획득했다면 서비스 장애로까지 전파되지는 않을 것이다. (connection 획득에 실패했으면 다른 에러로그가 찍혔을 것이다.)
따라서 warn 로그에 나와있는대로 maxLifeTime을 줄여야한다. (네트워크 지연 등등을 고려하여 wait_timeout 보다 2~3초 정도 짧게 잡아주는 걸 권장하는 것으로 알고 있다.)
위 warn 로그에 대한 내용도 다음 블로그에 자세하게 나와있다.
HikariCP Failed to Validate Connection Warning 이야기
⏳connectionTimeout
This property controls the maximum number of milliseconds that a client (that’s you) will wait for a connection from the pool.
If this time is exceeded without a connection becoming available, a SQLException will be thrown.
Lowest acceptable connection timeout is 250 ms. Default: 30000 (30 seconds)
커넥션을 맺는데 걸리는 시간을 의미하며 이 시간은 단순히 하나의 물리적인 커넥션을 맺는데 걸리는 시간을 의미하는 게 아니라 커넥션 풀에서 커넥션을 획득하는데 걸리는 시간을 의미한다.
커넥션 풀에 유효한 커넥션이 없으면 새로운 커넥션을 맺게 되는데 maximumPoolSize 등에 도달하는 등의 상황에 의해 더이상 커넥션을 맺지 못하게 될 가능성이 있다.
이런 상황에 어떻게 되는지 한 번 살펴보자.
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (connectionTimeout: 1100ms(1.1s), maximumPoolSize: 2) participant C1 as Connection 1 (in use) participant C2 as Connection 2 (in use) Server ->> Hikari: getConnection() Hikari ->> Hikari: wait 1.1s untill get available connection Hikari ->> Server: throw SQLException(connection timeout)
유효한 커넥션을 획득하지 못해 DB를 사용하지 못하는 상황이기 때문에 아마 해당 API는 제대로 처리하지 못하고, 이런 상황이 오래 유지되면 전면 장애도 발생할 수 있을 것이다.
이런 상황이 발생하는데는 아래와 같은 이유 등등이 있을 것이다.
⏳validationTimeout
This property controls the maximum amount of time that a connection will be tested for aliveness.
This value must be less than the connectionTimeout.
Lowest acceptable validation timeout is 250 ms. Default: 5000
커넥션의 유효성(사용 가능한 상태인지)을 검사하는데 걸리는 최대 마지노선 시간이라고 보면 된다. (기본값은 5초이고, 최소값은 250ms)
그리고 connectionTimeout 보다 짧게 유지해야한다고 하는데 HikariCP의 커넥션 유효성 검증 전략을 우선 짚고 이해해야한다.
다른 DB Connection Pool에서는 idle connection을 계속 유지하려고 select 1과 같은 무의미한 쿼리를 지속적으로 날려서 커넥션을 유지한다.
HikariCP에서는 이런 것조차 오버헤드(여러 대의 서버에서 여러 커넥션이 주기적으로 쿼리를 날리면 생각보다 오버헤드가 클 수도 있다.)라고 판단하는 듯하다. (관련 이슈: https://github.com/brettwooldridge/HikariCP/issues/766)
따라서 HikariCP에서는 주기적으로 쿼리를 날리지 않는다.
대신 JDBC4 이상의 드라이버를 사용한다면 단순한 validation 패킷 정도만 날리는 것만으로도 커넥션의 유효성을 검사할 수 있다.
아래 상황에 대해서 커넥션의 유효성을 검사한 이후에 아직 커넥션이 끊기지 않은 상태이니 사용 가능한 커넥션이라 판단해서 풀에 남기던지, 아니면 제거하던지 하게 된다.
참고로 validationTimeout이 발생하면 Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@63123dfa (No operations allowed after connection closed.). Possibly consider using a shorter maxLifetime value.
요런 warn 로그가 발생하곤 한다.
해당 로그 이후에 커넥션 획득에 실패했다는 에러 로그 같은 게 남지 않았으면 서비스가 장애까지 이어지지 않았다고 판단하면 된다.
저 로그 이후 connectionTimeout 이내에 유효한 커넥션을 획득했으니 에러로그가 남지 않았을 거다.
validationTimeout은 정말 작은 패킷을 주고 받기 때문에 어지간해서 발생하지 않는데 아래 상황에 발생할 수 있다.
다만 warn이기 때문에 너무 자주 발생하는 게 아니면 즉각 조치가 필요하지는 않을 것 같고, noise라고 느껴질 정도로 과하게 느껴지면 connectionTimeout과 validationTimeout을 함께 조금씩 늘려보는 것도 고려해보아야한다.
근데 connectionTimeout과 validationTimeout은 어떤 상관관계가 있기에 validationTimeout을 더 짧게 설정하라는 걸까?
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (connectionTimeout: 1100ms(1.1s), validationTimeout: 1100ms(1.1s)) participant C1 as Connection 1 (idle) participant C2 as Connection 2 (idle) Server ->> Hikari: getConnection() Hikari ->> C1: getConnection() C1 ->> Hikari: after validationTimeout(1.1s), failed to getConnection (Failed to validate connection) Hikari ->> C1: remove from connection pool Hikari ->> Server: throw SQLException(connection timeout)
validationTimeout이 connectionTimeout 보다 짧지 않기 때문에 오롯이 커넥션 하나가 살아있는지 확인하느라 시간을 다 쓸 수 있다.
빠르게 validation을 멈추고 다음 커넥션 획득을 시도했더라면 성공할 수도 있지 않았을까…??
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (connectionTimeout: 1100ms(1.1s), validationTimeout: 1000ms(1s)) participant C1 as Connection 1 (idle) participant C2 as Connection 2 (idle) Server ->> Hikari: getConnection() Hikari ->> C1: getConnection() C1 ->> Hikari: after validationTimeout(1s), failed to getConnection (Failed to validate connection) Hikari ->> C1: remove from connection pool Hikari ->> C2: getConnection() C2 ->> Hikari: Connection 2 Hikari ->> Server: Connection 2
위에 얘기했던대로 validationTimeout을 너무 과하게 잡아서 다른 커넥션 획득 시도의 기회조차 잃어버릴 수 있기 때문에
이를 방지하고자 validationTimeout이 connectionTimeout 보다 짧게 설정하면 커넥션 획득 시도를 여러 번 할 수 있기 때문에 장애를 방지할 수도 있다.
하지만 그렇다고 해서 connectionTimeout을 너무 길게 잡으면 우리 시스템은 온전히 처리했지만 클라이언트 측 시스템에서 Read Timeout이 발생할 수 있으니 이 부분은 상황에 맞게 설정해야한다.
⏳keepaliveTime
This property controls how frequently HikariCP will attempt to keep a connection alive, in order to prevent it from being timed out by the database or network infrastructure.
This value must be less than the maxLifetime value.
A “keepalive” will only occur on an idle connection.
When the time arrives for a “keepalive” against a given connection, that connection will be removed from the pool, “pinged”, and then returned to the pool.
The ‘ping’ is one of either: invocation of the JDBC4 isValid() method, or execution of the connectionTestQuery.
Typically, the duration out-of-the-pool should be measured in single digit milliseconds or even sub-millisecond, and therefore should have little or no noticible performance impact.
The minimum allowed value is 30000ms (30 seconds), but a value in the range of minutes is most desirable. Default: 0 (disabled)
idle connection에 대해서 keepaliveTime에 도달하면 주기적으로 커넥션의 유효성을 검증한다. (최소값은 30분, 기본값은 비활성화(0)이다.)
커넥션이 살아있다고 해도 idle time이 0으로 초기화 되는 건 아니고 그냥 커넥션이 잘 살아있는지 확인하는 것 뿐이다.
idle connection은 다양한 사유에 의해 DB 서버로부터 먼저 커넥션이 끊길 수 있다.
위와 같이 DB 서버에서 먼저 커넥션을 끊은 경우 아래와 같은 오버헤드가 발생할 수 있다.
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (keepaliveTime: 0(disabled)) participant C1 as Connection 1 (closed) participant C2 as Connection 2 (idle) Server ->> Hikari: getConnection() Hikari ->> C1: getConnection() C1 ->> Hikari: failed to getConnection (already closed connection) Hikari ->> C1: remove from connection pool Hikari ->> C2: getConnection() C2 ->> Hikari: Connection 2 Hikari ->> Server: Connection 2
Connection 1은 이미 종료됐기 때문에 굳이 커넥션 획득을 할 필요가 없었다.
하지만 HikariCP는 그 사실을 모르기 때문에 Connection 1 획득 절차가 끼어들게 되고 이 시간만큼 지연이 발생해서 혹시 connectionTimeout이 발생한다고 하면 장애가 발생할 수도 있다.
keepaliveTime을 설정하지 않았을 때의 문제는 커넥션이 종료됐다는 사실을 트래픽을 받은 시점에 알게 된다는 것이다.
트래픽을 받았을 때는 최대한 빠른 응답성을 보장해야하는데 저런 자잘한 것들로 인해 빠른 응답성을 보장하지 못하거나 장애를 유발할 수도 있게 된다. (간헐적일 수도 있겠지만…)
그럼 keepaliveTime을 설정했을 때 어떤 장점이 있는지 알아보자.
sequenceDiagram autonumber participant Server participant Hikari as HikariCP (keepaliveTime: 30000ms (30s)) participant C1 as Connection 1 (closed) participant C2 as Connection 2 (idle) Hikari ->> C1: isConnectionAlive (when keepaliveTime is up) C1 ->> Hikari: Failed to validate connection (already closed connection) Hikari ->> C1: remove from connection pool Server ->> Hikari: getConnection() Hikari ->> C2: getConnection() C2 ->> Hikari: Connection 2 Hikari ->> Server: Connection 2
트래픽을 받기 전부터 Connection 1의 종료사실을 인지하고 커넥션 풀에서 제거했기 때문에 트래픽이 들어왔을 때는 바로 유효한 커넥션인 Conenction 2부터 획득을 시도했다.
사소하고 찰나의 시간으로 인식할 수도 있지만 대용량 트래픽에서 이런 것들이 쌓이게 됐을 때 힘을 발휘할 수 있을 것 같다.
또한 DB에 뭔가 문제가 있다는 상황, 혹은 네트워크가 불안정하다는 상황을 트래픽을 받은 시점이 아닌 미리 파악할 수 있다는 장점도 존재한다.
서버가 엄청 많이 떠있는 서버는 keepaliveTime을 너무 짧게 설정하면 오히려 그게 오버헤드를 유발할 수도 있기 때문에 적절한 튜닝이 필요한 것 같다.
처음에는 maxLifeTime과 connectionTimeout 정도만 신경썼는데 DevOps(SRE 겸임) 개발자 분께서 올려주신 PR을 보고 저 설정은 도대체 무엇일까… 하고 고민하면서
질문하고 공부하면서 정리한 내용이 머릿속으로는 어느정도 있었는데 그림으로 한 번 그려보니 어떤 상황에 문제가 있고 어떤 문제를 해결하는지 좀 더 명쾌해진 것 같다.
아직 보지도 못한 설정들도 많을텐데 이런식으로 정복해나가면 그래도 조금이나마 더 나은 엔지니어가 되지 않을까? 싶다.
그리고 문득 시간이 지나 해당 아티클을 다시 보고 싶어졌다. (물론 해당 아티클은 테스트 주도 개발로 배우는 객체 지향 설계와 실천 책의 후기에 한글로 적혀있다.)
아티클을 보던 중에 후반 부분에 Mock과 관련해서 Mock Roles, not Objects라는 논문까지 썼다는 걸 보고 해당 논문까지 봐야 Mock에 대해 정확한 이해를 할 수 있을 것 같아 해당 논문을 보게 되었다.
그리고 목의 역사와 마찬가지로 해당 논문도 너무나 감명이 깊어 한 번 느낀점이나 내용을 정리해보고 싶었다.
먼저 TDD(Test Driven Development)에는 크게 두 가지 관점이 존재할 것 같다.
첫 번째로 “검증”이다.
코드들이 의도대로 동작하는지, 버그는 없는지 검증하는 것이다.
이를 통해 프로덕션에 코드를 내보내도 된다는 자신감이 올라가고, 리팩토링을 하거나 신규 기능을 추가하더라도 코드의 동작은 변하지 않았음에 확신을 가질 수 있다.
두 번째로 “설계”이다.
나의 코드를 검증하려고 테스트를 짜려고 하는데 테스트를 짜기가 힘들다면 “Code Smell”이 난다고 할 수 있다. (코드에 뭔가 구린 내가 나는 것이다.)
이를 통해 적절한 책임을 가진 객체로 쪼갬으로 인해 더 나은 설계로 유도해낼 수 있을 것이다.
나는 첫 번째 목적인 검증에 좀 더 집중했다.
더 나은 설계도 물론 중요하지만 일단 버그가 없는 게 “1순위”라고 생각했기 때문이다.
하지만 그러다보니 TDD를 하려고 할 때, 즉 신규 기능을 구현할 때 테스트를 먼저 짜려고 할 때 어디서부터 코드를 짜야할지, 뭘 테스트해야할지 막막했다.
아직 검증할 게 없는데 뭘 검증한단 말인가?
그래서 번번이 테스트부터 짜보겠다는 실패로 돌아가고, 코드를 짠 후에 내가 코드를 제대로 짰는지 검증하는 목적으로 테스트를 나중에 작성했다.
나의 이런 TDD는 접근 방법부터 잘못됐던 것 같다.
Test “Driven” Development면 테스트가 (나의 어플리케이션 코드를) 주도해야하는데 전혀 주도하고 있지 못했다.
자꾸 나의 어플리케이션 코드를 테스트에 끼워맞출 생각(어떻게 이 부분을 검증할까)만 하고 있다보니 단순히 “테스트만 먼저 짜면 TDD다”라고 생각했던 것 같다.
물론 “검증” 또한 포기할 수 없는 부분이다.
하지만 여기에 너무 매몰되다보니 결국 테스트가 나의 코드를 주도하게 하지 못하게 됐던 것 같다.
그러다보니 이렇게 논문까지 쓰고 TDD의 대가라고 부를 법한 사람들은 무슨 생각을 가지고 실제로 TDD를 어떻게 수행하는지 너무나 궁금했다.
논문을 보니 내가 생각했던 관점과 완전 다른 관점을 가지고 있었다.
Writing tests is a design activity,
…
This changes design from a process of invention,
where the developer thinks hard about what a unit of code should do and then implements it,
to a process of discovery,
where the developer adds small increments of functionality and then extracts structure from the working code.
…
Using TDD has many benefits but the most relevant is that
it directs the programmer to think about the design of code from its intended use,
rather than from its implementation.
테스트를 작성하는 것은 Design Activity(설계 행위)이며,
Design Activity는 design(코드의 설계)을 발명(invention)의 과정(어떤 코드가 무엇을 해야하고 어떻게 구현해야할지)에서 발견(discovery)의 과정(조그만 기능을 추가하고 동작하는 코드로부터 구조를 추출하는)으로 바꾼다고 설명하고 있다.
번역이 매끄럽지는 않지만 대충 어떤 뉘앙스인지 생각해보면 발명이라는 건 어떤 걸 만들어야겠다는 명확한 목표를 가지고 있는 것이고, 발견이라는 것은 명확한 목표를 가지고 있기 보다는 추상적인 무언가를 떠오르고 탐색하던 과정 중에 튀어나오는 것이 아닐까 싶다.
따라서 전자의 관점으로 설계를 하게 되면 어떤 일을 어떻게 해야하는 객체들이 명확하게 정의돼있다보니 설계가 매우 딱딱하게 강결합이 될 것이다.
이는 코드의 구조를 바꾸기 어렵다는 것을 뜻하며 구체적인 타입으로 확정짓는 것을 뜻하는 것 같다.
하지만 후자의 관점으로 설계를 하게 되면 무엇을 만들긴 해야하는데 아직 어떻게 해야하는지 명확하게 정의된 게 없다보니 두루뭉실하게 “이런 객체가 필요하지 않을까?”라는 작은 발견의 과정(작은 기능의 추가)을 반복해서 수행해나가다보니 설계가 유연하게 약결합 될 것이다.
이는 코드의 구조를 바꾸기 쉽다는 것을 뜻하며 추상적인 타입(인터페이스)를 사용한다는 것을 뜻하는 것 같다.
그리고 TDD에는 많은 이점들이 존재하지만 가장 중요한 건 개발자의 사고를 (구체적인) 구현이 아니라 코드의 설계에 대해 생각하도록 “지시”한다는 것이라고 한다.
이러한 지시를 통해 내 코드가 테스트에 의해 “주도”되는 것이 아닐까 싶다.
즉, 테스트가 내 코드를 주도하려면(테스트에 의한 개발이 되려면) 이러한 지시를 따라야하고, 이는 검증이 아닌 “설계”를 테스트의 주된 목적이라고 생각해야 달성 가능한 목표같다.
그럼 TDD가 Design Activity라는 것은 알았는데 어떻게 더 나은 설계를 만들 것인가…?
나는 그 답은 Need-Driven Development(Top-Down Development)를 통해 달성할 수 있다고 믿는다.
A core principle of Lean Development is that value should be pulled into existence from demand,
rather than pushed from implementation:
“The effect of ‘pull’ is that production is not based on forecast;
commitment is delayed until demand is present to indicate what the customer really wants.”
영어를 잘 하지 못해 뉘앙스를 정확히 이해한 건지는 모르겠지만, Lean Development(개발 프로세스에서 비효율 적인 부분을 제거한 방법론 정도로 알고 있다.)의 핵심 원칙은 존재하는 요구사항(existence from demand)에서 가치를 뽑아와야한다(be pulled)는 것 같다.
예측에 의해 뭔가를 만들어내기 보다는 “고객이 정말로 원하는 것”이 실제로 나타낼 때까지 commitment(뭔가를 확정짓는…?)를 계속 뒤로 미루라는 것 같다.
고객도 자신이 무엇을 원하는지 정확히 모르기 때문에 요구사항이 명확해질 때까지 계속 요구사항을 명쾌하게 만들어나가는 과정이 필요한 것 같다.
“혹시 이런 기능도 필요하지 않을까?”라고 생각하다 보면 위와 같은 형태의 소프트웨어가 나오게 될 가능성이 높을 것이다.
결국 “정말 필요한 기능”만 개발하라는 것인데 어떻게 해야하는 것일까…??
By testing an object in isolation,
the programmer is forced to consider an object’s interactions with its collaborators in the abstract,
possibly before those collaborators exist.
TDD with Mock Objects guides interface design by the services that an object requires,
not just those it provides.
This process results in a system of narrow interfaces each of which defines a role in an interaction between objects,
rather than wide interfaces that describe all the features provided by a class.
We call this approach Need-Driven Development.
단위 테스트에서 개발자는 객체와 협력객체 사이의 상호작용을 신경쓰도록 강요된다.
그것또한 추상적인 레벨에서 이루어지는데 왜냐하면 협력 객체들은 아직 존재도 하기 전이기 때문이다.
왜냐하면 TDD에 의해 테스트를 먼저 작성하고, Need-Driven Development에 의해 불필요한 객체는 아직 생성도 하기 전이기 때문이다.
이 시점에서 협력 객체는 커녕 아직 테스트하고자 하는 객체도 만들어지기 이전이다.
Mock Object로 TDD를 하는 건 단순히 객체가 제공하는 것 뿐만이 아니라 해당 객체가 필요로 하는 요구사항에 의해 인터페이스 설계를 가이드 한다.
이런 과정을 통해 narrow 인터페이스를 가진 시스템이 나오게 된다.
여기서 narrow한 인터페이스란 건 객체 사이의 상호작용에서 특정 역할만 수행한다는 것을 의미한다는 것 같다.
반면에 wide한 인터페이스는 여기저기서 사용할 수 있는 걸 뜻하며 하는 일이 굉장히 많은 객체를 뜻하는 것 같다.
이런 접근법을 Need-Driven Devlopment라고 부른다는데 좀 더 자세한 예시는 아래 나오게 된다.
To fulfil the needs of A, we discover that it needs a service S.
While testing A, we mock the responsibilities of S without defining a concrete implementation.
A의 요구사항을 만족시키기 위해 S라는 서비스가 필요하다는 걸 발견(discovery)하게 된다.
이 때 S의 구체적인 구현을 정의하는 것이 아니라 해당 책임을 모킹한다.
Once we have implemented A to satisfy its requirements we can switch focus and implement an object that performs the role of S
…
This process will then discover services required by B,
which we again mock out until we have finished our implementation of B
A의 요구사항 구현이 모두 끝났으면 이제 S의 “역할”을 수행하는 객체를 구현하는 것에 집중할 수 있다.
이제 B(S의 역할을 수행하는)가 필요로하는 서비스를 발견하는 절차들이 진행된다.
그리고 B의 구현이 끝날 때까지 그런 서비스들은 모킹하게 된다.
Our experience is that systems we produce this way tend towards very flat class hierarchies.
This avoids well-known problems, such as the Fragile Base Class,
which make systems harder to understand and modify.
이런 식으로 필요한 객체와 역할들을 발견(탐색)해 나가는 과정에서 매우 flat한 클래스 계층이 나온다고 한다.
이는 깨지기 쉬운(Fragile) Base Class 문제를 피할 수도 있다.
많은 클래스들이 해당 클래스에 의존하는 구조가 아니라 인접한 객체끼리만 관계를 맺고 있기 때문이 아닐까 싶다.
또한 상속이 아닌 인터페이스를 사용하고 있기 때문에 인터페이스가 바뀌지 않는 한 부모 객체에 영향을 받지 않는 것도 한 몫 하는 것 같다. (자바8의 인터페이스는 부모 인터페이스에 기본 구현체(default method)가 존재할 수 있긴 하지만…)
This process is similar to traditional Top-Down Development,
in which the programmer starts at the highest level of abstraction and proceeds, layer by layer, to fill in the detail.
The intention is that each layer of code is written in a coherent terminology,
defined in terms of the next level of abstraction
Need-Driven Development는 전통적인 Top-Down Development와도 유사한데
Top-Down Development에서는 가장 높은 레벨의 추상화에서 시작해서 구체적인 내용을 구현하기 위해 계층 별로 접근하게 된다.
각 계층의 코드는 다음 단계의 추상화에 정의된 “일관된 용어”로 작성된다.
핵심은 “일관된 용어로 정의된다”는 것에 있는 것 같다.
이는 일관된 추상화 수준을 뜻하는 게 아닐까?
Programming from the Bottom-Up has different risks.
All the authors have had the experience of developing a supporting class in isolation,
as part of a larger task,
only to find that the result was not right because we had misunderstood something.
반면 Bottom-up으로 프로그래밍 하는 건 다른 리스크를 가지고 있다. (논문에서 Top-Down은 각 레이어에서 중복을 야기한다는 리스크를 명시하고 있다.)
독립된 환경에서 개발해봤는데 뭔가 잘못 이해하고 개발한 게 있어서 결과가 올바르지 않은 걸 발견했다는 것이다.
아마 이게 필요하지 않을까? 나는 이거까지 책임져야하지 않을까? 하고 예측을 기반으로 각자 개발을 하다보니 나중에 객체 간의 협력을 해야할 때 뭔가 미묘하게 안 맞는 부분이 계속 생겼던 게 아닐까 싶다.
Need-Driven Development이라는 아티클을 보면 Need-Driven Development(Top-Down)과 Bottom-up의 차이가 좀 더 명확히 느껴질 것이다.
TDD는 단순히 테스트를 “먼저” 작성하는 게 아니라 테스트가 내 코드를 “주도”하게 만들어야하는 것 같다.
그럼 좀 더 테스트가 막강한 권력을 가지고 테스트가 내 코드를 어떠한 길로 인도(guide)해야하는 것 같다.
좀 더 강하게는 강제(force)하거나 지시(direct)를 내려야하는 것 같다.
나는 멍청하고 테스트가 내 코드가 어떻게 구현해야하는지 명령을 내리는 것이다.
그런 관점에서 보면 단순 “검증”만으로는 뭔가 부족했던 것 같다.
그걸 모르고 계속 TDD 거리니 매번 실패 했던 게 아닐까 싶다.
해당 논문을 읽고 나니 “Design Activity”가 무엇인지, 요구사항이 왜 중요한 것인지 좀 더 알게 된 거 같다.
]]>적다보니 글이 길어져 글을 나누었는데 해당 글을 읽기 전에 (Tomcat) ClientAbortException은 왜 발생할까? (Part 1)을 먼저 보는 것을 추천함.
https://tomcat.apache.org/tomcat-9.0-doc/api/org/apache/catalina/connector/ClientAbortException.html
Extend IOException to identify it as being caused by an abort of a request by a remote client.
외부 클라이언트 측에서 요청을 abort(중단) 시켰을 때 발생하는 예외로 보인다.
톰캣에서 발생시키는 예외인데 Spring Boot의 Web(Mvc) 모듈에서 기본적으로 사용하는 게 Embedded Tomcat이기 때문에 많은 분들께서 자주 마주치지 않았을까… 싶다.
구글링 해보면 뭐 브라우저 이슈(API 응답이 오기 전에 새로고침을 했다던가, 뒤로가기를 했다던가 등등)니 뭐니 하는데 내가 겪은 상황은 server → server 통신에서 발생한 것이기 때문에 서버 간의 통신 관점에서만 파보았다.
삽질을 해보고 싶은 사람은 https://github.com/perfectacle/client-abort-exception-playground 을 clone 하면 된다.
그리고 ClientAbortException이 발생해도 스프링에서 기본적으로 HandlerExceptionResolver에서 예외를 핸들링하기 때문에 로그에는 아무것도 남지 않는다.
따라서 해당 에러가 발생하는지 에러 로그로 명확히 확인해보는 게 훨씬 직관적이기 때문에 아래 @RestControllerAdvice를 추가했다.
1 |
|
서버 측에서 응답 패킷을 보내면 ClientAbortException이 발생한다
.1 | mac (60_000ms) |
1 | mac (2 * msl = 2 * 15_000ms = 30_000ms) |
서버 쪽 API에서 큰 응답을 준다고 가정
1 |
|
클라이언트 측은 응답을 받다가 끊어야하기 때문에 리드 타임아웃 설정을 25ms로 매우 짧게 설정함.
1 | fun main() { |
1 | 06:56:05.670 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!done!do" |
06:56:05.670 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "e!done!done!done!done!done!done!done!done!done!done!don...
)06:56:05.670 [main] DEBUG org.apache.http.impl.conn.PoolingHttpClientConnectionManager - Connection released: [id: 0][route: {}->http://localhost:8080][total available: 1; route allocated: 1 of 100; total allocated: 1 of 100]
)1 | 2022-03-20 06:56:05.568 INFO 86561 --- [nio-8080-exec-1] c.e.playground.LargeResponseController : request is arrived! |
1 | 2312022-03-20 06:56:05.565966127.0.0.1127.0.0.1TCP6851612 → 8080 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=64 TSval=4237975396 TSecr=0 SACK_PERM=1 |
68
329에서 Read Timeout으로 설정한 25
ms 이전에 도착함 (06:56:05.582
682)68
329 시점부터 계산해보면 06:56:05.593
329이다.653
137으로 실제로는 ACK를 받은 시점으로부터 85ms가 흘렀다.서버 쪽 API에서 각 응답을 주는 패킷의 지연시간이 제각각이라고 가정
1 |
|
클라이언트 측은 서버 측 응답 패킷의 최대 지연 시간보다 짧게 Read Timeout을 설정함.
1 | fun main() { |
1 | 10:04:13.248 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET http://localhost:8080/stream |
10:04:13.294 [main] DEBUG org.apache.http.impl.conn.DefaultHttpClientConnectionOperator - Connection established 127.0.0.1:55447<->127.0.0.1:8080
)10:04:13.295 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> ...
)10:04:13.401 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "hello, once[\r][\n]"
)10:04:13.507 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "hello, twice[\r][\n]"
)10:04:13.712 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[read] I/O error: Read timed out"
)10:04:13.712 [main] DEBUG org.apache.http.impl.conn.DefaultManagedHttpClientConnection - http-outgoing-0: Close connection
)1 | 2022-03-20 10:04:13.401 INFO 54891 --- [pool-1-thread-3] c.e.playground.StreamResponseController : hello, once |
ClientAbortException(java.io.IOException: Broken pipe)
발생워낙 순식간에 지나가서 netstat으로 소켓의 상태는 관찰하지 못함.
1 | 1742022-03-20 10:04:13.293423127.0.0.1127.0.0.1TCP6855447 → 8080 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=64 TSval=811634482 TSecr=0 SACK_PERM=1 |
295
402에서 Read Timeout으로 설정한 200
ms 이전에 도착함 (10:04:13.401
563)401
563에서 Read Timeout으로 설정한 200
ms 이전에 도착함 (10:04:13.506
841)506
841에서 Read Timeout으로 설정한 200
ms이 넘도록 아무런 패킷이 오지 않아 Read Timeout이 발생해서 서버 측에 FIN/ACK 패킷을 날려서 커넥션을 종료할 준비를 하고 있음. (10:04:13.713
445)012
150, 마지막 응답 패킷(hello, twice)를 보낸 10:04:13.506
841에서 500ms가 지난 시점임.)적다보니 글이 길어져 글을 나누었는데 해당 글을 읽고 난 후에 (Tomcat) ClientAbortException은 왜 발생할까? (Part 2)를 마저 보는 것을 추천함.
https://tomcat.apache.org/tomcat-9.0-doc/api/org/apache/catalina/connector/ClientAbortException.html
Extend IOException to identify it as being caused by an abort of a request by a remote client.
외부 클라이언트 측에서 요청을 abort(중단) 시켰을 때 발생하는 예외로 보인다.
톰캣에서 발생시키는 예외인데 Spring Boot의 Web(Mvc) 모듈에서 기본적으로 사용하는 게 Embedded Tomcat이기 때문에 많은 분들께서 자주 마주치지 않았을까… 싶다.
구글링 해보면 뭐 브라우저 이슈(API 응답이 오기 전에 새로고침을 했다던가, 뒤로가기를 했다던가 등등)니 뭐니 하는데 내가 겪은 상황은 server → server 통신에서 발생한 것이기 때문에 서버 간의 통신 관점에서만 파보았다.
삽질을 해보고 싶은 사람은 https://github.com/perfectacle/client-abort-exception-playground 을 clone 하면 된다.
그리고 ClientAbortException이 발생해도 스프링에서 기본적으로 HandlerExceptionResolver에서 예외를 핸들링하기 때문에 로그에는 아무것도 남지 않는다.
따라서 해당 에러가 발생하는지 에러 로그로 명확히 확인해보는 게 훨씬 직관적이기 때문에 아래 @RestControllerAdvice를 추가했다.
1 |
|
1 | mac (60_000ms) |
서버 측에서 응답 패킷을 보내려다가 ClientAbortException이 발생한다
.서버 측에서 응답 패킷을 보내려다가 ClientAbortException이 발생한다
.서버 쪽 API에서 처리하는데 10초 소요되고, 매우 작은 문자열을 응답한다고 가정
1 |
|
클라이언트 측 리드 타임아웃 설정은 3초로 서버 쪽 처리 시간보다 더 짧게 설정함
1 | fun main() { |
1 | 21:15:26.241 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET http://localhost:8080/slow |
21:15:26.289 [main] DEBUG org.apache.http.impl.conn.DefaultHttpClientConnectionOperator - Connection established 127.0.0.1:60596<->127.0.0.1:8080
)21:15:26.290 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> ...
)21:15:29.296 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[read] I/O error: Read timed out"
)21:15:29.296 [main] DEBUG org.apache.http.impl.conn.DefaultManagedHttpClientConnection - http-outgoing-0: Close connection
)1 | 2022-03-19 21:15:26.291 INFO 8275 --- [nio-8080-exec-3] c.e.s.SlowResponseController : request is arrived! |
아무런 에러 로그를 출력하지 않기 때문에 서버 측에서는 클라이언트가 올바르게 응답을 처리했는지 알 방법이 없다.
1 | 2392022-03-19 21:15:26.289278127.0.0.1127.0.0.1TCP6860596 → 8080 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=64 TSval=3550708683 TSecr=0 SACK_PERM=1 |
클라이언트 측에서는 소켓 연결 종료를 준비하고 있었는데 서버 측으로부터 의도치 않은 HTTP 패킷이 왔기 때문에 더 이상 패킷을 받을 수 없다는 RST 패킷을 전송한 것으로 보임. (원래는 서버에서 FIN 패킷을 한 번 보내주고 클라이언트가 다시 ACK 패킷을 보내서 소켓을 종료해야한다.)When an unexpected TCP packet arrives at a host, that host usually responds by sending a reset packet back on the same connection. A reset packet is simply one with no payload and with the
RST
bit set in the TCP header flags.
출처: https://www.pico.net/kb/what-is-a-tcp-reset-rst/
서버 쪽 API에서 처리하는데 10초 소요되고, 매우 큰 문자열을 응답한다고 가정
1 |
|
클라이언트 측 리드 타임아웃 설정은 3초로 서버 쪽 처리 시간보다 더 짧게 설정함
1 | fun main() { |
1 | 05:19:22.860 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET http://localhost:8080/slow-large |
05:19:22.906 [main] DEBUG org.apache.http.impl.conn.DefaultHttpClientConnectionOperator - Connection established 127.0.0.1:50422<->127.0.0.1:8080
)05:19:22.907 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> ...
)05:19:25.913 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[read] I/O error: Read timed out"
)05:19:25.913 [main] DEBUG org.apache.http.impl.conn.DefaultManagedHttpClientConnection - http-outgoing-0: Close connection
)1 | 2022-03-20 05:19:22.908 INFO 63172 --- [nio-8080-exec-2] c.e.p.SlowLargeResponseController : request is arrived! |
ClientAbortException(java.io.IOException: Broken pipe)
발생1 | 1432022-03-20 05:19:22.906081127.0.0.1127.0.0.1TCP6850422 → 8080 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=64 TSval=3840422295 TSecr=0 SACK_PERM=1 |
현재 시퀀스 넘버(1)
와 다음 패킷의 시퀀스 넘버(8193)
를 알려주고 있음.현재 패킷의 시퀀스 넘버는 8193
이니 163번 패킷의 다음 패킷이고, 그 다음 패킷의 시퀀스 넘버는 16385
임. (05:19:32.935668)현재 패킷의 시퀀스 넘버는 16385
이니 164번 패킷의 다음 패킷이고, 그 다음 패킷의 시퀀스 넘버는 24577
임. (05:19:32.935695)서버 쪽 API에서 처리하는데 70초 소요된다고 가정
1 |
|
클라이언트 측 리드 타임아웃 설정은 3초로 서버 쪽 처리 시간보다 훨씬 짧게 설정함
1 | fun main() { |
1 | 23:43:15.010 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET http://localhost:8080/very-slow |
23:43:15.053 [main] DEBUG org.apache.http.impl.conn.DefaultHttpClientConnectionOperator - Connection established 127.0.0.1:62960<->127.0.0.1:8080
)23:43:15.054 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> ...
)23:43:18.058 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[read] I/O error: Read timed out"
)23:43:18.059 [main] DEBUG org.apache.http.impl.conn.DefaultManagedHttpClientConnection - http-outgoing-0: Close connection
)1 | 2022-03-19 23:43:15.055 INFO 47262 --- [nio-8080-exec-4] c.e.s.VerySlowResponseController : request is arrived! |
ClientAbortException(java.io.IOException: Broken pipe)
발생1 | 972022-03-19 23:43:15.053056127.0.0.1127.0.0.1TCP6862960 → 8080 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=64 TSval=2309492195 TSecr=0 SACK_PERM=1 |
(Gradle) implementation vs api에서는 compile/runtime 의존성을 관리하는 방법에 대해 정리했다.
하지만 이는 실제 src/main 경로에 대해서만 의존성을 관리하는 것이지 src/test 경로에서 사용하는 테스트 의존성(testCompileClasspath, testRuntimeClasspath)에 대해서는 딥하게 다루지 않았다.
테스트도 관리해야할 대상이고 하나의 소프트웨어라는 관점에서 테스트의 의존성 조차도 신경을 써줘야한다.
기본적으로 implementation과 api로 의존성을 추가한 경우에도 testCompileClasspath, testRuntimeClasspath에 추가돼서 테스트에서도 사용이 가능하다.
하지만 compileClasspath, runtimeClasspath에도 추가되다보니 실제 프로덕션에서는 사용할 필요가 없고, 테스트에서만 사용할 목적으로 testImplementation을 많이 사용한다.
testImplementation으로 의존성을 관리하기 위해서는 java 플러그인을 활성화해야한다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (java는 gradle core 플러그인이기 때문에 plugins 모듈에 별도로 정의 안 하고도 서브 모듈들에게 적용이 가능하다.)
1 | subprojects { |
build.gradle.kts에서는 kotlin jvm 플러그인만 활성화 시켜주면 된다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (kotlin jvm 플러그인은 gradle core 플러그인이 아니기 때문에 plugins 모듈에 별도로 정의 해놔야 서브 모듈들에게 적용이 가능하다.)
1 | plugins { |
그리고 의존성 추가를 위해 build.gradle(or build.gradle.kts)에 아래와 같이 디펜던시들을 추가하게 된다.
1 | dependencies { |
실제로 gradle dependency를 보게되면 compileClasspath와 runtimeClasspath에는 추가되지 않고, testCompileClasspath와 testRuntimeClasspath에만 추가된 걸 볼 수 있다.
testCompileClasspath에 추가됐기 때문에 src/test에서 junit 5를 사용할 수 있다.
compileClasspath에 추가되지 않았기 때문에 src/main에서는 junit 5를 사용할 수 없다.
단일 모듈의 경우에는 testImplementation, testCompileOnly, testRuntimeOnly 정도로 테스트 전용으로 의존성을 관리할 수 있다.
하지만 멀티 모듈의 경우에는 조금 복잡해진다.
프로젝트에 두 가지 모듈(producer와 consumer)을 만들어서 차이점을 확인해보자.
producer는 모듈을 제공하는 측이고, consumer는 모듈을 소비하는 측이다.
따라서 consumer 모듈의 build.gradle(또는 build.gradle.kts)를 보면 producer 모듈에 의존하는 모습을 볼 수 있다.
1 | dependencies { |
이 때 producer 모듈에 기본 생성자는 없고, 생성자에 인자가 많아서 생성하기 번거로운 클래스가 있다고 가정해보자.
1 | public class Some { |
테스트에서 생성자에 모든 인자를 넣어서 매번 생성하기 번거로우므로 아래와 같은 빌더를 src/test 하위 경로에 만들자.
1 | public final class SomeBuilder { |
이제 내가 원하는 인자들만 설정하고 나머지는 빌더에 설정된 기본값을 사용하여 테스트에서 쉽게 해당 객체를 찍어낼 수 있다.
1 | public class SomeTest { |
producer 모듈에서 테스트 작성 시에 이렇게 SomeBuilder를 통해 원하는 객체를 원하는 모양으로 쉽게 찍어낼 수 있었는데 consumer 모듈에서는 어떨까??
consumer 모듈에서는 producer 모듈의 src/main에 있는 Some 클래스에는 접근이 가능한데 src/test에 있는 SomeBuilder 클래스에는 접근이 안 된다.
왜 그런 걸까??
우선 프로젝트를 빌드해보자.
1 | ./gradlew build |
그리고 나서 producer 모듈의 빌드된 jar를 까보면 src/main에 있는 Some 클래스만 존재하는 걸 볼 수 있다.
애초에 jar 파일에 SomeBuilder가 존재하지 않기 때문에 consumer 모듈에서는 접근조차 불가능한 것이다.
그럼 문제를 어떻게 해결해야할까?
가장 간단한 방법은 consumer 모듈의 src/test에도 똑같이 SomeBuilder 파일을 복붙하는 방법이다.
하지만 IDE의 리팩토링 기능으로 관리가 되지 않기 때문에 동일한 파일을 두 번 관리해야한다는 매우 비효율을 낳게 된다.
src/main에 있는 파일만 jar로 뽑듯이 src/test에 있는 파일들도 jar로 뽑아내기 위해 gradle에는 java-test-fixtures라는 플러그인이 존재한다.
먼저 src/test 하위 경로에 있는 파일들을 노출시키고 싶은 producer 모듈의 build.gradle 파일에 java-test-fixtures 플러그인을 추가해주자.
1 | plugins { |
build.gradle.kts 같은 경우에는 아래와 같이 추가하면 된다.
1 | plugins { |
플러그인을 추가한 후 producer 모듈에 새로운 경로를 추가하려고 하면 인텔리제이와 같은 IDE에서 testFixtures 경로를 자동으로 추천해주게 된다.
그리고 testFixtures 하위에 있는 파일들은 아래와 같은 특징을 가진다.
java-test-fixtures 플러그인을 추가하기 전에는 testCompileClasspath와 testRuntimeClasspath만 존재하고, junit 5만 의존성으로 가지고 있는 모습이다.
java-test-fixtures 플러그인을 추가하고 보면 testFixturesCompileClasspath와 testFixturesRuntimeClasspath가 추가된 모습을 볼 수 있다.
testCompileClasspath와 testRuntimeClasspath에 포함된 producer 모듈들은 아마 producer/src/main, producer/src/testFixtures 모듈이 아닐까 싶다.
그리고 testFixturesCompileClasspath와 testFixturesRuntimeClasspath에 포함된 producer 모듈은 producer/src/main 모듈이 아닐까 싶다.
그렇기에 SomeBuilder를 src/test에서 src/testFixutres로 옮겨도 아무런 문제가 없다.
testFixtures에서는 main에 있는 Some에는 접근이 가능하지만 test에 있는 SomeTest2에는 접근이 불가능하다.
test에서는 main에 있는 Some과 testFixtures에 있는 SomeBuilder에 모두 접근이 가능하다.
main에서는 당연하게도 test에 있는 SomeTest와 testFixtures에 있는 SomeBuilder에 모두 접근이 불가능하다.
이 상태에서 다시 빌드를 때려보자
1 | ./gradlew build |
기존에는 보지 못했던 testFixtures 관련 태스크들이 수행된 것을 볼 수 있다.
그리고 빌드된 jar를 보면 *-test-fixtures.jar 파일이 추가됐고, 해당 jar를 까보면 testFixtures 하위에 존재하던 SomeBuilder가 존재한다.
이렇듯 java-test-fixtures 플러그인은 src/test 하위에 존재하는 불필요한 테스트 클래스는 jar에 포함시키지 않고 내가 원하는 클래스들만 jar에 추가시켜준다.
하지만 이렇게 test-fixtures.jar로 빌드했다고 해서 바로 consumer 모듈에서 사용할 수 있는 건 아니다.
consumer 모듈의 의존성을 보면 testCompileClasspath와 testRuntimeClasspath에 producer 모듈이 존재하긴 하는데 이건 일반 jar(src/main을 빌드한)만 의존성으로 가지고 있다는 뜻이다.
아래와 같이 test-fixtures.jar(src/testFixtures를 빌드한)도 의존성으로 추가해줘야한다.
1 | testImplementation(testFixtures(project(":producer"))) |
testImplementation으로 추가했기 때문에 compileClasspath와 runtimeClasspath에는 전혀 차이가 존재하지 않고, testCompileClasspath와 testRuntimeClasspath에만 producer 모듈(test-fixtures.jar)이 의존성에 추가된 걸 볼 수 있다.
이렇게 consumer 모듈의 테스트 클래스에서도 producer 모듈의 src/testFixtures에 존재하는 SomeBuilder와 producer 모듈의 src/main에 존재하는 Some 클래스에 모두 접근이 가능한 것을 볼 수 있다.
그리고 jar에 포함되지 않는 producer 모듈의 src/test에 존재하는 SomeTest 클래스에는 접근이 불가능하다.
당연히 consumer 모둘의 src/main에 존재하는 클래스에서는 producer 모듈의 src/testFixtures에 존재하는 SomeBuilder에는 접근이 불가능하다.
대신 producer 모듈의 src/main에 존재하는 Some 클래스에는 접근이 가능하다.
만약 producer 모듈에서 인메모리 db로 테스트 할 일이 있어서 h2 db를 testRuntimeOnly로 추가했다고 가정해보자.
(h2 db의 클래스는 우리 테스트 클래스에서 직접 사용하기 보다는 Spring Boot Auto Configuration 등등에서 런타임에 사용하기 때문에 testCompileClasspath에는 추가될 필요가 딱히 없다.)
1 | testRuntimeOnly("com.h2database:h2:2.1.210") |
testRuntimeOnly로 추가했기 때문에 testRuntimeClasspath를 제외한 다른 클래스패스에는 h2가 추가되지 않았다.
testCompileClasspath에 존재하지 않기 때문에 테스트 클래스에서 H2 관련 클래스를 사용해서 컴파일 하면 컴파일 타임에 오류가 난다. (왜 IDE에서는 빨간 줄이 생기지 않는지 의문이다. 일시적 버그인가…)
그리고 consumer 모듈에서도 h2를 사용하여 테스트를 진행한다고 가정해보자.
하지만 단순히 producer 모듈에 testRuntimeOnly로 h2를 추가했다 하더라도 consumer 모듈의 testRuntimeClasspath에는 노출되지 않는다.
producer 모듈은 (testFixtures)compile/runtimeClasspath를 기준으로 jar 파일을 생성하고 consumer 모듈에서 해당 jar 파일에 의존하게 되는데 h2는 해당 클래스패스에는 존재하지 않고, testRuntimeClasspath에만 존재하기 때문이다.
따라서 producer 모듈의 testFixturesCompileClasspath/testFixturesRuntimeClasspath에 추가해야 test-fixtures.jar에 의존성이 추가되고
1 | testFixturesRuntimeOnly("com.h2database:h2:2.1.210") |
consumer 모듈에서도 test-fixtures.jar를 의존성으로 추가해줘야
1 | testImplementation(testFixtures(project(":producer"))) |
consumer 모듈의 testRuntimeClasspath에도 h2가 의존성으로 추가된 모습을 볼 수 있다.
]]>의존성(라이브러리/프레임워크)을 추가하기 위해 build.gradle(or build.gradle.kts)에 아래와 같이 디펜던시들을 추가하게 된다.
1 | dependencies { |
참고로 compile은 depreacate 됐기 때문에 사용하면 안 되고 implementation을 쓰라고 나와있다. (compile은 api와 마찬가지로 consumer의 (test)compile/runtimeClassPath에 모두 추가되니 사용하지 않는 게 좋다.)
The compile configuration has been deprecated for dependency declaration.
This will fail with an error in Gradle 7.0. Please use the implementation configuration instead.
Consult the upgrading guide for further information: https://docs.gradle.org/6.9/userguide/upgrading_version_5.html#dependencies_should_no_longer_be_declared_using_the_compile_and_runtime_configurations
1 |
|
그럼 implementation은 뭐고 api는 뭘까??
implementation으로 의존성을 관리하기 위해서는 java 플러그인을 활성화해야한다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (java는 gradle core 플러그인이기 때문에 plugins 모듈에 별도로 정의 안 하고도 서브 모듈들에게 적용이 가능하다.)
1 | subprojects { |
build.gradle.kts에서는 kotlin jvm 플러그인만 활성화 시켜주면 된다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (kotlin jvm 플러그인은 gradle core 플러그인이 아니기 때문에 plugins 모듈에 별도로 정의 해놔야 서브 모듈들에게 적용이 가능하다.)
1 | plugins { |
출처: https://docs.gradle.org/current/userguide/java_plugin.html#tab:configurations
기본적으로 implementation으로 의존성을 추가한다는 사실은 아래 클래스패스에 추가한다는 사실이다.
실제로 implementation으로 jackson-core 모듈을 추가하고 보면 4가지 클래스패스에 모두 추가된 걸 볼 수 있다.
api로 의존성을 관리하기 위해서는 java-library 플러그인을 사용해야한다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (java-library는 gradle core 플러그인이기 때문에 plugins 모듈에 별도로 정의 안 하고도 서브 모듈들에게 적용이 가능하다.)
1 | subprojects { |
build.gradle.kts에서는 똑같이 kotlin jvm 플러그인만 활성화 시켜주면 된다.
1 | plugins { |
멀티 모듈인 경우 아래와 같이 활성화 시켜줘야한다. (kotlin jvm 플러그인은 gradle core 플러그인이 아니기 때문에 plugins 모듈에 별도로 정의 해놔야 서브 모듈들에게 적용이 가능하다.)
1 | plugins { |
이미지를 보면 implementation과 마찬가지로 api도 compileClassPath, runtimeClassPath, testCompileClassPath, testRuntimeClassPath에 추가된다고 나와있다.
실제로 api로 jackson-core 모듈을 추가하고 보면 4가지 클래스패스에 모두 추가된 걸 볼 수 있다.
여태까지 봤을 때는 둘 다 compileClassPath, runtimeClassPath, testCompileClassPath, testRuntimeClassPath에 추가된다는 걸 봐서 큰 차이점은 없어보인다.
하지만 이건 해당 모듈을 사용하는 컨슈머 입장이 돼봐야 그 차이점을 알게 된다.
프로젝트에 두 가지 모듈(producer와 consumer)을 만들어서 차이점을 확인해보자.
먼저 producer 모듈에 의존성을 추가할 때 implementation과 api로 각각 추가해보자
implementation으로 추가한 jackson-core와 api로 추가한 commons-lang3 모듈이 모두 클래스패스에 추가됐다.
consumer 쪽에서 producer 모듈을 의존성으로 추가하는데 여기서 차이점이 나온다. (producer 모듈을 api로 추가해도 마찬가지다.)
producer 모듈에서 implementation으로 추가했던 의존성인 jackson-core는 (test)runtimeClassPath에만 추가되고, (test)compileClassPath에는 추가되지 않았다.
그리고 api로 추가했던 의존성인 commons-lang3는 모든 클래스패스에 추가됐다.
(test)compileClassPath에 의존성을 전파하지 않음으로써 얻는 이점들은 다음과 같다.
consumer module의 compileClassPath에 있는 commons-lang3 모듈 같은 경우에는 실제 소스코드에서 사용이 가능하다.
하지만 consumer moudle의 compileClassPath에 없는 jackson-core 같은 경우에는 실제 소스코드에서 사용이 불가능하다.
compileClassPath에 없기 때문에 consumer 모듈에서 직접적인 사용이 불가능한 것이지, runtime에 jackson-core를 사용하는 producer 모듈을 사용하는 것에는 아무런 문제가 없다. (runtimeClassPath에 있기 때문에)
producer 모듈에 jackson-core를 이용하는 클래스를 작성해보자.
1 | import com.fasterxml.jackson.core.JsonParser; |
그리고 consumer 모듈에서 jackson-core를 이용하는 Sample 클래스를 사용하는 클래스를 작성해보자.
1 | public class Some { |
Some 클래스의 메인 함수를 호출하면 Sample 클래스의 a 메서드가 호출되서 test~ 문자열이 정상적으로 호출되는 걸 볼 수 있다.
consumer 모듈 입장에서는 불필요한 의존성 전파(jackson-core 모듈이 consumer까지 전파)되는 걸 막아줘서 import 자체가 되지 않다보니 자동완성에서 import 할 수 있는 가짓수가 줄어들다보니 어떤 클래스를 사용해야하는지 고민할 시간이 줄어들고(생산성 향상),
producer 입장에서는 해당 모듈을 외부로 노출시키지 않다보니 마음대로 구현체를 갈아끼워도 컴파일 타임에 오류가 나지 않을 것이라는 신뢰가 어느정도 생긴다는 장점이 존재한다.
producer 모듈에 guava를 implementation으로 추가해보자
1 | implementation("com.google.guava:guava:31.0.1-jre") |
그리고 consumer 모듈에도 똑같이 guava 모듈을 추가하는데 굉장히 하위 버전을 추가해보자
1 | implementation("com.google.guava:guava:10.0") |
그리고 이번에는 producer 모듈에서 api로 추가했었던 commons-lang3 모듈을 consumer 모듈에 추가하는데 버전을 좀 낮게 추가해보자.
1 | implementation("org.apache.commons:commons-lang3:3.0") |
이제 consumer 모듈의 classPath를 보면
즉, implementation을 쓰면 consumer 입장에서 소스코드를 직접 작성하는 것과 연관이 있는 (test)compileClassPath는 의존성이 전파가 되지 않았기 때문에 consumer 모듈에 추가한 버전이 적용되었고,
소스코드를 실제 실행하는데 필요한 (test)runtimeClassPath는 의존성이 전파됐기 때문에 의존성 충돌에 의해 가장 최신버전이 적용된다. (안 그러면 런타임에 메서드나 클래스를 찾을 수 없다는 오류가 발생할 수 있다.)
반대로 최신버전에서는 사라진 코드를 사용했다면 컴파일은 성공하는데 런타임에 오류가 발생할 수도 있기 때문에 runtime 의존성도 체크하면서 사용해야 안전하다. (최대한 런타임 의존성과 호환성이 맞는 버전을 사용해야 안전하다.)
반면 api를 쓰면 (test)compile/runtimeClassPath에 모두 의존성을 전파하기 때문에 의존성 충돌로 인해 원하는 버전을 쓰려면 버전을 강제하는 방법을 쓰거나 해야해서 사용하기가 좀 구려진다.
consumer 측에서 producer에서 api로 의존성을 추가한 commons-lang3를 사용한다고 해보자.
1 | import org.apache.commons.lang3.StringUtils; |
그리고 producer 측에서 commons-lang3 모듈의 의존성 버전을 바꿔보자.
1 | api("org.apache.commons:commons-lang3:3.10") |
그리고 consumer 측의 Some 클래스의 main 함수를 실행하면 Some 클래스는 하나도 수정한 게 없는데 다시 컴파일 하는 걸 볼 수 있다.
1 | 6:04:36 AM: Executing ':consumer:Some.main()'... |
하지만 producer에서 implementation으로 추가했던 jackson-core나 guava 같은 경우에는 consumer 측의 (test)compileClassPath에는 포함조차 돼있지 않기 때문에 해당 모듈은 의존성을 바꾼다 하더라도 consumer에서는 컴파일을 할 필요가 없다.
1 | 6:05:36 AM: Executing ':consumer:Some.main()'... |
컴파일 클래스패스가 줄어들었다는 것은 컴파일 해야할지 말아야할지 판단할 근거도 줄었다는 뜻이다.
위에서 보듯이 producer에서 api로 추가한 모듈들은 consumer에서 사용하는지, 안 하는지에 따라서 해당 클래스를 재컴파일 해야하는지 말아야하는지 비교를 해야한다.
하지만 전부 implementation으로 막혀있다면 그 비교 대상 자체가 확연히 줄어들게 될 것이다.
그로 인해 컴파일 속도가 빨라진다. (엄청나게 빨라지는 것까지는 아니겠지만… 의존성이 많으면 많을 수록 더 큰 빛을 발할 것 같다.)
Prefer the
implementation
configuration overapi
when possible
일단 무지성으로 implementation을 쓰고 어쩔 수 없을 때만 고민 한 100번 정도 한 다음에 api를 쓰면 된다.
any type that is used in the following list is irrelevant to the ABI, and therefore should be declared as an
implementation
dependency:
• types exclusively used in method bodies
• types exclusively used in private members
• types exclusively found in internal classes (future versions of Gradle will let you declare which packages belong to the public API)
ABI(Application Binary Interface)와 무관한 케이스에는 implementation을 쓰면 된다.
An API dependency is one that contains at least one type that is exposed in the library binary interface, often referred to as its ABI (Application Binary Interface). This includes, but is not limited to:
• types used in super classes or interfaces
• types used in public method parameters, including generic parameter types (where public is something that is visible to compilers. I.e. , public, protected and package private members in the Java world)
• types used in public fields
• public annotation types
ABI(Application Binary Interface)와 관련있는 케이스에는 api를 쓰면 된다.
아니다, 최대한 의존성을 줄여야한다.
implementation이라 할지라도 (test)runtimeClassPath에 포함되기 때문에 의존성 충돌로 인해 문제가 발생할 수 있다.
런타임 의존성 충돌로 인해 실제 런타임에 내가 만든 소스코드가 제대로 동작하지 않을 수 있다.
내가 사용한 모듈(컴파일 클래스패스에 추가한) 버전에서는 존재하는 메서드였는데 런타임 의존성 충돌로 최신버전으로 주입됐을 때 최신버전에서는 삭제된 메서드일 때 NoSuchMethodException 같은 게 날 수 있다.
혹은 라이브러리의 버전이 바뀌면서 내부 동작이 바뀌는 등의 무서운 일이 발생한다면 더욱 큰 장애로 이어질 수도 있다.
그리고 컴파일 타임에 발견되지 않고 런타임에 발견되는 문제는 해당 코드 블럭이 실행돼야지만 발견되는 장애이기 때문에 더더욱 무섭다.
참고로 나는 개정되기 이전 버전을 읽었음.
읽게 된 계기는 회사 동료가 이 책을 읽고 가슴이 설렜다고 함.
그래서 사내에 기증된 도서에도 있길래 읽었음.
나의 난독+독해능력이 너무 딸려서 줄을 치면서(그나마 내용을 기억하기 위한 최소한의 행위/노력) 읽고 싶었지만 회사 책이라 그러지는 못함.
그러다보니 그냥 읽기만 하고 다음날 되면 전날 내용 다 까먹음.
그러다보니 내가 이 책을 읽고 있기는 한데 남들한테 이 책이 무슨 내용이고 왜 좋고 어떤 영감을 받았는지 왜 추천하는지 하나도 설명하지 못함.
그래서 2독을 결심하고 조금이나마 노력을 들여 나의 기억력 + 독해력 향상을 위해 노트에 받아적다가 팔도 아프고, 아무 노트에 대충 정리해놓다보니 나중에 잊혀질까 아까워서 그냥 블로그에 적기로 결심함.
물론 손으로 적었을 때가 노력이 제일 많이 들어가다보니 기억이나 독해력 향상에는 도움이 많이 되는 것 같지만,
노트에 적고 또 장기보존을 위해 블로그에 또 적자니 시간도 아깝고… 시간이 무한정 한 것이 아니기 때문에 걍 블로그에 적기로 결심.
참고로 이 책은 새로운 것을 창조하는 회사를 만드는 방법
을 다루는 책임.
따라서 스타트업 창업을 생각하거나 본인의 야망을 어떻게 실현시킬지, 어떤 생각으로 일을 하거나 인생을 살아가야하는지에 대한 도움이 될만한 책이라고 생각함.
굳이 창업 안 하더라도 성공한 사람, 혁신을 이뤄낸 사람들은 어떻게 생각하고 어떻게 행동했는지 를 통해 배울 수 있는 점이 많음.
익숙한 것을 베끼는 건 1 -> N이 되는 꼴임. (모방, 쉬움)
새로운 걸 만들어야 0 -> 1이 되는 것임. (창조, 어려움)
창조(새로운 걸 만드는 행위)는 모든 순간에서 단 한번만 일어남: 검색엔진을 만들어서 제 2의 래리 페이지, 세르게이 브린이 될 수 있는가? 그건 모든 순간에 있어서 단 한 번 밖에 일어날 수 없는 행위임.
내가 검색엔진 만들면 그냥 1이 N이 되는 거임. (아니면 이미 많은 아이디어라면 N에서 N+@가 되는 거고…)
1 | 나의 생각: 무엇이 창조이고 무엇이 모방인가? 그 기준은 무엇인가? 창조가 아니면 전부 노답인가? One of them(모방)이 부정적이긴 하지만 후발주자들이 성공하는 케이스도 있지 않은가? |
하지만 이런 창조(0 -> 1)는 매우 어렵고 수많은 기적이 필요함.
하지만 인간은 그런 기적을 만들어 낼 수 있음.
그리고 그 기적을 기술(technology)이라고 부름.
기술이 기적인 이유는 더 적은 것으로 더 많은 일
을 해주기 때문. (오프라인 결제는 점원이 병목이지만 온라인 결제는 그런 병목도 없음, 그냥 서버만 있으면 더 많은 결제를 받아낼 수 있음. 점포도 필요 없고 인건비도 안 나가고 서버비만 나감.)
1 | 나의 생각: 결국엔 기술력이 핵심이란 것일까?? |
책에서 이런 내용들은 초등학교 2학년 때나 배울법한 기본적인 사실이라고 함. (산업혁명(농업사회에서 자동화 사회? 석탄… 공장의 발전??) 같은 것을 배우는 그런 시점을 말하는 것인가??)
근데 우리가 이런 것들을 배웠음에도 불구하고 자주 까먹는 이유(애초에 이런 걸 생각조차 하지 못하는 이유)는 대부분 했던 일을 반복하는 세상
속에서 우리가 살고 있기 때문이다.
1 | 나의 생각: 반복하다보면 결국 익숙해지기 마련임. |
그동안의 모든 혁신은 창조에서 왔음.
그리고 성공한 사람들은 예기치 못한 곳에서 가치를 찾았는데 기본적인 원리를 충실히 했기 때문임.
1 | 나의 생각: 내 생각에는 창조가 아니고 모방으로도 성공한 사람 많은 거 같은데?? |
Q: 정말 중요한 진실인데 남들이 당신한테 동의해주지 않는 것은 무엇인가요?
A: 대부분의 사람은 X라고 믿지만, 진실은 Y예요.
진실이 Y일지라도 Y라고 믿는 사람이 많지 않고(그건 많은 사람들이 알고 있는 진실일테므로, 책에서는 현재 교육 시스템이 문제가 있다고 지적하는 걸 예로 들고 있다.),
흔한 논쟁 중 한 쪽의 주장이 되지 않아야 좋은 대답이라고 할 수 있다고 한다. (책에선 신은 존재하지 않는다는 걸 예로 들고있다.)
대부분의 사람이 X라고 믿는 이유는 학교에서 배우는 지식은 모든 사람들이 동의한 내용
이기 때문이고,
내가 Y라고 믿는 이유는 미래를 예견
했기 때문이다. (물론 정확하지 않을 수 있겠지만…?)
미래가 중요한 이유는 세상이 현재(지금 우리가 보는 세상)와 다를 것이기 때문
이다.
따라서 현재와 10년 후의 미래가 다르지 않다면 그건 미래가 아직도 10년이나 남았다
는 것을 의미한다.
하지만 현재와 10년 후의 미래가 급격하게 달라진다면, 그건 미래가 코앞에 와있다
는 뜻이다.
또한 이런 미래를 바라볼 수 있는 예견 능력(천재적인 아이디어)이 있다 할지라도 어찌보면 불편한 사실일 수 있는 이 내용을 내뱉을 수 있는 용기
가 더 훌륭하다고 말한다.
1 | 나의 생각: 책에서 미래는 아직 오지 않은 순간들의 총합이라고 했는데 나에겐 정말 충격이었다. (1독 할 때는 충격도 받지 못했다, 이 책을 빨리 읽어야겠다는 생각 때문에 이런 깨우침을 느끼지 못한 것 같다.) |
하지만 미래를 정확히 예견할 수 있는 사람은 아무도 없다고 한다.
그럼에도 불구하고 아래 두 가지 사실은 확실하다고 한다.
현재를 바라보는 시각의 차이
가 있기 때문이라고 한다.1 | 나의 생각: 역시 빠른 성장에는 모방(카피)만 한 게 없는 것 같다. |
한동안 글로벌화가 진행되고 나면 여러 가지 융합과 획일성이 확대될 것이라고 대부분 생각한다고 함.
그를 뒷받침하는 증거로 선진국은 devloped(개발이 완료된)이라고 부르고, 개발도상국은 developing(개발 중인)으로 나누었다는 점이다.
선진국들은 이미 이룰 것을 다 이뤄서 끝마친 상태고, 개발도상국들은 선진국을 그저 따라잡는다는 의미를 내포하고 있다고 한다.
1 | 나의 생각: 영어를 몰라서 선진국과 개발도상국의 의미를 그냥 뉴스나 사전만을 통해 접했는데 영어 단어로 보고 나니 체감이 확 됐다. |
피터 틸(작가)은 대부분의 사람은 글로벌화가 미래를 결정할 것이라고 생각하지만, 기술이 더 중요
하다고 말한다.
만약 글로벌화가 미친듯이 진행되어 중국/인도의 인구가 미국 사람들처럼 똑같이 에너지를 쓴다면 에너지는 고갈될 것이고 지구의 환경은 더더욱 빠르게 황폐화될 것이기 때문이다.
또한 시간이 흐른다고 해서 자연스레 새로운 기술이 나타나는 것도 아니다
.
과거 오스트랄로피테쿠스 시절을 생각해보면 얼마나 발전이 더뎠는가? 그런 사회에서 성공은 남의 것을 빼앗는 것 뿐이다. (영토 전쟁 등등)
그러다 점점 시간이 흐르면서 원시시대의 농경, 중세의 풍차 등등의 간헐적인 진보만 있다가 1760년대에 증기기관이 출현하면서 폭발적 진보가 있었다.
이런 폭발적인 진보가 1970년대까지 이어진 결과 우리는 미래는 더 진보된 미래가 돼있으리라는 사실을 믿게 되었다.
하지만 그런 일들은 일어나지 않았으며 최근까지의 진보는 대부분 컴퓨터/통신 분야가 주를 이루었다.
저절로 세계가 더 나은 미래로 간다는 믿음은 잘못된 사실이었던 것이다.
1 | 나의 생각: 뭐 에너지 처럼 희소성이 있다거나 지구 환경에 피해를 끼치는 요소 말고도 글로벌화의 종말은 더이상 발전없는 미래가 될 것 같기도 하다. |
새로운 기술은 새로운 모험, 즉 무언가를 새로 시작하며 나타나는 경우가 많다고 한다.세상을 더 나은 곳으로 변화시킨 주체는 일종의 사명감으로 똘똘 뭉친 소규모 집단들
이었다.
큰 조직에서는 새로운 것을 개발하기가 어렵고, 관료적 계급 조직은 행동이 굼뜨고, 이해관계가 잔뜩 맞물려있는 조직은 위험을 감수하지 않는다.
반대편 극단인 외톨이형 천재(혼자)는 예술이나 문학의 고전을 남길지는 몰라도 산업 하나를 통째로 변화시키지는 못한다. (있다 해도 매우매우매우 드물 듯)
즉, 신생기업이 제대로 돌아가려면 실제로 뭔가 할 수 있을 만큼 작은 규모로 유지
되어야 한다.
1 | 나의 생각: 전형적인 대기업의 꼰대스러움/정치/실무역량과 관계 없는 이력서,면접 때문에 큰 조직에 대한 부정적 인식은 너무나 컸다. |
그리고 좀 더 극적으로 말하면 신생기업은 지금과는 다른 미래를 만들기 위한 당신의 계획을 납득시킬 수 있는 최대치의 사람
이라고 한다.
또한 새로운 생각이 민첩함 보다 중요
하다고 하는데 규모가 작아야 새로운 생각을 더 자유롭게 할 수 있다고 한다.
그리고 이 책은 특정 지식의 기록은 아니고 메뉴얼도 아니라고 한다.
오히려 이 책은 생각하는 연습을 해보는 자습서
라서 여러 질문에 관한 책
이라고도 한다.생각
이야말고 신생기업이 반드시 해야할 일이고, 당연시 되는 생각에 의문을 제기하고 백지상태에서 다시 사업을 생각
해야한다고 한다.
1 | 나의 생각: 신생 기업이 단순히 소규모라고 해서 규모만 중요한 게 아닌 거 같다. |
음… 생각보다 시간이 많이 걸려서 나머지 파트는 언제 작성 할 수 있을지… 이런 식으로 계속 읽어나갈 수 있을지는 고민이다.
(매일매일이 주말이라면 이렇게 할텐데 평일에는 이렇게까지 시간이 안 날 때가 많고, 여기에만 올인을 할 수 없으므로…)
미국에서만 할 수 있는 걸 해보자
라는 목표를 세우고 갔으나 많은 실패들이 있었고, 영어가 잘 안되다보니 aws reinvent 컨벤션 후기 보다는 라스베가스 여행기가 되어버린 것 같았다.어젯 밤 re:Play 행사를 갔다오고 나서 없던 자신감이 샘솟고 좀 더 미국에서만 경험할 수 있는 것을 경험해보고 싶다는 생각에 가득찼다.
그러다보니 동료 한 분과 아침식사를 하면서 무조건 외국 엔지니어들과 대화를 해보겠다는 목표를 세웠다.
일단 테이블에 앉을 때도 2명 정도 앉아있으면서 우리한테 대답해줄 거 같은 착한 사람을 물색하였다.
목표를 포착하고 앉아서 말없이 우리끼리만 대화를 하였다.
그러다 동료가 용기내어 말을 걸었고, 알고보니 그들은 United 항공사 소프트웨어 엔지니어들이었다.
대충 뭔 이야기를 했던 것 같은데 잘 기억은 안 나고 ‘우리 샌프란시스코에서 너네 항공사 타고 라스베가스로 왔어~’와 같은 시덥잖은 대화를 했던 것 같다.
그리고 나서 너무 비통했다…
왜 시덥잖은 얘기 밖에 하지 못하는 것일까…
그 시덥잖은 얘기마저도 왜 이리 하기 힘든 걸까…
글로벌 트렌드, 외국인들은 어찌 생각하는지, 그들은 어떻게 일하는지, 어떤 기술 문화를 가지고 있는지, 세계가 주목하고 있는 문제들은 무엇일지…
너무나 궁금했지만 차마 물어볼 수가 없었다. 물어볼 줄도 모르고 얘기해줘봤자 이해도 못할 것이기 때문이다.
이대로 가기에는 너무나 아쉬워서 밥대신 빵이랑 커피만 들고 또 목표물을 수색하였다.
어느정도 선해 보이는 사람들이 눈에 들어왔고 그대로 앉아서 ‘How are you?’같은 시덥잖은 안부를 물어보았다.
알고보니 그들은 어떤 공공기관 사람이었고 엔지니어는 아니고 매니저였다.
뭐 대충 한국은 정부에서 클라우드에 대한 신뢰도가 없어서 도입하기 정말 힘들다 너네 신기하다 했는데 뭐 그들도 자기네들도 정부의 제제 등등 때문에 빡세게는 못 쓴다고 했다.
엔지니어가 아니면 그닥 할 말이 없어서 어느 정도 얘기하다가 빠이쳤다.
이대로 가기에는 또 안타까웠다. 결국 시덥잖은 얘기 말고 한 게 없지 않은가…
그래서 이번에는 좀 더 전략적으로 다가갔다.
리인벤트 참석자들이 메고 있는 목걸이는 검은줄이면 참석자, 노란줄이면 AWS 직원이었다.
그래서 한 번 노란줄을 공략해보기로 했고 마침 혼자 밥먹고 있는 사람이 눈에 들어와서 두리번 거리는 척 하다 가서 앉았다.
또 시작은 밥먹는 척 하다가 ‘How’s Going?’ 같은 시덥잖은 안부 인사로 시작하였다.
그리고 대화하다보니 그는 AWS의 솔루션 아키텍트였고, 한 기업의 M&A 때문에 기술적인 컨설팅 같은 걸 해주고 있다고 하였다.
토스페이먼츠도 LGU+의 전자결제사업부와 M&A를 진행했기 때문에 공통점이 있다고 판단하여 옳다구나 싶어 허접한 영어를 막 내뱉었다.
그들의 구닥다리 시스템 때문에 일일이 배포하고 롤백도 수기로 하다가 장애가 난 사례를 얘기해주었다.Server 1 deploy, server 2 deploy, server 3 deploy... oh bug has occured! server 1 rollback, server 2 rollback, then they said rollback is done! but sometimes bug still occured!
진짜 occured 어떻게 발음해야하는 건지, 장애를 버그라고 얘기하면 되는 건지, 롤백이 완료되었다는 뭐라고 얘기해야하는 건지, 여전히 장애는 발생한다는 걸 영어로 뭐라고 얘기해야하는 건지…
무지성으로 랩하듯 그냥 말했다, 뭐라도 그와 공감대를 형성해야 기술적인 주제로 얘기를 이어나갈 수 있을 것 같았다.
다행히 바디랭귀지와 서버1 서버2 서버3의 임팩트가 있었는지 그도 웃으면서 내 얘기에 공감해주었다.
그리고 우리가 닥친 상황(레거시 시스템을 신규 시스템으로 마이그레이션)들을 얘기하면서 Strangler Fig
패턴에 대해 말해주며 점진적으로 기능을 마이그레이션 하는 것에 대해 설명해주었다.
뭔 소린지 잘 몰랐지만 Strangler Fig 패턴에 대한 세션을 들을 수 있었는데 너무 피곤해서 안 들었던 과거가 후회되었다.
그리고 용기내면 이렇게 조그만 인사이트라도 얻을 수 있는 사람이 존재하는데… 영어를 한다면?? 얼마나 큰 인사이트를 얻을 수 있는 기회가 여기 라스베가스에 있었던 것일까… 정말 비통했다.
이렇게 한바탕 외국인과 얘기를 해보고 나니 정말 한국만큼 핸디캡을 가지고 사는 나라도 없는 것 같았다. (다른 아시아인들은 잘 모르겠지만…)
국적을 빼놓고 보면 영어를 할 줄 알면 국적은 중요하지 않았다.
그들은 리모트로 일하면서 서로 다른 국가에 있는 사람들과 협업하고 리인벤트에 와서도 서로의 국가가 중요한 게 아니라 그냥 대화를 하면 되는 거였다.
하지만 우리는 Where are you from?
같은 게 필수 질문이 되었다.
그게 뭐가 중요한가? 그들이 어떤 환경에서 일하고 어떤 생각으로 일하고 어떤 문제를 얼마나 나이스하게 푸는 것이 중요한 건데…
정말 한국만 빼놓고 위아더월드로 그들만의 리그가 형성된 것 같았다.
그리고 한국 사람들을 보면 진짜 한국사람 끼리끼리 몰려다니고 그런 문화 자체가 다른 외국인들이 다가오기 힘들게 하는 문화같아 보이기도 하였다.
그냥 멀리서 뭉쳐다니는 사람 보면 ‘아 한국인인가보다…’하고 생각이 들 정도였다.
한국의 문화가 유독 뭉쳐다니는 거 좋아하는 것 같은데 이런 문화는 버려야할 문화…까지는 아닌데 좀 약해져야할 필요가 있다고 본다.
그렇기 때문에 용기내어 누군가에게 다가가기도 힘들고, 그 조직 외에 있는 사람이 들어오기도 힘든 문화인 것 같다.
그리고 영어 교육 진짜 뜯어고쳐야하는 것 같다.
말하기/듣기 위주로 가르쳐서 진짜 영어 할 줄만 알면 한국인들도 엄청난 메리트를 타고 나는 것이라고 본다.
번역기가 아무리 발달됐다고 해도 해외에서 로밍 제대로 안 터지고, 음성 인식 제대로 안 되고 그걸로 대화하다가 맥이 끊긴다.
그냥 관광지 가서 바디랭귀지 하는 수준 밖에 번역기는 발달되지 않은 것 같다.
정말 영어는 정말정말진짜진짜 중요하다는 것을 또 새삼 깨닫게 되었다.
아침 식사를 마치고 세션을 들으러 가던 도중 육교에서 색소폰을 불고 있는 사람을 만나게 되었다.
평상시 재즈힙합도 좋아해서 이 공연도 꽤 즐겁게 들었다.
비트는 내 스타일이었지만 목소리는 조금 앵앵대는 느낌이 있어서 영 내 스타일은 아니었지만 굉장히 좋은 경험이었다.
물론 뭐라고 하는지는 못 알아들었다. (아이스크림 같은 건 들렸다.)
밖에서 봤을 때 굉장히 이쁘게 꾸민 부스가 있었다.
바로 세일즈포스였는데 세일즈포스란 기업은 평상시 관심있던 기업이 아니다보니 얘네들이 무슨 문제를 풀었는지 궁금했다. (굿즈도 받을 겸…)
근데 보다보니 세일즈포스가 슬랙도 인수했다는 사실을 이 때 알게 되었다.
대충 보니까 쇼핑몰 같은 거 만들기 쉽게하는 솔루션들이랑 뭐 여러가지 있어보였는데 크게 눈에 들어오는 건 없었다.
영어만 된다면 더 물어보고 싶었는데… 역시나 영어가 안되니 뭘 더 물어보고 싶어도 물어볼 수가 없었다.
마지막 날이라 그런지 들을만한 세션이 얼마 없기도 하고 점심 장소로 이동을 하면서 주변 관광지들을 둘러보았다.
한국으로 치면 먹자골목 같은 분위기가 나는데 또 라스베가스 만의 분위기가 나서 신기했다.
한편으로는 평일 낮에 이런데 오는 사람들은 뭐하는 사람일까… 이 사람들도 휴가내고 놀러온 사람일까… 싶었다.
계속 가다보니 인앤아웃 버거집도 보였다.
점심 예약을 하지 않았더라면 한 번 먹어봤을 법 한데… 좀 아쉬웠다.
지나가다보니 플라밍고 호텔도 보였다.
플라밍고 호텔은 이름에서 알 수 있듯이 실제로 플라밍고를 볼 수 있다.
새들이 지저귀는 소리가 마치 숲속에서 지저귀는 새들의 소리 같았다.
플라밍고 호텔도 내부에 볼만한 것들이 많으니 라스베가스에 왔으면 한 번 가볼법한 것 같다.
이렇게 라스베가스는 호텔마다 특색이 있어서 시간이 된다면 한번 쭉 둘러보는 것도 좋은 것 같다. (공짜로 볼만한 요소들이 많다.)
회사 동료 분이 예약해주셔서 고든램지가 운영한다는 Pub & Grill에서 점심을 먹게 되었다.
엄청 짰다.
확실히 미국 음식들은 짜다.
같이 갔던 사람의 말로는 이거 만드려면 무슨 양파가 카라멜 색이 될 때까지 엄청 열심히 뭐 굽는대나 해야한다고 한다.
노력은 가상한데 그에 비해 맛은 훌륭하지 못했다. 그냥저냥… 짠 거 빼면 좀 먹을만 했던 것 같다.
위에 어니언 스프가 좀 짰다면 얘랑 같이 먹으면 좀 간이 맞는 듯 했다.
빵이 들어가있는 샐러드인데 좀 더 힘을 준 샐러드 느낌이었다.
다른 동료 분께서 시켜서 먹어봤는데 맛있었다.
이게 메인 요리였을 것이다.
소스 담은 것부터가 힘을 잔뜩 실은 느낌이었다.
확실히 고기가 고급지다는 것이 느껴졌지만 역시나 짰다.
짠 거 빼고는 너무나 맛있었고, 덕분에 소스는 그닥 안 찍어먹었던 거 같다.
미국인들이 왜 이렇게 성인병에 많이 걸리는지 알게 된 것 같았다.
푸딩인 줄은 모르겠고 그냥 초코빵 같은 거 위에 아이스크림이 얹어져있었다.
단짠단짠 조합에 부합해보이긴 하지만 이것도 몹시나 달았던 것 같았다.
그래도 계속 짠 걸 먹는 것에 비해 오랜만에 달달한 걸 먹으니 먹을만 했다.
맛은 좋았다. (좀 많이 달았던 거 같긴 하지만…)
지나가다가 또 시저스 호텔이 보여서 한 방 찍어보았다.
로마 황제 시저를 테마로 만든 호텔인 건지 로마의 건축문화를 본따 만든 듯한 조형물들이 많이 보였다.
미라지 호텔도 보았는데 폭포처럼 잘 꾸며놓았다.
미라지 호텔은 화산쇼가 유명하다는데 그거는 눈으로는 봤는데 사진으로 남겨놓은 거는 딱히 없다.
실제로 미라지 호텔 건너편에서 지나가다가 보았는데도 불길 때문에 좀 따뜻하다는 느낌이 들 정도였다.
샌프란시스코 공항에서 경유를 위해 기다리다가 무슨 유기농 음식점? 같은 곳에 들어가서 뭔지도 모르고 수프를 시켰는데… 웬 꿀꿀이 죽이 나왔다.
만원도 넘었는데 돈이 너무 아까웠고 억지로 먹다가 버렸다.
그리고 신기한 건 미국은 분리수거를 크게 안 하는 건지 그냥 음식물도 쓰레기통에 다 버리는 것 같았다.
마지막으로 한국으로 14시간 가량의 비행을 끝으로 이번 여행을 끝마쳤다…
라고 생각하기도 잠시 한국에서는 또 코로나가 빵 터지면서 입국 절차도 까다로워져 여기서만 1시간 넘게 대기했다.
또한 해외입국자는 코로나 검사를 무조건 받아야하는데 보건소에서 이거 기다리는 것만 2시간 반을 기다렸다…
한국에는 새벽에 들어왔지만 막상 집에 들어가니 점심시간이 넘었고 너무나 추웠다.
그리고 10일 간의 자가격리 기간동안 집에만 있으니 너무 답답했고, 다행히 음성이 나와서 그 후에는 무사히 출근할 수 있었다.
정말정말정말 좋은 경험이었다. (영어를 할 수 있었더라면 배가 되었겠지만…)
비록 기술적 인사이트는 크게 얻지 못하였다 할지라도 내 인생에 있어서 큰 성장을 한 것 같았다.
백날 천날 영어가 중요하다… 중요하다… 라고 듣기만 하고 영어로 된 아티클도 대충 배경지식으로 때려맞추고 소스코드로 검증해보는 식으로 했지만
실시간으로 사람들과 소통하려고 하다보니… 이건 정말 답이 없었다.
영어가 됐다면 기회의 땅 미국에서 더 많은 사람들과 대화하며 많은 인사이트를 얻어낼 수 있었을텐데 한 편으로는 아쉬웠다.
또한 미국이라는 나라와 그 나라의 문화를 체험한다는 정말 값진 경험을 하게 되었다.
내가 살면서 미국이란 나라를 가볼 일이 있을까… 라는 생각으로 살아갔는데 정말 수천만원 어치의 경험을 한 것 같다.
내가 생각했던 미국과 직접 겪어본 미국은 달랐다.
미국 기업에 취업해서 미국에서 살아볼 수도 있지 않을까? 라는 생각을 가진 적이 있긴 했지만 직접 경험하고 오니 난 무조건 한국에서 살겠다고 마음 먹었다.
땅덩어리가 넓으니 차가 없으면 살기가 너무 힘들고(대중교통을 경험하지 않아서인지 정확한 판단은 아니겠지만), 주변 편의시설까지 가는데도 너무 힘들었다.
우리나라는 그냥 호텔 1층에 편의점이 있거나 주변에 널린 게 편의점인데 여기는 편의점 같은 곳을 가려면 또 호텔 밖으로 걸어서 육교랑 횡단보도를 몇 번이나 건너가야 했다.
그리고 편의점이라 부를법한 곳에 라면도 없었고, 내 입맛에 맞는 것은 별로 없었다.
팁 문화도 생소하기도 하고 얼마를 줘야할지 이런 거 고민할 필요도 없는 한국 가게들이 너무나 편해보였다.
그리고 길거리에는 대마초 냄새를 종종 맡을 수 있었고, 도시에 군견과 경찰을 보면 그나마 치안 좋은 게 이정도인데 여기 살려면 정말 정신을 바짝 차려야하는 것 같았다.
이렇게 직접 경험하고 나니 한국만큼 살기 편안하고 좋은 나라는 없다는 것을 깨닫게 되고 그런 곳은 잠깐 여행만 갔다 오고 한국에서 계속 살아야겠다는 다짐을 했다.
그리고 AWS 리인벤트를 경험하고 나서 고용 문화에 대해서도 다시 생각해보게 되었다.
우리나라는 노인 빈곤률이 높고 일자리도 잘 취업이 되지 않는다고 한다.
하지만 리인벤트에 진행 안내 요원으로 일을 했던 사람을 보면 아마 단기 아르바이트일 거 같았고, 나이가 지긋하신 분들도 많았다.
그런 분들이라고 해서 일을 못하는 것도 아니고 친절하게 일을 하고 즐겁게 일을 하는 것을 보니 이게 진짜 노인 공경인 건가… 고령화 사회에 기업들이 이런 일자리들을 줘야하는 것이 아닌가 하는 생각이 들었다.
우리나라는 유교문화라고는 하지만 말만 노인 공경하는 것 같은데 이런 실질적인 부분에서 오히려 미국이 노인 공경을 잘하는 것처럼 보였다.
또한 미국인들의 문화 중에 신기했던 게 모르는 사람한테도 인사 건네고 말을 건넨다는 것이다.
엘레베이터에서 처음 만난 노부부가 우리 보고 굿모닝을 시전하고, aws 리인벤트 참석하는 다른 외국인들이 너네도 리인벤트 때문에 왔냐고 물어보고…
심지어 아침을 먹을 때도 같은 테이블에 있었던 사람들이 우리에게 먼저 말을 걸어주기도 하였다.
이런 게 정인가… 싶기는 하지만 한국인들이 정이 많다 뭐 이런 얘기를 하지만 나는 차라리 이런 부분에서 정이 있다는 것을 느꼈다.
한국에서는 주변 이웃끼리도 인사를 잘 안 하는데 미국의 이런 문화에서는 정말 이웃끼리도 잘 지낼 것 같다는 생각이 들었다.
그리고 한국은 괜히 뭐 잘못 하면 ‘왜 나대냐’라는 듯한 시선이 있는데 미국에서는 이런 분위기에 대해서 굉장히 자유롭고 관대한 것 같았다.
그러다보니 이런 문화 속에서 더 토론이나 자유로운 의견 공유가 가능한 것 같았다.
그리고 MBTI에서 I(내향적)와 E(외향적)이 있는데 미국인들은 죄다 E처럼 보였다.
행사 진행요원 할아버지와 할머니가 춤추고 재미나게 일을하는 걸 보면 정말 일을 재미있고 신나게 하는 듯 해보였다.
근데 얘기하는 걸 들어보면 둘은 오늘 처음 만났거나 AWS 행사에서 처음 만난 것 같았다.
그들은 인생도 즐겁게 살고, 일도 즐겁게 하는 듯 해 보였다.
어떻게 그렇게 사는 걸까? 그건 자연스레 뿌리박힌 그들의 문화(인사를 자유롭게 건네고, 말은 먼저 건네도 이상하게 보지 않는 문화)와 관련이 있지 않을까 싶었다.
한국은 정말 살기 좋은 동네이고 치안도 짱짱맨인 동네이다.
하지만 사람들 간에 살아가는 방식이나 문화 측면에서는 너무 보수적인 것들은 좀 버려야할 필요가 있다고 본다.
그래야 더 자유로운 의사소통이 되고 다양한 의견 공유를 통해 더 나은 방향으로 나아갈 수 있으리라고 본다.
한국 사람들이 머리는 정말 똑똑한데 그들만의 리그에 갇혀 산다는 느낌도 들어서 글로벌 트렌드도 주도한다던지 그들과 함께 어우러져 세계를 이끌어나갈 수 있는 기업과 인재들이 더욱 더 나오길 바란다.
앞으로 인생에 다시는 이런 좋은 기회와 경험들이 주어질지 모르겠지만, 앞으로 올 기회를 잡기 위해 준비된 사람이 되어야겠다.
]]>미국에서만 할 수 있는 걸 해보자
라는 목표를 세우고 갔으나 많은 실패들이 있었고, 영어가 잘 안되다보니 aws reinvent 컨벤션 후기 보다는 라스베가스 여행기가 되어버린 것 같았다.라스베가스에 오기 전까지만 해도 잘 몰랐는데 라스베가스와 그랜드캐니언은 가까웠다.
물론 차를 타고 가면 갔다 오는데 하루 종일이 걸릴 정도라서 그닥 가깝다고 느껴지지 않을지 모르지만… 미국의 땅덩어리를 생각해보면 가까운 수준인 것 같다.
새벽부터 차를 타고 갈 체력도 없기도 하고… 우리는 관광이 주 목적이 아닌 AWS 리인벤트가 주 목적이기 때문에 하루를 몽땅 날려버릴 수는 없었다.
그리고 차를 타고 가는 것도 매우 지루하기도 하고 주변 풍경도 막상 크게 볼 것이 없다고 한다.
그러다보니 비용이 비싸긴 하지만… (인당 56만원 정도 냈던 것 같다.)
버스를 타고 헬기장까지 이동하고 나서야 뭔가 헬기를 탄다는 게 실감이 났다.
나는 헬리콥터하면 영화에서 밖에 보지 못했기 때문에 뭔가 양옆이 뚫려있고, 머신건 같은 게 달려있어서 굉장히 위험하다고 생각을 했다.
그러다보니 신체포기 각서 같은 걸 서명하고 탑승할 줄 알았는데 그런 건 없었고, 양 옆에 문도 있어서 나름 안전하였다.
헬기가 이륙하는 장면을 찍어보았는데 정말 이때부터 실감이 제대로 났다.
헬기로 후버댐을 보니 진짜 미국의 대자연의 경관을 한껏 만끽할 수 있었다.
헬기로 이동하는 중에 절벽을 깎아 내린듯 한 비슷비슷한 풍경들이 눈에 들어왔다.
차로 이걸 몇시간 동안 지나갈 생각을 하면 너무 지루했을 것 같다.
역시 돈이 짱인 거 같다. (돈으로 시간을 살 수 있다는 말이 무엇인지 크게 체감하였다.)
헬기에서 착륙한 후 그랜드캐니언을 한바퀴 쭉 찍어보았다. (뒤에 더 있지만 사람 얼굴들이 좀 나와서 잘랐다.)
주위를 삥 둘러보았는데 뭐 비슷한 광경이었다.
미국의 대자연… 우와… 한 1~2분 정도 체감한 것 같다.
그 이상의 감흥이 오지는 않았지만 뭐 그래도 한국에서 해볼 수 없는 경험이었고 자연들도 아름다웠기 때문에 나름 만족한다.
하지만 누가 또 오자고 하면 글쎄… 다시 올 정도는 아닌 것 같다.
그리고 이런 척박한 환경에 선인장도 자라고, 까마귀도 날아다니는 걸 보고 진짜 어디에든 생물이 존재는 한다는 사실도 신기했다.
아점 겸 해서 다과를 준비해줬다.
이것도 헬기 예약할 때 들어가있는 거긴 한데 여기선 뭐든 무서워서 이거 돈 안 내는 거냐고 물어보고 먹었다.
그리고 좀 느긋하게 먹고 싶었는데 헬기 기사가 시간 됐다고 싸갈 거면 싸가라고 재촉하였다. (물론 바람도 많이 불어서 좀 춥기도 하였다.)
돌아가는 길에도 몇컷 찍기는 했는데 이미 오면서 본 광경이기도 하고 비슷비슷해 보여서 흥미가 좀 떨어진 상태이긴 했다.
오전에는 관광모드로 그랜드캐니언을 갔다왔다면 오후에는 라스베가스에 온 본질인 AWS 세션 듣기에 집중했다.
그 중에도 넷플릭스 세션들이 인사이트 얻기 좋다는 얘기를 들어서 넷플릭스의 Keeping Netflix reliable using prioritized load shedding 세션을 들었다.
발표자료는 이미 2020년 11월에 넷플릭스 테크 블로그에 올라온 Keeping Netflix Reliable Using Prioritized Load Shedding을 토대로 제작되었다.
어떻게 하면 넷플릭스가 서비스의 품질을 더 지킬 수 있는 건지에 대한 세션 발표였다.
나는 뒷단 서비스들이 망가졌을 때 서킷브레이커를 도입하여 장애 전파를 막는 것까지만 생각하였다.
하지만 트래픽이 너무 과하거나 기타 등등의 사유로 API Gateway가 힘들어한다면…? 같은 상황은 생각해보지 못했다.
넷플릭스는 트래픽이 하도 많아져 이런 상황까지 겪어봤을테고, 무작정 서버를 증설하는 대신 다른 방법으로 문제를 해결했다. (물론 너무 심각하면 증설해야하지만)
트래픽의 우선순위를 부여하여 리소스가 얼만큼 남았을테니 중요하지 않은 트래픽들은 실패로 떨구고… 하는 방식을 통해 유저의 실시간 스트리밍에는 영향이 절대 없도록 했다는 내용이다.
영어로 진행된 세션이니만큼 100% 이해를 하지는 못했지만, 괜히 넷플릭스가 테크 기업이 된 게 아니구나… 이런 식으로까지 생각을 해서 문제 해결을 해야하는구나… 하고 깨달았다.
나의 경우에도 대입해보면 결제/환불 같은 중요 트래픽은 살리고 그 나머지 트래픽은 실패로 떨굼으로써 어떻게든 결제와 환불에는 문제가 없게 끔 트래픽에 우선순위를 정할 수도 있겠구나… 하는 생각이 들었다.
그리고 세션이 끝나고 넷플릭스 엔지니어가 질문을 받는 시간을 가졌는데… 영어를 할 줄 모르지만 괜히 주변에 가서 뭐라도 하나 더 줏어 들었다.
이해가 되지는 않았지만 세계적인 엔지니어와 영어로 대화하는 다른 엔지니어를 보면서… 너무 부러웠다.
우리가 가지고 있는 문제들은 넷플릭스 엔지니어라면 어떻게 해결했을까? 그들은 어떤 문화와 사고방식을 갖고 있길래 이런 식으로까지 기술을 도입하게 된 것일까?
영어를 하지 못한다는 사실이 너무나 가슴이 아팠던 상황이었다…
저녁은 Hot N Juicy crawfish에서 먹었다.
해산물이 나온다는 거 말고 아무것도 모른 채로 먹었는데 살짝 매콤하지만 맛있었다.
비닐 장갑을 껴도 그 사이로 국물이 슬쩍 들어오는 것도 같았고… 손에 냄새도 좀 벤다는 점이 단점인 것 같았다.
그리고 뭐 먹긴 하는데 메뉴가 계속 먹다보면 질리기도 하고 배가 막 엄청 부르지는 않았다.
그리고 TV에는 또 무슨 소 제압하기? 같은 대결을 하는지 카우보이들이 나와서 줄을 던져서 황소를 얼마나 빠른 시간 안에 제압하는지를 겨루는 방송이 나오고 있었다.
정말 미국은 카우보이 문화가 많이 발달한 것 같았다.
나는 스페인의 투우 같은 것만 생각했었는데 미국도 서부 시대에 카우보이 문화가 많이 발달했다고 한다.
나는 개인적으로 힙합을 좋아하고 그 중에서도 드럼의 쿵치딱 거리는 소리가 좋아 붐뱁 장르를 좋아한다.
그러다보니 3개월 정도 드럼을 배우기도 하였고 드럼 소리를 좋아하는 편이었다.
근데 우연히 길을 가던 도중에 조약하지만 드럼 요소라 불릴만한 장비를 갖추고 있고… 거기다 소울풀 한 흑인이 앉아있다?
이건 못 참치~란 생각으로 한 곡 연주해줄 수 있냐고 물어보자 돈을 내야 연주해준다고 하였다.
어디서 들었는데 ‘프로는 돈으로 말한다’라는 얘기가 있었는데 딱 그 말이 떠오르면서 프로처럼 보였다.
그리고 팁을 주겠다 얘기하고 바로 즉흥연주가 시작되었다.
흑인의 드럼연주도 기가 막혔지만 진짜 간지 터지는 포인트는 백인 노인과의 합주이다.
나는 둘이 팀인 줄 알았다, 근데 알고보니 백인 노인도 그냥 길가던 행인 중 한명이었다.
내가 생각한 예술가의 이상적인 그림이었고 정말 나의 심금을 울리는 연주였다.
바로 당장 귀국하자마자 드럼 레슨 끊어야겠다고 생각이 들 정도였다. (하지만 아직까지 드럼 학원은 등록하지 않았다.)
이런 사람들이야말로 돈을 잘 벌어야하고 잘 돼야한다는 생각에 나름 팁을 두둑히 줬던 걸로 기억한다.
한국에도 이런 공연들이 많아졌으면 하고 나의 심금을 울릴 수 있는 이런 연주라면 그에 대응하는 대가를 지불하고 볼 용의가 얼마든지 있다.
조약하지만 에펠탑을 흉내낸 관광코스가 있길래 가보았다.
안에 들어가는 건 공짜지만 타워 위로 올라가서 구경하는 것은 돈을 내야한다.
진짜 라스베가스에서 카지노 빼면 섭할 정도로 어딜가나 카지노가 보였다.
우리나라도 남산타워에 사랑의 자물쇠인가 뭐시기인가… 있는데 어디가 원조인지 궁금해졌다.
에펠타워 꼭대기까지는 엘레베이터를 타고 이동하는데 이렇게 투명하게 뻥 뚫려있어서 밖이 보인다.
에펠타워 꼭대기에서 본 뷰도 정말 멋졌다.
그리고 뜻밖에 다른 외국인 커플이 프로포즈하는 장면도 보았다.
결혼하려면 이정도 되는 근사한 곳에 와서 반지 주면서 프로포즈를 해야 결혼할 수 있는 것 같았다.
여자는 감동한 듯 울먹이며 남자를 끌어안았다. (이 순간 모두가 박수를 치며 축하해주었다.)
근데 여기서 또 재밌는게 남자/여자 둘 만 있던 게 아니라 남자 측 엄마로 추측되는 사람도 함께 있었다는 사실이다.
우리나라로 치면 시어머니 앞에서 남자가 프로포즈를 한 건데… 마마보이인가? 이 생각도 살짝 들기도 하면서 문화 충격이었다.
한국에서 좀 과장해서 막장드라마 시나리오였다면 시어머니가 ‘네가 우리 애를 벌써부터 잡는구나 잡아?’하는 시나리오도 연출될 수 있을만한 그림이었다.
하여튼 미국이란 동네는 참으로 신기했다.
에펠타워의 하나의 장점은 벨라지오 호텔의 분수쇼를 위에서 볼 수 있다는 사실이었다.
땅에서 보는 분수쇼도 멋있었지만 위에서 본 분수쇼는 또 달랐다.
땅에서 보면 1차원 적으로 밖에 보지 못해 분수가 일렬로 나열돼있는 줄 알았는데 위에서 보니 동그란 모양의 분수도 있다는 사실을 알 수 있게 되었다.
이런 타워 아래로 내려오면 국룰처럼 기념품 가게가 있다.
나는 여태까지 ty가 T
hank Y
ou의 줄임말인 줄 알았는데 브랜드 로고라는 걸 처음 알게 되었다.
re:Play는 AWS re:Invent에서 행사 마지막 전날 밤에 진행되는 파티 같은 행사이다.
진짜 이건 미쳤다. 말로 설명이 안 된다. 테크 기업에서 스케일이 큰 행사를 하기도 힘든데 이렇게 넓은 대지를 빌려 파티 문화까지 만들었다고?
정말 정말 이건 미쳤다고 생각이 들고 아마존이란 기업에 존경심이 생겼다.
입구를 따라 쭉 들어오다보면 월드 디제이 페스티벌 마냥 디제이가 신나는 음악을 흔들어 제끼고 있었다.
그 와중에 오징어게임을 리믹스 한 음악을 틀고 있었다. 국뽕이 차오르는 순간이었다.
re:Play에는 탁구 등등 여러 놀이거리도 있었지만 우리는 장애물 피하기 같은 것과 팀먹고 연타하여 누가 제일 빠르게 누르나 같은 걸 해보았다.
뭐니뭐니 해도 re:Play의 꽃은 디제잉인 것 같았다.
한국에서는 클럽 같은 곳을 한 번 밖에 가보지 않았고 재미도 없었지만, 시간이 흐른 탓인지 나름 재미있었다.
하지만 미국이라 그런지 이런 곳에서까지 대마초를 피는 사람이 있었고 정말 냄새가 역해서 토하는 줄 알았다.
대마초하니까 떠오른 건데 마약에 호기심이 있다가도 그 역한 냄새를 맡으면 호기심이 싹 사라진다.
라스베가스에서도 특정 길거리를 지나가거나 하면 항상 역한 대마초 냄새가 났다.
처음에는 몸 한 3주간 안 씻은 노숙자 몸에서 나는 냄새인 줄 알았는데 대마초 냄새였다.
정말 그정도로 역하고 미국이란 나라에 한 번 더 충격을 받게 된 계기였다.
행사가 끝날 때 쯤 나오면 사람이 몰릴 것 같아 미리 나오면서 또 어디 줏어먹을 거 없나… 두리번 거리는 하이에나처럼 돌아다니다가 티셔츠를 득템하였다.
행사 막바지라 그런지 필요한 만큼 다 가져가라고 해서 진짜 한 10장은 들고 온 것 같았다.
2장은 집에서 잠옷으로 요긴하게 쓰고 있고, 나머지는 회사 동료들에게 뿌렸다.
아침 일찍부터 스케쥴을 시작해서 밤 늦게까지 놀다보니 하루가 참 길었다.
확실히 그랜드캐니언을 보니 미국은 자연도 그 나라에 일부분인 것 마냥 엄청난 스케일을 자랑하였다.
캠핑 같은 거 좋아하는 사람한테는 천국일 거 같다는 생각이 들었다.
또한 넷플릭스라는 기업의 기술역량에 다시 한 번 존경심이 생겼다.
평상시 Hystrix니 Zuul이니 여러 오픈소스를 만들 정도의 기업이라 기술 중심 기업이라는 것은 어느정도 알고 있었는데 역시나 스케일이 다른 것 같았다.
그리고 넷째 날이 정말 제대로 라스베가스를 즐겼다는 생각이 들었다.
간지 폭발하는 흑인 드러머를 만난 건 내 인생에 있어서 잊을 수 없었다.
또한 re:Play라는 미친 파티를 경험하고 나서 아마존에 대한 존경심이 샘솟았다.
내일만 버티면 된다는 생각에 이제 좀 마음이 놓이는 날이었다.
]]>미국에서만 할 수 있는 걸 해보자
라는 목표를 세우고 갔으나 많은 실패들이 있었고, 영어가 잘 안되다보니 aws reinvent 컨벤션 후기 보다는 라스베가스 여행기가 되어버린 것 같았다.요번 리인벤트에서는 특정 서비스에 딥다이브 하기 보다는 좀 더 추상적인 ‘아키텍처’ 관점의 세션들을 많이 들어보았다.
내가 AWS의 서비스에 대한 이해도가 낮기도 하다보니 내 소스코드에도 적용 가능한 추상적인, 이론적인 내용들은 무엇이 있을까?하다보니
이벤트 드리븐, 클라우드 네이티브, 모던, next generation 뭐 이런 키워드 있는 것들을 주로 들었던 것 같다.
하지만 영어가 되지 않아 대부분 이해가 되지 않던 와중 나를 충격에 빠뜨린 세션이 있었다.
Building next-gen applications with event-driven
architectures라는 세션이었는데 이벤트 드리븐에 꽂혀서 신청했던 세션이다.
나는 당연히 테크 기업이 나와서 발표를 하겠거니… 라고 생각했는데 타코벨에서 나와서 발표를 하였다.잠깐만... 타코벨?? 타코... 그 멕시코 음식 파는 기업 아니야??
라는 생각이 뇌리를 스쳐지나갔다.
우리나라로치면 원할머니 보쌈이 AWS 리인벤트에 나와서 이벤트 드리븐 아키텍쳐로 자기네들이 가진 문제를 풀었다고 하는 것이다.
말이 되는가? 원할머니 보쌈은 음식이 메인이고 기껏해야 배민이나 쿠팡이츠, 요기요 같은 플랫폼 기업에 음식점 등록하고 수수료 떼는 게 끝 아닌가?
우리나라에서 푸드 테크기업이라고 불리면서 직접적으로 음식을 만들어 파는 곳이 있는가? 라고 했을 때 떠오르는 곳이 없었다.
그래… 타코벨에서 AWS를 어찌저찌 썼다고 치자… 그래서 그들은 무슨 문제를 풀었던 걸까??
미국의 슈퍼볼 같은 행사의 TV 광고를 한 번 때리면 주문량이 미친 듯이 폭주한다는 것이다.
그래서 그들은 배달기사 / 음식점 / 고객 사이에서 발생하는 상호작용 사이에서 이벤트 드리븐을 적용했다는 것이다.
그것도 서버리스로!! 그래서 미친듯이 폭발하는 트래픽을 견뎌낼 수 있었다고 한다…
이들은 배민 같은 중간 플랫폼 사업자들을 끼지 않고 직접 배달을 하고 있었고, 음식점 포스에도 기술을 도입했다.
생각이나 해보자… 월드컵 경기 중간에 TV에서 네네치킨 광고를 한다고 네네치킨에서 이벤트 드리븐 아키텍쳐를 상상이나 할 수 있을까?
그냥 배민 같은 곳이 안 터지길 빌어야하는 것이다.
이걸 보고 또 느낀 점이 있었다…
역시 사업은 글로벌로 해야하는구나… 그래야 어떤 비즈니스도 돈이 될 정도의 트래픽들이 모인다는 사실이다.
우리나라가 미국 정도의 인구규모만 되더라도 내수시장에서 먹고 살 수 있겠지만… 이미 한국은 저출산 시대와 그 사이에 피터지는 경쟁으로 인해 거대 플랫폼 기업들이 다 뜯어먹고 있는 시장같아 보였다.
미국 정도 규모에서 일부만 먹더라도… 한국에서의 10%와 미국에서의 10%는 정말 하늘과 땅 차이이기 때문에 인구가 깡패라는 점도 느꼈다.
우리나라가 미국 정도 인구 규모에 슈퍼볼 같이 배달이 폭주할 만한 행사들이 종종 있다면… 원할머니 보쌈에서도 이벤트 드리븐 아키텍쳐를 고민할 날이 오지 않을까??
이번 세션이 나한테 큰 충격을 준 만큼 정말 질문하고 싶은 내용이 많았다.
세션을 들은지 한참이 지난 지금에 와서도 이렇게 질문들이 생각이 나는데… 이런 질문을 할 수 없는 나의 영어 실력이 참으로 비통했다.
진짜… 영어를 할 줄 아는 사람이면 나 정도는 금방 제끼겠구나… 영어가 내 앞길을 막는 날이 언젠가 올 줄 알았는데 오늘이 그날이구나… 하고 느꼈다.
라고 말하면서 이전 포스트에서도 말했듯이 영어공부를 열심히 하지 않는 걸 보면… 어디 해커스 학원 같은데 돈이라도 쳐발라야 돈이 아까워서 공부를 할까 싶다.
라스베가스에 온지 4일 째가 되었다.
그러다보니 먹고자고 세션 듣는 것들이 일상이 되었다.
그럼에도 불구하고 aws에서 제공하는 식단들은 너무나 물렸고, 이제는 맛도 없다고 느껴지고… 얼른 육개장 사발면 한사발 얼큰하게 때리고 싶은 마음 뿐이었다…
시차적응이 된 것도 같지만 아침부터 세션을 듣고 호텔들을 돌아다니다보면 지치는 건 마찬가지였다.
세션을 들어도 이해가 잘 되지 않으니 자포자기같은 심정을 먹다보니 자연스레 체력을 좀 보충하자는 생각에 또 리플렉션 룸에서 휴식을 청했다.
리플렉션 룸에서 쉬면서 노트북으로 Self-paced lab도 할 수 있어서 그나마 좀 내 템포대로 진행할 수 있어 편했다.
세션을 듣기도 더이상 지치다보니 자연스레 ‘미국에서만 할 수 있는 걸 찾아보자’란 생각에 또 AWS 부스 이곳 저곳 기웃기웃 거렸다.
하지만 역시 언어의 장벽에 막히고 자신감이 많이 줄어들은 상태라 많은 곳을 둘러보지는 못했다.
그러다 뭔가 미국에서 밖에 할 수 없는 것이 눈에 들어왔다.
정확한 명칭은 뭔지는 모르겠지만 카우보이 뭐시기가 아닐까… 싶다.
확실히 미국은 이런 카우보이 문화가 발달한 것인지 이런 놀이문화도 있는 것이 신기했다.
내 앞에 여러 사람들이 10초 대 초반에 떨어지는 걸 보고 나는 더 오래 버티리라는 다짐을 하고 올라타게 되었다.
근데 막상 찍힌 동영상을 보니 즐기기 보다는 ‘기록을 깨겠다’라는 경쟁심으로 불타있어 보였다.
다른 외국인들은 한손으로 타고 소리도 지르고 즐기던데… 나는 즐기러 온 게 아닌가? 라는 생각이 들었다.
여기까지 와서도 어떻게든 이겨보겠다는 그런 생각에 스트레스를 날려보내려면 마음을 다르게 고쳐먹어야겠다는 생각도 들었다.
셋째 날도 그냥 지나가다가 또 안 찍은 것 같은 공간을 몇 개 찍어보았다.
셋째 날은 그냥저냥 지쳐서 크게 한 건 없던 것 같다.
셋째 날에도 영어의 필요성을 절실히 체감하였지만 타코벨 세션이 정말 큰 충격을 주었다.
일개 음식점이라고 생각했던 기업이 테크기업이 됐다고?? 이벤트 드리븐 아키텍쳐를 고민한다고??
우리나라에서 감히 상상이나 할 수 있겠는가? 원할머니 보쌈이나 네네치킨 같은 곳에서…??
왜 미국을 기회의 땅이라고 하는 건지… 왜 미국 같은 곳에 와서 경험을 해봐야하는 건지 뼈저리게 느낀 날이었다.
우리나라에서는 조그만 비즈니스도 스케일이 커질 수 있고, 낙후한 산업이라고 생각했던 부분들이 미국에서는 거기마저도 기술을 도입하고 클라우드 위에서 돌아간다는 것이 신기했다.
영어를 못해도 이정도 깨달음을 얻을 수 있는데… 영어를 할 수 있었더라면 그들에게서 얼만큼의 인사이트들을 얻어낼 수 있을까?
감히 상상조차 되지 않았고, 영어 할 줄 아는 사람들이 진짜진짜 너무너무 부러웠다.
그리고 셋째 날 쯤… 되다보니 한국에 가고싶어졌다.
첫날에는 우와~ 라스베가스다~ 주변 풍경도 너무 삐까뻔쩍하고 멋있다~ 란 생각에 가득차있었다.
하지만 하루 이틀 지나다보니 그런 게 일상이 되었고, 오늘도 내일도 먹고자고세션듣고 먹고자고세션듣고 반복일 걸 생각하니 지루했다. (거기다 영어까지 못하니…)
남들은 여행으로 힐링을 한다지만 나는 딱히 힐링이 된다는 느낌 보다는 그냥 침대에 누워서 유튜브 보는 게 더 행복했다.
한편으로는 내 사비를 들여 친구들과 여행을 오면 좀 다른 느낌일까… 싶기도 했다.
나한테 이정도면 장기여행이고, 여행에 대한 나의 가치관을 다시 생각하게 된 계기가 된 것 같아 나중에 유럽여행에 대한 것도 고민을 좀 해봐야할 것 같다.
살면서 유럽도 별로 가볼 일이 없어서 가보긴 할 거 같지만 과연 내 생각만큼 즐겁고 행복할지는 이번 여행을 통해 더더욱 불확실해졌다.
미국에서만 할 수 있는 걸 해보자
라는 목표를 세우고 갔으나 많은 실패들이 있었고, 영어가 잘 안되다보니 aws reinvent 컨벤션 후기 보다는 라스베가스 여행기가 되어버린 것 같았다.베네시안 호텔에 아침부터 키노트가 있어서 이동을 하였다.
그래도 반복되는 길을 이틀 동안 왔다갔다 하다보니 도시의 풍경과 길들이 익숙해지기 시작했다.
또 신기한 것은 라스베가스 호텔 근처에서는 24시간 내내 음악 소리가 들리는 것 같았다.
밤에는 시끄러운 음악이 들렸던 것 같은데 아침에는 또 잔잔한 음악이었나… 여튼 분위기에 맞는 음악이 길거리에 울려퍼지는 게 신기했다.
어제만 해도 김치나 라면이 마려웠는데 잠자고 일어나니 그새 까먹고 빵보니 또 눈이 돌아가서 두 개나 집었다.
약간 좀 과하게 집었나… 생각이 들어 뒤를 쳐다봤는데 외국인들도 빵은 하나만 집고 있었다.
평상시에 아침도 잘 먹지 않는데 두개는 역시나 과했는지 한 개 밖에 먹지 못했다.
아침을 먹고 키노트 세션을 들으러 갔는데 대기시간에 유명한지 안 유명한지 모르겠는 밴드가 공연을 하였다.
키노트가 시작되어 연설을 했고 내가 샌프란시스코 - 라스베가스로 이동할 때 탔던 United 항공사라던지 나스닥 거래소라던지 이런 기업들이 어떻게 AWS를 사용해서 문제를 해결했는지 설명해주었다.
영어라서 뭔소린지 제대로 못 알아듣고, 그냥 그런갑다~란 생각으로 듣곤 했다.
나중에 알고보니 키노트는 동시통역을 제공해줬다고 한다.
근데 다른 키노트에서 들어보았는데 헤드셋이 너무 압박해서 귀가 아픈데 막상 주변 잡음은 다 들렸다.
그리고 영어를 한국어로 통역하다보니 어순이 맞지 않아 발표자가 한참 말하고 나서 한국어로 따다다다 통역을 하는 경우도 생겼고,
무엇보다 통역하는 사람이 엔지니어가 아닌 거 같은 게 통역의 퀄이 썩… 좋지 않았다.
키노트가 끝나서 또 다른 세션 장소로 이동 중이었는데 아침부터 DJ는 열일하고 있었다.
어제 저녁에도 다른 DJ가 있었던 것 같은데 아마 몇교대를 계속 돌리는 것 같았다…
그리고 칠판같은 공간에 여러 낙서가 있었고 기업을 홍보하는 듯한 문구도 있었다.
나는 악필이라 딱히 적지 않았고 동료 분께서 회사명을 적은 걸 기념해서 한 컷 찍어보았다.
(가끔 이렇게 영어 속에 다른 한글로 된 기업들의 문구를 보면 뭔가 반갑고 신기했다.)
코시국이다보니 입국할 때도 코로나 PCR 음성 검사 확인서가 있어야했다. (물론 출국할 때도 영문으로 된 확인서가 필요하기에 한국에서 8만원 가량을 내고 민간 병원에서 진행하였다.)
백신 2차 접종여부에 따라 달라지지만 나는 2차 접종을 했기 때문에 출국하기 72시간 이전에 검사받은 확인서가 필요하였다.
금요일 저녁에 출국이기 때문에 적당히 화요일 오후에 진행하였다.
무료로 해주는 곳은 시간이 좀 걸려서 혹시나 출국 전까지 안 나올 가능성이 존재하여 따로 유료로 하는 곳도 알아보았는데 호텔까지 와서 검사를 해주는데 30만원 가량이 들었다.
리스크에 도박을 해야했지만 30만원은 좀 선넘는다는 생각에 무료로 하거나 좀 더 싸게 할 수 있는 방법을 찾아보게 되었다.
대부분이 드라이브 쓰루 검사 밖에 지원을 해주지 않았지만 curative 사이트에서 라스베가스에 Walk in(차 없이 걸어서) 검사가 가능한 곳을 찾았다.
혼자 리스크를 감수하기에는 좀 쫄려서 동료 한 명을 섭외하고 같이 무료로 코로나 검사 예약을 진행하였다.
택시를 타고 이동하였는데 이상한 자동차 전시물과 건물, 주차장 말고 코로나 검사라고 보일법한 공간이 보이지 않았다.
그래서 뭔가 이상하여 건물을 쭉 한바퀴 돌아도 여전히 코로나 검사 안내 표지판이 하나도 보이지 않아 직원으로 보이는 사람 아무나 붙잡고 물어보았다.
뭐 영어는 잘 통하지 않았지만 대충 어느 방향으로 가라 정도까지만 알아듣고 또 가다가 이해가 안 되면 주변에 있는 사람 붙잡고 물어볼 예정이었다.
직원이 안내한 공간으로 아무리 가도 주차장 말고 다른 큰 건물 같은 건 보이지 않았다.
그러다 주차장 가장 구석에 컨테이너 박스가 하나 있는 것이 보였고 거기서 코로나 검사를 진행한다는 작은 안내표시판 같은 게 있어서 그걸 보고 겨우겨우 코로나 검사를 마칠 수 있었다.
가끔 코로나 검사 결과가 너무 빨리 나와 72시간이라는 기준을 준수하지 못하는 경우도 있어서 일부러 해당 시간 지나서 결과가 나오게 해달라고 얘기를 하고 그 다음날 검사 결과 이메일이 날아와서 코로나 검사는 다행히 잘 끝마칠 수 있었다.
세션을 계속 듣긴 듣는데 집중은 안 되고… 이해는 안 되고… 슬슬 지쳐갔다.
과연 세션을 무리해서 듣는 게 의미가 있을까? 라스베가스 현지에서만 할 수 있는 건 뭘까? 하고 고민하다가 찾은 게 Self-paced lab이었다.
들어가면 강의실에 온 거 마냥 자리에 PC(안타깝게 윈도우)들이 깔려있고 AWS 콘솔에 로그인을 하면 상황을 선택하여 AWS의 서비스들을 사용하여 해결하는 방식으로 AWS 서비스에 익숙해질 수 있게 만들어주었다.
들어가니 한국인 AWS 솔루션 아키텍트 사람도 있어서 간단한 대화를 나누었다. 내가 AWS를 직접 썼더라면 더 다양한 걸 물어봤을텐데 평상시 사용을 하지 않아서 궁금증이 덜 한 상태에서 만나서 별로 얘기할 껀덕지는 없었다.
그리고 나는 이해도가 느려서 그냥 상황 해결만 하는 것에 그치지 않고 이것 저것 설정을 바꿔보고 어떻게 동작하는지 궁금해서 뭔가 하나를 익히는데 오랜 시간이 걸리는 편이었는데 다른 일정 때문에 하나의 시나리오도 제대로 끝내지 못해 아쉬웠다.
저녁 먹기 전까지 또 시간이 살짝 붕 떠서 엑스포 구경을 갔다.
여기서부터 진짜 영어의 필요성을 절실히 체감하였다.
우선 여기 있는 기업들의 이름을 처음 들어보는 것도 많았는데 그들이 풀고자 하는 문제는 무엇이고, 그들의 솔루션들을 썼을 때 얼마나 편리해지는지 궁금했다.
그들은 어떤 AWS 서비스들을 사용해서 문제를 해결했는지 등등 궁금한 것은 많았는데 영어가 되지 않으니 용기가 생기지 않았다.
그들 입장에서도 말이 통해야 설명할 맛이 나고 홍보를 할텐데 대화도 안 통하는 사람이 와서 뻘쭘하게 서있거나 제대로 질문도 못하면 뭐라고 생각할까? 란 생각이 들어 선뜻 말을 걸지 못했다.
그래서 그냥 낯익은 기업이 보이면 스티커 가져가도 되냐? (Can I get some stickers?)와 같은 수준의 영어만 말하고 스티커만 몇 개 수집하고 말았다.
진짜 영어를 하지 못한다는 게 비통하다는 걸 처음으로 깨닫게 되는 순간이었다.
세션 이해 못하는 것 정도야 나중에 유튜브에서 다시 보면 되겠지… 정도로 말았는데 글로벌 테크 기업에서 주된 관심사는 무엇일까? 그들은 어떻게 해결했을까? 등등
어떻게 보면 여기에서 밖에 얻지 못할 것 같은 정보들을 하나도 얻지 못했다.
역시 기회는 준비된 자에게 오고, 준비가 되지 않은 자는 이렇게 회사에서 돈을 퍼줘서 떠먹여줘도 먹지 못한다는 사실을 절실히 깨닫게 되었다.
(라고 말하지만 그럼에도 불구하고 한국에 와서 열심히 영어공부하지 않는 걸 보면 사람은 쉽게 바뀌지 않는 것 같다…)
저녁은 진생이란 곳에서 먹었다.
한인식당이다보니 들어가마자 TV에서 K-pop 같은 게 틀어져있었고 종업원들도 한국인이고 한국인 손님들도 많아서 뭔가 한국에 온 듯한 이질감이 들었다.
김치찌개 같은 얼큰한 걸 먹지 않아서 아쉬웠지만 그래도 삼겹살 같은 한국 음식을 먹었다는 것에 위안 삼았다.
저녁을 먹고 주변을 좀 둘러다 보다 들어가기로 했는데 뉴욕뉴욕 호텔을 만나게 되었다.
베네시안 호텔이 이탈리아 베니스를 테마로 만들어졌다면 뉴욕뉴욕 호텔은 미국의 뉴욕을 테마로 만들어진 호텔이다.
한국에서 비슷하게 영어마을 같은 게 있지만 이건 진짜 제대로 흉내낸 듯한 느낌이었다. (개인적으로는 제대로 흉내냈다고 느꼈지만 동료들은 그냥 어줍짢게 흉내낸 느낌이 든다고도 하였다.)
관광지 답게 기념품 상점들이 많았다.
그것도 단순한 기념품 상점이 아닌 글로벌 기업들의 기념품 상점이라고 하니 궁금하였다. (하지만 뭐 하나도 사지는 않았다.)
코카콜라 기념품 샵인데 진짜 별에 별 게 다 있었다.
코카콜라 찐팬들이라면 눈 돌아갈테지만 나는 그정도까지는 아니고 즐겨먹는 음료이기 때문에 그냥 눈으로만 즐겼다.
귀여운 북극곰 인형 정도 하나 사서 조카한테 줄까 싶었지만 인형은 너무 많다고 누나가 그래서 딱히 사지는 않았다.
m&m’s라는 초콜릿인지 과자 기업의 기념품 샵도 있었다.
자주 먹지는 않지만 그냥 슈퍼마켓에서 종종 보던 로고라서 궁금증에 들어갔고 가족단위로 놀러온 사람들에게는 좋은 관광코스가 될 것 같았다.
아이들도 좋아하는 듯 보였다.
하지만 여기서도 뭐 딱히 사지는 않았다.
그리고 마지막 날의 대미를 장식을 벨라지오 호텔의 분수쇼를 관람하였다.
벨라지오 호텔의 분수쇼는 꼭 한 번 보는 걸 추천한다.
물론 계속 하는 건 아니고 30분인가 몇분 주기로 하긴 하지만 안 보고 오면 너무 아까운 쇼인 것 같다.
영어의 필요성을 절실히 체감하였다.
기회의 땅이라는데 나는 준비가 안 돼있어서 남이 떠먹여줘도 기회를 얻지 못했다.
진짜 영어를 할 줄 아는 건 쇼미더머니 치트키를 쓰는 것이나 다름이 없는 것 같았다.
내가 아무리 기술적 역량이 뛰어난다한들 세상의 흐름에 뒤쳐지는 건 너무나 빠르게 진행이 될 것 같았다.
라고 말하고 한국에 와서 영어 공부를 열심히 하지 않는 걸 보면… 음… 어떻게 해야 사람이 바뀔런지 궁금하기도 하다.
또한 둘째 날부터 그나마 호텔 근방을 조금이나마 벗어나보았다.
코로나 검사를 하러 우버인가 리프트를 타고 근방으로 가보긴 했는데 확실히 차 없으면 미국에서는 관광이 너무 힘들 것 같았다.
영어도 안 되니 대중교통을 이용하여 원하는 목적지까지 가는 건 너무나 두려웠다. (로밍을 했는데 인터넷이 잘 되지 않아 지도 앱을 보고 따라가는 것도 한계가 있어보였다.)
그리고 호텔 주변은 그냥 일반 도심같은 느낌이 들었는데 차를 타고 조금만 이동하니 주변이 사막이라는 걸 체감할 수 있게 끔 황량한 풍경들이 조금씩 보이는 것 같았다.
둘째 날부터 조금씩 호텔 근방을 벗어나보고 벨라지오 분수쇼도 보고 하다보니 ‘아… 드디어 라스베가스에 왔구나…’라는 느낌이 들었다.
그 전까지는 근방에서만 활동하다보니 그냥 AWS 리인벤트 세션 들으러 온 기분 밖에 나지 않았는데 뭔가 주변 관광 코스라고 할법한 공간들을 돌아다녀 보니 라스베가스에 왔다는 느낌을 조금이나마 체감할 수 있었다.
그리고 잠들기 전에 또 느낀 게 하나 있다.
어떻게 마지막 날까지 버티지?
버틴다는 생각이 들은 이유는 기름진 음식들, 반복된 패턴(일어나서 밥먹고 세션듣고 밥먹고 세션듣고 밥먹고 잠자기), 세션을 들어도 이해가 안 되니 지루함이 컸다.
마지막 날까지 버텨내야한다는 생각을 하다보니 점점 더 무리하게 세션을 듣는 것을 포기하고 선택과 집중을 해야겠다는 생각이 들었다.
미국에서만 할 수 있는 걸 해보자
라는 목표를 세우고 갔으나 많은 실패들이 있었고, 영어가 잘 안되다보니 aws reinvent 컨벤션 후기 보다는 라스베가스 여행기가 되어버린 것 같았다.한국시간 기준 일요일 저녁 출발이었고, 코시국이라 인천공항은 사람이 별로 없었다.
하지만 미국으로 가는 항공편만 사람이 좀 북적여서 수하물을 붙이는데 30분 가량 걸렸다.
코시국이라 기내식이 없을 줄 알았는데 식욕은 거스를 수 없는 본능이기 때문인지 나왔다.
생애 첫 기내식이라 기대를 품고 먹었고 그냥저냥 나쁘지 않게 먹었다.
또 몇시간이 흘러 두 번째 기내식이 나왔다.
10시간이 넘는 비행시간이라 그런지 두 번이나 나왔는데 두 번째부터 물렸다.
그냥 먹고 자고 먹고 자고 마치 사육당하는 기분이었다.
바로 라스베가스로 가는 게 아니라 샌프란시스코 공항에 경유하게 되었다.
인천공항까지만 하더라도 미국으로 간다는 느낌이 전혀 들지 않았다.
한국인에게 체크인 하고, 한국인 승무원이 탑승하고, 비행기에도 대부분이 한국인이어서 미국을 간다는 것이 체감되지 않았다.
하지만 샌프란시스코 공항에 도착하고 나서부터는 광고판이며 간판이며 모두 영어였다.
또한 비행기에서도 바로 내 옆자리에 외국인이 앉아있었고, 또 한국과의 가장 큰 차이점은 승무원들의 외모였다.
한국은 승무원하면 ‘젊고 이쁘다’인데 미국은 ‘인종도 다양하고 연령도 다양하다’였다.
연세가 좀 있는 듯한 흑인 승무원 분도 계셨는데 왠지 모르게 전문성이 가득해보였다.
이렇듯 한국과 미국은 승무원이라는 직종에서부터도 큰 차이가 있어보였다.
카지노의 도시답게 라스베가스는 공항부터 카지노가 보였다.
공항에 택시들이 줄서있는 건 어딜가나 국룰인 것 같다.
우리는 Wynn Las Vegas 호텔에 머무르게 되었다.
근데 입구에서부터 정말 압도되었고… 내부는 이미 크리스마스 장식이 너무 이쁘게 되어있었다.
한국에서 이런 호텔을 가본 적이 없었다보니(있는지도 모르겠지만) 여기 정말 호텔이 맞나?란 생각이 들 정도로 너무너무 근사했다.
크리스마스 장식의 감동도 잠시… 카지노의 도시답게 호텔에는 카지노 슬롯머신들이 삐까뻔쩍하게 즐비해있다.
전에 일본에 놀러간 적이 있었는데 카지노와 비슷한 빠칭코를 경험 한 적이 있었다.
그 때 일본인들은 빠칭코에는 관심은 없고 그냥 시간을 죽이러 오는 사람들도 많아보였다.
약속시간까지 기다리기 애매할 때 빠칭코 가게에 가서 그냥 머신을 돌려만 놓고 핸드폰을 보는 사람들도 많았기 때문이다.
카지노도 그런 느낌으로 하는 걸까… 궁금증이 많았지만 겁도 나고 피곤했기 때문에 바로 시도해보지는 않았다.
오션뷰도 아닌 Crypto 뷰…
AWS Reinvent 기간이라 그런지 힐튼 호텔 광고에 crypto.com이 보이니 뭔가 오묘했다…
시간이 지나면 데이터독이나 기타 테크 기업들의 광고도 나왔다.
방은 아쉽게도 1인실이 아닌 2인실이었다. (이것마저 1인실을 바라면 너무 도둑놈 같아 보인다.)
슬리퍼는 당연히 없을 것 같아서 한국에서 하나 가져왔고, 호텔에 있는 음료/과자 같은 거 밑에 저울이 달려있어 무게가 조금이라도 달라지면 바로 과금이 된다고 했다.
호텔에서 짐정리 한 후 세미나 등록을 위해 The Venetian Resort Las Vegas 호텔로 이동을 하였다.
나도 잘 몰랐는데 라스베가스의 호텔들은 세미나나 각종 컨벤션들을 위해 사용된다고 한다.
호텔 안에 그런 걸 위해 별도의 공간들이 많이 마련돼있고, AWS는 단순 한 호텔이 아닌 Wynn, Venetian, Caesars Forum 등등 다양한 곳에서 진행이 되었다.
호텔 간 이동거리는 걸어서 한 15분 정도 걸렸던 것 같고, 그 안에 카지노도 있기 때문에 길을 헤매는 경우도 많았다.
그러다보니 하루에 최소 2만보는 걸었고 아침 일찍부터 듣게되면 오후에는 시차적응 + 안하던 걷기 운동을 하게 됨에 따른 피로감이 몰려와서 졸리곤 하였다.
베네시안 호텔로 걸어서 약 15분 정도를 이동하였는데 그냥 길거리들이 다 삐까뻔쩍하고 관광의 도시답게 정말 잘 꾸며놓았다.
속으로 그래… 이게 미국이지… 이런 생각을 하면서 걸었다.
베네시안 호텔은 이탈리아의 관광도시인 베니스(Venezia)를 테마로 만든 호텔이라고 한다.
그래서인지 이탈리아에나 있을 법한 분위기들을 주로 연출하고 있는데 바다에서 배를 타는 듯한 느낌의 관광상품도 있는데 나중에 가족이나 연인끼리 오면 그냥 한번 해볼법 한 것 같다.
베네시안 호텔에 들어가서 aws 리인벤트를 등록하러 가는데 AWS 로고가 보이고 관련된 장소들이 등장하자 뭔가 압도되는 느낌이 들었다.
아무리 국내에서 날고 긴다하는 테크 기업들이 있다지만 ‘글로벌 기업은 진짜 다르구나… 어떻게 이런 스케일로 행사를 진행할 수 있지??’ 이런 생각이 들었다.
또 한편으로 ‘나 놀러온 게 아니라 세미나 들으러 온 거였지?’ 하고 정신이 확 들기도 했다.
Registration 부스에 가서 등록을 마치고 Swag 부스에서 AWS Reinvent 10주년 기념 후드집업도 받았다.
등록을 마치고 미국에서의 첫끼니는 아웃백에서 먹게 되었다.
아웃백의 본고장인 미국에서 먹는다는 것에 매우 설렜지만 주문을 하는 것부터가 난항이었다.
한국 아웃백도 별로 가본 적이 없어서 메뉴도 잘 모르고 선택할 것도 많아서 선택장애가 오곤 하였는데 미국은 영어로 된 메뉴판에서 영어로 주문한다고 하니 거기서부터가 난관이었다.
다행히 일행 중에 영어를 잘하시는 분이 계셔서 주문을 성공적으로 마치고 아주 맛있게 먹었다.
또한 미국이라 그런지 양이 참 많았다. 7명이서 메뉴를 5개 시켰는데도 남을 정도였다.
아, 여담으로 아웃백은 가게를 찾아 들어가는 것부터가 또 문제였다.
아웃백은 2층에 있는데 2층으로 가려면 1층을 통해 갔어야했는데 1층에 또 카지노가 있어서 어디로 가야하는지부터도 찾는데 시간이 좀 걸렸었다.
진짜 라스베가스는 카지노 없으면 섭할 정도로 카지노는 어딜 가나 존재하는 것 같았다.
저녁을 먹고 AWS 리인벤트의 전야제인 Midnignt Madness에 참석하였는데
일반인 참여자가 올라와서 락음악에 맞추어 허공에 드럼&기타질하기, OX 퀴즈 등등 같은 것이 진행되었지만 미국 블랙코메디인지 나하고는 코드가 잘 맞지 않았다.
그리고 한국은 잘 모르겠지만 미국은 묘기 스포츠 같은 것들이 잘 형성돼있어서인지 자전거&스케이트 보드로 엄청난 퍼포먼스를 보여주었다.
윈 호텔의 커튼은 자동으로 걷고, 칠 수 있다.
첫날은 아침을 제공해주지 않았고, 스타벅스에 가서 샌드위치랑 커피로 간단히 떼웠다.
본토 스타벅스라 그런지 아침부터 대기줄이 길었고, 한번 쯤 미국 스타벅스에 가본다는 자그마한 목표도 달성을 해보았다.
그리고 시간이 좀 남아서 reflection room을 돌아보았다.
reflection room이라는 용어를 처음 들어봐서 구글에 검색해보았을 때는 ‘와… 거울이 가득한 고요한 방에서 명상을 하는 공간인가? 심신의 안정을 찾는 공간인가?’라는 생각이 들면서 이런 공간까지 있는 진짜 대단한 행사라는 생각이 들었다.
하지만 실상은 그냥 공간하나 대여해서 요가매트 깔아놓고 알아서 명상&요가 하는 공간이었다.
나는 요가나 명상을 별로 해본 적이 없다보니 실제로는 시차 적응이 안 돼서 졸릴 때 종종 리플렉션 룸에 와서 잠을 청하곤 했다. (빈백도 있어서 잠 자기 편안했다.)
호텔까지 가려면 또 20분 가량 걸어서 가야하다보니 엄두도 안 났는데 그래도 휴식하기 적당한 공간이라 종종 애용하였다.
그리고 또 놀란 게 이슬람교인지 모르겠지만 오후에 특정 시간이 되니 하나 둘 리플렉션 룸으로 오더니 특정 방향을 보고 절을 하는 것을 보고 ‘와… 찐 종교인이구나…’하고 신기했던 경험도 있다.
점심부터는 AWS 측에서 제공해줘서 가까운 호텔에 가면 먹을 수 있었다.
아웃백을 먹을 때까지만 해도 고기나 기름진 음식이 너무 좋았고 초딩 입맛인 나한테는 너무나 좋았다.
하지만 아침에 스타벅스에서 샌드위치를 먹고 나서 점심에도 또 샌드위치를 먹을 생각을 하니… 너무나 물렸다.
쌀은 없고, 얼큰한 음식도 없어서 이때부터 조금씩 고통이었다.
그래도 아직은 세미나 첫날이기 때문에 먹을만 하였다.
세션을 듣긴 들었는데… 영어이다보니 이해 안 되는 게 태반이었다. (AWS를 안 쓰다보니 이해가 안 되는 것도 많았고…)
확실히 이 때부터 영어의 필요성을 체감하기 시작한 것 같다.
어제는 베네시안 호텔 외부를 주로 봤다면 베네시안 호텔에서 세션을 듣다보니 베네시안 호텔 내부도 돌아다니다 어제는 못봤던 곳들도 많이 보게 되었다.
그러다 베네시안 호텔 2층에서 마치 하늘이 뚫려있는 듯한 공간을 만났다.
하지만 정말 세트장처럼 꾸며놓아서 저기서 밥을 먹게 된다면 진짜 이탈리아 베니스에 온듯한 느낌이 들 것 같았다.
일반적인 세션 말고 리더십 세션은 오페라나 뮤지컬 공연장 같은 큰 공간에서 하였다.
첫날 저녁은 라스베가스에서 유명한 쌀국수 집이라는 Pho Kim Long에 다녀왔다.
막상 찍고보니 음식 사진은 없고 간판만 찍었다.
맛의 조예가 깊지 않다보니 뭐 엄청 대단하다… 다르다… 특별하다… 라고 느끼기보다는 그냥 뭐 먹을만 했다? 맛있다? 정도였다.
그래도 계속되는 샌드위치/고기 파티에서 조금은 벗어나서 색다른 음식을 먹을 수 있어 좋았다.
그래도 김치랑 라면이 마렵긴 마찬가지였다.
대충 첫날의 일정을 마치고 돌아오면서 어디서 못 본 것 같은 분수라서 한 컷 찍었다.
첫날의 일정이 생각보다 빡세서 일행은 이슈를 처리하다가 중간에 잠이 들어버렸다…
인천공항에서(11/28)부터 세미나 첫날(11/29)까지의 소감은 ‘이제 첫날이라고?’였다.
15~6시간 정도 되는 긴 비행(중간 경유시간 포함)부터 낯선 문화, 기름진 음식들, 그리고 세션을 들으러 호텔을 이리저리 이동하는 것까지…
아직 첫날 밖에 끝나지 않았다는 것이 믿기지 않았다.
인천에서 미국으로 갈 수록 시간이 느려지는데 그러다보니 한국시간(KST) 11/28 저녁 8시 쯤에 출발해서 라스베가스 현지시간(PST) 11/28 저녁 6시 쯤에 도착하였는데 이것도 한 몫 한 것 같긴 하다.
여행 가면 시간이 빨리간다는데 난 왜 이리 시간이 안 가는 거지?
라는 생각이 들다가 아 맞다… 나 여행온 거 아니지… 라고 다시 정신을 차리곤 하였다.
가장 큰 문제는 세미나에 집중하려해도 AWS 배경지식 부족 + 언어에서 오는 문제점으로 인해 세션에 집중할 수 없었다.
그래도 회사에서 지원까지 받았고 나 대신 열심히 일하는 동료들도 있는데… 라는 생각으로 내일부터는 좀 더 세션을 이해해야겠다고 다짐하였다.
그리고 너무 과하게 스케쥴을 잡다보니 피곤하고 시간에 쫓기듯 이동하다보니 점심도 제대로 못 먹고 세션을 들으러 가기도 하였다.
그래서 좀 템포를 조절하여 세션을 들어야겠다고 생각하였다.
https://docs.spring.io/spring-boot/docs/current/reference/html/configuration-metadata.html
Spring Boot jars include metadata files that provide details of all supported configuration properties.
The files are designed to let IDE developers offer contextual help and “code completion” as users are working withapplication.properties
orapplication.yml
files.
Configuration Metadata는 IDE에서 yml 혹은 properties에서 사용하는 Configuration의 자동완성을 도와주는 메타데이터이다. (소스코드에는 영향을 1도 안 미친다.)
You can easily generate your own configuration metadata file from items annotated with
@ConfigurationProperties
by using thespring-boot-configuration-processor
jar.
The jar includes a Java annotation processor which is invoked as your project is compiled.
@ConfigurationProperties 어노테이션이 붙은 클래스에 대한 Configuration Metadata File은 spring-boot-configuration-processor를 통해 생성할 수 있다고 한다.
build.gradle.kts에 kapt 플러그인을 활성화시켜준다. (코틀린 컴파일러로 컴파일하기 때문에 자바로 작성한 어노테이션을 해석하지 못하기 때문)
1 | kotlin("kapt") version "1.6.0" |
build.gradle.kts에 아래 디펜던시들을 추가해준다. (멀티 모듈인 경우 모든 모듈에 일일이 추가하는 게 귀찮으니 루트의 build.gradle.kts에 추가해주는 것이 좋다.)
1 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") |
다만 몇 가지 한계점이 있는데 아래와 같다.
코틀린은 nullable을 지원하다보니 소스코드에서 null에 대한 체크를 매번하지 않아도 돼서 매우 편하다.
하지만 이건 우리 소스코드 내부의 사정이고 소스코드 외부에서 들어오는 데이터의 경우에는 단정지을 수 없다.
그 단적인 예가 네트워크를 통해 들어오는 HTTP API의 응답이다.
1 | class ResponseV1( |
이런 응답 객체가 있다고 할 때 과연 number와 text는 non-null을 100% 보장할 수 있을까??
1 |
|
외부 API의 응답을 모킹하기 위해 wiremock을 사용하였다.
만약 외부 API의 응답 중 text 필드가 오지 않았더라면 RestClientException(cause exception은 HttpMessageNotReadableException)을 던지게 된다.
그러면 아래와 같이 코드를 개선해볼 수 있다.
1 | class ResponseV2( |
우선 생성자를 전부 nullable로 정의해서 객체의 성공을 보장하고, 멤버변수는 전부 기본값을 정의해서 non-null을 보장하였다.
1 |
|
혹시나 Data Class를 꼭 사용해야한다면 아래와 같이도 할 수 있다.
1 | data class ResponseV3( |
jackson은 기본 생성자를 리플렉션하여 객체를 생성하는데 기본 생성자가 없으니 객체 생성을 위해 사용할 생성자에 @JsonCreator 어노테이션을 달아주었다.
자바에서도 똑같이 null에 대한 검증을 모두 끝마친 깔끔한 response dto 객체를 원할 것이다.
1 | public class Response { |
기본적으로 응답 객체를 수정하는 행위는 소스코드의 예측력을 떨어뜨리므로 불변객체로 만들고,
불변객체이므로 getter를 사용하나 필드에 직접 접근하나 재할당하지 못한다는 사실은 똑같기 때문에 불필요하게 getter 메서드를 사용하지 않고, 접근이 필요한 필드의 경우에만 public 접근 지정자를 사용하여 직접 필드를 참조하도록 하였다.
jackson은 기본 생성자를 리플렉션하여 객체를 생성하는데 기본 생성자가 없으니 객체 생성을 위해 사용할 생성자에 @JsonCreator 어노테이션을 달아주었다.
또한 클라이언트로부터 어떤 요청이 들어올지 모르니 일단 생성자에서는 전부 null을 허용하고 기본값을 할당하였다.
코틀린은 nullable을 지원하다보니 소스코드에서 null에 대한 체크를 매번하지 않아도 돼서 매우 편하다.
하지만 이건 우리 소스코드 내부의 사정이고 소스코드 외부에서 들어오는 데이터의 경우에는 단정지을 수 없다.
그 단적인 예가 네트워크를 통해 들어오는 HTTP API의 요청이다.
1 | class RequestV1( |
이런 요청 객체와 API가 있다고 할 때 과연 number와 text는 non-null을 100% 보장할 수 있을까??
1 |
|
클라이언트에서 http 요청을 보낼 때 충분히 필수 필드를 누락할 수 있고, 이 때 서버에서 HttpMessageNotReadableException 예외를 던지게 된다.org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of ... problem: Parameter specified as non-null is null: method example.web.mvc.RequestV1.<init>, parameter text
기본적으로 이 경우에는 DefaultHandlerExceptionResolver에서 예외를 핸들링하여 warn 로그를 찍게 된다.
이런 경우에는 HttpMessageNotReadableException 보다는 MethodArgumentNotValidException 예외를 던지는 것이 더 적합해보인다.
그러면 아래와 같이 코드를 개선해볼 수 있다.
1 | class RequestV2( |
우선 생성자를 전부 nullable로 정의해서 객체의 성공을 보장하고, 멤버변수는 전부 기본값을 정의해서 non-null을 보장하였다.
생성자의 인자를 기준으로 요청을 검증하는 게 아니라 이미 생성된 객체를 기준으로 검증을 하기 때문에 멤버변수에 할당된 기본값 기준으로 어노테이션을 설정해야한다.
1 |
|
혹시나 Data Class를 꼭 사용해야한다면 아래와 같이도 할 수 있다.
1 | data class RequestV3( |
생성자 함수가 아닌 멤버변수에 어노테이션을 설정하기 위해 @field라고 어노테이션 타겟을 명시했다.
(참고: Annotation use-site targets)
또한 jackson은 기본 생성자를 리플렉션하여 객체를 생성하는데 기본 생성자가 없으니 객체 생성을 위해 사용할 생성자에 @JsonCreator 어노테이션을 달아주었다.
자바에서도 똑같이 null에 대한 검증을 모두 끝마친 깔끔한 request dto 객체를 원할 것이다.
1 | public class Request { |
기본적으로 요청 객체를 수정하는 행위는 소스코드의 예측력을 떨어뜨리므로 불변객체로 만들고,
불변객체이므로 getter를 사용하나 필드에 직접 접근하나 재할당하지 못한다는 사실은 똑같기 때문에 불필요하게 getter 메서드를 사용하지 않고, 접근이 필요한 필드의 경우에만 public 접근 지정자를 사용하여 직접 필드를 참조하도록 하였다.
jackson은 기본 생성자를 리플렉션하여 객체를 생성하는데 기본 생성자가 없으니 객체 생성을 위해 사용할 생성자에 @JsonCreator 어노테이션을 달아주었다.
또한 클라이언트로부터 어떤 요청이 들어올지 모르니 일단 생성자에서는 전부 null을 허용하고 기본값을 할당하였다.
생성된 요청 객체의 멤버변수에는 적절한 벨리데이션을 위한 어노테이션을 추가하면 된다.
1 | private Map<String, String> mappings; |
맵에서 entrySet(key/value 쌍)을 가져와 forEach 돌면서 특정 조건에 맞으면 맵에서 요소를 삭제했더니 한 번만 요소가 삭제되고나서 ConcurrentModificationException을 던졌다.
여기서 아래와 같은 의문점이 생겼다.
Concurrent
ModificationException을 던진 걸까?In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process. Such designs often check the system’s state at several points in an operation, so any failures can be detected early. The responsibility of a fail-fast module is detecting errors, then letting the next-highest level of the system handle them.
위키피디아 Fail-fast에서 따온 건데, 실패 조건에 부합한다면 바로 후속 작업 같은 걸 멈추는 걸 fail-fast라고 부르는 것 같다.
비슷하게 gradle에서 테스트 같은 태스크를 돌릴 때 fail-fast 옵션을 킬 수 있는데, 하나의 테스트라도 실패하면 그 뒤에 테스트들은 실행조차 하지 않고 테스트가 실패했다고 처리하는 방식이다.
비슷한 맥락으로 runtime에서 터질 에러를 compile-time으로 땡겨와서 에러를 잡는 것도 Fail-fast 전략이라고도 부르는 것 같다. (자바에서는 not null 타입이 없어서 NullPointerException으로 runtime에 에러가 던져졌는데 코틀린에서는 not null 타입이 생기면서 null을 넘기면 compile-time에 에러가 생겨 좀 더 빠른 실패가 보장된다던지… 등등)
실제 내가 사용했던 Map의 구현체인 LinkedHashMap의 javadoc을 보면 아래와 같이 나온다.
The iterators returned by the iterator method of the collections returned by all of this class’s collection view methods are fail-fast: if the map is structurally modified at any time after the iterator is created, in any way except through the iterator’s own remove method, the iterator will throw a ConcurrentModificationException. Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.
this class(LinkedHashMap)의 collection view를 반환하는 메서드에 의해 반환된 컬렉션의 iterator 메서드에 의해 반환된 iterators는 fail-fast라고 한다. (자바의 Collections Framework에서 View에 대한 내용은 https://softwarecave.org/2014/03/19/views-in-java-collections-framework/ 를 참조 바람)
mappings.entries.forEach
여기서 말하는 collection view를 반환하는 methods는 위 예시에서 entries(내부적으로 자바의 entrySet 메서드 호출)를 의미하고, forEach 메서드 안에서 내부적으로 iterator 메서드를 호출하여 iterator를 반환받고 iterating 하고 있는 것이다.
만약 map(예시에서 LinkedHashMap)이 iterator 생성 이후 구조적으로 변경(put(add)/remove 메서드를 통해 구조가 바뀌는 경우)되는 경우에는 iterator는 ConcurrentModificationException을 던진다.
이를 통해 잠재적으로 동시에 Map이 수정되는 현상을 방지하며 빠르고, 깔끔하게 실패처리를 하고 있다고 한다.
Map 입장에서는 이게 멀티쓰레드 환경에서 돈 건지 아닌지 모르고, 혹시나 모를 동시성 이슈에 대비해 구조가 바뀌면 바로 ConcurrentModificationException을 던지는 것 같다.
실제 LinkedHashMap의 구현체를 보면 아래와 같다.
1 | public V remove(Object key) { |
removeNode 메서드의 맨 아랫 부분의 조건문을 보면 실제 삭제가 발생할 때 ++modCount
를 통해 변경된 횟수를 늘리고 있다.
1 | public Set<Map.Entry<K,V>> entrySet() { |
entrySet 메서드를 통해 반환되는 EntrySet의 iterator 메서드는 EntryIterator를 반환하고, EntryIterator가 상속받은 HashIterator는 fast-fail을 위해 생성자에서 Map(LinkedHashMap)의 modCount를 expectedModCount 변수에 저장하고 있다.
1 | final class EntryIterator extends HashIterator |
EntryIterator의 next 메서드는 부모 클래스인 HashIterator의 nextNode() 메서드를 호출하는데 그 안에서 객체 생성 당시의 modCount(expectedModCount)와 현재 Map(LinkedHashMap)의 modCount를 비교해서 다르면 ConcurrentModificationException을 던지고 있는 것을 볼 수 있다.
문제가 발생(할 가능성이 보이면)하면 후속작업을 하지 않고 바로 fail 처리(예외 던져버리기)를 해버리는 점에서 fail fast iterator라고 불리는 것 같다.
그럼 다시 문제상황에서 어떻게 코드가 내부적으로 돌아갔을지 확인해보자.
1 | public void clear(final CardCompanyCode cardCompanyCode) { |
이 문제를 해결하기 위해서는 map.remove 메서드가 아닌 iterator.remove() 메서드를 사용해야한다.
1 | abstract class HashIterator { |
iterator.remove() 메서드 안에서도 실제로 removeNode(map.remove에서도 호출함) 메서드가 호출되지만, expectedModCount를 현재 modCount로 갱신하는 게 큰 차이점이다.
따라서 위 예시는 아래와 같이 바꾸면 해결된다.
1 | public void clear(final CardCompanyCode cardCompanyCode) { |
Fail Safe Iterator라는 용어가 없지만 Fail Fast Iterator와 반대되는 개념이라고 보면 된다.
대표적으로 ConcurrentHashMap의 Collection View를 반환하는 메서드(entrySet, keySet, valueSet 등등)의 iterator 메서드가 생성하는 iterator가 있다.
1 | static final class EntryIterator<K,V> extends BaseIterator<K,V> |
ConcurrentHashMap의 entrySet 메서드의 반환타입인 EntrySetView의 iterator 메서드의 반환타입인 EntryIterator의 next 메서드를 보면 ConcurrentModificationException을 던지지 않는 것을 볼 수 있다.
즉, fail fast iterator와 달리 새로운 요소가 추가/삭제되더라도 끝까지 모든 요소를 순회하는 것이다.
ConcurrentHashMap에서 요소가 추가/제거되더라도 ConcurrentModificationException을 던지지 않는 이유는 ConcurrentHashMap은 추가/삭제 메서드에 synchronized 키워드를 사용하여 락을 잡은 후 다른 쓰레드에서 건드리지 못하도록 하기에 동시성으로부터 안전하기 때문이다.
1 | public V remove(Object key) { |
즉, fail safe iterator는 요소가 추가/삭제 되더라도 ConcurrentModificationException을 던지지 않고 모든 요소를 순회할 수 있으며 동시성 이슈로부터도 안전하다(Safe).
위와 같이 syncronized로 해결하는 케이스도 있지만, CopyOnWriteArrayList처럼 원본 collection을 카피한 후 카피한 collection으로부터 iterator를 생성하여 사용하는 fail safe iterator도 있다. (원본 collection과 생성된 iterator는 무관하기 때문에 ConcurrentModificationException을 던지지 않는다.)
]]>이해를 편하게 돕기 위해 엔티티는 아래와 같은 구조를 가진다.
1 |
|
1 | public class SomeType { |
SomeType은 JPA에서 모르는 커스텀 타입이기 때문에 컨버터를 만들어주자.
1 |
|
테스트 편의성을 위해 convertToEntityAttribute 메서드에 브레이크 포인트를 걸고 확인해보면 편하다.
테스트를 위해 Repository에 Readonly 트랜잭션이 아닌 findBy 메서드를 만든다.
(SimpleJpaRepositroy의 findById 메서드는 readonly 트랜잭션이기 때문에 커스텀 메서드를 만들었다.)
1 | public interface ParentRepository extends JpaRepository<Parent, Long> { |
그리고 findByNo를 호출하면서 SomeAttributeConverter.convertToEntityAttribute 메서드는 몇 번 호출되는지 보자.
TwoPhaseLoad.initializeEntityFromEntityEntryLoadedState 메서드가 핵심이다.
1 | if ( isReallyReadOnly ) { |
readonly 트랜잭션이면 성능최적화를 위해 스냅샷을 만들 필요가 없다고 하고 있고, 그게 아니면 스냅샷을 만들고 있고 그 안에서 딥카피가 수행되고 있다.
여기서 핵심은 트랜잭션을 생성하지 않더라도 스냅샷(딥카피)를 만든다는 것이다.
1 | public interface ParentRepository extends JpaRepository<Parent, Long> { |
위와 같이 트랜잭션을 생성하지 않더라도 리드온리 트랜잭션은 아니기 때문에 else 구문을 탄다.
이번에는 readonly 트랜잭션을 사용하는 SimpleJpaRepository.findById 메서드를 사용하여 스냅샷(딥카피)을 만드는지 직접 확인해보자.
1 |
|
위에서 보다싶이 SimpleJpaRepository는 타입에 readonly 트랜잭션이 적용돼있어서 해당 어노테이션을 오버라이딩 하지 않은 모든 메서드는 readonly 트랜잭션을 사용한다는 것을 알 수 있다.
이렇듯 JPA(하이버네이트)에서는 readonly 트랜잭션이면 성능최적화를 위해 엔티티의 스냅샷(딥카피)을 만들지 않는 걸 볼 수 있다.
]]>글을 정리하다 보니 너무 깊게 파고 정리한 거 같아 글이 너무 길어져서 아무도 읽지 않을 것 같아 정리부터 해보겠습니다.
제목은 엔티티 매니저의 persist와 merge에 대해 개념을 설명할 것처럼 적어놨지만 이해를 돕기 위해, 흥미 유발을 위해 사내에서 겪었던 문제 과정을 서술하겠습니다.
1 |
|
자식을 낳는 Mother 엔티티와 Child 엔티티가 1:N 양방향 관계 매핑이 돼있는 상황입니다.
그리고 Mother의 모든 Cascade(영속성 전이) Action에 대해 children에게 전파가 되도록 하였습니다.
이제 산모는 출산 예정일이 다가와 산부인과에서 출산을 시작합니다.
1 | // 산부인과 표현이 이게 맞는지 모르겠네용~ |
코드가 더럽긴 하지만, 일단 코드는 잘 돌아갈 것 같습니다만…
TalentedPersonTrainingSchool의 register 메서드를 호출할 때 TalentedPerson 객체에 child.id 필드에 접근합니다.
save를 하기 전에 엔티티의 ID를 사용하려고 해서 문제가 발생했으니 이제 save를 먼저 호출하면 될 것 같습니다.
1 |
|
save를 먼저 호출했음에도 불구하고, child의 id가 null입니다.
하지만 mother.children[0]에 있는 child에는 id가 박혀있습니다!!
또한 child와 mother.children[0]의 레퍼런스가 다른 걸 보아 다른 객체로 보이는군요!!
JPA 알못인 저에게는 정말 이해할 수 없는 미스테리였습니다.
먼저 쿼리 로그를 한 번 봐봅시다.
save를 가장 나중에 호출한 케이스입니다.
1 | // motherRepository.findByIdOrNull(father.wifeId) |
너무 내용이 길어서 3 줄로 요약해보면
child가 insert 된 이유는 flush를 호출했기 때문이 아니라 flush 이전에 cascade를 했기 때문입니다.
우선 JPQL을 호출하기 전에 child의 insert는 호출됐는데 왜 mother의 update는 호출이 되지 않은 건지 너무나 궁금했습니다.
1 | /** |
그리고 그 안에는 AutoFlushEvent를 발생시키고 있습니다.
DefaultAutoFlushEventListener의 onAutoFlush 메서드를 이벤트 리스너로 등록하고 있습니다.
1 | public void onAutoFlush(AutoFlushEvent event) throws HibernateException { |
onAutoFlush 메서드에서는 flush가 필요한지 확인하고 있는데
1 | protected void flushEverythingToExecutions(FlushEvent event) throws HibernateException { |
flushEverythingToExecutions를 보면 prepareEntityFlushes, prepareCollectionFlushes를 통해 플러시 전처리를 하고,
flushEntities, flushCollections 메서드를 통해 실제로 플러시를 하는 것 같습니다.
이제 prepareEntityFlushes 메서드를 딥다이브 해봅시다.
현재 영속성 컨텍스트에 엔티티는 Mother(#1) 엔티티 하나 뿐이고, flush 하기 전에 엔티티에 대해서 영속성 전이시키는 걸 볼 수 있습니다.
참고로 getCascadingAction()의 결과는 ACTION_PERSIST_ON_FLUSH입니다.
이것도 내용이 길어서 3줄 요악 해보겠습니다.
결국 Mother의 변경내역은 쿼리 지연 저장소에 저장됐지만 현재 JPQL에서 사용하는 family_register와 상관 없는 테이블인 mother이므로 flush가 호출되지 않습니다.
이제 엔티티를 flush할 준비(prepareEntityFlushes 메서드)가 끝났으니 다음 부분(flushEntities 메서드)을 딥다이브 해봅시다.
1 | protected final boolean isUpdateNecessary(FlushEntityEvent event) throws HibernateException { |
DefaultFlushEntityEventListener의 isUpdateNecessary 메서드에서 dirtyProperties 유무에 따라 업데이트가 필요한지 판단하고 있는데 하나가 있기 때문에 true를 반환합니다.
1 | public void onFlushEntity(FlushEntityEvent event) throws HibernateException { |
isUpdateNecessary가 true이기 때문에 scheduleUpdate 메서드가 호출되는데 이름만 봐도 바로 지연 저장소에 저장할 거 같은 메서드네요.
하지만 여기까지 왔다고 해서 flush가 정말로 되는 건 아닙니다.
1 | public void onAutoFlush(AutoFlushEvent event) throws HibernateException { |
flushMightBeNeeded에서 ‘flush가 필요할지도 몰라’ 정도까지만 판단을 하고, flushIsReallyNeeded에서 ‘정말로 flush가 필요한가?’에 대한 검토를 또 하고 있네요.
(지금까지 우리는 flushEverythingToExecutions에 대해 딥다이브를 마쳤습니다.)
해당 액션이 family_register 테이블과 관련이 있는지 확인하고 있습니다.
관련이 없기 때문에 false를 반환합니다.
JPQL 호출 시 flush를 무조건 호출하는 줄 알았는데 쿼리 지연 저장소에 생긴 쿼리의 테이블과 관련이 있다는 사실도 참 신기하네요. (어찌보면 쿼리를 날릴 필요가 없으면 안 날리는 게 최적화 측면에서는 당연해보이긴 하네요.)
이것도 내용이 길어서 3줄 요악 해보겠습니다.
새로운 엔티티냐, 아니냐에 따라 persist vs merge 메서드를 호출하는데
1 | public boolean isNew(T entity) { |
AbstractEntityInformation 클래스의 isNew 메서드를 보면 primitive 타입이 아니면 null이거나 Number 타입이면 0인 경우에만 새로운 엔티티
라고 취급하고 있습니다.
근데 Mother는 id에 값이 있기 때문에 새로운 엔티티가 아니라서 EntityManager의 merge 메서드가 호출됩니다.
여기도 3줄 요약해보겠습니다.
motherRepository.save(mother)에서는 아무런 메서드가 날아가지 않고, 부모 트랜잭션(obGyn.naturalDeliveryWith 메서드)이 끝날 때 무슨 코드 때문에 쿼리를 호출하는 건지도 궁금해졌습니다.
1 | public void flushBeforeTransactionCompletion() { |
하이버네이트의 기본 FlushMode는 AUTO이기 때문에 doFlush가 true이고, managedFlush 메서드를 호출하게 됩니다.
FlushEvent의 이벤트 리스너 안에서 Managed Entity가 존재하므로 if 문 안을 보면, flushEverythingToExecutions을 호출하는데 이 때 Dirty Checking과 쿼리 지연 저장소에 저장이 이루어집니다.
그리고 performExecutions 안에서 실제 쿼리 지연 저장소에 있는 내용에 대해 flush가 호출됩니다.
여기도 너무 길어서 4줄 요약해보자면
1 |
|
여기가 핵심입니다.
이전에 JPQL 호출 시 AutoFlushEvent의 이벤트 리스너에서는 ACTION_PERSIST_ON_FLUSH CascadingActions의 cascade를 호출하면서 Child 엔티티에 대해 PersistEvent를 발생
시켰는데,
motherRepository.save 호출 시 MergeEvent의 이벤트 리스너에서는 ACTION_MERGE인 CascadingActions의 cascade를 호출하면서 Child 엔티티에 대해 MergeEvent를 발생
시키고 있습니다.
그럼 PersistEvent와 MergeEvent의 차이점을 알아봅시다.
또 결정적 차이가 여기서 나옵니다.
PersistEvent의 이벤트 리스너인 DefaultPersistEventListener 클래스의 onPersist 메서드에서 호출하는 DefaultPersistEventListener 클래스의 entityIsTransient 메서드에서는 entity에 대해 카피를 뜬 적이 없습니다.
하지만 MergeEvent의 이벤트 리스너인 DefaultMergeEventListener 클래스의 onMerge 메서드에서 호출하는 DefaultMergeEventListener 클래스의 entityIsTransient 메서드에서는 entity에 대해 카피를 뜨고 있습니다.
카피 뜰 때 default constructor가 없으면 아마도 org.hibernate.InstantiationException: No default constructor for entity
요런 예외를 던지지 않을까 싶네요.
기본 생성자를 호출했기 때문에 아직 값은 카피되지 않고 객체 생성까지만 된 상태입니다.
그리고 copyCache라는 MergeContext에 entity를 key로, copy를 value
로 해서 넣고 있습니다.
그리고 saveTransientEntity 메서드에서 실질적인 insert가 이루어지는데 entity를 넘기는 게 아니라 copy를 넘기고
있습니다.
이렇게 copy를 뜨고, copy에만 id를 할당하고, collection을 비운 후 copy로 채우기 때문에 외부 변수는 여전히 id가 null인 상태로 남게 됩니다.
여담으로 child가 Transient 상태이기 때문에 카피를 뜨고 카피로 레퍼런스를 바꾸고 했는데, 이미 Persistent 상태인 child였다면 카피를 뜨지 않아 레퍼런스를 바꾸지 않습니다.
이제 진짜 하고 싶었던 핵심인 엔티티 매니저의 persist와 merge 메서드에 대해 이야기 해보겠습니다.
1 | public interface EntityManager { |
별 건 없고, persist는 return 타입이 없고, merge는 있는 게 가장 큰 차이입니다.
어디서 봤는데 return 타입이 없으면 원본 객체를 수정하고, return type이 있으면 새로운 객체를 반환하는 게 뭐 뭘 분리해서 좋은 패턴이다~
라는 걸 본 거 같은데 아시는 분 있으면 댓글 남겨주시면 감사하겠습니다.
여튼 위에서 말했듯 그런 패턴을 지킨 걸로 보입니다.
persists는 return 타입이 없는데 id는 할당해야하니 당연히 새로운 객체를 만들 수는 없고 원본 객체를 수정할테고,
merge는 return 타입이 있는 걸로 보아 원본 객체는 수정하지 않고, id가 할당된 새 객체를 반환하는 걸로 보입니다.
persist와 merge에 대해 이해하면 위에서 있었던 PersistEvent와 MergeEvent가 왜 그렇게 동작했는지 이해할 수 있게 됩니다.
JPQL 호출 시 cascade가 이루어질 때는 PersistEvent가 발생하기 때문에 persist 메서드의 특성을 생각해보면 원본 객체에 id가 할당됐던 것이 당연한 게 됩니다.
그리고 save 호출 시 cascade가 이루어질 때는 새로운 엔티티가 아니라서 MergeEvent가 발생했기 때문에 merge 메서드의 특성을 생각해보면 새로운 객체를 반환하고, 레퍼런스도 바꿔치는 게 당연한 게 됩니다.
그럼 persist와 merge에 대한 간단한 예제를 보시면 이해하시는 데 더 도움이 될 것 같습니다.
1 |
|
단순히 save 메서드의 위치를 바꿨다고 해서 이렇게까지 동작이 달라질 줄은 몰랐습니다.
복잡한 연관관계(CascadeAction 등등)와 JPQL이 어느 타이밍에 호출되는데 엔티티는 현재 어떤 상태인지 등등을 고려해가면서 코드를 짜야하니 예측성이 너무 떨어지는 것 같습니다.
엔티티를 객체-테이블 매핑 이상의 역할인 도메인(비즈니스 로직을 담은) 객체로 사용하고, 역할에 맞게 객체를 덜 쪼갰기 때문에 요런 문제가 발생하긴 했지만…
이제 JPA가 그렇게 좋은지 모르겠네요… 예측성이 너무 떨어지고, 알아야할 게 너무 많은 거 같습니다.