반응형

등장 배경

Java 8 이전에는 Future라는 비동기 작업을 위한 클래스가 사용되었다.
하지만 실제 개발에 적용해 보면 구조적인 제약이 명확하게 드러난다.

  1. 결과를 얻기 위해선 항상 대기해야 한다
    Future#get()은 호출 스레드를 블로킹한다.
    따라서 여러 비동기 작업을 동시에 수행하더라도, 결국 결과를 순차적으로 기다릴 수밖에 없다.
  2. 작업 완료 시점을 감지할 방법이 없다
    Future는 콜백을 등록하거나 완료 시점을 통지받는 기능이 없다.
    “끝나면 자동으로 다음 일을 실행해라” 같은 패턴이 불가능하다.
  3. 여러 Future를 결합하기 어렵다
    Future 간의 관계를 표현할 수 없기 때문에, 두 개 이상의 작업을 병렬로 실행하고 결과를 합치는 로직을 직접 작성해야 했다.
    보통 ExecutorService, CountDownLatch, 혹은 CompletionService를 함께 사용했지만 코드 가독성과 유지보수성이 떨어졌다.

이러한 문제를 해결하기 위해 Java 8에서는 CompletableFuture가 등장했다.


기본 개념

CompletableFuture는 Future와 CompletionStage 두 인터페이스를 구현한다.
이 조합 덕분에 결과를 가져올 수도 있고, 완료 후 동작을 체인 형태로 정의할 수도 있다.

즉, "결과를 기다리는 구조"에서 "결과 이후를 설계하는 구조"로 바뀐 것이다.

다음은 간단한 예시이다.

CompletableFuture<String> cf =
    CompletableFuture.supplyAsync(() -> "Hello")
        .thenApply(s -> s + " World")
        .thenApply(String::toUpperCase);

System.out.println(cf.join()); // HELLO WORLD

각 thenApply는 앞선 결과가 준비되면 자동으로 호출된다.
명시적으로 get()을 호출할 필요가 없다.

 

이제 CompletableFuture에서 제공하는 기능을 알아보자.


주요 생성 메서드

Method Description Return
runAsync 반환값이 없는 작업을 비동기로 실행 CompletableFuture<Void>
supplyAsync 값을 반환하는 비동기 작업 실행 CompletableFuture<U>
completedFuture 이미 완료된 결과를 가진 Future 생성 CompletableFuture<U>
failedFuture 예외 상태로 즉시 완료된 Future 생성 CompletableFuture<U>

 

기본적으로 내부에서는 ForkJoinPool.commonPool()을 사용하지만, 명시적으로 Executor를 지정할 수도 있다.

ExecutorService executor = Executors.newFixedThreadPool(4);
CompletableFuture.supplyAsync(this::loadData, executor);

결과 처리 메서드

thenApply / thenAccept / thenRun

Method Description Return
thenApply 결과를 가공하고 새 값을 반환 CompletableFuture<U>
thenAccept 결과를 소비하지만 반환 없음 CompletableFuture<Void>
thenRun 결과와 무관하게 실행 CompletableFuture<Void>
CompletableFuture.supplyAsync(() -> 5)
    .thenApply(n -> n * 2)
    .thenAccept(System.out::println)
    .thenRun(() -> System.out.println("done"));

thenCompose

비동기 작업이 다른 비동기 작업을 반환할 때, 중첩 구조를 평탄화한다.
(즉, CompletableFuture<CompletableFuture<T>> → CompletableFuture<T>)

CompletableFuture<String> result =
    CompletableFuture.supplyAsync(() -> "data")
        .thenCompose(d -> CompletableFuture.supplyAsync(() -> d + " processed"));

thenCombine

두 개의 독립된 작업을 병렬로 실행하고, 두 결과를 조합한다.

CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> sum = f1.thenCombine(f2, Integer::sum);

allOf / anyOf

  • allOf: 모든 Future가 완료될 때까지 대기
  • anyOf: 하나라도 완료되면 즉시 반환
CompletableFuture.allOf(f1, f2, f3).join();
CompletableFuture.anyOf(f1, f2, f3).join();

예외 처리

비동기 체인에서는 예외가 즉시 전파되지 않는다.
따라서 별도의 예외 처리 메서드를 사용해야 한다.

Method Description Return
exceptionally 예외 시 대체 값 반환 CompletableFuture<U>
handle 성공과 실패 모두 처리 가능 CompletableFuture<U>
whenComplete 결과 확인용 콜백 CompletableFuture<U>
CompletableFuture.supplyAsync(() -> 1 / 0)
    .exceptionally(ex -> -1)
    .thenAccept(System.out::println);

내부 동작

CompletableFuture는 완료 상태를 저장하고,
그 이후 실행해야 할 연산(Completion)을 내부 연결 리스트에 유지한다.

작업이 완료되면 이 리스트를 순회하면서 콜백을 실행한다.
이 과정은 락(lock) 없이 CAS(Compare-And-Swap)으로 처리되어,
동시성 환경에서도 높은 성능을 유지한다.

 

 

CompletableFuture는 단순히 Future의 개선판이 아니다.
비동기 연산을 하나의 논리적 흐름으로 구성할 수 있게 만든 새로운 프로그래밍 모델이다.

이 객체를 잘 이해하면, “작업 완료 후 무엇을 할지”를 명확하게 설계할 수 있고,
복잡한 스레드 제어나 동기화 코드 없이도 자연스러운 비동기 코드를 작성할 수 있다.

반응형

'Language > JAVA' 카테고리의 다른 글

자바의 제네릭(Generic)  (0) 2025.09.23
Java 21 Virtual Thread 알아보기  (1) 2025.04.11
Java 패턴 매칭(Pattern Matching)  (0) 2025.03.25
Java 16 Record class  (1) 2025.03.24
Java 17 sealed class (sealed - permit 예약어)  (0) 2025.03.24
반응형

자바(Java)는 대표적인 정적 타입 언어로, 컴파일 타임에 데이터 타입을 엄격하게 검사하여 안정적인 프로그램 실행을 보장한다. 하지만 다양한 타입의 데이터를 처리하는 코드를 작성하다 보면, 하나의 클래스나 메서드를 여러 타입에 맞게 재사용해야 할 상황이 빈번하게 발생한다. 이럴 때 코드의 재사용성은 높이면서도 타입 안전성을 유지할 수 있는 기능이 필요하다. 바로 이 문제를 해결하기 위해 도입된 것이 제네릭(Generic)이다.

