Java – Collection – Map – ConcurrentHashMap

> Thread-Safe ?

  동기화(Synchronize)라고 표현하기도 하며 어떠한 Class의 인스턴스가 여러개의 Thread에서 동시 참조되고 해당 객체에 Operation 이 발생해도 정합성을 유지해줄때 보통 우리는 Thread-Safe 하다 라고 표현한다.  @ThreadSafe 어노테이션을 이용해 해당 Class가 Thread-Safe 함을 표시하기도 한다.   어떠한 경우에도 개발자가 의도한대로 정확하게 동작한다라고도 이야기 할 수 있다.  참조하는(사용하는) 쪽에서 특별한 동기화 없이도 정확히 동작한다는 것을 이야기한다.  멀티 Thread 환경에서는 필수 적인 요소이다.  Java에서 Thread-Safe 를 이루는 방향 및 조건은 여러 가지가 있으나 이번 Post에는 따로 소개하지는 않는다.


> ConcurrentHashMap

  검색과 갱신 전체에 걸쳐 Thread-Safe 함을 보장하면서도 높은 성능을 보장하는 HashMap 이다.  HashMap처럼 기본적으로는 Hashtable 과 동일한 Spec을 제공한다.   Hashtable 또한 Thread-Safe를 보장한다. 그러나 차이점은 모든 작업이 Thread-Safe 임에도 불구하고 검색작업(get과 같은)에는 Lock이 수반되지 않으며, 전체 테이블을 잠궈야 하는 액션도 없다.  
  ConcurrentHashMap의 검색 작업(get 포함)은 Lock이 이루어지지 않으며 갱신 작업(put 및 remove 포함)과 동시에 수행 될 수 있다.  반대로 Hashtable 의 내부 코드를 살펴보면 Thread-Safe를 보장하는 방법으로 put, get 등의 검색 및 갱신 작업의 method 레벨에서 synchronized 키워드를 사용한다.  이는 간편하게 동시접근을 막아 Thread-safe 를 보장하는 방법 중 하나이다.  그러나 method 레벨에서 synchronized 키워드를 이용하면 Lock의 매개로 활용객체가 바로 객체바로 자신이 된다(this).  이는 전체적인 성능 저하를 가져온다.  어느 한 순간에 Lock이 설정된 어떤 method도 하나의 Thread만이 진입할 수 가 있게 된다. (누군가 get으로 Lock을 획득 중이면, put도 진입할 수가 없다.)

 ConcurrentHashMap의 검색은 검색 method가 실행되는 시점에 가장 최근에 완료된 갱신 작업의 결과를 반영한다.  putAll 및 clear와 같은 집계 작업의 경우 동시 검색에는 해당 시점에 put / remove 중인 일부 항목만 반영될 수 있다.  마찬가지로, iterator, Spliterator, Enumeration 수집 생성 시점 또는 이후 어느 한 시점의 상태를 반영하는 요소를 반환한다.  Hashtable에서 활용되던 ConcurrentModificationException은 이제 더이상 사용되지 않는다.  


> ConcurrentHashMap의 활용 예

 멀티 Thread 환경에서의 Thread-safe 한 Counter  (computeIfAbsent 사용)

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class ConcurrentHashMapTest {
	public static void main(String[] args) {
		int loopSize = 30;
		CountDownLatch countDownLatch = new CountDownLatch(loopSize);
		ExecutorService tp = Executors.newFixedThreadPool(10);

		Map<String, LongAdder> testMap = new HashMap<>();

		for(int i = 0; i < loopSize; i++) {
			int selector = (i % 3);
			String type = (selector == 0) ? "HR" : (selector == 1) ? "SALES" : "IT";
			tp.submit(new Runner(type , countDownLatch, testMap));
		}

		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		System.out.println(testMap.toString());
		tp.shutdown();
	}

	static class Runner implements Runnable {
		private final String runnerNo;
		private final CountDownLatch countDownLatch;
		private Map<String, LongAdder> testMap;
		Runner(String runnerNo, CountDownLatch countDownLatch, Map<String, LongAdder> testMap){
			this.runnerNo = runnerNo;
			this.countDownLatch = countDownLatch;
			this.testMap = testMap;
		}
		@Override
		public void run() {
			try {
				testMap.computeIfAbsent(runnerNo, (value) -> new LongAdder()).increment();
			} catch (Exception e){
				e.printStackTrace();
			} finally {
				countDownLatch.countDown();
			}

		}
	}
}

ExecutorService를 이용해 30개의 Thread를 동시에 수행한다.  Thread 동시 시작을 위해 CountdownLatch가 활용되었다. 

12Line을 살펴보면 각 Thread에서 공유할 Map의 구현체를 HashMap을 사용하였다.   Thread에서는 생성자로 입력받은 Type을 Key로 같는 Map 의 Value를 LongAdder(Counter)로 활용한다.

Thread가 모두 종료되면 해당 Map의 내용을 출력한다.  Java 8 부터 추가된 computeIfAbsent를 이용해 값이 없으면 생성(new LongAdder()) 하고 있을 경우 Count (increment()) 한다.

30개의 Thread에 각 각 10개씩 Type String 값을 “HR”, “SALES”, “IT” 라고 할당해주었기 때문에 Count 값 역시 10개씩 출력되어야 한다.

먼저 HashMap을 이용했을때의 결과를 살펴보자.  


HashMap 결과

  • 1회차
{HR=7, IT=8, SALES=8}

Process finished with exit code 0
  • 2회차
{SALES=8, HR=7, IT=8}

Process finished with exit code 0
  • 3회차
{HR=9, IT=10, SALES=10}

Process finished with exit code 0

해당 결과는 역시 Non-ThreadSafe 의 전형을 보여준다. 

이제, 구현체를 ConcurrentHashMap으로 변경 후 다시 한번 결과를 살펴보자.  ( 12Line : new HashMap<>(); -> new ConcurrentHashMap<>(); )


ConcurrentHashMap 결과

{HR=10, IT=10, SALES=10}

Process finished with exit code 0

몇번을 수행해도 결과는 같다. 

 ConcurrentHashMap이 Thread-safe 하다 하더라도, 여러개의 Action (예 : get 수행 후 없을 경우 put 과 같은) 을 수행 할 경우까지 보장하지는 않는다.

이러한 경우를 위해 Java 8 이후에는 여러 가지 복합 작업을 수행할 수 있는 method를 제공해준다.  computeIfAbsent 는 그 중 하나이다.

Java – Constant pool과 String pool

> String 리터럴에 대한 의문

  아래와 같은 코드를 보자.

public class StringPoolTest {

	public static void main(String[] args) {

		StringPoolTest spt = new StringPoolTest();
		spt.stringTest("HelloWorld");

	}

	public void stringTest(String helloWorld){
		StringTestArea sta = new StringTestArea();

		System.out.println( sta.TestString1().equals(helloWorld) );

	}
}


public class StringTestArea {

	public String TestString1(){
		String str = "HelloWorld";
		return str;
	}

}

코드 안에 계속해서 등장하는 String 리터럴은 (“HelloWorld”) 어떻게 처리가 될까? String type의 변수에 값을 할당하거나, 혹은 할당하지 않더라도 6번 Line과 같이 코드에 등장하는 String 리터럴은 계속해서 Runtime 시점에 메모리 공간을 할당 받아 점유할까?  stringTest() 메서드를 여러번 호출한다면 어떻게 될까? 호출할때마다 리터럴 “HelloWorld”은 어딘가 메모리에 로드되고 또 GC 대상이 될까?

test1 변수에 할당된 리터럴 “HelloWorld” 와 아래의 변수에 할당되지는 않았지만, System.out.println() 메서드의 파라메터가 되는 “HelloWorld”는 완전하게 동일한데 중복된 값이 메모리의 여기 저기에 로드 되고 있는 걸까?


