1. Optional의 등장
Optional은 선택적인 이라는 용어로 선택적으로 무언가를 처리할때 사용합니다.
NPE가 자주 발생하는 문제를 명시적으로 "값이 없을 수도 있음" 으로 표현해서 런타임시에 안전성을 보장하기 위해 만들어진 컨테이너 객체 입니다.
T 객체를 넣을수도 있고 안넣을수도 있어 private 생성자로 객체 생성을 막아놨다. 내부 상태가 null이거나 값 T 1개로 구정되어 있는 불변 객체이다.
public final class Optional<T>
private Optional(T value) {
this.value = value;
}
/**
* Returns an {@code Optional} describing the given non-{@code null}
* value.
*
* @param value the value to describe, which must be non-{@code null}
* @param <T> the type of the value
* @return an {@code Optional} with the value present
* @throws NullPointerException if value is {@code null}
*/
public static <T> Optional<T> of(T value) {
return new Optional<>(Objects.requireNonNull(value));
}
예를 들면 아래와 같이 생성가능하다면 null인 객체를 생성한 이상한 모양의 객체가 되어버립니다.
그래서 생성자를 private로 막고 of , ofNullable, empty 3가지로 명확한 의미를 전달 합니다.
of : 값이 반드시 존재해야한다.
ofNullable : 객체가 null일 수도 있다.
empty: 비어있다.
미리 만들어진 싱글턴 객체를 재사용해서 불변성을 보장하고 의미를 명확히하고 잘못된 사용을 방지합니다.
➡ 이런 클래스들은 유틸리티 또는 컨테이너 역할에 집중되므로, 직접 생성보다 팩토리 메서드가 설계상 더 적합합니다.
Optional<String> invalid = new Optional<>(null); // 직접 생성 → 규칙 위반
값이 없을 수 있는 결과를 처리할 때 주로 사용되며 람다식과 같이 사용되어 명확한 의미를 전달하거나 NPE를 방지할 때 사용되게 됩니다. 아래에서 설명드릴 람다와 스트림과도 잘 어울리며 불변 객체이지만 내부에 든 객체가 가변성이 있으면 멀티쓰레드에서 문제가 될 수 있습니다.
public void sendWelcomeEmail(String email) {
userRepository.findByEmail(email)
.ifPresent(user -> emailService.sendWelcome(user));
}
public Optional<User> findUserById(String id);
Optional 에서 데이터를 꺼낼 수 있는 방법은 4가지가 존재합니다.
import java.util.Optional;
import java.util.function.Supplier;
public class OptionalTest {
public static void main(String[] args) {
OptionalTest test = new OptionalTest();
// ✅ Optional에 값이 있는 경우
Optional<String> present = Optional.of("REAL VALUE");
// ✅ Optional에 값이 없는 경우
Optional<String> empty = Optional.empty();
System.out.println("==== Optional 값이 있는 경우 ====");
try {
test.getOptionalData(present);
} catch (Exception e) {
System.out.println("예외 발생: " + e);
}
System.out.println("\n==== Optional 값이 없는 경우 ====");
try {
test.getOptionalData(empty);
} catch (Exception e) {
System.out.println("예외 발생: " + e);
}
}
private void getOptionalData(Optional<String> data) throws Exception {
String defaultValue = "DEFAULT TEST";
// 1. get() 사용
try {
String result1 = data.get();
System.out.println("1. get(): " + result1);
} catch (Exception e) {
System.out.println("1. get(): 예외 발생 → " + e);
}
// 2. orElse()
String result2 = data.orElse(defaultValue);
System.out.println("2. orElse(): " + result2);
// 3. orElseGet()
Supplier<String> supplier = new Supplier<String>() {
@Override
public String get() {
System.out.println("3.→ Supplier 실행됨");
return "New Supplier";
}
};
String result3 = data.orElseGet(supplier);
System.out.println("3. orElseGet(): " + result3);
// 4. orElseThrow()
Supplier<? extends Exception> exceptionSupplier = new Supplier<Exception>() {
@Override
public RuntimeException get() {
return new RuntimeException("No value present in Optional");
}
};
try {
String result4 = data.orElseThrow(exceptionSupplier);
System.out.println("4. orElseThrow(): " + result4);
} catch (Exception e) {
System.out.println("4. orElseThrow(): 예외 발생 → " + e);
}
}
}
2. 람다식
익명클래스라고 inner class에서 잠깐 말이 나왔던 적이 있다. 1회성으로 사용되는 클래스를 코드 안에서 정의하고 바로 인스턴스화 하는 걸로 알고 있었는데 이름 없는 클래스여서 익명클래스이다.
아래와 같이 class정의를 하지않고도 전체 구조가 하나의 객체처럼 작동하게 됩니다.
class MyRunnable implements Runnable {
public void run() {
System.out.println("실행 중");
}
}
Runnable r = new MyRunnable();
r.run();
Runnable r = new Runnable() {
public void run() {
System.out.println("실행 중");
}
};
r.run();
타입 변수명 = new 타입() {
// 인터페이스의 메서드 구현
@Override
public void someMethod() {
...
}
};
자바8부터는 이러한 가독성 떨어지는 익명클래스를 아래와 같이 람다식으로 구현 가능합니다.
복잡하면 가독성이 떨어진다 단점이 있지만 기존의 익명클래스와 비교하면 매우 간결한 것을 볼 수 있습니다.
함수형 인터페이스를 인자로 받는 메서드나 함수형인터페이스에서 적용 가능합니다.
Runnable r = () -> System.out.println("Hello");
심지어 메서드 참조를 통해 람다식을 더 간결하게 표현할 수도 있습니다.
// 일반 람다식
list.forEach(name -> System.out.println(name));
// 메서드 참조
list.forEach(System.out::println);
이 코드를 javap -c로 디컴파일하면 이런 식으로 나옵니다:
0: invokedynamic #2 run()Ljava/lang/Runnable; [
BootstrapMethods ...
]
자바8 부터는 람다식을 사용함으로써 바이트코드도 아예 바뀌어 클래스 생성하고 로더하는 방식이 아닌
invokedynamic + LambdaMetafactory를 통해 클래스 수를 감소시키며 JVM이 최적화를 해줍니다.
3. 함수형 인터페이스
자바는 아래의 인터페이스에 람다식을 넣을 수 있게 @FunctionalInterface를 통해 허용 합니다.
Runnable은 run() 이라는 한개의 추상메소드만 있었기 때문에 가능합니다. (컴파일 후 abstract가 자동으로 붙여집니다)
@FunctionalInterface는 단 하나의 추상 메서드만 존재하는 인터페이스임을 컴파일 타임에 강제로 확인해주는 어노테이션입니다.
이를 통해 람다식이 가능케하며 추상 메서드가 2개 이상이면 어떤 메서드를 구현했는지 모호해지므로 사용할 수 없습니다.
이를 통해 위에서 Runnable r = () -> System.out.println("Hello"); 이 run() 메서드를 통해 작동할 수 있게 됩니다.
@FunctionalInterface
interface MyFunction {
void doSomething();
}
아래는 주로 사용되는 함수형인터페이스 예시 코드 입니다.
Consumer<String> printer = s -> System.out.println("출력: " + s);
printer.accept("Hello");
Supplier<String> supplier = () -> "생성된 값";
System.out.println(supplier.get());
Function<String, Integer> toLength = s -> s.length();
System.out.println(toLength.apply("abc")); // 3
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("123456")); // true
List<String> names = List.of("Alice", "Bob", "Charlie");
System.out.println("익명 클래스:");
names.stream()
.filter(new Predicate<String>() {
public boolean test(String name) {
return name.startsWith("A");
}
})
.forEach(System.out::println);
System.out.println("람다식:");
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println);
4. Stream
Java의 전통적인 컬렉션 처리 방식은 복잡하고 에러가 많습니다. 특히 백엔드에서는 데이터베이스에서 다건을 꺼내서 조작하는 일이 많은데 아래와 같은 코드는 가독성이 낮고 병렬 처리는 어렵고 코드 재사용도 거의 어렵습니다.
기존 처럼 하나씩 연산하는게 아닌 최종 연산 전 까지 실제 연산을 미뤄 Stream을 사용한다면 아래처럼 변경이 가능합니다.
List<String> filtered = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
filtered.add(name.toUpperCase());
}
}
Collections.sort(filtered);
스트림을 사용한다면 소스, 중간연산, 최종연산의 구성으로 원본 데이터의 흐름을 그대로 가져와 변경하지 않고 한줄로 작성 가능합니다.
절차지향적으로 짜지말고 "무엇을 할지"에 SQL 식 스타일로 집중합니다.
메서드 체이닝을 통해 가독성을 향상 시키며 .parallelStream()을 통해 자동 병렬 처리를 진행합니다.
컬렉션이외에도 다양한 데이터 집합 소스에서 가능하며
list.stream(), Arrays.stream, Files.lines, IntStream.range(1,10) , Stream.generate() 등이 가능해집니다.
List<String> result = names.stream()
.filter(s -> s.startsWith("A"))
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
Source | 데이터의 출처 | list.stream() |
Intermediate Operations | 중간 처리 (필터, 변환 등) | .filter(), .map() |
Terminal Operations | 결과 출력/수집 등 | .collect(), .forEach(), .count() |
Intermediate 연산은 지연 실행(lazy) → .collect() 같은 Terminal 연산이 호출될 때 실행됨
스트림에는 순차스트림과 병렬스트림이 있으며 한 쓰레드가 앞에서 처리하는지 아니면 여러쓰레드가 데이터 조각을 병렬로 처리 후 만들어내는지 차이가 있습니다. 계산이 간단할때는 Sw
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}
병렬스트림을 사용할때는 순서가 보장이 안될 수 있고 데이터가 일정 기준 이상 많아졌을때 사용하는 것이 더 좋습니다.
쓰레드 생성 및 컨텍스트 스위칭의 비용이 있어 연산 자체가 비용이 적다면 순차스트림이 훨씬 빠르게 작동합니다.
이 부분은 개발 시 테스트를 통해 이 데이터에 뭐가 더 빠를지 가설을 세우고 확인을 하면서 개발을 해야합니다.
📦 순차 스트림은 "하나의 직원이 차례대로 작은 택배를 포장"
🏭 병렬 스트림은 "여러 직원이 나눠서 포장, 하지만 각 택배가 너무 작으면 왔다갔다 하는 비용이 더 듦"
또한 for문은 CPU에서 루프 최적화가 잘되고 JVM도 JIT 컴파일로 최적화를 쉽게해서 더 빠르다는 장점이 있고
Stream도 boxing 비용이 있기 때문에 Stream<Integer> 보다는 IntStream, LongStream등 상황에 맞게 사용하는게 중요합니다.
직접 참조 | 값 자체를 스택에 저장 (ex: int, boolean) |
간접 참조 | 힙에 있는 객체 주소를 스택에 저장하고 그걸 통해 참조 (ex: Integer, String, 배열 등) CPU는 바이트 단위로 캐시하므로 캐시 미스 확률이 높아진다. |
Stream 성능 테스트 비교 코드
import java.util.Arrays;
import java.util.stream.IntStream;
public class StreamPerformanceTest {
private static final int N = 1_000_000;
public static void main(String[] args) {
int[] arr = IntStream.rangeClosed(1, N).toArray(); // int[] 생성
// 1. for문
long start1 = System.nanoTime();
long sum1 = sumUsingForLoop(arr);
long end1 = System.nanoTime();
System.out.printf("1. for문: 합계 = %d, 시간 = %.2fms%n", sum1, (end1 - start1) / 1_000_000.0);
// 2. stream + boxing
long start2 = System.nanoTime();
Integer sum2 = Arrays.stream(arr)
.boxed() // Integer로 boxing
.mapToInt(Integer::intValue) // 다시 int로 변환
.sum();
long end2 = System.nanoTime();
System.out.printf("2. stream + boxing: 합계 = %d, 시간 = %.2fms%n", sum2, (end2 - start2) / 1_000_000.0);
// 3. parallelStream + boxing
long start3 = System.nanoTime();
long sum3 = Arrays.stream(arr)
.boxed()
.parallel()
.mapToInt(Integer::intValue)
.sum();
long end3 = System.nanoTime();
System.out.printf("3. parallelStream + boxing: 합계 = %d, 시간 = %.2fms%n", sum3, (end3 - start3) / 1_000_000.0);
// 4. parallelStream + 복잡한 계산
long start4 = System.nanoTime();
double result4 = IntStream.range(0, N)
.parallel()
.mapToDouble(StreamPerformanceTest::complexCalculation)
.sum();
long end4 = System.nanoTime();
System.out.printf("4. parallelStream + 복잡한 계산: 합계 = %.2f, 시간 = %.2fms%n", result4, (end4 - start4) / 1_000_000.0);
// 5. stream + 복잡한 계산
long start5 = System.nanoTime();
double result5 = IntStream.range(0, N)
.mapToDouble(StreamPerformanceTest::complexCalculation)
.sum();
long end5 = System.nanoTime();
System.out.printf("5. stream + 복잡한 계산: 합계 = %.2f, 시간 = %.2fms%n", result5, (end5 - start5) / 1_000_000.0);
}
// for문 합계
private static long sumUsingForLoop(int[] arr) {
long sum = 0;
for (int j : arr) {
sum += j;
}
return sum;
}
// 복잡한 수식
private static double complexCalculation(int x) {
return ((x * 3 + 7L) * (x * 3 + 7)) % 13 + Math.sqrt(x + 1);
}
}