제네릭은 자바 5부터 도입된 기능으로, 클래스나 메서드에서 사용할 데이터 타입을 파라미터화할 수 있게 해 준다. 이를 통해 하나의 코드 구조를 여러 타입에 대해 유연하게 활용할 수 있고, 컴파일 타임에 잘못된 타입 사용을 미리 차단할 수 있다. 덕분에 자바 개발자는 보다 안전하고 효율적인 코드를 작성할 수 있게 되었다.

그러나 제네릭은 단순히 문법을 익히는 것으로 끝나지 않는다. 타입 소거(Type Erasure)라는 개념을 기반으로 동작하기 때문에, 런타임에서는 제네릭 타입 정보가 사라진다는 점을 이해해야 하며, 이로 인해 발생하는 객체 생성 불가, 배열 생성 불가, instanceof 사용 불가 등의 제약도 함께 고려해야 한다. 실무에서는 제네릭을 잘못 사용하여 발생하는 문제들이 의외로 많고, 그로 인한 유지보수 비용도 적지 않다.

또한, 제네릭을 실질적으로 잘 활용하기 위해서는 단순한 클래스나 메서드 선언을 넘어, 타입 제한(Bounded Types), 와일드카드(?, ? extends, ? super), 그리고 PECS 원칙(Producer Extends, Consumer Super)과 같은 고급 개념까지 정확히 이해해야 한다. 이러한 개념들은 단순히 문법적 지식을 넘어, 설계 의도와 데이터 흐름을 어떻게 표현할지에 대한 중요한 도구가 된다.


제네릭(Generic)이란 무엇인가?

제네릭은 클래스, 인터페이스, 메서드를 선언할 때 타입 정보를 고정하지 않고, 나중에 실제 사용 시점에 지정할 수 있도록 만들어주는 기능이다. 즉, 타입을 코드 작성 시점에 결정하는 것이 아니라, 인스턴스를 생성하거나 메서드를 호출하는 시점에 명시적으로 지정할 수 있다는 뜻이다.

다음은 제네릭을 사용하지 않은 클래스 예시이다.

public class ObjectBox {
    private Object value;

    public void set(Object value) {
        this.value = value;
    }

    public Object get() {
        return value;
    }
}

이 클래스는 다양한 타입을 저장할 수는 있지만, 데이터를 꺼낼 때는 형 변환(casting)이 필요하다.

ObjectBox box = new ObjectBox();
box.set("Hello");

String str = (String) box.get(); // 형변환 필요

위와 같이 형변환을 잘못하면 ClassCastException이 발생할 수 있고, 이는 런타임에서야 알 수 있는 오류이다.

제네릭을 사용하면 다음과 같이 코드의 안전성과 가독성이 향상된다.

public class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

이제 Box<String>처럼 명시적으로 타입을 지정하면, 컴파일러가 모든 타입 검사를 대신해주기 때문에 개발자는 안심하고 사용할 수 있다.

Box<String> stringBox = new Box<>();
stringBox.set("Hello");

String str = stringBox.get(); // 형변환 불필요


제네릭을 사용하는 이유

제네릭은 단지 코드 재사용성을 높이기 위한 문법이 아니다. 제네릭이 존재하는 가장 중요한 이유는 컴파일 타임에 타입 안정성(type safety)을 확보하기 위함이다. 개발자가 잘못된 타입을 다루는 실수를 했을 때, 컴파일러가 이를 미리 감지해주는 것이 핵심이다.

또한 제네릭은 자바에서 널리 사용되는 컬렉션 클래스(List, Set, Map 등)을 보다 안전하고 효율적으로 사용할 수 있게 해 준다. 예전에는 모든 데이터를 Object 타입으로 저장해야 했기 때문에, 항상 형 변환을 수반했고, 이로 인해 많은 오류가 발생했다.

제네릭을 사용함으로써 다음과 같은 이점을 얻을 수 있다:

  1. 컴파일 타임 타입 검사로 안정성 확보
  2. 불필요한 캐스팅 제거로 코드 간결화
  3. 다양한 타입을 수용하는 코드 재사용성 향상
  4. 라이브러리 사용자에게 명확한 타입 인터페이스 제공

예를 들어, 문자열 리스트를 만들 때 다음과 같이 선언하면,

List<String> list = new ArrayList<>();
list.add("Java");
String s = list.get(0); // 캐스팅 없이 바로 사용 가능

컴파일러는 list가 String 타입만 저장할 수 있도록 제한하고, 개발자가 list.add(123) 같은 실수를 했을 경우 컴파일 단계에서 바로 오류를 발생시킨다.


타입 소거(Type Erasure)

자바의 제네릭은 컴파일 타임 전용 문법이다. 즉, 런타임에는 제네릭 타입 정보가 모두 제거된다. 이를 타입 소거(Type Erasure)라고 부른다.

이 개념을 이해하면 다음과 같은 사실을 받아들일 수 있다:

List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();

System.out.println(list1.getClass() == list2.getClass()); // true

이 코드는 true를 출력한다. List<String>과 List<Integer>는 컴파일 타임에는 서로 다른 타입처럼 보이지만, 바이트코드로 컴파일된 후에는 동일한 ArrayList 클래스로 처리된다. 즉, 제네릭은 런타임 시점에는 존재하지 않는다.

이로 인해 다음과 같은 제약이 생긴다:

  • 제네릭 타입으로 객체를 생성할 수 없다 (new T() 불가)
  • 제네릭 타입의 배열 생성이 불가능하다 (new T[10] 불가)
  • instanceof 연산자를 사용할 수 없다 (obj instanceof T 불가)

타입 소거는 자바가 하위 호환성을 유지하기 위해 채택한 전략이다. 과거에 작성된 제네릭이 없는 코드와도 함께 동작할 수 있어야 했기 때문이다.


제네릭 클래스 및 메서드 선언

제네릭은 클래스뿐 아니라 메서드에도 적용할 수 있다. 제네릭 클래스는 타입 매개변수를 클래스 선언부에 포함시키고, 제네릭 메서드는 메서드 선언 앞에 <T>를 붙여 사용한다.

제네릭 클래스 예시

public class Container<T> {
    private T data;

    public void set(T data) {
        this.data = data;
    }

    public T get() {
        return data;
    }
}

사용 시점에 타입을 지정할 수 있다:

Container<String> stringContainer = new Container<>();
stringContainer.set("Generic");

Container<Integer> intContainer = new Container<>();
intContainer.set(42);

제네릭 메서드 예시

public class Utility {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }
}

