💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.
들어가며
최근에 저는 웹사이트의 공연 정보를 크롤링하여 사용자들에게 공연 정보를 제공하고, 공연을 예매할 수 있는 애플리케이션 개발을 진행하였습니다. 이 프로젝트에서는 웹사이트에서 크롤링한 공연 정보를 사용자에게 효율적으로 전달하기 위해 Transfer Object 객체를 활용하였습니다.
Transfer Object 객체는 공연의 제목, 장소, 날짜, 가격 등 다양한 정보를 하나의 객체로 묶어, 데이터의 일관성을 유지하면서도 효율적인 데이터 전송을 가능하게 했습니다. 이 Transfer Object 객체를 구현하면서, 저는 Collection과 Map 인터페이스를 상속받는 객체들이 주로 사용되는 것을 발견하였습니다. 이는 목록형 데이터를 저장하고 관리하는데 가장 적합한 방법이 배열이며, 그 다음으로는 Collections 관련 객체들이기 때문입니다.
배열은 생성 시점에 크기를 미리 지정해야 하는 반면, Collection의 대부분 객체들은 객체가 추가될 때마다 자동으로 크기가 확장되는 특징을 가지고 있습니다. 이런 차이점을 알아본 후, 저는 한 가지 고민을 가지게 되었습니다. 실제로 개발을 진행하면, 배열과 Collection 중 어떤 방법이 데이터를 저장하고 불러오는 데 있어서 더 뛰어난 성능을 보일까? 또한, 데이터의 크기가 커질수록 이 두 방법의 성능 차이는 어떻게 달라질까?
이런 고민을 해결하기 위해, 이번 글에서는 배열과 Collection 각각의 성능 차이에 대해 심도있게 알아보려고 합니다. 이를 통해 데이터의 크기에 따른 성능 변화와, 어떤 방법이 더 효율적인 데이터 관리 방법이 될 수 있는지에 대해 논의하려 합니다. 이를 통한 통찰로, 프로젝트의 성능을 더욱 향상시킬 수 있을 것입니다.
1) Collection 및 Map 인터페이스의 이해
앞서 언급했듯이, 배열 외에 데이터를 저장하고 관리하는 가장 효율적인 방법은 Collection과 Map 인터페이스를 상속받은 객체를 활용하는 것입니다. 이 인터페이스들의 구성은 아래에 설명한 그림과 같습니다.

이 중 Queue 인터페이스는 JDK 5.0 버전에서 새롭게 추가된 인터페이스입니다. 이제 각 인터페이스에 대해 간단하게 살펴보도록 하겠습니다.
- Collection : 가장 상위 인터페이스입니다.
- Set : 중복을 허용하지 않는 집합을 처리하기 위한 인터페이스입니다.
- SortedSet : 오름차순을 갖는 Set 인터페이스입니다.
- List : 순서가 있는 집합을 처리하기 위한 인터페이스이기 때문에 인덱스가 있어 위치를 지정하여 값을 찾을 수 있습니다. 중복을 허용하며, List 인터페이스를 상속받는 클래스 중에 가장 많이 사용하는 것으로 ArrayList가 있습니다.
- Queue : 여러 개의 객체를 처리하기 전에 담아서 처리할 때 사용하기 위한 인터페이스이다. 기본적으로 FIFO를 따릅니다.
- Map : Map은 키와 값의 쌍으로 구성된 객체의 집합을 처리하기 위한 인터페이스입니다. 이 객체는 중복을 허용하지 않습니다.
- SortedMap : 키를 오름차순으로 정렬하는 Map 인터페이스입니다.
(1) Set 인터페이스
Set 인터페이스는 중복이 없는 집합 객체를 만들 때 유용합니다. 왜냐하면 Set 객체에 데이터를 집어 넣으면 중복된 데이터는 들어가지 않기 때문입니다.
Set 인터페이스를 구현한 클래스로는 HashSet, TreeSet, LinkedHashSet 세가지가 있습니다.
- HashSet : 데이터를 해시 테이블에 저장합니다. 따라서 데이터의 저장 순서는 고려하지 않습니다.
- TreeSet : 데이터를 red-black 트리라는 자료구조에 저장합니다. 따라서 값에 따라 순서가 정해집니다. 데이터를 저장하는 동시에 정렬을 수행하기 때문에, HashSet에 비해 성능이 느릴 수 있습니다.
- LinkedHashSet : 해시 테이블에 데이터를 저장하지만, 데이터가 저장된 순서에 따라 순서가 정해집니다. 이는 삽입 순서를 유지하면서도 해시 테이블의 검색 및 삭제 성능을 보장합니다.
Red-Black Tree 란? 이진 트리 구조로 데이터를 담는 구조를 말하며, 다음과 같은 특징이 있습니다.
1. 각각의 노드는 검은색이나 붉은색이어야 합니다.
2. 가장 상위(root) 노드는 검은색입니다.
3. 가장 말단(leaves) 노드는 검은색입니다.
4. 붉은 노드는 검은 하위 노드만을 가집니다.(따라서 검은 노드는 붉은 상위 노드만을 가집니다.)
5. 모든 말단 노드로 이동하는 경로의 검은 노드 수는 동일합다.