> Constant pool ? 

  Java Class File의 구성 항목 중 하나인 Constant pool은 리터럴 상수 값을 저장하는 곳이다.  여기에는 String 뿐 아니라, 모든 종류의 숫자, 문자열, 식별자 이름, Class 및 Method 에 대한 참조와 같은 값이 포함된다.  Constant pool은 특정 상수에 대한 모든 인덱스 또는 참조를 16비트(type u2) 번호로 제공되며, 여기서 인덱스 값 1은 표의 첫 번째 상수를 나타낸다.

그럼 위의 소스에 대해 javap를 이용해서 Class의 구조와 Byte code를 확인해보자.  ( ※ javap – The Java Class File Disassembler)

C:\TestBoard>javap -verbose -private StringPoolTest
Classfile /C:/Users/USER/IdeaProjects/TestBoard/out/production/TestBoard/StringPoolTest.class
  Last modified 2018. 12. 20; size 722 bytes
  MD5 checksum 8c7857ce091e30c9c6e124e1d89e5d60
  Compiled from "StringPoolTest.java"
public class StringPoolTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#26         // java/lang/Object."<init>":()V
   #2 = Class              #27            // StringPoolTest
   #3 = Methodref          #2.#26         // StringPoolTest."<init>":()V
   #4 = Methodref          #2.#28         // StringPoolTest.stringTest:()V
   #5 = String             #29            // HelloWorld
   #6 = Fieldref           #30.#31        // java/lang/System.out:Ljava/io/PrintStream;
   #7 = Methodref          #32.#33        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #8 = Class              #34            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               LStringPoolTest;
  #16 = Utf8               main
  #17 = Utf8               ([Ljava/lang/String;)V
  #18 = Utf8               args
  #19 = Utf8               [Ljava/lang/String;
  #20 = Utf8               spt
  #21 = Utf8               stringTest
  #22 = Utf8               test1
  #23 = Utf8               Ljava/lang/String;
  #24 = Utf8               SourceFile
  #25 = Utf8               StringPoolTest.java
  #26 = NameAndType        #9:#10         // "<init>":()V
  #27 = Utf8               StringPoolTest
  #28 = NameAndType        #21:#10        // stringTest:()V
  #29 = Utf8               HelloWorld
  #30 = Class              #35            // java/lang/System
  #31 = NameAndType        #36:#37        // out:Ljava/io/PrintStream;
  #32 = Class              #38            // java/io/PrintStream
  #33 = NameAndType        #39:#40        // println:(Ljava/lang/String;)V
  #34 = Utf8               java/lang/Object
  #35 = Utf8               java/lang/System
  #36 = Utf8               out
  #37 = Utf8               Ljava/io/PrintStream;
  #38 = Utf8               java/io/PrintStream
  #39 = Utf8               println
  #40 = Utf8               (Ljava/lang/String;)V
{
  public StringPoolTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LStringPoolTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class StringPoolTest
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method stringTest:()V
        12: return
      LineNumberTable:
        line 5: 0
        line 6: 8
        line 8: 12
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  args   [Ljava/lang/String;
            8       5     1   spt   LStringPoolTest;

  public void stringTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #5                  // String HelloWorld
         2: astore_1
         3: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: aload_1
         7: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: ldc           #5                  // String HelloWorld
        15: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        18: return
      LineNumberTable:
        line 11: 0
        line 13: 3
        line 15: 10
        line 17: 18
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  this   LStringPoolTest;
            3      16     1 test1   Ljava/lang/String;
}
SourceFile: "StringPoolTest.java"

39번 Line   [ #29 = Utf8  HelloWorld ] 를 보면 Constant pool에 해당 “HelloWorld” 리터럴이 한번만 들어간걸 확인할 수 있다.  아마도 Compiler Optimization 과정 중 이루어진 일로 추정된다.  그리고 해당 리터럴을 사용하는 메서드에서는 Constant pool의 인덱스를 참조한다.   

  결국 코드에 등장하는 리터럴 들은 Class 단위로 Constant Pool에서 관리가 이루어지고, 한 Class에 등장하는 동일한 리터럴 값은 여러번 메모리에 Load되지 않음을 알 수 있다.   Runtime 시점에 해당 리터럴에 대한 참조는 Class가 Load되어 있는 JVM 메모리 영역 (Java 1.7 이전은 Permanent 영역, Java 8 이후는 Metaspace 영역) 에서 이루어진다고 볼 수 있다.

 

> String Pool ?

  그런데 아래의 코드를 한번 보자.

public class StringPoolTest {

	public static void main(String[] args) {

		StringPoolTest spt = new StringPoolTest();
		spt.stringTest("HelloWorld");

	}

	public void stringTest(String helloWorld){
		StringTestArea sta = new StringTestArea();

		System.out.println( sta.TestString1() == helloWorld );

	}
}


public class StringTestArea {

	public String TestString1(){
		String str = "HelloWorld";
		return str;
	}

}


결과  (StringPoolTest 의 “HelloWorld”와 StringTestArea의 “HelloWorld”가 동일한 것으로 확인됨 )

true

Process finished with exit code 0


“==” 비교를 이용했음에도 불구하고 각기 다른 Class의 Constant String 리터럴이 완전하게 동일한 것으로 비교된다.  

이는 Java String 처리의 특징 중에 하나인 String Pool을 이용하기 때문이다.

String은 다른 int, boolean, long과 같이 기본 자료형 (primitive) 이 아니다.  그러나 기본 자료형과 같이 리터럴을 할당할 수 있다.  그리고 JVM은 이렇게 리터럴이 할당된 String 객체는 String Pool이라는 테이블에 저장하고 만약 이미 String Pool에 해당 값이 존재할 경우 해당 인덱스만 참조하도록 한다.  (== String.intern() 메서드의 효과와 동일하다.)

그런데, 만약 String 변수에 리터럴을 할당하지 않고 new String() 을 이용해 스트링 객체를 생성하거나, String world = “World”; “Hello” + world; 와 같이 문자열을 조립하는 경우 다른 결과를 보인다.

아래의 코드 결과를 확인해보자.  결과가 true인 경우를 살펴보면 어떤 경우에 String pool 테이블이 이용됐는지 알 수 있다.

public class StringPoolTest {

    public static void main(String[] args) {
        String helloWorld = "HelloWorld";
        String world = "World";
        System.out.print((helloWorld == "HelloWorld") + " ");
        System.out.print((Other.helloWorld == helloWorld) + " ");
        System.out.print((helloWorld == ("Hello"+"World")) + " ");
        System.out.print((helloWorld == ("Hello"+world)) + " ");
        System.out.print((helloWorld == ("Hello"+world).intern()) + " ");
        System.out.println(helloWorld == new String("HelloWorld"));
    }

}

class Other { public static String helloWorld = "HelloWorld"; }


결과

true true true false true false

Process finished with exit code 0

Java – Collection – Map – WeakHashMap (약한 참조 해시맵)

> Weak Reference 

  WeakHashMap의 작동 방식을 이해하려면 JVM의 GC와 관련하여 WeakReference  를 조금은 이해할 필요가 있다.  Java에서는 세 가지 주요 유형의 참조(Reference) 방식이 존재한다.   

  1. 강한 참조 (Strong Reference)
    – Integer prime = 1;   와 같은 가장 일반적인 참조 유형이다.    prime 변수 는 값이 1 인 Integer 객체에 대한 강한 참조 를가진다.  이 객체를 가리키는 강한 참조가 있는 객체는 GC대상이 되지않는다.

     

  2. 부드러운 참조 (Soft Reference)
    – SoftReference<Integer> soft = new SoftReference<Integer>(prime);   와 같이 SoftReference Class를 이용하여 생성이 가능하다.  만약 prime == null 상태가 되어 더이상 원본(최초 생성 시점에 이용 대상이 되었던 Strong Reference) 은 없고 대상을 참조하는 객체가 SoftReference만 존재할 경우 GC대상으로 들어가도록 JVM은 동작한다.   다만 WeakReference 와의 차이점은 메모리가 부족하지 않으면 굳이 GC하지 않는 점이다.  때문에 조금은 엄격하지 않은 Cache Library들에서 널리 사용되는 것으로 알려져있다.

     

  3. 약한 참조 (Weak Reference)
    – WeakReference<Integer> soft = new WeakReference<Integer>(prime);   와 같이 WeakReference Class를 이용하여 생성이 가능하다.  prime == null 되면 (해당 객체를 가리키는 참조가 WeakReference 뿐일 경우) GC 대상이 된다.  앞서 이야기 한 내용과 같이 SoftReference와 차이점은 메모리가 부족하지 않더라도 GC 대상이 된다는 것이다.    다음 GC가 발생하는 시점에 무조건 없어진다.

 

> WeakHashMap

  일반적인 HashMap의 경우 일단 Map안에 Key와 Value가 put되면 사용여부와 관계없이 해당 내용은 삭제되지 않는다.  Map안의 Element들이 일부는 사용되고 일부는 사용되지 않을 수 있는 경우도 있으나, 그것의 구현은 전적으로 프로그래머에 달려있게 된다.  예를 들면 Key에 해당하는 객체가 더이상 존재하지 않게 되는 경우이다.  그래서 만약 어떤 객체가 null이 되어 버리면 해당 객체를 key로 하는 HashMap의 Element도 더이상 꺼낼 일이 없는 경우가 발생하는 것을 가정해볼 수 있다.

  WeakHashMap은 WeakReference의 특성을 이용하여 HashMap의 Element를 자동으로 제거, GC 해버린다.   Key에 해당하는 객체가 더이상 사용되지 않는다고 판단되면 제거한다는 의미이다.    아래의 사용예를 보자.

public class WeakHashMapTest {

    public static void main(String[] args) {
        WeakHashMap<Integer, String> map = new WeakHashMap<>();

        Integer key1 = 1000;
        Integer key2 = 2000;

        map.put(key1, "test a");
        map.put(key2, "test b");

        key1 = null;

        System.gc();  //강제 Garbage Collection

        map.entrySet().stream().forEach(el -> System.out.println(el));

    }
}

결과  (null 로 할당된 key1이 Map 내에서 사라졌다.)

2000=test b

Process finished with exit code 0


이 클래스는 주로 equals 메소드가 == 연산자를 사용하는 Key를 사용할 경우 유용하다.  그런 Key가 버려지면 결코 동일한 Key는 생성되지 않으므로,  나중에 WeakHashMap 에서 해당 키의 조회를 수행하는 것은 불가능하게 되기 때문이다.  그러나 Key가 String 과 같이 단일 값을 가지는 Class고 == 연산자 외에 equals를 사용하여 서로 다른 객체라도 동일성을 체크하여 유지 하고 싶은 경우 적절하지 않다. 

WeakHashMap은 HashMap과 마찬가지로 Thread-safe하지 않으며 필요할 경우 Collections.synchronizedMap 을 이용할 수 있다.


주의점 1 – String은 new가 아닌 리터럴 방식으로 생성될 경우 (String a = “abc”; 와 같이)  intern()에 의해 String pool을 JVM은 사용하므로 key가 null이 된다고 하도 WeakHashMap은 자동으로 삭제되지 않는다.   이는 Integer 와 같은 Class의 (-127 ~128) 값들도 마찬가지 이다.  JVM은 미리 불변 객체로 해당 값을 보관하고 있다.  (Strong 참조)


주의점 2 – WeakHashMap 내의 Value는 통상 강한 참조에 의해 보관 유지된다.  따라서 Value 객체가 직접 또는 간접적으로 자신의 Key를 강한 참조하지 않도록 주의해야한다.  그러면 키가 삭제되지 않기 때문이다.  Value 객체는 WeakHashMap 자체 를 통해 간접적으로 키를 참조 할 수 있다.   만약 Key를 참조하는 Value를 사용하여 WeakHashMap 또한 올바르게 동작하길 원한다면 WeakReferences 내에서 Value를 다시 래핑하는 방식을 써야 한다.  [  예 : m.put (key, new WeakReference (value)) ]

Java – Collection – Map

> Map (Interface)

  Map의 가장 기본 개념은 Key를 Value에 매핑하는 자료의 한 구조란 것 이다.  Key는 중복될 수 없으며, 각 Key는 하나의 Value(객체)만을 가질 수 있다.   이는 Map안에 존재하는 Key로 재차 삽입(put)할 경우 해당 Value는 덮어쓰기 된다는 걸 의미하기도 한다.  Map interface의 구현체들은 (put, get, remove, containsKey, containsePlue, size, empty)와 같은 기본 작업을 구현한다.  결국 Map은 Key를 이용해 Value를 쉽게 얻고자 할 때 사용한다. 

  Map은 기본적으로 List, Set과 같은 Java Collection과는 다르게, Collection Interface를 구현하지 않는다.  하지만, Data를 삽입(add or put)하고 삭제(remove)하는 등의 일반적인 자료구조의 형태를 가지고 있다는 점에서 유사하다.

 

> Hashtable

  가장 처음으로 등장한(JDK 1.0)  Map의 종류이다. 내부 적으로는 Entry라는 버킷을 만들어 배열형태로 자료들을 저장한다. Hashtable은 기본적으로 Thread-safe하다. (동기화, synchronized)  또한 Null Key와 value를 허용하지 않는다.  

 실제로는 Hashtable은 최초로 등장한 Map이기에 이전 Java 결과물들의 지원을 위해 존재하는 이상의 의미를 현재는 가지질 않는다.   Java API Document에서도 Thread-safe 한 구조가 필요하지 않을 경우 HashMap의 사용을 권장하고 있다.   설사 Thread-safe한 구조 필요하다해도 ConcurrentHashMap을 사용하도록 권장한다. 

 hashCode와 equals를 이용하고 내부 버킷을 운용하는 방법은 HashMap과 유사하므로 해당 내용은 HashMap에서 설명하도록 하겠다.

 

> HashMap

  HashMap은 Null Key & value를 허용한다. HashMap은 동기화되지 않고 Null을 허용한다는 점을 제외하고는 Hashtable과 거의 동일하다.   HashMap은 Element 들의 순서를 보장하지 않는다.

  hashTable의 원리는 Element가 될 Class의 hashCode() 메서드를 이용해 Hash 값을 알아낸 후, 해당 Hash값으로 내부 버킷의 배열 인자(index)를 결정, 분배하여 저장한다.   그리고 알고 있겠지만 동일한 Index로 분배가 될 경우(HashCrash) 해당 Index를 인자로 가지는 배열 멤버는 LinkedList와 같은 형태로 동일한 Index Element를 관리한다.  또한 HashMap의 구현에서는 해당 동일한 Index로 배정된 Element의 갯수가 8개 이상되면 그 Index만 TreeNode 형태로 변화한다.  (Red-black binary search tree)

  HashMap의 성능에 영향을 미치는 두가지 매개 변수가 있다.  (1) Capacity는 HashTable의 배열 크기이다 (초기 default 16).  (2) LoadFactor는 Capacity가 자동으로 증가하기 전에 HashTable이 얼마나 가득 찼는지를 나타내는 측도이다.   Element의 갯수가 LoadFactor 및 현재 Capacity를 초과하면 2배로 resize가 일어나며 내부 Element들은 다시 Index를 부여 받아 분배된다.

  일반적으로, Default loadFactor (0.75)는 시간과 공간 비용 사이에서 좋은 절충을 제공한다.  값이 높을수록 공간 오버 헤드는 감소하지만 조회 비용은 증가한다. 초기 Capacity를 설정할 때는 ReHashing 작업 수를 최소화하기 위해 예상되는 Element 갯수와 loadFactor를 고려해야 한다.  초기 Capacity가 삽입된 Element 갯수를 LoadFactor로 나눈 값보다 큰 경우에는 ReHasing 작업은 수행되지 않는다.

  많은 Element를 HashMap에 저장해야 하는 경우 충분한 Capacity를 초기 값으로 생성하면 resize 오버헤드를 줄일 수 있다.  또한 Element가 되는 객체의 hashCode() 메서드가 제대로 구현되도록 해서 동일한 Hash값이 발생하는걸 줄임으로 HashMap의 성능을 높일 수 있다. 

 

> TreeMap

Red-black binary search tree 자료구조를 표현한다.  Key의 Natural ordering에 따라 순서가 정해지며, 필요할 경우 생성자에 제공된 Comparator에 의하여 정렬 순서가 정해지도록 할 수 있다.

TreeMap은 키를 정렬 가능한 순서에 따라 저장하기 떄문에 hashCode() 메서드는 전혀 사용되지 않는다.  또한 TreeMap은 Red-black tree의 특성 대로 검색, 삭제, 삽입 모든 동작들이 O(log n)의 처리 성능을 가진다.

HashMap과의 주된 차이는 순서를 보장함에 따라 Iterator를 활용해 순서대로 순회가 가능하다는 것이다.

 

> LinkedHashMap

HashMap을 상속하여 구현되었다.  LinkedHashMap은 멤버가 되는 Element들이 doubly-linked (이중링크) 되었다는 점에서 HashMap과 차이가 있다.   Link는 삽입된 순서대로 Iteration 할 수 있게 생성된다.   동일한 Key를 Map에 다시 삽입하는 경우에는 삽입 순서는 영향을 받지 않는다. (containsKey(k) true 일때,  put(k, v) 가 호출되면 k가 다시 삽입된다.)

HashMap과 동일하게 LinkedHashMap도 성능에 영향을 미치는 두가지 매개 변수가 있다. (HashMap 참조, Capacity/Loadfactor) 다만, iteration time은 Capacity에 영향을 받지 않기 때문에, Capacity를 지나치게 높게 선택했을 경우의 페널티는 HashMap의 경우보다 덜 심각하다.

 

 

Java – Collection – Set

> Set (Interface)

  Set 은 중복 Element를 허용하지 않는 Collection의 종류이다.  또한 Set을 구현한 자료형들은 서로 equals와 hashCode를 이용하여 비교할 수 있게 엄격한 조건을 가지고 있다.  동일한 Element를 가지고 있는 Set의 두 구현체들은 서로 구현방법이 달라도 equals를 만족한다.  (이는 결국 동일한 객체만 중복으로 보지 않는다는걸 의미하기도 한다.)

 

> HashSet

  HashSet은 일반적으로 알고 있는 HashTable의 구조를 가진다.  내부적으로는 사실 HashMap을 기반으로 구현되어 있다.  Key-Value로 구성된 HashMap 에서 Key만을 사용하는 것으로 볼 수 있다.   Value는 불변객체를 이용해 Dummy값으로 설정한다.  또한 HashMap의 특징 처럼 Element가 추가된 순서 혹은, 특정 순서와 관계 없이 순회가 이루어진다. (Ordering 보장하지 않는다.)  또한 Null요소를 허용한다.  (중복 불허용이니, 결국 1개의 Null)
   Hash함수가 버킷간에 Element를 적절히 분산 시킨다는 가정하에 기본 작업 (add, remove, contains, size)에 일정한 시간 성능을 제공한다 .  iterator를 이용해 핸들링하려면, HashSet 인스턴스의 사이즈 (Element 수)와, 초기 HashSet 생성자의 (capacity)를 설정한 경우 잔여 공간의 합계에 비례 한 시간이 필요하다.  따라서 iterator 이용할 때 성능이 중요 할 경우 초기 용량을 알맞게 설정하는 것이 중요하다.

  HashSet은 기본적으로 non-thread safe하다. (동기화를 보장하지 않는다.)  복수의 thread가 동시에 HashSet를 이용하려면 외부적으로 동기화를 구현하거나,  Collections.synchronizedSet 메소드를 사용하여 Wrapping 하여 사용하여야 한다.

Set s = Collections.synchronizedSet (new HashSet (…));

 

> TreeSet

  NavigableSet 인터페이스의 구현체인 TreeSet은 실제 구현 Source를 보면 버킷(Element를 담는 공간)으로 TreeMap을 이용한다.  Element는 Natural Order (객체별 기본 Comparable 인터페이스 구현에 의한 compareTo 결과)로 정렬되거나 생성자에 부여된 Comparator에 의한 순서로 정렬 된다.

TreeSet의 버킷으로 이용되는 TreeMap은 Self-balancing Binary Search Tree의 종 류중 하나인 Red-black Tree 방식으로 내부 Element를 구조화 한다.

동기화 관련해서는 HashSet과 같은 특징을 가진다.

 

 

 

 

Java – Collection – List

> List (Interface)

  List Interface를 구현한, 자료구조 Class들의 특징을 이야기 하려고 한다.  List는, 소속된 Element의 순서가 있는 Collection 종류이다.   각 Element가 삽입되는 위치를 정확하게 제어 할 수 있다.  또한 정수로 되어있는 Index를 이용해 Element 접근할 수 있다.

  Set과 달리 List는 일반적으로 중복 요소를 허용한다.  문법적으로 이야기 하자면, 일반적으로 List는 e1.equals(e2)가 허용되는 소속 Element들이 존재할 수 있다.  그리고 일반적으로 null Element를 허용하는 경우 여러 개의 null Element를 허용한다.

Iterator를 이용하여 Element의 삽입 및 교체, 접근 등이 가능하며 구현체들은 Iterator를 얻기위한 메서드를 제공한다.

  List를 구현한 구현체로는 ArrayList와 LinkedList가 일반적으로 사용된다. 

  Element로의 접근 및 검색, 삽입, 삭제 등 에서 두 구현체는 구현방법에 따른 성능차이를 보인다.  경우에 따라 ArrayList와 LinkedList가 더 적합한 경우가 존재한다.  

 

> ArrayList

Element들을 저장하는 방법에 있어서 Array[배열]를 사용한다.   그러므로 배열의 장점 및 단점을 어느 정도 공유한다.  예를 들면, Index를 이용해 각 Element에 Direct로 접근 할 수 있는 장점이 있으며, Array가 초기 Size가 정해져 있는 단점이 있어 확장이 용이하지 않은 단점을 가지고 있다.  단지 ArrayList는 System.arraycopy를 이용해 특정 Size를 넘어가면 자동 확장하게 구현되어 있다.  그러나 분명 자동 확장이라 하더라도 확장할때의 성능 저하는 존재한다.   그러한 점 때문에 ArrayList는 초기 생성 할 때 배열의 초기 Size를 지정할 수 있다.  (DEFAULT_CAPACITY = 10)  어느 정도 크기가 예상될 경우 기본 크기를 그에 맞게 초기에 설정하는 것이 성능상 유리하다.

또한 List의 중간에 Member의 추가 및 삭제가 이루어질 경우도 부하가 존재한다.  배열의 구조를 생각한다면 당연하게 생각 될 것이다.

ArrayList 배열은 일단 확장하고 나면, Element들을 삭제하여도 그 배열 크기는 줄어들지 않는다.  Element의 수가 계속해서 늘었다 줄었다 한다면 ArrayList는 최적의 선택이 아닐 수 있다.

 

> LinkedList

Element들을 저장하는 데 배열을 이용하지 않고, 리스트 안에서 다음 Element를 가리키는 내부 객체를 이용한다.  LinkedList의 각 Element는 해당 Element의 고유 객체(item)와 함께 다음 Element를 가리키는 재귀적 타입인 next라는 필드를 가지고 있다.  또한 prev라는 필드는 직전의 Element를 가리킨다.   이를 이용하면 리스트 사이를 쉽게 이동할 수 있고 순서대로 각 원소를 찾아 처리할 수도 있다.  이러한 특징은 ArrayList의 단점을 없에 준다.  List의 중간에 삭제나 삽입이 이루어지기 수월하고, 확장에 따른 부하가 존재하지 않는다.  그러나 ArrayList가 가지고 있는 장점이 LinkedList에서는 단점이 된다.   Index를 이용한 Element로의 Direct접근이 불가능하다.  get(int index)를 이용할 경우 LinkedList는 List의 첫 요소 부터 혹은 끝부터 List를 순회하여 해당 index를 찾도록 구현 할 수 밖에 없는 것이다. 

 

> ArrayList와 LinkedList의 성능 비교

 

 

Java – Collection 개요 (자료구조)

> 필요없는 사족 (Posting 계기)

Java – Collection에 관한 참고자료 및 Post, 책 등은 사실 이렇게 중복 Post를 할 필요가 없을 정도로 많고 쉽게 찾아 볼 수 있다.  알고리즘에 대해 이야기 할 때, 그리고 자료구조 혹은 Data 핸들링을 위한 이야기 들 가운데 항상 등장한다.  하지만 여기 저기 흩어진 각자의 정리방법, 혹은 Tip들에 대해 블로그 Category로 모을 수 있으면 어떨까 하는 생각에 Posting작업을 시작하게 되었다.

Java를 다루는 이에게 Collection이란 기초적이면서도, 언제든 다시 상기해야 하는 시점이 오는 주제가 아닌가 싶다.

끈기 있게 할 수 있을지 모르겠다.  될 수 있으면 각 개별 Collection들의 특징, 혹은 관련된 Tip, 비교 자료 등을 빠짐없이 이 blog-Category에 모을 수 있다면 하는 소망이 있다.
(단순히 link하는 정도를 넘어)

 

> Java – Collection

Collection은 배열과 같은 다른 객체를 저장, 핸들링하는 것을 목적으로하는 객체이다.   배열과 달리 Collection에는 요소의 일부를 반환하고 변환 및 정렬 등의 메서드를 포함하고 있다.  또한 Collection은 primitive type(int, long, boolean 등)을  요소의 type으로 지정할 수 없다. (하지만 primitive type에 대응되는 wrapper Class를 사용할 수 있다. 예: int -> java.lang.Integer)

모든 Collection 객체는 궁극적으로 java.util.Collection 인터페이스를 구현한다.  그러나 인터페이스를 직접 구현하는 경우는 별로 없다.  Collection 추가 메소드를 지정하는 여러 하위 인터페이스가 있다.  이러한 하위 인터페이스들은 Collection의 기능을 결정한다.  개별 클래스는 일반적으로 구현방식에서만 차이를 보인다. (예를 들어, 모두 ArrayList와 LinkedList는 List의 일반적인 기능을 모두 만족하지만, 내부 구현 방식에 있어 차이를 보인다.)

Collection 인터페이스 의 대부분의 구현은 java.util에 있다.

Map은 Collection 인터페이스를 상속하지 않음에도 불구하고,  일반적으로 Collection을 이야기 할때 항상 포함되기에 함께 이야기해보겠다.

 

> Java Collection 구조

 

> 자료구조 별 일반 특징 비교 (대분류)

 

> 주요 자료구조 별 특징 비교 (실구현 Class)

 

> 참고 URL

Ehcache – 검색 (Search API)

> Ehcache 검색 (Search API)

Ehcache에서 제공하는 Search API를 이용하면 Index가 있는 Cache에 대하여 쿼리 검색이 가능하다.   참고로 Search API는 BigMemory Go, BigMemory Max라고 부르는 테라코타(사)의 Cache 전용 단일 및 분산 서버를 통해 이용할 때와,  오픈소스로 제공하는 별도의 Cache 서버 없이 Java-heap을 이용할때와 작동방식이 다르다.  하지만 두가지 방식다 EhCache Interface를 이용하기 때문에 Search API를 사용하는 방법은 동일하다.


> Cache Searchable 생성

Searchable이란? 검색가능한 “속성”이라고 이야기 할 수 있을 것 같다.  Key – Value로 구성되는 EhCache의 기본 구성에서는 바로 검색을 위하여 색인을 해야하는 필드의 지정이 필요하다.  당연한 이야기지만, 원하는 Value의 Key를 특정할 수 있다면 검색은 필요없다.  Value혹은 그 구성 항목의 일부, 혹은 Key의 일부 구성항목만으로 원하는 값/집합을 얻어야 할때 검색은 필요해진다.  Key와 Value의 멤버나 Method의 반환값 등 을 Searchable로 지정해서 원하는 쿼리의 조건을 받을 준비를 하는 것이다.

프로그램으로 설정하는 방법도 있지만, 본 포스트에서는 설정파일을 이용하는 방법만 다루어 보려고 한다.

Ehcache 설정파일 (ehcache.xml)에서 검색을 원하는 Cache 항목에 아래와 같이 <searchable> 및 <searchAttribute> 를 추가한다.

<cache name="EventAll"
       maxElementsInMemory="2000"
       eternal="true"
       overflowToDisk="false"
       memoryStoreEvictionPolicy="LFU"
       transactionalMode="off">
  <searchable>
        <searchAttribute name="codename" expression="value.getCodename()"/>
        <searchAttribute name="title"    expression="value.getTitle()"/>
        <searchAttribute name="program"  expression="value.getProgram()"/>
        <searchAttribute name="end_date" expression="value.getEnd_date()"/>
   </searchable>           
</cache>
  • <searchable> : 검색가능한 Cache로 지정한다.
  • <searchAttribute> : 검색을 위해 색인(Indexing)해야 할 필드(속성)을 정한다.  위에 예에서는 해당 Cache의 Value에는 DTO형식의 Entity Class가 사용되기에 해당 멤버변수의 Getter가 Expression에 들어갔다.   Expression은 항상 key, value, element 중 하나로 시작해야한다.  본 Tag로 지정된 항목만 Cache검색 Query의 조건으로 사용이 가능하다.  expression은 위와 같이 표현하거나, 혹은 AttributeExtractor를 이용해 추출을 고도화 할 수 도 있다.


> 검색의 수행

Ehcache cache  = ehCacheManager.getCacheManager().getEhcache("EventAll");

Attribute<String> cultcodeAt= cache.getSearchAttribute("cultcode");
Attribute<String> codenameAt= cache.getSearchAttribute("codename");
Attribute<String> titleAt   = cache.getSearchAttribute("title");
Attribute<String> programAt = cache.getSearchAttribute("program");

Criteria searchCriteria = new Or(
                                codenameAt.ilike  ("*" +  searchParam.getSearchText() + "*")
                              , titleAt.ilike     ("*" +  searchParam.getSearchText() + "*"))
                            .or(programAt.ilike   ("*" +  searchParam.getSearchText() + "*"))
                            .and(endDateAt.ge(CommUtil.getToday()));

Query query = cache.createQuery()
        .addCriteria(searchCriteria)
        .addOrderBy(cultcodeAt, Direction.DESCENDING)
        .includeValues()
        .end();

List<Result> resultList = query.execute().all();

  위의 Ehcache Search API 사용 예는  like 조건과 And 조건을 중첩하여 조건(Criteria)가 작성된 예이다.
            (codename like ‘%searchText%’
       OR title like ‘%searchText%’
       OR program like ‘%searchText%’)
      AND endDate = ‘20180426’

  • Attribute : (net.sf.ehcache.search.Attribute) 앞서 설정파일에 명시한 Searchable 값에 해당하는 Class.  
  • Criteria : (net.sf.ehcache.search.expression.Criteria) 조건식을 위한 Interface이다.  이를 구현한 비교, 논리 Operator들을 이용해 조건식을 만든다.  사용가능한 논리, 비교 Operator는 아래와 같다.
     
     
  • Query : (net.sf.ehcache.search.Query) 검색 조건식과 정렬순서, 그리고 결과로 받을 대상등을 정하여 쿼리를 작성할 수 있게 해준다.
    • addCriteria() : 작성한 검색 조건식을 주입해준다.
    • addOrderBy() : 정렬을 위해 검색 대상 Attribute와 정렬방식을 정한다.
    • includeValues() : 검색 결과가 포함할 대상을 정한다.  (Key도 결과에 포함할 경우 : includeKeys())
    • execute() : 검색을 실행하여 결과를 Ehcache의 결과표현 기본 객체로 반환해준다. (net.sf.ehcache.search.Results) 
    • all() : 결과를 (net.sf.ehcache.search.Results) 가 아닌 LIst<Result>로 받기 위해 사용되었다.


> 참고 URL

SpringBoot JUnit Test – 파라미터 반복 테스트

> SpringBoot – JUnit 파라미터 반복 테스트

  JUnit 테스트 기동을 위한 Spring 관련 환경설정 어노테이션은 어려가지가 있다.  그러나 환경 셋팅의 심플함을 주무기로 하는 SpringBoot의 강점 활용을 위해 여러 어노테이션들에 대한 학습 보다는 가능한 간단하게 설정 후 테스트를 진행할 수 있는 방법을 알아보려 한다.   동일한 Request에 대한 테스트라 할지라도 고려해야하는 Input 항목이 많을 수록, 테스트 케이스는 승수적으로 늘어날 가능성이 있다.  바로 Input 값들의 조합 (파라미터)을 변경해가며 반복 테스트를 수행해야 하는 경우이다. 금융사들의 업무시스템의 경우 더욱 그러한 특징이 도드라지지 않나 싶다.  SpringBoot – JUnit을 통해 해당 테스트를 수행하는 방법을 알아보고자 한다.

  사족이지만, TDD를 정착시켜 개발문화 및 효율을 한단계 앞으로 나아가게 하려는 움직임이 국내에도 많이 있는 것 같다.  구태여 TDD를 꺼내지 않더라도, JUnit 등의 테스트 프레임웍의 활용은 금융 외의 다른 도메인에서는 그 중요성에 집중하는 개발자들이 많다.  그러나 금융사들의 IT부서 대부분에게 Java 환경으로의 전환은 단순한 개발언어의 변경 외의 의미가 없어 보인다. (특히 계정계)  내가 처음 금융개발을 시작하게된 시점과 테스트방법은 전혀 나아가지 못 한 것 같다.

  일면 이해하는 면이 있다. 컴플라이언스 관련 개발이 많은 금융산업의 특성상 시간은 흘러가고 릴리즈 일자는 미룰 수 없다.   비즈니스 담당부서(고객)들은 IT 의존도가 높은 경우가 많고 또 미룰수 없는 릴리즈의 특징을 교묘히 악용하는 경우도 있다.(결국에는 그 날짜에 릴리즈하게 되니까)  이에 개발자들은 조금은 피해의식을 가지고 있다.  그렇기에 요구사항을 정제하고 테스트케이스를 미리 만들려는 의지를 얻기가 쉽지않다. 

  금융사의 IT개발, 운영 부서는 테스트의 중요성도 나름은 인식은 한다.  다만 더 나은 방법의 테스트에 대한 연구가 조금은 부족하고 도메인 지식 (금융업무 지식)을 가장 강조하면서도 테스트케이스의 작성은 결국 고객만의 역할이라는 인식, 그리고 마지막으로 변경 적용의 엄격함 / 비효율도 소스품질의 저하를 불러오는 악순환의 고리가 아닌가 싶다.  타국 이긴하지만, 역시 금융환경에서 우리와 비슷한 악조건을 이겨나가 결국 애자일, TDD를 정착시켰던 개발자의 [소프트웨어 장인]이란 책이 떠오른다.


> Dependency (Maven)

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

위와 같이 [spring-boot-starter-test] 의존성 추가 하나만으로 JUnit, Mockito, Hamcrest 등의 테스트를 위한 lib의 사용이 SpringBoot 안에서 가능해진다.

운영 환경에서는 활용되지 않기에, 배포제외 및 테스트 클래스 패스에만 적용을 위해 <scope>test</scopt> Maven Option을 적용하였다.


> JUnit 반복 테스트 Code

@RunWith(Parameterized.class)
@SpringBootTest
public class EventCacheDAOSearchTest {
  
    @ClassRule
    public static final SpringClassRule springClassRule = new SpringClassRule();

    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();    
    
    private String subjcode;
    private String searchText;
    private int pageNo;
    private boolean freeOnly;
    
    @Autowired
    private EventCacheDAO eventCacheDAO;
    
    public EventCacheDAOSearchTest(String subjcode, String searchText, int pageNo, boolean freeOnly) {
        this.subjcode = subjcode;
        this.searchText = searchText;
        this.pageNo = pageNo;
        this.freeOnly = freeOnly;
     }    
    
    @Parameterized.Parameters
    public static List parametersForSearch() {
       return Arrays.asList(new Object[][] {
           {"ALL","서초구", 1, false }
          ,{"ALL","서초구", 2, false }
          ,{"ALL","서초구", 1, true  }
          ,{"ALL","연주회", 1, false  }
          ,{"ALL","바이올린", 1, false }   
          ,{"1","강남", 1, true  }
          ,{"1","강남", 2, true  }
          ,{"18","", 1, true  }
          ,{"19","교육", 1, true  }        
       });
    }
    
    //SQL SearchText, EhCache Search간 결과비교
    @Test
    public void compareSearchTextMethod(){        
        
        List<Seoulevent> allEventList = eventCacheDAO.getEventsBySearchText(this.subjcode, this.searchText, this.pageNo, this.freeOnly);                 
        List<Seoulevent> allEventListCache = eventCacheDAO.getCacheEventsBySearchText(this.subjcode, this.searchText, this.pageNo, this.freeOnly);             
        assertTrue(allEventList.size() == allEventListCache.size());   
    
    }
    
}
  1. @RunWith(Parameterized.class) – 파라미터 반복테스트를 위한 어노테이션 사용이다.  테스트 Class의 생성자 인자를 통해 파라미터를 받아 인스턴스 변수를 이용한 테스트가 가능하게 해준다.  

  2. @SpringBootTest – 해당 Test ClassPath가 속한 Project의 SpringBoot Application Config를 자동으로 Load하여 주며(application.properties 등), 테스트를 위한 일부 어노테이션 사용을 가능하게 해준다.  웹서버가 포함된 SpringBoot의 특징을 활용해 웹서버 기동 환경을 만들어주기도 한다. [@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)]
    SpringBoot가 아닌 Spring에서의 @ContextConfiguration, @SpringApplicationConfiguration 를 대체한다.  

  3. @ClassRule, @Rule (선언된 멤버) – Test Class의 기준을 정하는 JUnit 어노테이션이다.  위의 선언된 문장은 2문장을 함께 사용할 경우 @RunWith(SpringRunner.class)를 대체 한다.  @RunWith(SpringRunner.class or SpringJUnit4ClassRunner.class) 는 @RunWith는 JUnit 내장된 runner 대신 SpringJUnit4ClassRunner.class 클래스를 참조하여 테스트를 실행하도록 해준다.  Bean 등을 위한 Spring의 여러 Container를 일반적인 Load 시점과 마찬가지로 기동하는데 그 목적이 있다.   @RunWith는 다중 인자를 허용하지 않는다.  그렇기에 부득이 하게 Parameterized.class를 사용하기 위하여 위와 같이 선언하였다.  

  4. @Parameterized.Parameters – 파라미터 정의를 위한 어노테이션이다.  해당 어노테이션이 선언된 메서드는 List형으로 파라미터를 반환한다.  List item의 멤버 순서대로 해당 파라미터들은 본 Class 생성자에 주입된다.  그러므로 생성자를 그에 맞게 선언하여 파라미터를 해당 클래스의 인스턴스 멤버에 입력하는 등에 처리가 필요하다.   List의 멤버 Size (갯수) 만큼 @Test 어노테이션이 붙은 메소드들이 반복 수행된다.  

  5. 예로 보여진 위의 코드는 결과가 같다고 추정되는 2가지의 메소드를, 입력값 변화 반복테스트를 통해 결과를 비교하여 검증하는 테스트이다.

SpringBoot – Transaction (트랜잭션)

> Transaction (트랜잭션)

너무 기본적인 이야기 일수도 있지만, 트랜잭션은 처리의 원자성을 보장하기 위한 개념이다.   처리의 원자성이란, 한번에 처리되기 원하는 액션들의 그룹핑을 보장하는 것으로 표현하면 어떨까 싶다.  Cache에도 분명 적용가능하긴하나 보통은 액션이란 Database – DML(Insert/Update/Delete)이며, 결국 Commit/Rollback, All or Nothing 으로 그 원자성을 보장한다.  몇가지 예외 적인 경우를 제외하고 나면 트랜잭션 관리가 없는 형태의 Business System 개발은 감히 있을 수 없다고 말해도 무방하지 않을까 생각된다.

새로 시작하는 회사이긴 했지만, 주 System에 Spring Framework를 사용하는 어떤 금융사(핀테크)의 경우 트랜잭션 처리에 대한 표준을 정하지 않고 개발이 거의 완료단계까지 진행된 경우도 보았다.  거의 모든 Request가 Non-transaction으로 처리되고 있었다.   꽤나 복잡한 형태의 거래 형태도 존재 했기 때문에, 트랜잭션 관리 부재에 대한 문제는 불거져 나오기 시작했다.  Spring의 강점인 선언적 Transaction을 사용하여 어느 정도 문제는 커버할 수 있었으나, 많은 부분을 위험요소로 남겨둔채로 Release까지 진행됬다.

조금은 반대의 경우이긴 한데 몇 몇 금융회사를 다니며 느낀점을 또 잠시 이야기 하자면, 트랜잭션의 원자성 보장에 대해 금융IT 개발자의 대부분이 무감각하며 특별히 신경쓰지 않았다.  EJB, Spring등의 트랜잭션 관리가 비교적 손쉬운 Framework를 사용중인 회사도 마찬가지 였다.  그도 그럴 것이 금융시스템의 경우 위에서 이야기한 트랜잭션 원자성 보장이 필수적이며, 또 그렇기에 당연한 개념으로 인지하는 경향 때문이 아닌가 싶다.   차세대와 같은 프로젝트 초기에 Request 처리 방법이 확정되면 그 당연한것에 대한 구현기법에 의문을 갖는 사람이 없었다.  All or Nothing 이라는 개념은 분명 중요하다.  그러나 궁금해 하지 않는 이러한 성향은  뒤집어 이야기하면 트랜잭션의 분리, 전파를 조정하여 원하는 형태의 세세한 트랜잭션을 처리하는 기법에 대해 친숙하지 않다고 말할 수도 있을 것 같다.

이 포스트에서는 SpringBoot에서의 Transaction 여러 적용 방법 중 하나를 알아보며,  Transaction 경계 및 일부 특징에 대해서도 알아보고자 한다.

 

Spring에서 Transaction 관리를 위해 사용되는 AOP의 프록시 매커니즘은 두가지이다.

  • JDK Dynamic Proxy 
    JDK Dynamic Proxy는 인터페이스에 대한 Proxy만을 지원하며, 클래스에 대한 Proxy를 지원할 수 없다.  JDK Proxy가 가지는 또 하나의 단점은 Target 클래스에 Proxy를 적용할 때 PointCut에 정보에 따라 Advice되는 메써드와 그렇지 않은 메써드가 존재한다.  그러나 JDK Proxy를 사용할 경우 Target 클래스에 대한 모든 메써드 호출이 일단 JVM에 Intercept한 다음 Advice의 invoke 메써드를 호출하게 된다. 그 후에 이 메써드가 Advice되는 메써드인지 그렇지 않은지를 판단하게 된다.  이 과정에서 JVM에 의하여 Intercept한 다음 invoke 메써드를 호출할 때 JDK의 reflection을 이용하여 호출하게 되는것이다.  이는 Proxy를 사용할 때 실행속도를 저하시키는 원인이 된다.
     
  • CGLIB Proxy
    CGLIB Proxy 또한 JDK Proxy처럼 Runtime시에 Target 메써드가 호출될 때 해당 메써드의 Advice적용 여부를 결정하게 된다. 그러나 CGLIB는 메써드가 처음 호출 되었을때 동적으로 bytecode를 생성하여 이후 호출에서는 재사용하는 과정을 거치게 된다.  이 같은 과정을 통하여 두번째 호출이후부터는 실행속도의 향상을 가져올 수 있는 방법을 사용하고 있다.  또한 CGLIB Proxy는 클래스에 대한 Proxy가 가능하다.

    (토비의스프링(1권) 참조 – CGLIB Proxy는 final 클래스에는 적용할 수 없으며, 상속을 통해 프록시를 만들기 때문에 해당 클래스의 생성자가 2번 호출된다.  또한 Class단위로 적용되기 때문에 불필요한 public 메서드에까지 트랜잭션이 적용된다.   클래스프록시는 코드를 함부로 손댈 수 없는 레거시 코드나, 여타제한 때문에 인터페이스를 만들기 어려울 경우에만 사용해야한다.)

    -> (SpringBoot Ver 1.5.3,  Spring Ver 4.3.7 기준) 현재 해당 문제는 모두 해결 된것으로 확인 됐다.  이제 CGLIB Proxy는 final클래스에도 적용가능하며 생성자가 2번 호출되지 않는다.  또한 메서드 단위로도 트랜잭션 설정이 가능하다.   오히려 JDK Dynamic Proxy가 주는 문제나 성능 이슈로 SpringBoot의 기본 Default는 CGLIB 으로 되어 있다.


  1. SpringBoot에서의 설정 및 Transaction 속성

    SpringBoot를 이용하면 전통적인 Spring의 방식과는 다르게 기본적으로 Transaction 관리를 위한 별도 설정(XML, Javaconfig) 없이 트랜잭션 어노테이션사용이 바로 가능하다.  (CGLIB Proxy Default)
    아래는 일반적인 트랜잭션의 속성이다.  

    • 트랜잭션의 속성 1 (전파 – Propagation)

      트랜잭션의 경계를 정의하며 시작 방법을 결정하는 속성이다.   실제 Business, 혹은 비기능적 요구사항에 대한 개발을 진행하다보면 트랜잭션 경계를 가져가야 할 경우가 있다.  이를 태면, 메인 로직이 진행되고 메인 로직은 트랜잭션으로 묶어야 할 여러 Action을 포함한다고 하자.  그리고 이와 동시에 어떤 Repository(DB)에 메인 로직이 진행을 알리는 처리LOG를 적재(Insert)하고자 한다면,  이 처리LOG는 메인 로직이 중간에 어떤 이유에 있어서든(의도한 Exception 이거나 아니더라도) 중단 되더라도 메인 로직의 commit or rollback과 관계 없이 적재가 필요할 수 있다.  그럴경우 메인 로직과는 별개의 트랜잭션이 시작되어야 할 것이다.  (새로운 트랜잭션 경계의 시작)

      > REQUIRED  :  Defualt 속성이다.  트랜잭션의 시작 시점에 이미 진행중인 트랜잭션이 있으면 해당 트랜잭션에 참여하며 없을 경우 새로운 트랜잭션을 시작한다.
      > REQUIRES_NEW  :  항상 새로운 트랜잭션을 시작한다.   이미 진행중인 트랜잭션이 있다면 잠깐 보류되고 해당 트랜잭션 경계가 종료 된 후 다시 시작된다. 
      > SUPPORT  :  이미 진행중인 트랜잭션이 있으면 참여하고, 없을 경우 Non-transaction으로 시작한다.
      > MANDATORY  :  이미 진행중인 트랜잭션이 반드시 있어야만 해당 경계를 넘어 시작할 수 있다.  없을 경우 Exception을 발생시킨다.
      > NOT_SUPPORT  :  Non-transaction으로 시작하며, 이미 진행중인 트랜잭션이 있으면 잠시 보류시킨다.
      > NEVER  :  Non-transaction 상태에서만 해당 경계를 넘어갈 수 있다.  이미 진행중인 트랜잭션(parent)가 있으면 예외를 발생시킨다.  
      > NESTED  :  이미 진행중인 트랜잭션(parent)이 있을 경우 중첩트랜잭션을 생성하여 시작한다.  생성된 중첩트랜잭션은 (parent)가 rollback되면 함께 되지만, 해당 트랜잭션안에서의 Commit/Rollback은 (parent)에 영향을 주지 않는다.  이미 진행중인 트랜잭션이 없을 경우 새로운 트랜잭션을 만든다.

    • 트랜잭션의 속성 2(격리수준-Isolation)

      동시에 여러 Thread 별로 여러 트랜잭션이 진행될 때, 결과를 타 트랜잭션에 어떻게 노출할 것인지를 결정한다.

      > DEFAULT  : 사용하는 데이터 액세스 기술 또는 DB드라이버의 디폴트 설정을 따른다.  대부분의 DB는 READ_COMMITTED를 기본으로 한다.
      > READ_UNCOMMITTED  :  가장 낮은 격리수준.   트랜잭션의 종료에 따른 Commit / Rollback이 이루어지지 않아도 해당 변화가 다른 트랜잭션에 노출된다. (성능은 가장 좋음)
      > READ_COMMITTED  :  Commit / Rollback 되지 않은 정보는 다른 트랜잭션에서 읽을 수 없다.  서로 다른 트랜잭션이 동시에 Row를 핸들링하는 경우 의도치 않은 결과가 올 수 있다.
      > REPEATABLE_READ  :  하나의 트랜잭션이 읽은 로우를 다른 트랜잭션이 수정하는 것을 막아준다.  하지만 New 로우 추가는 제한 않는다. SELECT 후 타 트랜잭션에서 추가로 결과가 달라질수도 있다.
      > SERIALIZABLE  :  가장 강력한 격리수준이다.  순차적으로 진행해서 여러 트랜잭션이 동시에 같은 테이블 정보를 엑세스하지 못하게 한다.  (성능은 가장 낮다)

    • 트랜잭션의 속성 3(Rollback, Commit 관련)

      선언적 트랜잭션에서는 트랜잭션 경계지점(Method)을 기점으로 다시 제어가 돌아왔을때 (해당 Method가 끝날때), 아무 Exception 발생없이 끝나면 Commit, RuntimeException이 throw 되면 Rollback 한다.   일반 Exception(checked)의 경우 commit 이  DEFAULT 이다.  

      > rollback 조건-Exception 변경 (rollbackFor) : rollback이 이루어지는 Exception(throw된)을 변경 한다.
      > commit 조건-Exception 변경 (noRollbackFor) : 반대로 commit (noRollback) 이 이루어지는 Exception을 추가 해줄수도 있다.

    • 트랜잭션의 속성 4(읽기전용 – readOnly)

      트랜잭션을 읽기전용으로 설정한다.  성능최적화, 혹은 의도적으로 조회만을 위한 트랜잭션을 만들기위해 사용할 수 있다.  
       

  2. 선언적 트랜잭션 사용 (Annotation)

    선언적 트랜잭션이란 실사용 코드에 트랜잭션에 대한 구문개입 없이 설정파일 (XML, javaconfig)이나 어노테이션만으로 트랜잭션을 사용하는 것이다.   선언적 트랜잭션 경계설정이 사용 가능하게 된 것은 스프링 AOP 사용의 일예이며 TransactionProxy Bean 덕분이다.   선언적 트랜잭션 사용은 2가지 방법이 주로 사용되나, 여기서는 어노테이션을 이용하는 방법만을 다룬다.  어노테이션을 이용하는 방법은 일면 번거러운면이 분명있다.  그래도 설정파일(XML) 없이 환경구성이 가능한 SpringBoot에서  진행을 해보려 하니 딱히 XML을 따로 만들지 않으려 한다.  그러나 모든 메스드에 일괄적으로 적용하기 위해서는 Springboot에서도 설정파일(XML)에 TX네임스페이스를 이용할 수 밖에 없어보인다.

    • @Transactional
      @Transactional
      public void batchEvent(){
      	
      	try{		
      		apiCallAndAddEvents();
      		
      		apiCallAndAddSubjcode();
          	
      		commService.addBatchJobLog("OK");
          	
      	}catch(Exception e){
      		commService.addBatchJobLog("FAIL");
      		throw new RuntimeException(e);
      	}
      }	

      경계로 가져가고 싶은 클래스, 인터페이스, 메서드에 위와 같이 지정하여 사용할 수 있다.   또한 트랜잭션 에트리뷰트 설정에 따라 해당 위치가 트랜잭션의 경계로 적용되어진다고 볼 수 있다.  위의 경우에는 메스드에 사용하였다.   어노테이션이 중복으로 적용될 경우 메소드 < 클래스 < 인터페이스 순으로 우선순위가 정해진다.  
      트랜잭션은 또한 별도의 에트리뷰트(파라메터)를 갖을 수 있다.  @Transactional(propagation=Propagation.REQUIRES_NEW)  와 같은 형태로 작성되며 1장에서 이야기한 트랜잭션의 속성들을 지정하여 사용할 수 있다.  
      위의 코드를 예를 들자면 2작업 (apiCallAndAddEvents(), apiCallAndAddSubjcode()) 이 트랜잭션으로 묶여 처리되고 정상적으로 종료되면 정상 로그 (OK)를 적재하며, Exception이 발생할경우 오류 로그 (FAIL)을 남기는 코드의 예이다.  위의 코드에는 명시하지 않았지만, commService.addBatchJobLog() 메서드에는 트랜잭션 경계를 다시 부여하여 2작업 결과와 관계없이 로그가 적재되도록 구성되었다.  에러 로그를 적재한 후에는 메인 로직의 Rollback을 위해 다시 RuntimeException을 throw한다.
       

  3. 기타

    • Rollback대상 Exception 발생 후 Rollback 절차  (JPA : Transaction marked as rollbackOnly)

      위에서도 설명한 것처럼 선언적 트랜잭션이 설정된 하나의 그룹이 끝날때 Transactional 전파 옵션에 따라 Rollback or Commit 처리가 이루어진다.  JPA를 사용중일때 다만 주의 할 점은 Rollback대상으로 인지된 Exception (DEFAULT : RuntimeException을 상속한)이 발생하면, 해당 오류의 Try ~ Catch여부와 관계 없이 해당 트랜잭션은 rollbackOnly로 Marked 된다. 이런 경우 Try ~ Catch를 통해 해당 Exception을 Catch하고 다시 Rollback을 위한 Exception을 throw하지 않으면 에러가 발생한다.  (javax.persistence.RollbackException: Transaction marked as rollbackOnly)
      그러나 때로는 Exception Catch를 해당 메서드 밖으로 보내고 싶지 않을때도 분명있다.  이럴 경우, 아래와 같이 처리하여 Exception throw없이 rollback을 수행하게 할 수도 있다. 

      TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
       

    • SpringBoot 의 AOP Proxy 기본방식인 CGLIB 및 JDK Dynamic Proxy 방식 사용에 대하여

      구글링을 진행하며 몇 몇 SpringBoot의 AOP Proxy기본 방식이 CGLIB로 확정된 이유에 대해 궁금해 하는 포스트를 보았다.  나도 해당 내용이 의문이 들어 찾아본 결과 github의 SpringBoot Project에서 owner 및 SpringGroup 멤버로 부터 의문에 답을 찾을 수 있었다.  또한 SpringBoot에서 JDK Dynamic Proxy 방식으로 전환하는 것에 대해서도 약간의 문제가 있음을 알 수 있었다.  (spring.aop.proxy-target-class=false)

      > We’ve generally found cglib proxies less likely to cause unexpected cast exceptions. (LINK)

      >  Unfortunately, it’s not as simple as that. The problem is that we’re fighting against three different ways in the Framework that the proxy type can be configured:

      1. @EnableAspectJAutoProxy
      2. @EnableCaching
      3. @EnableTransactionManagement
        ....... 
        (LINK)