메서드 내부에서 타입에 독립적인 처리가 가능해진다:

String[] strArray = { "Java", "Kotlin" };
Integer[] intArray = { 1, 2, 3 };

Utility.printArray(strArray);
Utility.printArray(intArray);

다중 타입 매개변수 선언

자바 제네릭은 두 개 이상의 타입 매개변수를 동시에 사용할 수 있다. 일반적으로 Map<K, V>와 같이 키-값 구조를 다룰 때 자주 사용된다.

public class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() { return key; }
    public V getValue() { return value; }
}

사용 예시:

Pair<String, Integer> pair = new Pair<>("Age", 30);
System.out.println(pair.getKey() + " = " + pair.getValue());

다중 제네릭 매개변수는 각각의 타입을 독립적으로 다룰 수 있게 해주며, API 설계 시 매우 유용하다.


extends를 이용한 타입 제한

제네릭 타입 매개변수는 기본적으로 모든 클래스 타입을 수용하지만, 때로는 특정 타입 계층 내에서만 제한적으로 사용해야 할 필요가 있다. 이를 가능하게 하는 것이 상한 제한(Bounded Type Parameters)이며, 자바에서는 extends 키워드를 사용한다.

public class Calculator<T extends Number> {
    public double add(T a, T b) {
        return a.doubleValue() + b.doubleValue();
    }
}

위 코드는 T가 Number 클래스를 상속받은 클래스만 허용하도록 제한한다. 덕분에 Integer, Double, Float 등의 객체를 사용할 수 있으며, 내부에서 doubleValue() 같은 메서드를 안전하게 호출할 수 있다.

사용 예시

Calculator<Integer> intCalc = new Calculator<>();
System.out.println(intCalc.add(10, 20));

Calculator<Double> doubleCalc = new Calculator<>();
System.out.println(doubleCalc.add(3.14, 2.71));

컴파일 오류 발생 예시

Calculator<String> strCalc = new Calculator<>(); // 컴파일 오류

이처럼 T extends SomeType 형태는 제네릭 타입이 특정 클래스 또는 인터페이스를 반드시 상속하거나 구현하도록 강제할 수 있는 방법이다. 이 방식은 타입 안정성 유지뿐 아니라, 코드 내부에서 안전하게 해당 타입의 기능을 사용할 수 있게 해 준다.


와일드카드: ?, ? extends, ? super

기본 와일드카드 ?

와일드카드 ?는 제네릭 타입을 불특정 타입으로 일반화할 수 있게 해 준다. 예를 들어, 어떤 타입의 리스트든 받아서 출력만 하고자 할 때 다음과 같이 작성한다.

public void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

이 메서드는 List<String>, List<Integer>, List<Object> 등 어떤 타입의 리스트든 받아서 처리할 수 있다. 단, list.add(...)는 허용되지 않는다. 왜냐하면 ?는 구체적인 타입을 알 수 없기 때문에, 타입 안전성을 보장할 수 없기 때문이다.

상한 제한 와일드카드 ? extends T

? extends T는 T 또는 그 하위 타입을 허용한다. 주로 읽기 전용(read-only) 컬렉션을 인자로 받을 때 사용한다.

public double sumList(List<? extends Number> list) {
    double sum = 0;
    for (Number num : list) {
        sum += num.doubleValue();
    }
    return sum;
}

이 메서드는 List<Integer>, List<Double>, List<Float> 등 Number 하위 타입의 리스트를 받아서 합계를 계산할 수 있다. 그러나 이 리스트에 새로운 값을 추가할 수는 없다.

list.add(123); // 컴파일 오류

이유는 컴파일러가 실제 리스트의 구체적인 타입을 알 수 없기 때문에, 타입 안전성을 위협할 수 있다고 판단하기 때문이다.

하한 제한 와일드카드 ? super T

? super T는 T 또는 그 상위 타입을 허용한다. 주로 쓰기 전용(write-only) 컬렉션을 처리할 때 사용한다.

public void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}

이 코드는 List<Integer>, List<Number>, List<Object>와 같이 Integer의 상위 타입을 사용하는 리스트에 안전하게 값을 추가할 수 있다. 다만, 리스트에서 값을 꺼낼 때는 Object 타입으로만 접근할 수 있다.


PECS 원칙: Producer Extends, Consumer Super

자바 제네릭의 와일드카드를 사용할 때 혼란스러운 부분을 정리해주는 명확한 가이드라인이 바로 PECS 원칙이다. 이는 Josh Bloch가 『Effective Java』에서 제안한 개념으로 다음과 같이 요약된다:

  • Producer ⇒ extends
  • 데이터를 제공(produce)하는 객체라면 ? extends T 사용
  • Consumer ⇒ super
  • 데이터를 소비(consume)하는 객체라면 ? super T 사용

예시: extends (읽기 전용)

public void printAll(List<? extends CharSequence> list) {
    for (CharSequence cs : list) {
        System.out.println(cs);
    }
}

이 메서드는 List<String>, List<StringBuilder> 등 다양한 하위 타입을 받아서 안전하게 출력만 할 수 있다.

예시: super (쓰기 전용)

public void addNumbers(List<? super Integer> list) {
    list.add(10);
    list.add(20);
}

이 메서드는 Integer 값을 리스트에 안전하게 추가할 수 있으며, List<Number>나 List<Object>를 인자로 받을 수 있다.

제네릭 관련 제한 사항

자바의 제네릭은 매우 유용하지만, 다음과 같은 제한 사항이 존재한다. 이들은 모두 타입 소거라는 개념에서 비롯된다.

1. 제네릭 타입으로 객체 생성 불가

public class Factory<T> {
    public T create() {
        return new T(); // 컴파일 오류
    }
}

우회 전략: Class 객체 사용

public class Factory<T> {
    private Class<T> clazz;

    public Factory(Class<T> clazz) {
        this.clazz = clazz;
    }

    public T create() throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }
}

2. 제네릭 타입으로 배열 생성 불가

T[] array = new T[10]; // 컴파일 오류

이 제한은 타입 정보가 런타임에 존재하지 않기 때문에 발생한다.

우회 전략 1: Object 배열로 생성 후 캐스팅

@SuppressWarnings("unchecked")
T[] array = (T[]) new Object[10];

우회 전략 2: Array.newInstance() 사용

T[] array = (T[]) Array.newInstance(clazz, 10);

Array.newInstance()는 리플렉션 기반으로 타입 정보를 유지할 수 있는 안전한 방법이다.

3. 제네릭 타입에 instanceof 사용 불가

