웹 서비스에서는 요청 처리, 로그 수집, 데이터 가공처럼 여러 작업을 다룬다. 자바는 스레드로 작업을 병행하고, 람다와 Stream으로 데이터 처리 로직을 간결하게 표현할 수 있다. 서로 다른 기능이지만, 공통적으로 코드가 실행되는 방식을 이해해야 안전하게 사용할 수 있다.
프로세스는 실행 중인 프로그램을 뜻하고, 스레드는 프로세스 안에서 실행되는 작업 흐름이다. 하나의 프로세스에는 여러 스레드가 존재할 수 있으며, 이들이 같은 객체의 데이터를 공유하면 동시성 문제가 발생할 수 있다.
// ThreadExample.java
public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 1; i <= 3; i++) {
System.out.println(Thread.currentThread().getName() + ": " + i);
}
};
Thread worker = new Thread(task, "log-worker");
worker.start();
worker.join();
System.out.println("작업 완료");
}
}
start()가 새 스레드를 시작한다. run()을 직접 호출하면 새 스레드가 생성되지 않고 현재 스레드에서 일반 메서드로 실행된다. join()은 대상 스레드 종료를 기다린다. 여러 스레드 간 출력 순서는 일반적으로 보장되지 않는다.
counter++;
겉보기엔 한 줄이지만 값을 읽고, 1을 더하고, 다시 쓰는 여러 동작이 포함된다. 서로 다른 스레드가 동시에 수행하면 증가 횟수가 누락될 수 있다. 원자적 증가가 필요하면 AtomicInteger 등을 고려한다.
// AtomicExample.java
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
public static void main(String[] args) throws InterruptedException {
AtomicInteger completed = new AtomicInteger();
Thread first = new Thread(() -> {
for (int i = 0; i < 1_000; i++) {
completed.incrementAndGet();
}
});
Thread second = new Thread(() -> {
for (int i = 0; i < 1_000; i++) {
completed.incrementAndGet();
}
});
first.start();
second.start();
first.join();
second.join();
System.out.println(completed.get()); // 2000
}
}
AtomicInteger의 단일 원자적 연산이 안전하더라도 여러 필드를 함께 바꿔야 하는 규칙까지 자동으로 보호되지는 않는다. 필요한 범위를 판단해 동기화 전략을 선택해야 한다.
단순 실험은 new Thread(...)도 가능하지만, 서버에서 많은 작업을 다룰 때는 실행 전략을 통제할 수 있는 ExecutorService가 유용하다.
// ExecutorExample.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ExecutorExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Integer> result = executor.submit(() -> 20 + 22);
System.out.println(result.get()); // 42
} finally {
executor.shutdown();
}
}
}
submit()은 결과를 받을 수 있는 Future를 반환한다. get()은 결과가 나올 때까지 기다릴 수 있으며, 실제 서비스에서는 타임아웃과 취소 처리를 함께 검토한다. 최신 Java에는 가상 스레드도 있지만 여기서는 기본 스레드 동작을 먼저 이해한다.
함수형 인터페이스는 구현해야 하는 추상 메서드가 하나인 인터페이스다. 이런 타입의 인스턴스를 람다 표현식으로 만들 수 있다.
import java.util.function.Predicate;
Predicate<String> isValidId = id -> id != null && id.startsWith("USER-");
System.out.println(isValidId.test("USER-7")); // true
대표적인 표준 함수형 인터페이스는 Predicate<T>(판정), Function<T, R>(변환), Consumer<T>(사용), Supplier<T>(공급)가 있다. 람다는 메서드가 필요한 위치에 짧은 동작을 전달하기 좋다.
// StreamExample.java
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<Integer> paymentAmounts = List.of(12_000, 4_000, 26_000, 8_000);
int total = paymentAmounts.stream()
.filter(amount -> amount >= 10_000)
.mapToInt(Integer::intValue)
.sum();
System.out.println(total); // 38000
}
}
stream()은 원본 컬렉션을 바탕으로 처리 파이프라인을 시작한다.filter는 조건에 맞는 원소를 통과시킨다.mapToInt는 정수 스트림으로 변환한다.sum은 터미널 연산이므로 실제 계산을 수행한다.대부분의 중간 연산은 지연 평가(lazy evaluation)된다. 원본 리스트는 바뀌지 않는다. 스트림은 일회성 파이프라인이므로 터미널 연산 이후 같은 스트림 객체를 다시 사용하지 않는다.
List<String> names = List.of("kim", "lee", "park");
List<String> upper = names.stream()
.map(String::toUpperCase)
.toList(); // Java 16 이상
toList() 결과는 수정할 수 없는 리스트다. 원본 순서를 유지하는 스트림이라면 결과의 만나는 순서도 유지된다.
parallelStream()은 모든 데이터를 무조건 빠르게 처리하지 않는다. 데이터가 적거나 순서·공유 상태가 중요할 때는 오히려 느리거나 복잡해질 수 있다. 일반 stream()으로 시작하고 실제 병렬 처리가 필요한지 측정해 보자.
start()는 새 스레드를 시작하고 join()은 종료를 기다린다.ExecutorService는 작업 실행과 종료를 체계적으로 관리한다.filter·map 등으로 데이터 처리 과정을 연결한다.