
Stream API, Java I/O, Thread
컬렉션이나 배열의 데이터를 파이프라인 방식으로 처리하는 기능이다. Java 8에서 Lambda와 함께 도입됐다.
for문과 비교하면 차이가 바로 보인다.
// for문 방식 - 어떻게 하는지를 서술
int sum = 0;
for (Employee emp : emps) {
if (emp.getSalary() >= 3500000) {
sum += emp.getSalary();
}
}
// Stream 방식 - 무엇을 하는지가 보임
int sum = emps.stream()
.filter(emp -> emp.getSalary() >= 3500000)
.mapToInt(emp -> emp.getSalary())
.sum();
코드가 짧아지는 것도 있지만, 진짜 장점은 의도가 명확히 보인다는 거다. filter → mapToInt → sum 순으로 읽으면 "급여 필터링 후 합산"이 바로 이해된다.
| for문 | Stream | |
|---|---|---|
| 단순 작업 성능 | 빠름 | 상대적으로 느림 |
| 가독성 | 낮음 | 높음 |
| 연산 규칙 추가 시 | 유지보수 어려움 | 유지보수 쉬움 |
| 병렬 처리 | 직접 구현 | .parallel() 한 줄 |
| 추천 상황 | 적은 데이터, 단순 반복 | 많은 데이터, 복잡한 처리 |
모든 Stream 코드는 이 흐름이다.
생성 → 중간 처리(0개 이상) → 최종 처리
list.stream() // 1. 생성
.filter(emp -> emp.getSalary() > 3000000) // 2. 중간 처리
.mapToInt(emp -> emp.getSalary()) // 2. 중간 처리
.average() // 3. 최종 처리
.getAsDouble();
핵심 특성이 두 가지 있다.
지연 연산(Lazy Evaluation): 중간 처리는 최종 처리가 호출될 때까지 실제로 아무것도 실행되지 않는다. 최종 처리가 붙어야 비로소 파이프라인 전체가 동작한다.
일회성: Stream은 한 번 사용하면 재사용이 안 된다. 다시 쓰려면 새로 만들어야 한다.
// 배열에서 생성
String[] arr = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(arr);
// 배열 일부분만
Stream<String> stream = Arrays.stream(arr, 1, 3); // index 1~2만 → [b, c]
// 컬렉션에서 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
// 값 직접 넣기
Stream<String> stream = Stream.<String>builder()
.add("Eric").add("Elena").add("Java")
.build();
// 규칙 있는 수열 - 30부터 2씩 증가, 5개
Stream<Integer> stream = Stream.iterate(30, n -> n + 2).limit(5);
// → 30, 32, 34, 36, 38
// 같은 값 반복 생성
Stream<String> stream = Stream.generate(() -> "gen").limit(5);
// → gen gen gen gen gen
// 랜덤 난수
Stream<Integer> stream = Stream.generate(() -> new Random().nextInt(10)).limit(5);
iterate와 generate는 무한히 생성하므로 반드시 limit()으로 개수를 제한해야 한다.
중간 처리는 Stream을 변환하고 또 Stream을 반환한다. 그래서 체이닝이 가능하다.
// "a"가 포함된 단어만 → [java, ureca]
Arrays.stream(arr).filter(word -> word.contains("a"))
// 길이 5 이상인 단어만 → [hello, world, ureca]
Arrays.stream(arr).filter(word -> word.length() >= 5)
// 실전: 급여 350만 이상인 사원의 사원번호만
Arrays.stream(emps)
.filter(emp -> emp.getSalary() >= 3500000)
.map(emp -> emp.getEmpno())
// Employee → 이름(String)으로 변환
emps.stream().map(emp -> emp.getName())
// Employee → 급여(int)로 변환 (IntStream 반환)
emps.stream().mapToInt(emp -> emp.getSalary())
// "ABCBDC" → [A, B, C, D]
Arrays.stream("ABCBDC".split("")).distinct()
// 오름차순 (기본)
stream.sorted()
// 내림차순 (Comparator Lambda)
stream.sorted((n1, n2) -> n2 - n1)
DAY 05에서 배운 Arrays.sort(arr, Comparator)와 같은 원리다.
stream.limit(5) // 앞에서 5개만 자르기
stream.skip(3) // 앞에서 3개 건너뛰고 이후부터
List<String> list = Arrays.asList("this is java", "i am a developer");
list.stream()
.flatMap(data -> Arrays.stream(data.split(" ")))
// → "this", "is", "java", "i", "am", "a", "developer"
map은 요소 하나를 하나로 변환하고, flatMap은 요소 하나를 여러 개로 펼친다. 문장을 단어 단위로 쪼갤 때 전형적으로 쓰인다.
최종 처리가 호출되면 그때서야 파이프라인 전체가 실행된다.
stream.forEach(s -> System.out.print(s + " "));
// 메서드 참조 방식 (동일한 의미)
stream.forEach(System.out::println);
int[] intArr = {2, 4, 6};
// 모두 짝수인가? → true
Arrays.stream(intArr).allMatch(a -> a % 2 == 0);
// 하나라도 3의 배수인가? → true (6이 있으니까)
Arrays.stream(intArr).anyMatch(a -> a % 3 == 0);
// 3의 배수가 하나도 없는가? → false
Arrays.stream(intArr).noneMatch(a -> a % 3 == 0);
// 짝수 개수
Arrays.stream(arr).filter(n -> n % 2 == 0).count();
// IntStream에서 사용 가능한 통계
.sum()
.min()
.max()
.average() // → OptionalDouble 반환, .getAsDouble() 필요
// 급여 합계 (초기값 0에서 시작해 차례대로 더함)
emps.stream()
.map(Employee::getSalary)
.reduce(0, (a, b) -> a + b);
sum()이 내부적으로 reduce를 쓰는 거다. 직접 쓸 일은 sum/average로 표현 안 되는 복잡한 누적 연산이 필요할 때다.
// List로 수집
List<String> names = emps.stream()
.map(Employee::getName)
.collect(Collectors.toList());
// 문자열 합치기 (구분자 +, 앞 <, 뒤 >)
String result = emps.stream()
.map(Employee::getName)
.collect(Collectors.joining("+", "<", ">"));
// → <ureca+홍길동+길동홍+uplus+backend>
// 급여 기준으로 그룹핑
Map<Integer, List<Employee>> map = emps.stream()
.collect(Collectors.groupingBy(Employee::getSalary));
// 첫 번째 요소
Optional<Employee> emp = emps.stream().findFirst();
// 아무거나 하나 (병렬 스트림에서 유용)
Optional<Employee> emp = emps.stream().findAny();
Optional로 반환하는 이유는 스트림이 비어있을 수도 있어서다. .get()으로 값을 꺼낼 수 있다.
::)Lambda를 더 짧게 쓰는 방법이다. DAY 05에서 배운 Lambda의 확장이다.
// Lambda → 메서드 참조
emp -> emp.getSalary() → Employee::getSalary
s -> System.out.println(s) → System.out::println
n -> Integer.parseInt(n) → Integer::parseInt
arr -> new Integer[arr] → Integer[]::new
클래스::인스턴스메서드, 객체::메서드, 클래스::정적메서드, 클래스::new 네 가지 형태가 있다.
// 순차 처리 → 순서 보장 A B C D E F G H I
Arrays.stream(strArray).forEach(System.out::print);
// 병렬 처리 → 순서 보장 안 됨 (실행마다 달라짐)
Arrays.stream(strArray).parallel().forEach(System.out::print);
.parallel() 하나만 붙이면 내부적으로 멀티스레드로 나눠서 처리한다. 데이터가 많을 때 성능 이점이 있지만, 순서가 중요한 작업에는 쓰면 안 된다.
Stream이 항상 for문보다 좋은 건 아니다
오늘 StreamTest5_Performance.java에서 직접 성능을 측정해봤다. 1000만 개 데이터 기준으로 단순 곱하기 연산에서는 for문이 Stream보다 빨랐다.
이유가 몇 가지 있다. Stream은 함수형 인터페이스(Lambda)를 호출하는 오버헤드가 있고, Integer 같은 Wrapper 타입을 쓰면 boxing/unboxing 비용도 추가된다.
근데 실무에서 1000만 건 단순 연산을 매 요청마다 처리하는 상황이 얼마나 있을까. 대부분의 서비스에서 성능 차이는 체감하기 어렵다. 오히려 복잡한 조건 필터링 + 그룹핑 + 집계를 for문으로 짜면 코드가 엉망이 된다.
결론적으로, Stream은 가독성과 유지보수성을 위해 쓰는 거다. 성능이 진짜 병목이 되는 지점에서만 for문으로 내려가면 된다. 최적화는 측정 후에 하는 거지, 처음부터 for문으로 짜는 건 아니다.
Java I/O는 두 계열로 나뉜다.
byte 계열 : InputStream / OutputStream → 이미지, 영상, 바이너리 파일 등 모든 데이터
char 계열 : Reader / Writer → 텍스트 파일 전용
char 계열로 이미지 파일을 복사하면 데이터가 손상된다. 텍스트가 아닌 데이터는 반드시 byte 계열을 써야 한다.
I/O는 대표적인 CheckedException이라 반드시 try-catch나 throws로 처리해야 한다. 그리고 사용 후 반드시 close()를 해줘야 한다.
FileCopy1 - byte 단위로 1바이트씩 읽기 (가장 느림)
try (FileInputStream fis = new FileInputStream(readFile);
FileOutputStream fos = new FileOutputStream(saveFile)) {
int read;
while ((read = fis.read()) != -1) { // -1이면 EOF(파일 끝)
fos.write(read);
}
}
FileCopy2 - 버퍼 배열로 한 번에 읽기 (더 빠름)
byte[] buf = new byte[1024]; // 2의 n승으로 설정하면 좋다
int read;
while ((read = fis.read(buf)) != -1) {
fos.write(buf, 0, read); // 실제 읽은 개수만큼만 write
}
FileCopy3 - char 계열로 텍스트 파일 복사
try (FileReader fr = new FileReader(readFile);
FileWriter fw = new FileWriter(saveFile)) {
char[] buf = new char[1024];
int len;
while ((len = fr.read(buf)) != -1) {
fw.write(buf, 0, len);
}
}
-1은 데이터를 더 이상 읽을 수 없는 상태다. 파일이면 EOF(End of File), 네트워크라면 Socket이 close된 경우다.
try-with-resources를 쓰면 블록이 끝날 때 자동으로 close()가 호출된다. FileCopy3처럼 finally에서 직접 close하는 방식보다 훨씬 간결하다.
Primitive 타입과 문자열을 그대로 입출력할 수 있는 Stream이다.
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(file));
DataInputStream dis = new DataInputStream(new FileInputStream(file))) {
dos.writeLong(256);
dos.writeDouble(3.14);
dos.writeInt(10);
dos.writeBoolean(true);
dos.writeUTF("hello");
// 주의: 쓴 순서대로 읽어야 한다
System.out.println(dis.readLong()); // 256
System.out.println(dis.readDouble()); // 3.14
System.out.println(dis.readInt()); // 10
System.out.println(dis.readBoolean()); // true
System.out.println(dis.readUTF()); // hello
}
쓴 순서와 읽는 순서가 반드시 일치해야 한다. 순서가 틀리면 데이터가 엉뚱하게 읽힌다.
객체 자체를 파일에 저장하고 복원하는 기능이다.
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
oos.writeObject(new Employee("1", "ssafy", 100000000));
System.out.println(ois.readObject()); // Employee 객체로 복원
}
사용하려면 해당 클래스가 반드시 Serializable 인터페이스를 구현해야 한다. 구현하지 않으면 NotSerializableException이 발생한다.
public class Employee implements Serializable { // 필수
private String empno;
private transient String name; // transient: 직렬화에서 제외됨
private int salary;
}
transient로 선언된 필드는 직렬화에서 제외된다. 파일에 저장되지 않고, 복원 시 기본값(null, 0 등)으로 채워진다. 비밀번호나 민감한 정보를 직렬화에서 빼고 싶을 때 쓴다.
static 필드도 직렬화에서 제외된다. static은 객체가 아닌 클래스 레벨의 데이터라서 직렬화 대상이 아니다.
강사님이 나중에 Web 개발할 때 Serializable이 중요하다고 짚어주셨다. HTTP 세션 저장, 분산 캐시(Redis 등), 네트워크 전송 시 객체를 byte 배열로 변환해야 하는데 여기서 직렬화가 쓰인다.
프로세스(Process)는 실행 중인 프로그램 자체다. 각자 독립된 메모리 공간을 가진다.
스레드(Thread)는 프로세스 안에서 실행되는 작업 흐름이다. 같은 프로세스 내 스레드들은 Heap 메모리를 공유한다.
멀티프로세스 : 여러 프로그램을 동시에 실행 (각각 독립 메모리)
멀티스레드 : 하나의 프로그램 안에서 여러 작업 동시 실행 (Heap 공유)
자바 프로그램이 실행되면 main 메서드를 실행하는 메인 스레드가 자동으로 생성된다. t1.start()를 호출하면 새 스레드가 생성되어 run()을 실행하고, 메인 스레드는 기다리지 않고 계속 진행한다. 이게 비동기 동작이다.
MyThread t1 = new MyThread("t1");
t1.start();
System.out.println("main end....."); // t1이 끝나기 전에 출력될 수 있음
start()가 아닌 run()을 직접 호출하면 새 스레드가 아닌 메인 스레드가 실행하는 것이라 동기처럼 동작한다.
I/O 클래스의 전체 구조를 알아두면 좋다.
| byte Input | byte Output | char Input | char Output | |
|---|---|---|---|---|
| 추상 | InputStream | OutputStream | Reader | Writer |
| File | FileInputStream | FileOutputStream | FileReader | FileWriter |
| Memory(Array) | ByteArrayInputStream | ByteArrayOutputStream | CharArrayReader | CharArrayWriter |
class MyThread extends Thread {
private int i;
public MyThread(String name) {
super(name); // Thread 이름 설정. 생략하면 Thread-0, Thread-1... 자동 부여
}
public void run() {
for (i = 1; i <= 500; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
try { Thread.sleep(100); } catch (Exception e) {}
}
}
}
// 실행
MyThread t1 = new MyThread("t1");
MyThread t2 = new MyThread("t2");
t1.start();
t2.start();
Thread.sleep(ms)는 해당 스레드를 지정한 밀리초 동안 일시 정지시킨다. InterruptedException이 CheckedException이라 반드시 try-catch가 필요하다.
t3.setPriority(Thread.MAX_PRIORITY); // 10
t1.setPriority(Thread.MIN_PRIORITY); // 1
// 기본값은 NORM_PRIORITY (5)
우선순위가 높을수록 CPU를 더 많이 할당받지만, OS 스케줄러에 따라 보장은 안 된다.
스레드 간 데이터 공유에서 중요한 포인트다.
// Thread 상속 방식 → 각 스레드가 별도의 MyThread 객체 생성
MyThread t1 = new MyThread("t1"); // t1만의 i
MyThread t2 = new MyThread("t2"); // t2만의 i
// → i는 각 객체의 필드라 공유 안 됨
// Runnable 방식 → 하나의 MyRunnable 객체를 여러 스레드가 공유
MyRunnable job = new MyRunnable(); // i를 가진 객체 하나
Thread t1 = new Thread(job, "t1"); // 같은 job을 참조
Thread t2 = new Thread(job, "t2"); // 같은 job을 참조
// → i는 같은 객체의 필드라 공유됨
이 차이 때문에 Runnable 방식에서는 동기화가 필요하다.
class MyRunnable implements Runnable {
private int i;
public void run() {
for (i = 0; i <= 50;) {
synchronized (this) { // this(MyRunnable 객체)를 락으로 사용
System.out.println(Thread.currentThread().getName() + ":" + ++i);
try { Thread.sleep(300); } catch (Exception e) {}
}
}
}
}
자바는 단일 상속이라 이미 다른 클래스를 상속받은 경우 Thread를 상속받을 수 없다. Runnable을 구현하는 방식이 더 유연하다.
동기화는 Runnable이나 Thread에 거는 게 아니다. 여러 스레드가 공유하는 자원(객체)에 걸어야 한다. 위 코드에서 synchronized(this)는 MyRunnable 객체(공유 자원)에 락을 거는 것이다.
SyncStack.java를 보면 동기화 발전 과정이 주석으로 설명되어 있다.
1단계 - 동기화 없음: 데이터 없는데 pop() 시도하면 IndexOutOfBoundsException 발생
2단계 - suspend/resume 시도: 동기화 상태에서 suspend()를 호출하면 lock을 쥔 채로 블락되어 Deadlock 발생
3단계 - wait/notify로 해결:
public class SyncStack {
private ArrayList<Character> stack;
public void push(char data) {
this.notify(); // 대기 중인 스레드 깨우기
stack.add(data);
}
public char pop() {
while (stack.size() == 0) {
try { wait(); } catch (InterruptedException e) {}
// wait(): lock을 반납하고 대기 → notify()가 올 때까지
}
return stack.remove(stack.size() - 1);
}
}
wait()와 notify()의 핵심 차이:
suspend(): lock을 쥔 채로 멈춤 → Deadlock 위험wait(): lock을 반납하고 대기 → 다른 스레드가 진입 가능 → Deadlock 없음생산자가 데이터를 만들어 공유 자원에 넣고, 소비자가 꺼내 쓰는 패턴이다.
// 공유 자원에 synchronized로 동기화
class Consumer extends Thread {
private SyncStack stack; // 공유 자원
public void run() {
for (int i = 0; i < 100; i++) {
synchronized (stack) {
System.out.println(getName() + ":" + stack.pop());
}
}
}
}
class Producer extends Thread {
private SyncStack stack; // 같은 공유 자원
public void run() {
for (int i = 0; i < 100; i++) {
synchronized (stack) {
char data = (char)('A' + Math.random() * 26);
stack.push(data);
}
}
}
}
// 하나의 SyncStack을 Consumer 3개, Producer 3개가 공유
SyncStack stack = new SyncStack();
new Consumer("c1", stack).start();
new Producer("p1", stack).start();
// ...
데이터가 없는데 Consumer가 pop()을 시도하면 wait()으로 대기, Producer가 push() 후 notify()로 깨운다. 이 흐름을 확실히 이해해야 한다.
t1.start();
t1.join(); // 메인 스레드가 t1이 끝날 때까지 블락됨
t2.start(); // t1 완료 후에 시작
t3.start();
join()은 해당 스레드가 종료될 때까지 현재 스레드를 블락시킨다. 순서가 중요한 작업에서 쓴다.
MyThread t1 = new MyThread("t1");
t1.setDaemon(true); // 데몬 스레드로 설정
t1.start();
Thread.sleep(5000);
System.out.println("main end");
// main이 끝나면 데몬 스레드 t1도 자동 종료됨
데몬 스레드는 Master Thread(메인 스레드)가 종료되면 함께 종료된다. 백그라운드 작업(로그 수집, 가비지 컬렉션 등)에 쓴다. setDaemon(true)는 start() 전에 호출해야 한다.
Runnable의 run()은 반환값이 없다. 작업 결과를 돌려받으려면 Callable을 쓴다. Callable은 ThreadPool(ExecutorService)을 통해서만 실행 가능하다.
// Callable: 결과를 반환하는 스레드 작업
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 10 + 20;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(task); // 비동기로 실행
// future.get()은 결과가 나올 때까지 대기(블락)해서 가져옴
Integer result = future.get(); // 30
future.get()이 핵심이다. 결과를 꺼내기 위해 대기한다는 개념을 확실히 알아야 한다. 비동기로 실행하고, 나중에 결과가 필요한 시점에 get()으로 가져오는 패턴이다.
// 스레드 5개짜리 풀로 메일 1000개 발송 (ThreadTest7)
ExecutorService pool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 1000; i++) {
final int idx = i;
pool.execute(new Runnable() {
public void run() {
// 메일 발송 처리
System.out.println(mails[idx][0] + " → " + mails[idx][1]);
}
});
}
pool.shutdown();
// Callable로 10000개 합산 후 결과 수집 (ThreadTest8)
List<Future<Integer>> results = new ArrayList<>();
for (int i = 1; i <= 10000; i++) {
final int idx = i;
Future<Integer> future = pool.submit(() -> {
int sum = 0;
for (int j = 1; j <= idx; j++) sum += j;
return sum;
});
results.add(future);
}
// 모든 결과 수집 (각 future.get()이 해당 작업 완료까지 대기)
for (Future<Integer> f : results) {
System.out.println(f.get());
}
ThreadPool을 쓰는 이유는 스레드를 매번 new로 만들면 비용이 크기 때문이다. 미리 만들어둔 스레드를 재사용하면 성능이 좋아진다. 서버에서 수많은 요청을 처리할 때 필수다.
| Runnable | Callable | |
|---|---|---|
| 반환값 | 없음 (void) | 있음 (제네릭 타입) |
| 예외 | checked 예외 선언 불가 | throws Exception 가능 |
| 실행 방법 | Thread에 직접 가능 | ExecutorService.submit()만 가능 |
| 결과 수령 | 불가 | Future.get()으로 대기 후 수령 |
| 사용 상황 | 결과 필요 없는 단순 작업 | 결과를 받아야 하는 작업 |
Stream 지연 연산 Lazy Evaluation 일회성 filter map flatMap collect reduce Optional 메서드 참조 :: parallel byte 계열 I/O char 계열 I/O EOF -1 try-with-resources DataInputStream DataOutputStream Serializable transient ObjectOutputStream ObjectInputStream 프로세스 vs 스레드 메인 스레드 동기 vs 비동기 start() vs run() Thread.sleep() setPriority() Thread 상속 Runnable Callable synchronized wait() notify() Deadlock Producer-Consumer SyncStack join() Daemon Thread ThreadPool ExecutorService Future future.get()