if (obj instanceof T) { ... } // 컴파일 오류

우회 전략: Class 객체 사용

if (clazz.isInstance(obj)) {
    T casted = clazz.cast(obj);
}

4. static 멤버에 제네릭 타입 사용 불가

public class Container<T> {
    private static T value; // 컴파일 오류
}

제네릭 타입은 인스턴스 수준에서만 유효하며, static 영역에서는 사용할 수 없다.

해결 방법: static 제네릭 메서드로 분리

public static <T> void print(T data) {
    System.out.println(data);
}

제네릭(Generic)은 자바(Java)라는 언어가 가지는 타입 안정성과 유연성을 동시에 충족시키기 위해 만들어진 구조적 장치다. 코드를 더욱 안전하고 견고하게 만드는 동시에, 다양한 타입에 유연하게 대응할 수 있는 설계를 가능하게 해주는 도구이기도 하다. 이번 시리즈를 통해 우리는 제네릭이 단지 <> 기호를 사용하는 문법 요소가 아니라, 자바가 지향하는 타입 시스템의 본질과 밀접하게 연결되어 있다는 사실을 확인할 수 있었다.

특히 타입 소거(Type Erasure)를 통해 제네릭의 타입 정보가 컴파일 후에는 사라진다는 점은, 제네릭이 가진 여러 제한 사항들을 설명하는 핵심 개념이다. new T()나 T[]와 같은 제네릭 타입 기반의 인스턴스 생성을 할 수 없는 이유도 바로 이 때문이다. 그리고 이러한 제약은 단점처럼 보일 수 있지만 하위 호환성과 안정성을 동시에 추구했던 자바 언어의 철학에서 비롯된 설계 결정이라는 점도 이해할 수 있었다.

이와 함께 제네릭 클래스와 메서드를 정의하고 활용하는 방법을 익혔고 두 개 이상의 타입 매개변수를 활용하여 구조적인 데이터를 효과적으로 다룰 수 있는 방식도 확인해 보았다. 또한 extends를 이용한 상한 제한, ? 와일드카드를 이용한 타입 유연성 확보, 그리고 PECS 원칙(Producer Extends, Consumer Super)을 통해 데이터 흐름에 맞는 설계를 어떻게 구현할 수 있는지를 살펴보았다.

제네릭의 설계 한계로 인해 발생하는 여러 가지 불편함, 예를 들어 instanceof T 불가, static 영역에서의 타입 매개변수 사용 제한 등 은 실무에서 자주 마주치는 문제이기도 하다. 하지만 이를 회피하거나 무시하는 것이 아니라 Class<T>를 이용한 인스턴스 생성 우회, Array.newInstance()를 통한 배열 생성, 리플렉션과의 결합 등을 통해 얼마든지 효과적으로 극복할 수 있다.

반응형
반응형

Java 21에서 도입된 Virtual Thread는 기존의 플랫폼 스레드 모델과는 근본적으로 다른 방식으로 동작하는 새로운 스레드 개념이다.

기존 Thread의 구조와 문제점

기존 Java 스레드는 Thread 클래스를 통해 생성되며, 각 Java 스레드는 OS 커널 스레드와 JNI(Java Native Interface)를 통해 매핑되는 1:1 모델을 기반으로 한다.

스레드 실행 중 I/O 작업이나 sleep() 상태가 발생하면, 운영체제는 효율적인 자원 활용을 위해 컨텍스트 스위칭을 수행한다. 이 과정에서 현재 실행 중인 Kernel Level Thread의 작업을 일시 중단하고 다른 Thread로 전환하게 된다. 컨텍스트 스위칭은 CPU 레지스터, 스택 포인터, 명령어 포인터와 같은 실행 상태 정보를 저장하고 복원해야 하는 복잡한 작업으로, 수 마이크로초(us)의 지연 시간이 발생하는 원인이 된다.

더불어 Java Thread는 운영체제의 Kernel Level Thread와 1:1로 매핑되는 구조적 한계를 가지고 있다. 각 CPU가 지원하는 커널 스레드 수는 제한적이므로, 이를 초과하는 스레드를 생성하려면 과도한 자원을 소모하게 되어 성능에 문제가 발생할 수 있다. 또한 각 Java 스레드는 512KB에서 1MB 사이의 큰 고정 스택 메모리를 필요로 하기 때문에, 다수의 스레드를 생성할 경우 시스템 메모리 자원이 급격히 소모되는 문제가 있다.

이러한 문제를 해결하기 위해 기존에는 다음과 같은 방식을 사용했다.

  1. 스레드 풀: ExecutorService를 사용하여 제한된 수의 스레드를 관리하는 방식으로, 스레드 생성/소멸 비용을 줄일 수 있지만 동시성 확장에 한계가 있다.
  2. 비동기 프로그래밍: Callback, CompletableFuture, Reactive Programming(Reactor, RxJava) 등을 통해 I/O 작업 중에도 스레드가 차단되지 않도록 하는 방식이다. 그러나 코드 복잡성이 증가하고 디버깅이 어려워지는 단점이 있다.

Virtual Thread의 개선점

Java 21에서 도입된 Virtual Thread는 기존 스레드와 다르게 Java 런타임이 직접 관리하는 가벼운 스레드이다.

다수의 Virtual Thread가 JVM에서 관리하는 Platform Thread 풀을 공유하여 실행된다. 이는 마운팅/언마운팅 메커니즘을 통해 작동한다.

  • CPU 자원이 필요한 Virtual Thread는 JVM에서 관리하는 JVM 스케줄링 큐에 삽입되고 스케줄링 순서에 따라 플랫폼 스레드에 마운트 되어 실행
  • I/O 작업이나 sleep() 상태로 인한 블로킹이 감지되면 언마운트되어 다른 Virtual Thread가 해당 플랫폼 스레드를 사용할 수 있음

이 메커니즘으로 Kernel Level Thread의 컨텍스트 스위칭을 최소화할 수 있다. Virtual Thread는 기존 스레드와 달리 수 킬로바이트 수준의 매우 작은 크기를 가지며, 스택 영역에 고정 할당되지 않고 필요할 때만 힙 영역에 할당되어 관리된다. 이러한 특성으로 인해 컨텍스트 스위칭 비용과 메모리 사용량을 크게 절감할 수 있다.