(2) List
List 인터페이스를 구현한 클래스들에 대해 살펴보겠습니다. List는 기본적으로 '배열의 확장판'이라고 생각하시면 됩니다. C나 Java의 배열은 처음 선언할 때 데이터를 담을 수 있는 크기가 정해져 있습니다. 그러나 List 인터페이스를 구현한 클래스들은 데이터의 크기가 자동으로 늘어나므로, 데이터의 개수를 정확히 알 수 없을 때 매우 유용하게 사용됩니다.
List 인터페이스를 구현한 대표적인 클래스에는 ArrayList, LinkedList, 그리고 Vector가 있습니다. 이 중 Vector 클래스는 List 인터페이스를 구현한 원조 클래스라고 볼 수 있습니다. 이 세 가지 클래스 모두 기본적인 List의 특성을 가지고 있지만, 내부 구현 방식에 따라 사용하는 상황이나 성능에 차이가 있습니다.
- Vector : 객체 생성 시에 크기를 지정할 필요가 없는 배열 클래스입니다
- ArrayList : Vector와 비슷하지만, 동기화 처리가 되어 있지 않습니다.
- LinkedList : ArrayList와 동일하지만, Queue 인터페이스를 구현했기 때문에 FIFO 큐 작업을 수행합니다.
(3) Map
Map은 '키(Key)와 값(Value)'의 쌍으로 데이터를 저장하는 구조를 가지고 있습니다. 따라서 단일 객체만 저장하는 다른 Collection API와는 다르게 별도로 분류됩니다. 이러한 Map은 'ID와 패스워드'나 '코드와 이름' 등 고유한 값과 그 값을 설명하는 데이터를 보관할 때 매우 유용합니다.
Map 인터페이스를 구현한 대표적인 클래스에는 HashMap, TreeMap, LinkedHashMap이 있습니다. 그리고 이들의 원조 격인 Hashtable 클래스도 있습니다. 이 네 가지 클래스 모두 기본적인 Map의 특성을 가지고 있지만, 내부 구현 방식과 성능, 사용하는 상황에 차이가 있습니다.
- Hashtable : 데이터를 해시 테이블에 저장하는 클래스입니다. 내부에서 관리하는 해시 테이블 객체가 동기화되어 있으므로, 동시에 여러 스레드가 접근해도 안전하게 데이터를 관리할 수 있습니다. 따라서 동기화가 필요한 경우에는 이 클래스를 사용하면 좋습니다.
- HashMap : Hashtable과 마찬가지로 데이터를 해시 테이블에 저장하지만, 두 가지 큰 차이점이 있습니다. 첫째, 'null 값을 허용'합니다. 둘째, '동기화를 지원하지 않습니다.' 이로 인해 Hashtable보다 빠른 성능을 제공하지만, 여러 스레드가 동시에 접근할 경우 데이터의 일관성을 보장하지 않습니다.
- TreeMap : 'red-black 트리'라는 자료구조에 데이터를 저장합니다. TreeSet과의 큰 차이점은 '키(Key)에 의해 데이터의 순서가 정해진다'는 점입니다. 이로 인해 키를 기준으로 정렬된 상태를 유지하며 데이터를 관리할 수 있습니다.
- LinkedHashMap : HashMap과 거의 동일하지만, '이중 연결 리스트(doubly-linked list)' 방식을 사용하여 데이터를 저장한다는 점이 다릅니다. 이로 인해 데이터의 삽입 순서를 기억하고, 이를 바탕으로 데이터를 관리할 수 있습니다.
(4) Queue
Queue는 '데이터를 순서대로 처리하기 위해' 사용되는 자료구조입니다. 예를 들어, 문자 메시지를 처리하는 서버에서는 들어온 순서대로 메시지를 처리해야 하는데, 이때 Queue에 데이터를 추가하고, 첫 번째로 요청된 데이터부터 순서대로 처리하면 됩니다.
List와 Queue 모두 순서를 가지고 있지만, List의 가장 큰 단점은 '데이터가 많은 경우 처리 시간이 늘어난다'는 점입니다. List에서 가장 앞에 있는 데이터를 지우면, 그 다음 데이터부터 마지막 데이터까지 모두 한 칸씩 앞으로 이동해야 하기 때문에, 데이터가 많을수록 이런 작업에 소요되는 시간이 증가합니다. 반면에 Queue는 이런 단점을 보완하고, 성능을 향상시킬 수 있습니다.
Queue 인터페이스를 구현한 클래스에는 LinkedList와 PriorityQueue, 그리고 java.util.concurrent 패키지에 속한 여러 컨커런트 큐 클래스가 있습니다.
- PriorityQueue: 큐에 추가된 순서와 상관 없이 '먼저 생성된 객체가 먼저 나오도록' 되어 있는 큐입니다.
- LinkedBlockingQueue: '저장할 데이터의 크기를 선택적으로 정할 수도 있는' FIFO 기반의 링크 노드를 사용하는 블로킹 큐입니다.
- ArrayBlockingQueue: '저장되는 데이터의 크기가 정해져 있는' FIFO 기반의 블로킹 큐입니다.
- PriorityBlockingQueue: '저장되는 데이터의 크기가 정해져 있지 않고', 객체의 생성 순서에 따라 순서가 결정되는 블로킹 큐입니다.
- DelayQueue: 큐가 '대기하는 시간을 지정'하여 처리하도록 되어 있는 큐입니다.
- SynchronousQueue: put() 메서드를 호출하면, 다른 스레드에서 take() 메서드가 호출될 때까지 대기하도록 되어 있는 큐입니다. 이 큐에는 실제로 데이터가 저장되지 않습니다.
- 참고: 블로킹 큐는 크기가 지정된 큐에서 더 이상 공간이 없을 경우, 공간이 생길 때까지 대기하도록 만들어진 큐를 의미합니다.
2) Set 클래스 중 무엇이 가장 빠를까?
(1) Set 클래스별 성능 테스트
Set 클래스들의 성능을 비교하기 위해 JMH 테스트 코드를 작성하였습니다. 데이터 저장에 얼마나 많은 시간이 소요되는지 확인해 보도록 하겠습니다. 아래는 테스트 코드입니다.
package com.perf.collection;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class SetAdd {
static final int LOOP_COUNT = 1000; // 반복 횟수 상수
Set<String> set; // Set 인터페이스 선언
String data = "abcdefghijklmnopqrstuvwxyz"; // 추가될 데이터의 기본 형태
@GenerateMicroBenchmark // HashSet에 요소 추가에 대한 성능 측정 메소드
public void addHashSet() {
set = new HashSet<String>(); // HashSet 인스턴스 생성
for(int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
set.add(data + loop); // 각 반복에서 고유한 문자열 추가
}
}
@GenerateMicroBenchmark // TreeSet에 요소 추가에 대한 성능 측정 메소드
public void addTreeSet() {
set = new TreeSet<String>(); // TreeSet 인스턴스 생성
for(int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
set.add(data + loop); // 각 반복에서 고유한 문자열 추가
}
}
@GenerateMicroBenchmark // LinkedHashSet에 요소 추가에 대한 성능 측정 메소드
public void addLinkedHashSet() {
set = new LinkedHashSet<String>(); // LinkedHashSet 인스턴스 생성
for(int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
set.add(data + loop); // 각 반복에서 고유한 문자열 추가
}
}
}
테스트 결과는 아래와 같습니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| HashSet | 375 | 해시 테이블을 사용하지만, 해시 충돌로 인해 순회 시간이 걸립니다. |
| TreeSet | 1,249 | 정렬된 상태를 유지하는 레드-블랙 트리를 사용하여, 데이터가 많을수록 순회 시간이 늘어납니다. |
| LinkedHashSet | 378 | 삽입 순서를 유지하는 구조로, 순회 성능이 가장 좋습니다. |
분석 결과, HashSet과 LinkedHashSet의 성능이 비슷하며, TreeSet은 그보다 성능 차이가 발생합니다.
+ 추가적으로, Set의 초기 크기를 지정하여 객체를 생성한 후 데이터를 추가하는 테스트 코드를 작성해 보았습니다.
@GenerateMicroBenchmark
public void addHashSetWithInitialSize() {
set=new HashSet<String>(LOOP_COUNT);
for(int loop=0;loop<LOOP_COUNT;loop++) {
set.add(data+loop);
}
}
이 방법은 데이터 크기를 미리 알고 있을 때 유용합니다. HashSet의 성능을 비교해 보면 아래와 같습니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| HashSet | 375 | 초기에 설정된 해시맵의 크기를 초과하는 데이터를 추가하면, 해시맵의 크기를 증가시키는 리사이징 작업이 필요하게 되고, 이 작업은 시간이 소요됩니다. |
| HashSetWithInitialSize | 352 | HashSet 객체를 생성할 때, 저장될 데이터의 크기를 미리 지정하였습니다. |
위 결과를 보면 큰 차이는 없지만, 저장되는 데이터의 크기를 알고 있을 경우, 객체 생성 시 크기를 미리 지정하면 성능상 이점이 있음을 알 수 있습니다.
(2) Set 클래스별 데이터를 읽는 시간
이번에는 Java의 Set 클래스들이 데이터를 읽는데 얼마나 많은 시간이 걸리는지 비교해보도록 하겠습니다. 아래는 데이터를 가져오는 시간을 측정하기 위한 JMH (Java Microbenchmark Harness) 테스트 코드입니다.
package com.perf.collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
@State(Scope.Thread) // 성능 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class SetIterate {
int LOOP_COUNT = 1000; // 반복할 횟수
Set<String> hashSet; // HashSet 인스턴스
Set<String> treeSet; // TreeSet 인스턴스
Set<String> linkedHashSet; // LinkedHashSet 인스턴스
String data = "abcdefghijklmnopqrstuvwxyz"; // 테스트에 사용할 데이터
String[] keys; // 키 배열
String result = null; // 마지막으로 접근한 요소를 저장할 변수
// 테스트 시작 전 초기 설정을 위한 메소드
@Setup(Level.Trial)
public void setUp() {
// 각 Set 인스턴스를 초기화하고 테스트 데이터를 추가
hashSet = new HashSet<String>();
treeSet = new TreeSet<String>();
linkedHashSet = new LinkedHashSet<String>();
for (int loop = 0; loop < LOOP_COUNT; loop++) {
String tempData = data + loop;
hashSet.add(tempData);
treeSet.add(tempData);
linkedHashSet.add(tempData);
}
}
// HashSet을 순회하는 테스트 메소드
@GenerateMicroBenchmark
public void iterateHashSet() {
Iterator<String> iter = hashSet.iterator(); // HashSet의 Iterator
while (iter.hasNext()) { // HashSet을 순회
result = iter.next(); // 각 요소에 접근
}
}
// TreeSet을 순회하는 테스트 메소드
@GenerateMicroBenchmark
public void iterateTreeSet() {
Iterator<String> iter = treeSet.iterator(); // TreeSet의 Iterator
while (iter.hasNext()) { // TreeSet을 순회
result = iter.next(); // 각 요소에 접근
}
}
// LinkedHashSet을 순회하는 테스트 메소드
@GenerateMicroBenchmark
public void iterateLinkedHashSet() {
Iterator<String> iter = linkedHashSet.iterator(); // LinkedHashSet의 Iterator
while (iter.hasNext()) { // LinkedHashSet을 순회
result = iter.next(); // 각 요소에 접근
}
}
}
테스트 결과는 다음과 같습니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| HashSet | 26 | 해시테이블을 사용하지만, 데이터 순서를 유지하지 않아 LinkedHashSet보다 느립니다. |
| TreeSet | 35 | 이진 검색 트리를 사용하여 데이터를 정렬하므로, 순서 유지와 정렬 작업으로 인해 상대적으로 더 느립니다. |
| LinkedHashSet | 16 | 해시테이블과 연결 리스트를 사용하여, 데이터의 순서를 유지하면서 빠른 접근성을 제공하므로 가장 빠른 속도를 보여줍니다. |
결과를 보면, LinkedHashSet이 가장 빠르며, 그 뒤로 HashSet, TreeSet 순으로 데이터를 가져오는 속도가 느려집니다. 그러나, 일반적으로 Set은 여러 데이터를 저장하고 해당 데이터가 존재하는지 확인하는 데 주로 사용됩니다.
따라서 데이터는 Iterator를 통해 순차적으로 가져오는 것이 아니라, 보통은 랜덤하게 접근합니다. 이에 대한 성능 비교도 함께 고려해볼 필요가 있습니다.
(3) 랜덤 데이터 추출 방법
이번에는 랜덤한 데이터를 가져오기 위해, RandomKeyUtil 클래스에 generateRandomKeysSwap()라는 메서드를 만들었습니다. 이 메서드는 데이터의 개수만큼 무작위 키를 생성하는 역할을 합니다.
자세히 설명하자면, 아래와 같은 코드를 사용했습니다:
package com.perf.collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class RandomKeyUtil {
// Set에서 키를 무작위로 섞어 반환하는 메서드
public static String[] generateRandomSetKeysSwap(Set<String> set) {
int size = set.size();
String result[] = new String[size];
Random random = new Random();
int maxNumber = size;
Iterator<String> iterator = set.iterator();
int resultPos = 0;
// Set의 모든 요소를 배열에 복사
while (iterator.hasNext()) {
result[resultPos++] = iterator.next();
}
// 배열의 요소를 무작위로 섞음
for (int loop = 0; loop < size; loop++) {
int randomNumber1 = random.nextInt(maxNumber);
int randomNumber2 = random.nextInt(maxNumber);
String temp = result[randomNumber2];
result[randomNumber2] = result[randomNumber1];
result[randomNumber1] = temp;
}
return result;
}
}
이 'generateRandomSetKeysSwap()' 메서드를 사용하면, 무작위로 키를 생성할 수 있습니다. 이렇게 생성된 키는 데이터의 개수와 동일하게 생성되므로, 각 데이터에 고유한 랜덤 키를 부여하는 데 활용할 수 있습니다.
(4) 비순차적 데이터 추출 방법
다음으로, 우리는 비순차적으로 데이터를 선택하는 SetContains 클래스를 만들어 보겠습니다.
package com.perf.collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class SetContains {
int LOOP_COUNT = 1000; // 반복할 횟수
Set<String> hashSet; // HashSet 인스턴스
Set<String> treeSet; // TreeSet 인스턴스
Set<String> linkedHashSet; // LinkedHashSet 인스턴스
String data = "abcdefghijklmnopqrstuvwxyz"; // 테스트에 사용할 데이터
String[] keys; // 키 배열
// 테스트 시작 전 초기 설정을 위한 메소드
@Setup(Level.Trial)
public void setUp() {
// 각 Set 인스턴스를 초기화하고 테스트 데이터를 추가
hashSet = new HashSet<String>();
treeSet = new TreeSet<String>();
linkedHashSet = new LinkedHashSet<String>();
for (int loop = 0; loop < LOOP_COUNT; loop++) {
String tempData = data + loop;
hashSet.add(tempData);
treeSet.add(tempData);
linkedHashSet.add(tempData);
}
// 무작위 키 배열을 생성
if (keys == null || keys.length != LOOP_COUNT) {
keys = RandomKeyUtil.generateRandomSetKeysSwap(hashSet);
}
}
// HashSet에서 키 포함 여부를 테스트하는 메소드
@GenerateMicroBenchmark
public void containsHashSet() {
for (String key : keys) { // 생성된 키를 순회하며 검색
hashSet.contains(key); // 각 키의 포함 여부를 확인
}
}
// TreeSet에서 키 포함 여부를 테스트하는 메소드
@GenerateMicroBenchmark
public void containsTreeSet() {
for (String key : keys) { // 생성된 키를 순회하며 검색
treeSet.contains(key); // 각 키의 포함 여부를 확인
}
}
// LinkedHashSet에서 키 포함 여부를 테스트하는 메소드
@GenerateMicroBenchmark
public void containsLinkedHashSet() {
for (String key : keys) { // 생성된 키를 순회하며 검색
linkedHashSet.contains(key); // 각 키의 포함 여부를 확인
}
}
}
이 클래스를 실행한 결과는 아래와 같습니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| HashSet | 32 | 데이터를 저장하고 검색하는 데에 해시 함수를 사용하기 때문에 평균적으로 매우 빠른 검색 속도를 제공합니다. |
| TreeSet | 841 | TreeSet은 이진 검색 트리를 기반으로 한 Set입니다. 이진 검색 트리의 특성상, 모든 데이터는 정렬된 상태로 저장됩니다. 이로 인해 추가적인 정렬 작업이 발생합니다. |
| LinkedHashSet | 32 | LinkedHashSet은 HashSet의 하위 클래스로, 추가된 순서에 따라 원소를 저장합니다. 내부적으로 이중 연결 리스트를 사용하여 원소의 순서를 유지하며, 해시 테이블을 사용하여 빠른 검색 성능을 제공합니다. |
위 결과를 보면 HashSet과 LinkedHashSet의 속도는 빠르지만, TreeSet의 속도는 상대적으로 느립니다. 그렇다면 왜 TreeSet 클래스를 만들었을까요? TreeSet의 특징은 데이터를 저장하면서 동시에 정렬하는 것입니다. TreeSet 선언을 보면 이해가 될 겁니다.
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, Serializable
여기서 중요한 것은 NavigableSet 인터페이스입니다. 이 인터페이스는 특정 값보다 큰 값이나 작은 값, 가장 큰 값, 가장 작은 값 등을 추출하는 메서드를 선언하고 있습니다. 이 기능은 JDK 1.6부터 추가되었습니다.
따라서, 데이터를 순서에 따라 탐색해야 하는 경우에는 TreeSet을 사용하는 것이 좋습니다. 하지만 그럴 필요가 없다면, HashSet이나 LinkedHashSet을 사용하는 것이 효과적일 것입니다.
3) List 관련 클래스 중 무엇이 빠를까?
(1) List 클래스별 성능 테스트
이번에는 List 인터페이스를 구현한 ArrayList, LinkedList, Vector 클래스 각각의 속도를 비교해 보려고 합니다.
다음은 이를 위한 코드입니다:
package com.perf.collection;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class ListAdd {
int LOOP_COUNT = 1000; // 반복할 횟수
List<Integer> arrayList; // ArrayList 인스턴스
List<Integer> vector; // Vector 인스턴스
List<Integer> linkedList; // LinkedList 인스턴스
// ArrayList에 요소를 추가하는 테스트 메소드
@GenerateMicroBenchmark
public void addArrayList() {
arrayList = new ArrayList<Integer>(); // ArrayList 인스턴스 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
arrayList.add(loop); // 각 반복에서 정수 추가
}
}
// 초기 크기를 지정한 ArrayList에 요소를 추가하는 테스트 메소드
@GenerateMicroBenchmark
public void addArrayListWithInitialSize() {
arrayList = new ArrayList<Integer>(LOOP_COUNT); // 초기 크기가 지정된 ArrayList 인스턴스 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
arrayList.add(loop); // 각 반복에서 정수 추가
}
}
// Vector에 요소를 추가하는 테스트 메소드
@GenerateMicroBenchmark
public void addVector() {
vector = new Vector<Integer>(); // Vector 인스턴스 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
vector.add(loop); // 각 반복에서 정수 추가
}
}
// LinkedList에 요소를 추가하는 테스트 메소드
@GenerateMicroBenchmark
public void addLinkedList() {
linkedList = new LinkedList<Integer>(); // LinkedList 인스턴스 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) { // LOOP_COUNT만큼 반복하며 요소 추가
linkedList.add(loop); // 각 반복에서 정수 추가
}
}
}
이제 결과를 확인해 봅시다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| ArrayList | 28 | ArrayList는 인덱스를 통해 데이터에 직접 접근하기 때문에, 데이터 추가에 있어서 상대적으로 빠른 처리 속도를 보입니다. |
| Vector | 31 | Vector는 ArrayList와 유사한 구조를 가지지만, 동기화를 지원합니다. 따라서, 멀티 스레드 환경에서 안전하지만, 동기화로 인해 약간의 성능 저하가 발생하여 ArrayList보다 속도가 느립니다. |
| LinkedList | 40 | LinkedList는 이전 노드와 다음 노드의 정보를 가지는 노드 기반의 구조로, 데이터 추가 시 노드를 생성하고 연결하는 과정이 필요하여 ArrayList와 Vector에 비해 속도가 느립니다. |
보시다시피, 어떤 클래스를 사용하든 데이터를 넣는 속도에 큰 차이가 없는 것으로 나타났습니다.
(2) List 클래스별 데이터 추출 속도
이번에는 데이터를 꺼내는 속도를 측정한 결과를 확인해 보겠습니다. 테스트에 사용된 코드는 아래와 같습니다.
package com.perf.collection;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class ListGet {
int LOOP_COUNT = 1000; // 반복할 횟수
List<Integer> arrayList; // ArrayList 인스턴스
List<Integer> vector; // Vector 인스턴스
LinkedList<Integer> linkedList; // LinkedList 인스턴스
int result = 0; // 결과값 저장 변수
// 테스트 시작 전 초기 설정을 위한 메소드
@Setup(Level.Trial)
public void setUp() {
arrayList = new ArrayList<>(); // ArrayList 인스턴스 생성 및 초기화
vector = new Vector<>(); // Vector 인스턴스 생성 및 초기화
linkedList = new LinkedList<>(); // LinkedList 인스턴스 생성 및 초기화
for(int loop = 0; loop < LOOP_COUNT; loop++) {
// 각 리스트에 LOOP_COUNT만큼의 정수를 추가
arrayList.add(loop);
vector.add(loop);
linkedList.add(loop);
}
}
// ArrayList에서 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getArrayList() {
for(int loop = 0; loop < LOOP_COUNT; loop++) {
result = arrayList.get(loop); // 각 인덱스에서 요소를 가져와서 result에 저장
}
}
// Vector에서 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getVector() {
for(int loop = 0; loop < LOOP_COUNT; loop++) {
result = vector.get(loop); // 각 인덱스에서 요소를 가져와서 result에 저장
}
}
// LinkedList에서 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getLinkedList() {
for(int loop = 0; loop < LOOP_COUNT; loop++) {
result = linkedList.get(loop); // 각 인덱스에서 요소를 가져와서 result에 저장
}
}
}
데이터를 추가하는 시간은 유사하지만, 데이터를 꺼내는 시간은 아래와 같이 다릅니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| ArrayList | 4 | ArrayList는 내부적으로 데이터를 배열로 관리하기 때문에 인덱스를 이용한 데이터 검색이 매우 빠릅니다. |
| Vector | 105 | Vector는 ArrayList와 비슷한 구조를 가지지만, 동기화를 지원하기 때문에 멀티스레드 환경에서는 안전하지만 이로 인해 속도가 느려집니다. |
| LinkedList | 1,512 | LinkedList는 데이터의 추가, 삭제는 빠르지만, 인덱스를 이용한 검색은 느립니다. 이는 LinkedList가 데이터를 검색할 때, 처음부터 끝까지 순차적으로 탐색해야 하기 때문입니다. |
여기서 ArrayList의 속도가 가장 빠르며, Vector와 LinkedList는 속도가 상당히 느린 것을 확인할 수 있습니다. LinkedList가 특히 느리게 나타나는 이유는 LinkedList가 Queue 인터페이스를 상속받아 순차적으로 데이터를 처리하기 때문입니다. 이를 개선하려면 순차적으로 결과를 가져오는 peek() 메서드를 사용해야 합니다.
LinkedList의 데이터 처리를 개선하기 위해 peek() 메서드를 사용하는 peekLinkedList() 메서드를 다음과 같이 수정해보겠습니다.
@GenerateMicroBenchmark
public void peekLinkedList() {
for(int loop = 0; loop < LOOP_COUNT; loop++) {
result = linkedList.peek();
}
}
위 코드로 변경 후의 성능 테스트 결과는 다음과 같습니다.
| 대상 | 평균 응답 시간 (마이크로초) | 이유 |
| ArrayList | 4 | ArrayList는 내부적으로 데이터를 배열로 관리하기 때문에 인덱스를 이용한 데이터 검색이 매우 빠릅니다. |
| Vector | 105 | Vector는 ArrayList와 비슷한 구조를 가지지만, 동기화를 지원하기 때문에 멀티스레드 환경에서는 안전하지만 이로 인해 속도가 느려집니다. |
| LinkedList | 1,512 | LinkedList는 데이터의 추가, 삭제는 빠르지만, 인덱스를 이용한 검색은 느립니다. 이는 LinkedList가 데이터를 검색할 때, 처음부터 끝까지 순차적으로 탐색해야 하기 때문입니다. |
| LinkedListPeek | 0.16 | LinkedList에서 peek() 메서드를 사용하면, 데이터의 첫 번째 요소를 빠르게 참조할 수 있습니다. |
이를 통해 LinkedList를 사용할 때, get() 메서드보다는 peek() 또는 poll() 메서드를 사용하는 것이 더 효율적임을 알 수 있습니다.
그런데 왜 ArrayList와 Vector의 성능 차이가 이렇게 큰지에 대해 의문이 생길 수 있습니다.
ArrayList는 여러 스레드에서 동시에 접근하면 문제가 발생할 수 있지만, Vector는 이를 방지하기 위해 get() 메서드에 synchronized를 사용하고 있습니다. 이로 인해 성능 저하가 발생하는 것입니다.
(3) List 클래스별 데이터 삭제 속도
마지막으로, 데이터 삭제 속도를 비교해봅시다.
아래의 Java 코드는 ArrayList, Vector, LinkedList에서 데이터를 삭제하는 속도를 측정합니다. 각 리스트에서 첫 번째 요소와 마지막 요소를 삭제하는 시나리오를 모두 고려하였습니다.
package com.perf.collection;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class ListRemove {
int LOOP_COUNT = 10; // 반복할 횟수
List<Integer> arrayList; // ArrayList 인스턴스
List<Integer> vector; // Vector 인스턴스
LinkedList<Integer> linkedList; // LinkedList 인스턴스
// 테스트 시작 전 초기 설정을 위한 메소드
@Setup(Level.Trial)
public void setUp() {
arrayList = new ArrayList<Integer>(); // ArrayList 인스턴스 생성 및 초기화
vector = new Vector<Integer>(); // Vector 인스턴스 생성 및 초기화
linkedList = new LinkedList<Integer>(); // LinkedList 인스턴스 생성 및 초기화
for (int loop = 0; loop < LOOP_COUNT; loop++) {
// 각 리스트에 LOOP_COUNT만큼의 정수를 추가
arrayList.add(loop);
vector.add(loop);
linkedList.add(loop);
}
}
// ArrayList의 첫 번째 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeArrayListFromFirst() {
ArrayList<Integer> tempList = new ArrayList<Integer>(arrayList); // 임시 리스트 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) {
tempList.remove(0); // 매 반복마다 첫 번째 요소 제거
}
}
// Vector의 첫 번째 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeVectorFromFirst() {
List<Integer> tempList = new Vector<Integer>(vector); // 임시 리스트 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) {
tempList.remove(0); // 매 반복마다 첫 번째 요소 제거
}
}
// LinkedList의 첫 번째 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeLinkedListFromFirst() {
LinkedList<Integer> tempList = new LinkedList<Integer>(linkedList); // 임시 리스트 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) {
tempList.remove(0); // 매 반복마다 첫 번째 요소 제거
}
}
// ArrayList의 마지막 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeArrayListFromLast() {
ArrayList<Integer> tempList = new ArrayList<Integer>(arrayList); // 임시 리스트 생성
for (int loop = LOOP_COUNT - 1; loop >= 0; loop--) {
tempList.remove(loop); // 매 반복마다 마지막 요소 제거
}
}
// Vector의 마지막 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeVectorFromLast() {
List<Integer> tempList = new Vector<Integer>(vector); // 임시 리스트 생성
for (int loop = LOOP_COUNT - 1; loop >= 0; loop--) {
tempList.remove(loop); // 매 반복마다 마지막 요소 제거
}
}
// LinkedList의 마지막 요소를 제거하는 테스트 메소드
@GenerateMicroBenchmark
public void removeLinkedListFromLast() {
LinkedList<Integer> tempList = new LinkedList<Integer>(linkedList); // 임시 리스트 생성
for (int loop = 0; loop < LOOP_COUNT; loop++) {
tempList.removeLast(); // 매 반복마다 마지막 요소 제거
}
}
}
실행 결과는 다음과 같습니다:
- 첫 번째 요소를 제거할 때의 평균 응답 시간 (마이크로초)
- ArrayList: 418
- Vector: 687
- LinkedList: 423
- 마지막 요소를 제거할 때의 평균 응답 시간 (마이크로초)
- ArrayList: 146
- Vector: 426
- LinkedList: 407