5. Parallel Array Sorting (Fork-Join 프레임워크)
테스트 환경: 4개의 CPU 코어를 가진 시스템에서 Double 배열을 사용하여 테스트를 진행했습니다.
결과:
- 배열의 크기가 작을 경우, Arrays.sort가 더 빠를 수 있습니다.
- 배열의 크기가 클 경우, Arrays.parallelSort가 더 효율적입니다.
long startTime = System.currentTimeMillis();
Arrays.sort(myArray);
long endTime = System.currentTimeMillis();
System.out.println("Time taken in serial: " + (endTime - startTime) / 1000.0);
startTime = System.currentTimeMillis();
Arrays.parallelSort(myArray2);
endTime = System.currentTimeMillis();
System.out.println("Time taken in parallel: " + (endTime - startTime) / 1000.0);
CPU가 여러개있고 데이터가 5000개 이상인 경우에는 Arrays.parallelSort()를 사용하는 것이 훨씬 빠른 것을 확인 할 수 있었습니다. (https://sanaulla.info/2013/04/08/arrays-sort-versus-arrays-parallelsort/
이외에도 날짜클래스가 크게바뀐 부분이 있어 그건 따로 포스팅하여 살펴볼 예정 입니다.
'개발 > Java' 카테고리의 다른 글
[Java] Thread (0) | 2025.06.08 |
---|---|
[Java] 시간과 날짜 (1) | 2025.06.07 |
[Java] 제네릭은 왜 도입되었을까? (1) | 2025.06.05 |
[Java] Arrays.sort() 와 Collections.sort() (3) | 2025.06.02 |
[Java] 예외를 던질 때 무슨 일이 일어날까? (1) | 2025.05.31 |