Virtual Thread의 주요 특징

  1. 경량성: 일반 스레드가 1MB 정도의 메모리를 사용하는 반면, 가상 스레드는 약 1KB 정도만 사용한다.
  2. 확장성: 수백만 개의 가상 스레드를 생성할 수 있어 대규모 동시성 처리가 가능하다.
  3. 동기 코드 스타일 유지: 비동기 프로그래밍 없이도 고성능 동시성 처리가 가능하다.
  4. 기존 Thread API와의 호환성: Thread 클래스를 그대로 사용하므로 기존 코드와의 호환성이 우수하다.

Virtual Thread의 주의사항

Virtual Thread가 pinned(고정) 상태가 될 수 있으므로 주의가 필요하다. Virtual Thread가 다음 상황에서는 언마운트될 수 없어 pinned 상태가 된다.

  1. synchronized 블록 내에서 블로킹 작업 수행 시: synchronized는 모니터 락을 사용하며, 이는 스레드 ID와 연결되어 있어 언마운트가 불가능하다.
  2. Native 메소드에서 블로킹 작업 수행 시: JVM이 Native 코드 내부의 블로킹 작업을 감지할 수 없다.

이러한 상황에서는 다음과 같은 대안을 고려해야 한다.

  • synchronized 대신 ReentrantLock, StampedLock 등의 명시적 락 사용
  • 블로킹 Native 메서드 대신 Non-blocking 메서드와 콜백 사용
  • JNI 코드를 최소화하거나 별도의 전용 스레드로 분리

Virtual Thread 사용 방법

Virtual Thread를 생성하는 방법은 크게 세 가지가 있다.

  1. Builder 클래스 사용
Thread thread = Thread.ofVirtual()
        .name("VT-TEST")
        .start(() -> System.out.println("Virtual Thread Test"));
  1. 정적 메소드 사용
Thread thread = Thread.startVirtualThread(()->System.out.println("Virtual Thread Test"));
  1. ExecutorService 사용
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 1백만 개의 태스크를 Virtual Thread로 실행
    IntStream.range(0, 1000000).forEach(i -> {
        executor.submit(() -> {
            // 각 Virtual Thread에서 수행할 작업
            Thread.sleep(Duration.ofMillis(100));
            return i;
        });
    });
    // ExecutorService가 자동으로 종료됨 (try-with-resources)
}

Virtual Thread vs Thread 성능 비교

Thread와 Virtual Thread의 실제 성능을 비교하기 위해 동일한 조건에서 벤치마크 테스트를 수행했다. 테스트는 100,000개의 스레드를 생성하고, 각각 100ms의 sleep() 연산을 수행하는 방식으로 진행했다.

/*
Thread 실행 소요 시간 측정
*/
long threadStartTime = System.currentTimeMillis();
int threadCount = 100000;
Thread[] threads = new Thread[threadCount];