결과를 살펴보면, 첫 번째 값을 삭제하는 메서드와 마지막 값을 삭제하는 메서드 간에 속도 차이가 크게 나타납니다. 반면에 LinkedList에서는 그 차이가 미미합니다. 이는 왜일까요?
ArrayList와 Vector는 내부적으로 배열을 사용합니다. 배열에서 0번째 값을 삭제하게 되면, 첫 번째 위치에 있던 값이 0번째로 이동해야 합니다. 하지만 이때, 단순히 한 값만을 이동시키는 것이 아니라, 첫 번째부터 마지막 위치에 있는 모든 값을 한 칸씩 앞으로 이동시켜야 합니다.
이런 과정 때문에, ArrayList와 Vector에서 첫 번째 값을 삭제하는 작업은 상대적으로 시간이 많이 소요됩니다. 이로 인해, 첫 번째 값을 삭제하는 메서드가 마지막 값을 삭제하는 메서드보다 느린 속도를 보이는 것입니다.
4) Map 관련 클래스 중 무엇이 빠를까?
(1) Map 클래스별 성능 테스트
마지막으로, Map 관련 클래스들의 속도를 비교해보겠습니다. 데이터 추가 작업의 속도는 대부분 비슷하므로, get() 메서드를 통해 데이터를 추출하는 시간에 초점을 두어 비교해보도록 하겠습니다.
다음은 이를 위한 코드입니다:
package com.perf.collection;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
@State(Scope.Thread) // 벤치마크 테스트의 스레드 범위를 설정
@BenchmarkMode({ Mode.AverageTime }) // 벤치마크 모드를 평균 시간으로 설정
@OutputTimeUnit(TimeUnit.MICROSECONDS) // 출력 시간 단위를 마이크로초로 설정
public class MapGet {
int LOOP_COUNT = 1000; // 반복할 횟수
Map<Integer,String> hashMap; // HashMap 인스턴스
Map<Integer,String> hashtable; // Hashtable 인스턴스
Map<Integer,String> treeMap; // TreeMap 인스턴스
Map<Integer,String> linkedHashMap; // LinkedHashMap 인스턴스
int keys[]; // 키 배열
// 테스트 시작 전 초기 설정을 위한 메소드
@Setup(Level.Trial)
public void setUp() {
hashMap = new HashMap<Integer,String>(); // HashMap 인스턴스 생성 및 초기화
hashtable = new Hashtable<Integer,String>(); // Hashtable 인스턴스 생성 및 초기화
treeMap = new TreeMap<Integer,String>(); // TreeMap 인스턴스 생성 및 초기화
linkedHashMap = new LinkedHashMap<Integer,String>(); // LinkedHashMap 인스턴스 생성 및 초기화
String data = "abcdefghijklmnopqrstuvwxyz"; // 테스트에 사용할 데이터
// 각 맵에 LOOP_COUNT만큼의 키-값 쌍을 추가
for (int loop = 0; loop < LOOP_COUNT; loop++) {
String tempData = data + loop;
hashMap.put(loop, tempData);
hashtable.put(loop, tempData);
treeMap.put(loop, tempData);
linkedHashMap.put(loop, tempData);
}
keys = RandomKeyUtil.generateRandomNumberKeysSwap(LOOP_COUNT); // 무작위 키 배열 생성
}
// HashMap에서 순차적으로 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getSeqHashMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
hashMap.get(loop); // 각 인덱스에서 요소를 가져옴
}
}
// HashMap에서 무작위 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getRandomHashMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
hashMap.get(keys[loop]); // 생성된 무작위 키를 사용하여 요소를 가져옴
}
}
// Hashtable에서 순차적으로 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getSeqHashtable() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
hashtable.get(loop); // 각 인덱스에서 요소를 가져옴
}
}
// Hashtable에서 무작위 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getRandomHashtable() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
hashtable.get(keys[loop]); // 생성된 무작위 키를 사용하여 요소를 가져옴
}
}
// TreeMap에서 순차적으로 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getSeqTreeMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
treeMap.get(loop); // 각 인덱스에서 요소를 가져옴
}
}
// TreeMap에서 무작위 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getRandomTreeMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
treeMap.get(keys[loop]); // 생성된 무작위 키를 사용하여 요소를 가져옴
}
}
// LinkedHashMap에서 순차적으로 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getSeqLinkedHashMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
linkedHashMap.get(loop); // 각 인덱스에서 요소를 가져옴
}
}
// LinkedHashMap에서 무작위 요소를 가져오는 테스트 메소드
@GenerateMicroBenchmark
public void getRandomLinkedHashMap() {
for (int loop = 0; loop < LOOP_COUNT; loop++) {
linkedHashMap.get(keys[loop]); // 생성된 무작위 키를 사용하여 요소를 가져옴
}
}
}
비교 결과는 아래와 같습니다:
| 클래스명 | 평균 응답 시간 |
| SeqHashMap | 32 |
| RandomHashMap | 40 |
| SeqHashtable | 106 |
| RandomHashtable | 120 |
| SeqLinkedHashMap | 34 |
| RandomLinkedHashMap | 46 |
| SeqTreeMap | 197 |
| RandomTreeMap | 277 |
대부분의 클래스들은 비슷한 성능을 보이지만, 트리 구조를 가진 TreeMap이 가장 느린 것으로 나타났습니다. 또한, Sun에서는 각 인터페이스별로 가장 일반적으로 사용되는 클래스를 다음과 같이 정리하였습니다:
- Set: HashSet
- List: ArrayList
- Map: HashMap
- Queue: LinkedList
5) Collection 관련 클래스의 동기화
HashSet, TreeSet, LinkedHashSet, ArrayList, LinkedList, HashMap, TreeMap, 그리고 LinkedHashMap은 동기화되지 않은 클래스입니다. 반면에, Vector와 Hashtable는 동기화가 되어 있는 클래스입니다.
즉, JDK 1.0에서 생성된 Vector나 Hashtable은 동기화 처리가 되어 있지만, JDK 1.2 이후에 만들어진 클래스들은 동기화 처리가 되어 있지 않습니다.
Collections 클래스는 최신 버전의 클래스들이 동기화를 지원하도록 도와주는, synchronized로 시작하는 메서드들을 제공합니다. 이 메서드들은 각각의 클래스에서 아래와 같이 사용할 수 있습니다.
Set s = Collections.synchronizedSet(new HashSet(...));
SortedSet s = Collections.synchronizedSortedSet(new TreeSet(...));
Set s = Collections.synchronizedSet(new LinkedHashSet(...));
List list = Collections.synchronizedList(new ArrayList(...));
List list = Collections.synchronizedList(new LinkedList(...));
Map m = Collections.synchronizedMap(new HashMap(...));
Map m = Collections.synchronizedMap(new TreeMap(...));
Map m = Collections.syncrhonizedMap(new LinkedHashMap(...));
또한, Map의 경우에는 키 값들을 Set으로 변환하여 Iterator를 통해 데이터를 처리하는 상황이 종종 발생합니다. 이런 상황에서는 ConcurrentModificationException이라는 예외가 발생할 수 있습니다.
이 예외는 여러 가지 원인으로 발생할 수 있는데, 그 중 하나는 한 스레드에서 Iterator를 통해 특정 Map 객체의 데이터를 꺼내는 동안, 다른 스레드에서 해당 Map을 수정하는 경우입니다.
이런 문제를 해결하려면 필요한 클래스를 직접 구현하여 사용하는 방법도 있지만, 가장 간편한 방법은 java.util.concurrent 패키지를 확인해보는 것입니다. 이 패키지에는 이런 문제를 해결할 수 있는 다양한 클래스들이 포함되어 있기 때문입니다.
'Java > 자바 성능 개선' 카테고리의 다른 글
| 06. static 재대로 사용하기 (0) | 2024.01.04 |
|---|---|
| 05. 지금까지 사용하던 for 루프를 더 빠르게 사용할 수 있다고? (0) | 2024.01.01 |
| 03. 왜 자꾸 String을 쓰지 말라는 걸까? (1) | 2023.12.19 |
| 02. 내가 만든 프로그램의 속도를 알고 싶다. (0) | 2023.12.19 |
| 01. 디자인 패턴은 꼭 써야한다 - (2) J2EE 디자인 패턴 (0) | 2023.12.19 |