Java 기초 문법과 객체지향을 익혔다면 다음으로 자주 만나는 개념이 제네릭, 컬렉션, 람다, 스트림이다.
이 기능들은 데이터를 타입 안전하게 보관하고, 반복되는 코드를 줄이며, 컬렉션을 선언적으로 처리하는 데 사용된다.
타입마다 비슷한 클래스를 따로 만든다고 생각해보자.
class StringBox {
String value;
}
class IntegerBox {
Integer value;
}
동작은 같은데 타입만 다르다. 제네릭을 사용하면 하나의 클래스로 만들 수 있다.
class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
Box<String> textBox = new Box<>();
textBox.set("response");
Box<Integer> countBox = new Box<>();
countBox.set(10);
컴파일러가 타입을 확인하므로 잘못된 값을 넣는 실수를 줄일 수 있다.
메소드 단위로도 타입 매개변수를 사용할 수 있다.
public static <T> void printValue(T value) {
System.out.println(value);
}
printValue(100);
printValue("hello");
printValue(true);
동작은 같고 타입만 달라지는 경우 코드 중복을 줄일 수 있다.
제네릭 타입에는 기본 자료형을 직접 넣을 수 없다.
// List<int>는 사용할 수 없음
대신 기본 자료형에 대응하는 Wrapper 클래스를 사용한다.
| 기본 자료형 | Wrapper |
|---|---|
int | Integer |
long | Long |
double | Double |
boolean | Boolean |
char | Character |
Java는 기본 자료형과 Wrapper 사이를 자동으로 변환해주는 오토박싱/언박싱을 지원한다.
Integer count = 10; // autoboxing
int value = count; // unboxing
단, Wrapper는 객체이므로 null이 될 수 있다.
Integer count = null;
// int value = count; // NullPointerException 가능
ArrayList: 가장 자주 쓰는 리스트크기가 고정된 배열과 달리 ArrayList는 요소를 동적으로 추가하거나 제거할 수 있다.
import java.util.ArrayList;
import java.util.List;
List<String> endpoints = new ArrayList<>();
endpoints.add("/users");
endpoints.add("/orders");
System.out.println(endpoints.get(0));
System.out.println(endpoints.size());
System.out.println(endpoints.contains("/orders"));
endpoints.set(0, "/members");
endpoints.remove("/orders");
가능하면 변수 타입은 구현체보다 인터페이스인 List로 선언하면 구현 교체가 쉬워진다.
List<String> items = new ArrayList<>();
LinkedList: 링크로 연결된 리스트import java.util.LinkedList;
LinkedList<String> queue = new LinkedList<>();
queue.add("job-1");
queue.addFirst("urgent-job");
queue.addLast("job-2");
LinkedList를 단순히 "삽입과 삭제가 언제나 빠르다"고 외우면 정확하지 않다. 특정 노드 위치를 이미 알고 있다면 연결 변경은 빠르지만, 인덱스로 원하는 위치를 찾는 데는 O(n) 이 걸릴 수 있다.
일반적인 순차 데이터 처리에서는 ArrayList가 더 자주 선택된다. 큐/덱 용도라면 ArrayDeque가 더 적합한 경우도 많다.
HashSet: 중복 없는 집합import java.util.HashSet;
import java.util.Set;
Set<String> roles = new HashSet<>();
roles.add("ADMIN");
roles.add("USER");
roles.add("ADMIN");
System.out.println(roles.size()); // 2
HashSet은 중복 요소를 허용하지 않고 기본적으로 반복 순서를 보장하지 않는다.
System.out.println(roles.contains("ADMIN"));
roles.remove("USER");
중복 제거, 포함 여부 검사에 잘 맞는다.
HashMap: Key와 Value로 데이터 저장import java.util.HashMap;
import java.util.Map;
Map<String, Integer> requestCount = new HashMap<>();
requestCount.put("/users", 120);
requestCount.put("/orders", 85);
System.out.println(requestCount.get("/users"));
System.out.println(requestCount.containsKey("/orders"));
System.out.println(requestCount.size());
HashMap은 키의 중복을 허용하지 않지만 값은 중복될 수 있다. 같은 키로 다시 put()하면 기존 값이 교체된다.
또한 일반 HashMap은 순서를 보장하지 않는다.
Iterator컬렉션 요소를 순차적으로 방문하기 위한 표준 인터페이스다.
import java.util.Iterator;
import java.util.List;
List<String> jobs = new ArrayList<>();
jobs.add("sync");
jobs.add("backup");
Iterator<String> iterator = jobs.iterator();
while (iterator.hasNext()) {
String job = iterator.next();
System.out.println(job);
}
반복 중 안전하게 현재 요소를 제거해야 할 때도 사용할 수 있다.
while (iterator.hasNext()) {
String job = iterator.next();
if (job.equals("backup")) {
iterator.remove();
}
}
한 번만 사용할 구현체를 별도 클래스 파일 없이 즉석에서 만들 수 있다.
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("작업 실행");
}
};
작은 구현에는 편리하지만 코드가 길어질 수 있다. 함수형 인터페이스라면 람다로 더 간결하게 표현할 수 있다.
람다는 이름 없는 동작을 값처럼 전달하는 문법이다.
(x, y) -> x + y
Java의 람다는 아무 메소드나 대신하는 것이 아니라 함수형 인터페이스의 구현으로 사용된다.
@FunctionalInterface
interface Calculator {
int calculate(int x, int y);
}
Calculator add = (x, y) -> x + y;
Calculator multiply = (x, y) -> x * y;
System.out.println(add.calculate(2, 3)); // 5
System.out.println(multiply.calculate(2, 3)); // 6
함수형 인터페이스는 추상 메소드가 하나인 인터페이스다.
스트림은 배열이나 컬렉션 데이터를 필터링, 변환, 집계하는 흐름을 선언적으로 표현한다.
List<Integer> responseTimes = List.of(120, 80, 310, 95, 220);
responseTimes.stream()
.filter(time -> time >= 200)
.map(time -> time / 10)
.forEach(System.out::println);
흐름을 나누면 다음과 같다.
데이터 소스
↓
filter: 필요한 데이터만 선택
↓
map: 다른 값으로 변환
↓
forEach: 최종 처리
List<Integer> slowTimes = responseTimes.stream()
.filter(time -> time >= 200)
.toList();
원본 리스트를 직접 변경하지 않고 새로운 결과를 얻을 수 있다.
반복문보다 스트림이 항상 좋은 것은 아니다.
단순한 순회나 복잡한 상태 변경이 필요한 코드에서는 반복문이 더 읽기 쉬울 수 있다. 반면 필터 → 변환 → 집계처럼 데이터 처리 단계가 명확한 경우 스트림이 의도를 잘 드러낸다.
long count = responseTimes.stream()
.filter(time -> time >= 200)
.count();
코드가 짧다는 이유보다 처리 과정이 더 명확해지는지를 기준으로 선택하면 된다.
Integer, Double 같은 Wrapper 클래스를 쓴다.ArrayList는 일반적인 동적 리스트로 가장 자주 사용된다.HashSet은 중복을 제거하고, HashMap은 Key-Value 구조로 데이터를 저장한다.HashMap은 키 중복은 허용하지 않지만 값 중복은 가능하다.Iterator는 컬렉션을 순회하며 반복 중 요소를 제거할 때 유용하다.