for (int i = 0; i < threadCount; i++) {
    threads[i] = new Thread(() -> {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    threads[i].start();
}

for (Thread t : threads) {
    t.join();
}
long threadEndTime = System.currentTimeMillis();
System.out.println("Thread Time: " + (threadEndTime - threadStartTime) + "ms");
/*
Virtual Thread 실행 소요 시간 측정
*/
long virtualThreadStartTime = System.currentTimeMillis();
int threadCount = 100000;
Thread[] virtualThreads = new Thread[threadCount];

for (int i = 0; i < threadCount; i++) {
    virtualThreads[i] = Thread.ofVirtual().start(() -> {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

for (Thread t : virtualThreads) {
    t.join();
}
long virtualThreadEndTime = System.currentTimeMillis();
System.out.println("Virtual Thread Time: " + (virtualThreadEndTime - virtualThreadStartTime) + "ms");

테스트 환경은 아래와 같다.

OS Windows 11 Pro 24H2
JDK OpenJDK 21
CPU AMD Ryzen 7 PRO 6850U
Memory 16GB

각 테스트는 3회 반복 실행하여 성능을 측정했다.

단위 : ms(밀리초)

테스트 결과를 분석해 보면, 일반 Thread는 컨텍스트 스위칭과 스레드의 생성 및 소멸에 많은 비용이 발생하여 전반적인 성능이 저하되는 것을 확인할 수 있었다. 반면 Virtual Thread는 효율적인 마운팅/언마운팅 메커니즘을 통해 블로킹 작업을 더 효과적으로 처리함으로써 훨씬 더 빠른 실행 속도를 보여주었다.

Virtual Thread vs Reactive Programming

Virtual Thread와 Reactive Programming은 모두 높은 동시성을 처리하는 방법이지만, 접근 방식이 다르다.

특성 Virtual Thread Reactive Programming
복잡성 낮음 높음
메모리 사용량 중간 낮음
디버깅 용이성 높음 낮음
적합한 용도 I/O 바운드 작업, 간단한 병렬 처리 고성능 데이터 스트림 처리, 이벤트 처리

Virtual Thread는 기존 동기 코드와 동일한 방식으로 작성할 수 있어 복잡성이 낮다. 반면 Reactive Programming은 비동기 스트림 처리와 이벤트 기반 프로그래밍 패러다임을 익혀야 하고, 콜백이나 연산자 체인 방식으로 코드를 작성해야 하므로 진입 장벽이 높다.

메모리 사용량 측면에서는 Virtual Thread가 일반 스레드보다는 가볍지만(약 1KB), 수백만 개의 스레드를 생성할 경우 상당한 메모리를 사용할 수 있다. Reactive Programming은 Back Pressure를 통해 데이터 흐름을 제어하고 이벤트 루프 방식으로 동작하여 적은 수의 스레드로도 높은 처리량을 달성할 수 있다.

디버깅의 경우 Virtual Thread가 더 용이한데, 이는 일반적인 스레드 디버깅과 동일한 방식으로 진행할 수 있기 때문이다. Reactive Programming은 비동기 실행으로 인해 Stack Trace가 복잡해지고, 여러 연산자를 거치면서 에러의 원인을 추적하기 어려울 수 있다.

모든 상황에서 Virtual Thread가 최적의 선택은 아니므로, 애플리케이션의 특성과 요구사항에 맞게 적절히 선택하여 사용하는 것이 중요하다.

반응형
반응형

1. Java의 Pattern Matching이란?

Pattern Matching은 Java에서 보다 간결하고 읽기 쉬운 코드를 작성하기 위해 도입된 기능이다.

이는 조건 검사를 더 효율적으로 수행하고, 복잡한 객체의 타입을 식별하여 각 상황에 맞는 동작을 실행할 수 있도록 돕는다.

주요 예제로는 switch 문과 instanceof 연산자를 활용한 Pattern Matching이 있다. 이 기능은 조건문에서 불필요한 반복 코드를 제거하고 코드의 가독성을 높인다.

2. Pattern Matching의 특징

기존의 Java 코드에서는 타입 확인 후 명시적으로 타입 변환(casting)을 수행해야 했다. Pattern Matching은 이러한 과정을 자동화하여 코드량을 줄이고, 가독성을 높여 준다. 그리고 Pattern Matching을 활용하면 타입 변환에서 발생할 수 있는 오류를 미리 방지할 수 있다. 이는 컴파일 타임에서 오류를 확인할 수 있게 해 준다. 또한, 조건문 안에서 더 복잡하고 다양한 로직을 쉽게 처리할 수 있도록 설계되었다. 이를 통해 개발자는 보다 직관적이고 간결한 코드를 작성할 수 있다.

3. 사용 예제

Pattern Matching을 활용한 switch문은 기존의 switch문과 유사하지만, 조건에 따른 타입 변환을 더 간결하게 처리할 수 있게 해준다.

public class PatternMatchingSwitchExample {
    public static void main(String[] args) {
        Object obj = "Hello, world!";
        
        String result = switch (obj) {
            case Integer i -> "정수입니다: " + i;
            case String s -> "문자열입니다: " + s.toUpperCase();
            case Double d -> "실수입니다: " + d;
            default -> "알 수 없는 타입입니다.";
        };
        
        System.out.println(result); // 문자열입니다: HELLO, WORLD!
    }
}

위 예제는 obj 타입의 변수가 어떤 타입인지 검사함과 동시에 자동으로 타입 매칭을 진행하여 case 스코프 내에서 사용할 수 있도록 제공하고 있다.

Pattern Matching을 활용한 instanceof문은 기존의 if문과 결합하여 사용할 수 있다.

public class PatternMatchingInstanceofExample {
    public static void main(String[] args) {
        Object obj = "Java Pattern Matching";
        
        if (obj instanceof String s) {
            System.out.println("문자열 길이: " + s.length());
        } else if (obj instanceof Integer i) {
            System.out.println("정수 값: " + i);
        } else {
            System.out.println("알 수 없는 타입입니다.");
        }
    }
}

위와 같이 instanceof로 obj가 String 타입인 경우 s라는 String 변수를 선언하여 if문 스코프 내에서 사용할 수 있게 제공해 준다.

public static void main(String[] args) {
    Object obj = "Java Pattern Matching";
    
    if (!(obj instanceof String s)) return;
    System.out.println(s);
}

위와 같이 조건을 반전하여 사용하는 것도 가능하다

public record Person(String name, int age) {}

public class PatternMatchingWithRecordExample {
    public static void main(String[] args) {
        Object obj = new Person("Alice", 30);
        
        // Pattern Matching을 활용하여 record 타입의 객체 필드에 접근
        if (obj instanceof Person(String name, int age)) {
            System.out.println(name + " is " + age + " years old.");
        } else {
            System.out.println("Unknown object type");
        }
    }
}

위는 record 타입을 검사하는 과정이다. Person이라는 객체임이 확인되면 그 내부의 name, age 필드의 값을 가져와 변수를 선언하여 즉시 접근이 가능하도록 할 수 있다.

정리

✅ Pattern Matching은 조건문을 간결하고 안전하게 만들어주는 기능

✅ switch와 instanceof를 간소화하여 코드 가독성 향상

✅ 객체 타입에 맞는 자동 캐스팅을 제공하여 타입 변환 오류 방지

✅ record와 결합 시, 객체 필드를 쉽게 매칭하고 코드 유지보수성 향상

✅ 컴파일 타임에서 오류를 발견할 수 있어 코드 안정성 향상

반응형
반응형

1. record 키워드란?

Java 16에서 도입된 record는 불변 객체를 생성하기 위한 특별한 클래스 유형이다.

쉽게 말해, record를 사용하면 모든 필드가 자동으로 final로 선언되며, 객체의 상태를 변경할 수 없게 된다. 이는 데이터 전송 객체(DTO; Data Transfer Object)나 간단한 데이터 구조를 만들 때 매우 유용하다. record는 기본적으로 데이터의 저장과 접근을 간편하게 해주는 메서드를 자동으로 생성해 주기 때문에, 개발자는 반복적인 코드 작성을 줄일 수 있다.

예를 들어, 일반적인 Java 클래스에서는 필드를 정의하고, 생성자, 접근자(getter) 메서드, toString(), equals(), hashCode() 메서드를 수동으로 작성해야 했다. 하지만 record를 사용하면 이러한 작업이 자동으로 처리된다. 따라서 코드의 가독성이 높아지고, 유지보수도 쉬워진다.

2. record 키워드의 특징

record의 가장 큰 특징은 불변성이다. record로 선언된 클래스는 내부 필드가 final로 선언되기 때문에, 객체가 생성된 이후에는 필드 값을 변경할 수 없다. 이는 멀티스레드 환경에서 안전성을 높여주며, 데이터의 일관성을 유지하는 데 큰 도움이 된다.

또한, record는 자동으로 생성되는 메서드가 있다. 예를 들어, record를 정의하면 다음과 같은 메서드가 자동으로 생성된다:

종류 설정

생성자 모든 필드를 초기화하는 생성자가 자동으로 생성된다.
Getter 각 필드에 대한 접근자 메서드가 자동으로 생성된다. 예를 들어, name이라는 필드가 있다면 name()이라는 메서드가 생성된다.
toString() 객체의 내용을 문자열로 표현하는 toString() 메서드가 자동으로 생성된다.
equals() 객체의 동등성을 비교하는 equals() 메서드가 자동으로 생성된다.
hashCode() 해시 코드를 생성하는 메서드가 자동으로 생성된다.

이러한 자동 생성 기능 덕분에 개발자는 코드의 중복을 줄이고, 더 간결한 코드를 작성할 수 있다.

3. record 클래스 사용 예제

이제 record를 실제로 어떻게 사용하는지 간단한 예제를 통해 살펴보자. 아래는 Person이라는 record를 정의하는 예제이다.

// Person.java
public record Person(String name, int age) {}

위의 예제에서 볼 수 있듯이, 선언이 아주 간결하다.

// Main.class
public class Main {
    public static void main(String[] args) {
        Person person1 = new Person("Alice", 25);
        Person person2 = new Person("Bob", 30);

        // 자동으로 생성된 getter 메서드 사용
        System.out.println(person1.name()); // Alice
        System.out.println(person1.age());  // 25

        // 자동 생성된 toString() 메서드 사용
        System.out.println(person1); // Person[name=Alice, age=25]

        // 자동 생성된 equals()와 hashCode() 메서드 사용
        System.out.println(person1.equals(person2));  // false
        System.out.println(person1.hashCode() == person2.hashCode());  // false
    }
}

위 예제에서 Person 객체는 record로 정의되었고, name(), age()와 같은 메서드는 자동으로 생성된다. 또한 toString(), equals(), hashCode() 메서드도 자동으로 생성되어 객체 비교와 출력이 가능하다. 또한 생성자를 오버라이딩하여 객체 생성 시 무결성 검사를 추가할 수 있다. 단, 일반적인 클래스와는 다르게 record는 선언부에서 이미 필드를 선언함과 동시에 매개변수를 받고 있어 생성자에서 괄호와 매개변수를 작성하지 않는다.

// Person.java
public record Person(String name, int age) {
    // 생성자 오버라이딩 (무결성 검사)
    public Person { // 매개변수 없음
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }
}

생성자가 오버라이딩이 가능한 것처럼 자동으로 생성되는 toString(), equals(), hashCode() 메서드도 오버라이딩이 가능하다.

// Person.java
public record Person(String name, int age) {
    // 생성자 오버라이딩 (무결성 검사)
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }

    // toString() 오버라이드
    @Override
    public String toString() {
        return "Person[name=" + name.toUpperCase() + ", age=" + age + "]";
    }

    // equals() 오버라이드
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && name.equals(person.name);
    }

    // hashCode() 오버라이드
    @Override
    public int hashCode() {
        return 31 * name.hashCode() + age;
    }
}

4. 정리

✅ record는 불변 객체를 쉽게 생성 가능

✅ 자동으로 생성되는 메서드 덕분에 코드 가독성이 높아짐

✅ 데이터 전송 객체나 간단한 데이터 구조를 만들 때 유용

✅ 불변 객체이므로 멀티스레드 환경에서도 안전성 제공

✅ 코드 유지보수가 쉬워짐

❌ 복잡한 객체에는 적합하지 않을 수 있음

❌ 상속이 불가능하여 확장성이 제한됨

❌ 객체 초기화 과정에서 불편할 수 있음 (특히 필드가 많을 경우)

반응형
반응형

1. sealed 키워드란

Java 17에서 sealed - permits 예약어가 추가되었다. sealed는 봉인된이라는 뜻으로, 기존에 상속을 완전히 차단하는 final과는 다르게, 일부 하위 클래스에 한하여 상속을 허용하는 기능을 제공한다. 이를 통해 상속 계층을 명확하게 정의하고 불필요한 확장을 방지하여 더 안전한 코드 설계를 가능하게 한다.

하지만 sealed 키워드는 유연성을 일부 희생해야 하는 단점도 존재한다. 새로운 하위 클래스를 추가하려면 원본 클래스를 수정해야 하므로 유지보수가 번거로울 수 있으며, permits 목록이 길어질 경우 관리가 어려워질 수 있다.

2. sealed 키워드의 특징

sealed 예약어는 클래스(class)뿐만 아니라 인터페이스(interface)에도 사용할 수 있다. 즉, 단순히 상속(extends)만 제한하는 것이 아니라, 구현(implements)도 제한할 수 있다. 이를 통해 클래스 계층 구조를 명확하게 정의하고, 불필요한 상속을 제한하여 예상치 못한 동작을 방지함으로써 더 안전한 코드 설계를 가능하게 한다.


3. sealed 클래스 사용 예제

다음 예제는 sealed와 permits 예약어를 활용하여 상속을 허용할 하위 클래스를 명시적으로 지정하는 방법을 보여준다.

// Animal.java
public sealed class Animal permits Dog, Cat {
	private String name;
	public Animal(String name){
		this.name = name;
	}
}

위 코드에서 permits를 사용하여 Dog와 Cat 클래스만 Animal을 상속할 수 있도록 제한하였다.

// Dog.java
public final class Dog extends Animal { 
	public Dog(String name){
		super(name);
	}
}

위처럼 permits에 명시된 하위 클래스는 문제없이 상속이 가능하다.

그러나 permits에 포함되지 않은 클래스가 상속을 시도하면 오류가 발생한다.

//Computer.java
public final class Computer extends Animal {
	public Computer(String name){
		super(name);
	}
}

🔻 오류 발생

The class Computer cannot extend the class Animal as it is not a permitted subtype of Animal

이처럼 permits에 포함되지 않은 클래스는 Animal을 상속할 수 없다.


4. 하위 클래스의 상속 규칙

sealed 클래스를 상속받은 하위 클래스는 계층 구조를 명확하게 정의하기 위해, 자신이 추가적인 확장이 가능한지 여부를 반드시 명시해야 한다. 이를 위해 final, sealed, non-sealed 중 하나를 선택해야 한다.

keyword description
final 하위 클래스 재상속 불가능
sealed 허용된 클래스에 한하여 상속 가능
non-sealed 제한 없이 상속 가능. 상위 클래스는 non-sealed 하위 클래스를 상속 받은 클래스를 추적할 수 없다,

5. 정리

  • ✅ sealed는 특정 하위 클래스만 상속을 허용하는 기능
  • ✅ sealed와 permits를 함께 사용하면 상속 계층을 명확히 정의 가능
  • ✅ sealed 클래스를 상속받는 하위 클래스는 반드시 final, sealed, non-sealed를 지정해야 함
  • ✅ 이를 통해 예측 가능한 계층 구조를 설계하고, 불필요한 상속을 제한하여 안전한 코드 작성이 가능
  • ❌ 새로운 하위 클래스를 추가하려면 원본 클래스를 수정해야 하므로 유지보수가 번거로울 수 있음
  • ❌ permits 목록이 길어질 경우 관리가 어려워질 수 있음
반응형
반응형

자바에서 직렬화는 객체 혹은 데이터를 전송 또는 저장하기 위해 Byte Array로 변환하는 과정을 말한다. 역직렬화는 반대로 변환된 Byte Array를 객체 혹은 데이터로 변환하는 과정이다.

직렬화(Serialization)

직렬화 방법은 아래와 같다.

public static byte[] serialize(Object obj){
    try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos)){
        oos.writeObject(obj);
        return baos.toByteArray();
    }catch(IOException e){
        e.printStackTrace();
    }
    return null;
}

여기서는 직렬화된 byte array를 반환하기 위해 ByteArrayOutputStream 객체를 사용했지만, 직렬화된 byte array를 바로 파일에 쓸 때는 FileOutputStream을 사용해도 된다.

public class App {
    public static void main(String[] args) {
        byte[] data = serialize(new Student("John", 20));
        System.out.println(new String(data));
    }

    public static byte[] serialize(Object obj){
        try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos)){
            oos.writeObject(obj);
            return baos.toByteArray();
        }catch(IOException e){
            e.printStackTrace();
        }
        return null;
    }
}
class Student{
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

Student라는 객체를 만들어서 직렬화하는 소스코드이다. 실행 결과는 아래와 같다.

java.io.NotSerializableException: Student
        at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
        at java.base/java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:358)
        at App.serialize(App.java:22)
        at App.main(App.java:15)

