개인적으로 Generic이 도입은 Java를 사용함에 있어 많은 편의성을 가져다 줬지만, 이에 못지않은 불편함 또한 주었다고 생각합니다. 즉 Type erasure로 인하여 하위 호환성을 유지할 순 있었지만, 한참이 지난 지금도 런타임시 타입을 알지 못하는 불편함으로 인해 개발자가 코드 작성시에 주의를 기울여야합니다.
이번 포스팅에는 Type erasure에 의해서 런타임시 표현되는 Unknown Type 불일치로 인해 주의해야할 점을 다루도록 하겠습니다.
Reifiable Type
Java에서 Runtime시에 완전하게 오브젝트 정보를 표현할 수 있는 타입을 가르켜 Reifiable 하다고 합니다.
즉 Compile 단계에서 Type erasure에 의해서 지워지지 않는 타입 정보를 말합니다.
Reifiable 가능한 타입 정보는 다음과 같습니다.
원시 타입(int, double, float, byte 등등)
Number, Integer와 같은 일반 클래스와 인터페이스 타입
Unbounded Wildcard가 포함된 Parameterized Type(List<?>, ArrayList<?> 등)
Raw Type(List, ArrayList, Map 등)
Unbounded Wildcard는 애초에 타입 정보를 전혀 명시하지 않았기 때문에, 컴파일시에 Type erasure 한다고 해도 잃을 정보가 없습니다. 따라서 Reifiable 하다고 볼 수 있으며, 컴파일시점에 Object로 치환됩니다.
그외 나머지는 Generic을 사용하지 않았기 때문에 타입 정보가 그대로 남습니다.
반면 아래 타입의 경우에는 Reifiable 하지 않습니다.
Generic Type(T)
Parameterized Type(List<Number, ArrayList<String> 등)
경계가 포함된 Parameterized Type(List<? extends Number>, List<? super String> 등)
Reifiable 하지 않은 경우에 대해서 차근차근 살펴보겠습니다.
우선 Generic Type은 Type erasure에 의하여 삭제되는 것은 이전의 포스팅들을 통해서 이해되셨을 것으로 생각합니다.
Parameterized Type의 경우에는 Type erasure에 의하여 컴파일시에 Raw Type으로 변경됩니다.
가령 List<String>, List<Integer>, List<List<String>>의 타입정보는 컴파일 시에 타입 안정성 검증 용도로 사용될 뿐 컴파일이 완료되면 Raw Type인 List로 치환됩니다. 따라서 Reifiable 하지 않습니다.
경계가 포함된 Parameterized Type 또한, 컴파일 시 타입 안정성 검증 용도로만 사용되므로 동일합니다.
그렇다면 위와같은 특성으로 인하여 주의해야할 점이 무엇이 있을까요?
Instance Test
publicclassMain{
publicstaticvoidmain(String[] args){
Integer a = 3;
System.out.println(isIntegerType(a));
}
publicstaticbooleanisIntegerType(Object b){
return b instanceof Integer;
}
}
전달받은 타입이 Integer인지 확인하는 메소드를 구현한다고 가정합시다. 가장 심플한 방법은 instanceof 문법을 사용해서 Integer 임을 확인할 수 있습니다.
그럼 해당 메소드를 Generic 메소드로 변경해도 문제 없을까요?
publicclassMain{
publicstaticvoidmain(String[] args){
Integer a = 3;
System.out.println(isIntegerType(a));
}
publicstatic <T> booleanisIntegerType(T b){
return b instanceof Integer;
}
}
위 코드를 수행하면 문제없이 동작합니다. 그 이유는 Integer 타입은 런타임시에도 Integer 타입이기 때문입니다.
Wildcard로 타입 인자가 지정되었을 때 타입에 대한 정보를 모르기때문에, 위 인터페이스에서 set 메소드를 호출할때마다 이를 지칭하기 위해 capture# of ?와 같은 형태로 컴파일러가 임의의 타입 변수를 할당합니다. 이를 Wildcard Capture라고 합니다.
문제는 알지 못하는 임의의 타입 T를 가르키는 capture# of ? 변수는 컴파일러가 내부적으로만 사용하고 타입 제약조건을 공식화하는데 사용하지 않는다는 점입니다.
따라서 Object 타입의 값을 알 수 없는 List에 집어넣으려고 하기 때문에 에러가 발생합니다.
publicstaticvoidreverse(List<T> list){
List<T> tmp = new ArrayList<>(list);
for(int i =0;i < tmp.size();i++){
list.set(i, tmp.get(i));
}
}
가장 심플한 방법은 reverse API를 위와 같이 타입 파라미터를 명시하는 방법입니다.
하지만, 관심대상이 아닌 T 타입을 컴파일러 제약때문에 API에 넣는다는 것은 그다지 좋은 디자인이 아닙니다.
따라서 API 디자인 규칙을 잘 준수하면서도 기능 동작을 구현하기위해 일종의 컴파일러 트릭을 사용합니다.
위 코드는 Helper 메소드를 사용하여 문제를 해결하였습니다. 즉 reverse 메소드를 호출할 때는 Wildcard Capture 정보를 타입 제약 조건으로 사용되지 않았습니다. 하지만 내부적으로 reverseHelper 메소드를 호출할때, 파라미터 타입에 T 타입을 명시하였기 때문에, capture# of ?와 같은 정보를 타입으로써 지정할 수 있습니다.
이후 동일 타입내에서 값을 추출하고 넣음을 보장할 수 있기때문에 값 할당이 허용됩니다.
Wildcard 제한
publicclassMain{
publicstaticvoidmain(String[] args){
List<?> list = new ArrayList<?>(); //Compile 에러 발생
}
}
Wildcard를 사용하면, 이를 기반으로 인스턴스 생성이 불가합니다.
만약, 타입 자동 추론을 이용하여 위 그림과 같이 list를 생성 할지라도, add 수행시 알 수 없는 타입이기 때문에 null외에 값 허용이 안됩니다.
이러한 제한을 둔 배경에는 Wildcard는 구체적인 타입 파라미터가 지정된 객체를 가르키는 것이기 때문에, 타입을 알 수 없는 Wildcard가 지정된 인스턴스 생성은 바람직하지 않기 때문입니다.
3. copy(List<? super T> dst, List<T> src) - src가 Integer 이므로 Integer 타입 추론될 경우에만 허용 가능합니다.
4. copy(List<? suepr T> dst, List<? extends T> src) - Object, Number, Integer 타입 어떤 것이 오든지 허용 가능합니다.
이를 통해 알 수 있는 사실은 적절한 경계를 사용함으로서 메소드의 타입을 추론하는데 있어서 한정된 타입만 사용되지 않도록하여 유연한 API 설계를 가능하도록 지원합니다.
그렇다면 하위 경계는 언제 주로 활용될까요?
전달받은구조 내부로 T나 T의 Subtype으로 값을 입력하는 경우주로 사용됩니다.
publicclassMain{
publicstaticvoidmain(String[] args){
List<Integer> ints = new ArrayList<>();
ints.add(1);
ints.add(3);
List<? super Integer> ints2 = ints;
ints.add(4);
for(Integer i : ints2){ //Compile 에러 발생
System.out.println(i);
}
}
}
위 예제와 같이 ints2 객체에서는 Integer Subtype 값 입력이 가능합니다.
반면, 해당 구조체로부터 값을 가져오려는 경우에는 컴파일 에러가 발생합니다.
그 이유는, 컴파일러 입장에서 해당 객체는 Integer의 상위 타입은 모두 올 수 있는데, 그것을 Integer 타입 변수로 할당 받는 것은 적합하지 않기 때문입니다.
하지만 하위 경계에도 예외가 존재합니다. 즉 하위 경계는 주로 값 입력을 위해 사용되나, 특정 타입에 경우에는 값을 가져올 수 있습니다. 그것은 바로Object타입입니다.
publicclassMain{
publicstaticvoidmain(String[] args){
List<Integer> ints = new ArrayList<>();
ints.add(1);
ints.add(3);
List<? super Integer> ints2 = ints;
ints.add(4);
for(Object i : ints2){
System.out.println(i);
}
}
}
그 이유는 Object는 모든 클래스의 부모 클래스이기 때문입니다.
아래 예시를 통해서 자세히 알아보겠습니다.
List<? super Integer> 변수 사용시, 어떠한 타입이 올지 모르므로 해당 타입을 임의의 타입 변수 X라고 가정해봅시다.
이때 Integer <: X이며, X <: Object 입니다.
publicinterfaceList<X> extendsCollection<X>{
...
X get(int index);
voidadd(X element);
...
}
따라서 X의 Supertype인 Object의 반환은 허용됩니다.
Get and Put Principle
지금까지 상위, 하위 경계를 학습하였습니다.
이전의 예시들에서 확인하였듯이 Wildcard는 사용할 수 있을 때 가능하면 사용하는 것이 좋은 습관입니다.
그 이유는 WIldcard 사용을 통해서 해당 API 호출하는 입장에서 전달하는 파라미터 내용이 내부적으로 어떻게 사용될지 유추가 가능할 뿐더러 유연한 타입 제공이 가능하기 때문입니다.
그렇다면 언제, 어떤 Wildcard를 사용하는 것이 좋을까요?
이를 결정하기 위한 간단한 원칙이 있습니다.(Get and Put Principle)
하위 경계(? extends T): 구조 바깥으로 T나 T의 Supertype으로 값을 읽어야 하는 경우 사용하자
상위 경계(? super T): 구조 내부로 T나 T의 Subtype으로 값을 입력해야하는 경우 사용하자
이전에 살펴본 copy 메소드를 통해 해당 원칙이 어떻게 적용되었는지 확인해봅시다.
publicstatic <T> voidcopy(List<? super T> dst, List<? extends T> src){}
Get and Put 원칙을 인지한 상태에서 위와 같은 API를 보면 다음과 같은 생각을 할 수 있습니다.
'src 변수에 전달하는 List 객체에서는 내부에서 값을 추출하기 위한 용도로 사용되며, dst 변수에 전달하는 객체에서는 값을 입력하는 용도로 사용되겠구나'
이번에는 Number 타입을 지닌 Collection의 합을 구하는 메소드를 직접 디자인한다면 어떻게 타입을 설계하는것이 좋을지 살펴보도록 하겠습니다.
publicstaticdoublesum(Collection<Number> cols){
double s = 0.0;
for(Number col : cols)
s = s + col.doubleValue();
return s;
}
먼저 위와 같이 Number 타입 지정을 고려해볼 수 있습니다. 하지만 이는 이전에 줄곧 얘기한 List<Number> 만 허용 가능한 메소드이기에 사용의 제약이 있습니다. 또한 Get and Put 원리에 따르면 해당 Collection에 별도 값 입력을 하지 않기 때문에 상위 경계(? extends Number)를 사용하는 것이 적합해보입니다.
이번에는 n을 입력하면 0부터 n-1까지 값을 채워주는 함수를 만들어 보겠습니다.
publicstaticvoidassign(Collection<? super Integer> ints, int n){
for(int i = 0 ; i < n; i++) ints.add(i);
}
위 경우에는 해당 Collection 안에 값을 채워넣는 작업이 수행되므로 하위 경계를 입력하는 것이 적합해보입니다.
기존에 문제되었던 코드에 상위 경계 타입 파라미터를 사용하면 변수 할당이 가능함을 확인했습니다. 하지만 이때 상위 경계 타입 적용 변수에 Number의 Subtype 값을 할당하려고 하면 이전에 발생하였던 문제(List<Integer> 타입이 Integer가 아닌 Double 등의 다른 값 참조 현상)가 재발하게됩니다.
따라서 상위 경계를 사용한 객체에 대해서는 Subtype 값을 할당하려고 시도하면 컴파일 단계에서 에러를 발생시킵니다.
그렇다면 어떠한 과정으로 컴파일 단계에서 Subtype 값 할당이 실패하게 되는 것일까요?
Capture 변환 과정을 통해 이를 알아보도록 하겠습니다.
Capture 변환
List<Integer>와 같이 명시적으로 타입을 정의하면 List에서 제공되는 메소드가 호출되었을 때는 다음과 같이 논리적으로 생각해볼 수 있습니다.
위 코드가 정상적으로 컴파일된다면, 두 객체는 Heap 같은 메모리 영역을 바라보게 될 것입니다.
지금까지는 아무런 이상이 없는 것 처럼 보입니다.
publicbooleanadd(Number elt){}
하지만 List<Number> 타입이 해당 메모리 영역을 가르킬 수 있으면 add 함수의 타입 파라미터는 Number로 추론됩니다. 따라서Substitution Principle을 적용한다면 Number의 Subtype인 Double 값을 논리적으로 할당할 수 있습니다.
Number 타입은 Integer의 Supertype이지 Subtype이 아닙니다. 따라서 위 경우에 nums 객체 값을 ints에 할당 가능하다면 List<Integer> 타입인 ints 입장에서는 Number 타입의 다른 Subtype이 존재하는 영역을 가르키게 됩니다.
문제는 ints 객체가 가르키는 메모리 영역으로부터 데이터를 읽고자 한다면 Integer 타입이 아닌 Double 타입을 참조할 수 있습니다. 따라서 이러한 경우에는 타입 안정성이 깨지게 됩니다. 그러한 이유로 이 또한 허용되지 않습니다.
Java는 대표적인 정적 타입 언어입니다. 즉 컴파일 시점에 사용하는 변수의 타입이 결정되며, 따라서 명시적으로 변수의 타입을 지정하거나 추론이 가능해야합니다.
만약 클래스내 일부 변수에 대하여 int형 또는 String형으로 상황에 따라 각기 다른 데이터를 담고 싶다면 어떻게 해야할까요?
classIntegerData{
int data;
double var2;
publicIntegerData(int data){this.data = data;}
publicintgetData(){return data;}
publicvoidmethod1(){}
publicvoidmethod2(){}
}
classStringData{
String data;
double var2;
publicStringData(String data){this.data = data;}
public String getData(){return data;}
publicvoidmethod1(){}
publicvoidmethod2(){}
}
publicclassMain{
publicstaticvoidmain(String[] args){
IntegerData a = new IntegerData(1);
StringData b = new StringData("1");
int c = a.getData();
String d = b.getData();
}
}
먼저 타입별 클래스를 만드는 방법을 먼저 떠올릴 수 있습니다.
하지만 일부 데이터 타입만 다름에도 불구하고 매번 클래스를 만드는 것은 매우 비효율적입니다.
그렇다면 어떻게 문제를 해결할 수 있을까요?
Idea) 모든 클래스는 Object를 상속받으니, Object 타입으로 변수를 지정하자
classData{
Object data;
publicData(Object data){this.data = data;}
public Object getData(){return data;}
}
publicclassMain{
publicstaticvoidmain(String[] args){
Data a = new Data(1);
Data b = new Data("1");
int c = (int) a.getData();
String d = (String) b.getData();
}
}
Object 타입으로 변수를 생성하면, Object 객체에 모든 변수 값을 저장할 수 있습니다. 따라서, 타입별로 클래스를 만들지 않아도되니 효율적입니다. 하지만 데이터를 가져올 때에는 원래 타입으로 캐스팅이 필요합니다.
음.. 캐스팅을 개발자가 명시적으로 지정해야한다는 약간의 불편함은 있지만 그런대로 괜찮아 보입니다.
하지만 정말 이대로 괜찮다고 말할 수 있을까요?
문제점
publicclassMain{
publicstaticvoidmain(String[] args){
Data a = new Data(1);
String b = (String) a.getData();
System.out.println(b);
}
}
개발자의 실수로 Data 객체에 들어있는 값의 타입이 String으로 착각하여 String 변수에 이를 담았다고 가정해봅시다.
실제로는 담겨져있는 int 타입의 변수에 대해서 String 타입으로 캐스팅을 수행하니 오류가 나야 정상입니다.
하지만 위 소스를 컴파일을 해보면 정상적으로 컴파일됩니다. 그 이유는 지정된 타입은 Object 이고, Object 타입은 String 타입의 부모 타입이기 때문입니다.
따라서, 에러가 발생하는 시점은 Application을 구동해서 데이터를 가져오는 코드가 실행되는 시점입니다. 즉 해당 방법은 TypeSafety 하지 않은 방법으로 Runtime 시점에 오류를 알 수 있는점은 잠재적인 오류를 내포하고 있으므로 좋지 못합니다.
publicclassMain{
publicstaticvoidmain(String[] args){
List list = new ArrayList();
list.add(1);
String a = (String) list.get(0);
}
}
java 1.4 doc ArrayList get 메소드
Generic이 지원되지 않는 Java 1.4 버전 이하에서는 우리가 흔히 사용하는 ArrayList 에서 여러 데이터 형을 담기 위하여 Object 타입으로 저장하였습니다. 또한 반환시에도 Object 타입으로 반환하였습니다. 따라서 Runtime 오류를 피하기 위해서 개발자가 조심해서 캐스팅을 수행해야했습니다.
Generic 등장
classData<T>{
T data;
publicData(T data){ this.data = data; }
public T getData(){return data; }
}
publicclassMain{
publicstaticvoidmain(String[] args){
Data<Integer> a = new Data<>(1);
Data<String> b = new Data<>("1");
int c = a.getData();
String d = b.getData();
}
}
이전에 살펴보았듯이 Object 타입으로 저장하는 것은 개발자가 명시적으로 타입을 예측하여 캐스팅을 수행해야했기에 잠재적 코드 결함율이 발생할 수 있었습니다. 따라서 이를 해결하고자 Java 진영에서는 1.5 버전부터 Generic을 지원하였습니다. Generic은 특정 변수에 대하여 객체 생성시 원하는 타입으로 지정할 수 있도록 도와줍니다.
위 코드와 같이 Data 클래스에 대하여 객체 생성시 <> 안에 원하는 Reference 타입을 명시해주면, 컴파일러가 해당 객체의 data 타입은 명시적으로 지정된 타입이라는 것을 보장해줍니다.
기존에는 반환 값이 Object였기 때문에 명시적으로 캐스팅을 수행해야했지만, Generic을 사용하면 컴파일러가 타입을 보장해주기 때문에 명시적 캐스팅이 불필요합니다.
마치 위 그림과 같이 타입을 지정하게되면 각각의 클래스가 별도로 존재하는 것처럼 논리적으로 생각할 수 있습니다.
publicclassMain{
publicstaticvoidmain(String[] args){
Data<Integer> a = new Data<>(1);
String d = (String)a.getData();
}
}
따라서 위와 같이 개발자의 실수로 Integer 데이터를 String 변수에 담으려고 해도 컴파일 단계에서 잘못된 타입임을 알고 에러를 표시하기 때문에 TypeSafety합니다.
도입부에 이해를 돕기 위해 논리적으로 Class 여러개가 생성된 그림을 보여드렸습니다. 그렇다면 실제 내부 구현도 위와 같이 되어있을까요? 지금부터 Generic 내부에 대해서 살펴보면서 원리를 알아보겠습니다.
용어
학습하기 전 관련 용어 일부를 알아보겠습니다.
Type Parameter : Generic 타입을 명시하기 위한 Placeholder 입니다. 즉 위 코드에서 Data 클래스의 T가 이에 해당됩니다.
Type Argument : 실제 Generic 타입에 명시된 타입을 의미합니다. 위 코드에서는 Integer가 해당됩니다.
Parameterized Type : Type argument에 의하여 Type Parameter가 치환된 전체 데이터 타입을 의미합니다. 위 코드에서는 Data<Integer>가 이에 해당됩니다.
위 세가지 용어가 혼동되지 않도록 주의하며, 본격적으로 Generic에 대해서 알아보겠습니다.
Type Erasure
classData<T>{
T data;
publicData(T data){ this.data = data; }
public T getData(){return data; }
}
publicclassMain{
publicstaticvoidmain(String[] args){
Data<Integer> a = new Data<>(1);
int c = a.getData();
}
}
동작 원리를 이해하기 위해 컴파일된 바이트 코드를 보는 것이 가장 좋지만, 이해를 돕기위해 위와 같이 Generic 문법을 사용한 자바 코드를 컴파일 한다음 해당 class 파일을 다시 디컴파일 결과를 보겠습니다.
먼저 Data 클래스를 보겠습니다. 기존에 Type Parameter T로 선언되었던 부분이 전부 Object 클래스로 타입이 변경되었습니다.
main 호출부에서도 Parameterized Type이 Data<T>였던 클래스 정보가 Data와 같이 변경되었고, 값을 가져오는 부분에서 캐스팅이 발생되었음을 확인할 수 있습니다.
자세히 보면 이는 Generic이 사용되기 이전에 작성하던 방식과 동일한 결과입니다.
이를 통해 알 수 있는 사실은 Generic을 사용하더라도 사용자가 지정한 Type은 사라지고 기존과 같이 Object 타입으로 지정되며, 대신 명시적으로 값을 가져오는 코드에 컴파일러가 캐스팅하는 코드를 삽입하여 Type 안정성을 보장해준다는 점입니다.
또한, 컴파일러가 개발자가 지정한 Type을 알고 있기 때문에, 중간에 개발자가 실수로 잘못된 캐스팅을 시도하는 코드가 있다면 잘못 캐스팅 되었음을 알 수 있습니다.
publicclassMain{
publicstaticvoidmain(String[] args)throws NoSuchFieldException {
Data<Integer> a = new Data<>(1);
System.out.println(a.getClass().getDeclaredField("data"));
}
}
이번 포스팅은 AxonFramework 관련 마지막 포스팅입니다. Saga 패턴 보상 트랜잭션 구현을 다루겠습니다.
2. Deadline
MSA 환경에서는 App이 여러개로 분산되어있으므로 하나의 App이 느려지거나 장애가 발생하면, 장애가 발생한 App을 호출하는 App에도 장애가 전파됩니다.
Axon에서 제공하는 Saga 패턴을 사용하면, 요청마다 Saga 인스턴스가 생성됩니다. 따라서 연관된 App의 장애로 인해 전체 트랜잭션에 Hang이 걸리게되면, 요청한 App 또한 안전하지 못합니다.
따라서 이를 완화하기 위해 Axon에서는 Deadline 기능을 제공합니다. Deadline은 App에서 지정한 시간동안 반응이 없으면, 이를 처리할 메소드를 Callback 형식으로 등록할 수 있는 기능입니다. 자세한 내용은 공식 문서를 참고 바라며, 데모 프로젝트에서는 CommandGateway 클래스에서 기본적으로 제공하는 sendAndWait 메소드를 통해서 일정 시간동안 응답이 없으면, 보상 트랜잭션을 발동하도록 구현하겠습니다.
sendAndWait 두 번째, 세 번째 인자를 통해 TimeOut을 지정하며, 해당 기간동안 응답이 없을 경우 Exception을 통해 보상 트랜잭션을 발동할 수 있습니다.
3. 트랜잭션 프로세스 설계
일반적인 상황
일반적으로 계좌 이체 요청을하면, 해당 은행에서 보유한 잔고보다 요청액수가 클 경우에는 이체 거절을하며, 반대의 경우에는 이체 승인을 합니다. 따라서 요청자인 Command 모듈에서는 Jeju 은행의 승인 혹은 거절 이벤트 발생 여부에 따라서 결과를 처리하면 됩니다.
보상 트랜잭션 발동 상황
보상 트랜잭션은 연결된 App 사이에 트랜잭션 문제가 발생하였을 때, 이미 처리된 데이터를 원상복구를 위하여 추가적인 트랜잭션을 발동하는 것입니다.
예제에서는 Timeout이 발생하게 되면, Jeju 은행 App의 응답과 관계없이 트랜잭션 Rollback을 위하여 보상 트랜잭션을 요청하고, Command 모듈에서는 다음 로직을 수행합니다.
만약 위 그림과 같이 만약 Jeju 은행에 발생한 장애로 인하여 계좌 요청을 진행하였지만 응답이 오지 않는 상황이라고 가정해봅시다. Command 모듈에서는 장애 방지를 위해 Timeout을 설정했기 때문에 일정 시간이 지나면 보상 트랜잭션을 발동할 것입니다.
이후 Jeju 은행 App이 정상화된다면 이전 요청을 수행할 것입니다. 그 결과 요청이 적절하지 않으면, 이체 거절 이벤트를 발송합니다. 만약 이체 거절 상황에서 요청받은 보상 트랜잭션을 처리한다면, 잔고는 그대로인 상황에서 보상 트랜잭션에 의해 잔고가 늘어나는 기현상이 발생합니다.
이를 해결하기 위해 다양한 방법이 있겠지만, 예제 프로젝트에서는 Timeout이 발생된 상황에서 이체 거절 메시지를 받게되면, 보상 트랜잭션 취소 요청하여 정상 처리하겠습니다.
위 그림은 Command 모듈에서 트랜잭션 요청 이후 처리해야할 과정을 개략적으로 순서도로 나타냈습니다.
이상으로 AxonFramework에 대한 포스팅을 마치겠습니다. 테스팅에 대해서는 다루지 않았는데, 분산 App 환경에서 테스트 코드 작성은 반드시 필요하다고 생각합니다. 따라서 공식문서를 참고하여 테스트 코드 작성 방법을 익힌다면, 보다 안전한 프로그램이 될 것입니다.
그 외에 쿠버네티스 지원, Tracing에 대해서도 공식문서에 소개되어 있으니 참고바랍니다. 또한, 지금까지 구현한 프로젝트 내용은 깃헙에 업로드 했습니다.
포스팅 내용 중 개선사항에 대해서는 댓글로 남겨주시면, 확인 후 내용 반영하도록 하겠습니다.