위처럼 예외가 발생하게 된다. 예외 종류는 NotSerializableException으로 객체가 직렬화될 수 없다는 예외이다. ”직렬화될 수 없다”라는 건 그럼 직렬화될 수 있는 객체 종류가 따로 있다는 뜻이 된다.

Serializable Interface

사실 자바에서 지원하는 직렬화는 Serializable 인터페이스 구현체만 가능하다.

class Student implements Serializable { //이 부분
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

이렇게 직렬화하고자 하는 클래스에 Serializable 인터페이스를 구현하면 된다.

public static void main(String[] args) {
    byte[] data = serialize(new Student("John", 20));
    System.out.println(new String(data));
    //실행 결과 : ??srStudentp?|YV?? IageLnametLjava/lang/String;xptJohn
}

하지만 이 방법이 작동하지 않는 예외적인 상황이 있다. 직렬화하고자 하는 객체의 필드 변수 중 직렬화 불가한 데이터 타입을 가진 경우이다. 그 예시를 한번 보자.

class Phone {
    private String number;
    private String type;

    public Phone(String number, String type) {
        this.number = number;
        this.type = type;
    }
}

class Student implements Serializable {
    private String name;
    private int age;
    private Phone phone;

    public Student(String name, int age, Phone phone) {
        this.name = name;
        this.age = age;
        this.phone = phone;
    }
}

Student 클래스는 Serializable 구현 클래스이지만 필드 변수에 있는 Phone은 Serializable 구현 클래스가 아니다. 이런 경우에 직렬화를 하게 되면 아래와 같은 결과를 볼 수 있다.

java.io.NotSerializableException: Phone
        at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
        at java.base/java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1585)
        at java.base/java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1542)
        at java.base/java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1451)
        at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1194)
        at java.base/java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:358)
        at App.serialize(App.java:22)
        at App.main(App.java:15)

직렬화하고자 하는 클래스에 Serializable 구현을 하더라도 그 필드 변수들 또한 Serializable 해야 한다는 것을 알 수 있다.

이제 자바에서 기본적으로 제공하는 클래스들이 Serializable인지 알아야 한다.

자바에 Built-In 된 대부분의 클래스는 Serializable 하다. 기본 데이터 타입인 int, double, char는 객체가 아니라 값이므로 관계가 없지만 기본 데이터 타입의 Wrapper 클래스(Integer, Double, Character, Double 등)는 객체이며 Serializable 하다. 또한 ArrayList, HashMap, HashSet과 같은 컬렉션 클래스, String, Date, BigInteger, BigDecimal 같은 문자열 및 기본 객체, Exception과 같은 예외 클래스 모두 Serializable 하다.

Thread, Socket, InputStream, OutputStream, Graphics와 같이 스레드, I/O 관련 리소스, UI 요소 리소스는 Serializable 하지 않다.

이런 것들을 소스코드 단에서 검사할 수 있는 방법이 있다.

Thread thread = new Thread();
if(thread instanceof Serializable) {
    System.out.println("Thread is serializable");
} else {
    System.out.println("Thread is not serializable");
}
//실행 결과 : Thread is not serializable

역직렬화(Deserialization)

역직렬화 방법은 아래와 같다.

public static Object deserialize(byte[] data){
    try(ByteArrayInputStream bais = new ByteArrayInputStream(data);
    ObjectInputStream ois = new ObjectInputStream(bais)){
        return ois.readObject();
    }catch(IOException | ClassNotFoundException e){
        e.printStackTrace();
    }
    return null;
}

여기서는 byte array를 받아 역직렬화를 수행하기 때문에 ByteArrayInputStream 객체를 사용했다. 만약 파일을 읽어서 바로 역직렬화를 수행할 경우 FileInputStream 객체를 사용하여도 무관하다.

public class App {
    public static void main(String[] args) {
        byte[] data = serialize(new Student("John", 20));
        Student student = (Student) deserialize(data);
        System.out.println(student.name);
    }

    public static byte[] serialize(Object obj){
        try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos)){
            oos.writeObject(obj);
            return baos.toByteArray();
        }catch(IOException e){
            e.printStackTrace();
        }
        return null;
    }

    public static Object deserialize(byte[] data){
        try(ByteArrayInputStream bais = new ByteArrayInputStream(data);
        ObjectInputStream ois = new ObjectInputStream(bais)){
            return ois.readObject();
        }catch(IOException | ClassNotFoundException e){
            e.printStackTrace();
        }
        return null;
    }
}

class Student implements Serializable {
    public String name;
    public int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

실제 실행 결과는 “John”이다 객체가 정상적으로 역직렬화되어 객체로 접근이 가능한 모습이다.

Base64 얹어 사용하기

아래는 Base64 Encoding/Decoding을 함께 얹은 직렬화/역직렬화 메소드이다.

//Serialize with base64 encoding
public static String serialize(Object obj){
    try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos)){
        oos.writeObject(obj);
        return Base64.getEncoder().encodeToString(baos.toByteArray());
    }catch(IOException e){
        e.printStackTrace();
    }
    return null;
}
//Deserialize with base64 decoding
public static Object deserialize(String data){
    try(ByteArrayInputStream bais = new ByteArrayInputStream(Base64.getDecoder().decode(data));
    ObjectInputStream ois = new ObjectInputStream(bais)){
        return ois.readObject();
    }catch(IOException | ClassNotFoundException e){
        e.printStackTrace();
    }
    return null;
}
반응형

'Language > JAVA' 카테고리의 다른 글

자바의 제네릭(Generic)  (0) 2025.09.23
Java 21 Virtual Thread 알아보기  (1) 2025.04.11
Java 패턴 매칭(Pattern Matching)  (0) 2025.03.25
Java 16 Record class  (1) 2025.03.24
Java 17 sealed class (sealed - permit 예약어)  (0) 2025.03.24

+ Recent posts