Java 심화 — 면접 대비 정리
Stream API, Optional, CompletableFuture, Generic, equals/hashCode, Java 8~21 주요 변경사항까지. Java 백엔드 면접 필수 개념을 정리한다.
Stream API
컬렉션 처리를 선언적으로 작성한다. 내부 반복으로 코드가 간결하고 병렬 처리가 쉽다.
List<Order> orders = orderRepository.findAll();
// 완료된 주문의 총 금액 계산
long totalAmount = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.COMPLETED)
.mapToLong(Order::getAmount)
.sum();
// 사용자별 주문 목록 그룹핑
Map<Long, List<Order>> ordersByUser = orders.stream()
.collect(Collectors.groupingBy(Order::getUserId));
// 상위 5개 주문 (금액 내림차순)
List<Order> top5 = orders.stream()
.sorted(Comparator.comparingLong(Order::getAmount).reversed())
.limit(5)
.collect(Collectors.toList());
중간 연산 vs 최종 연산
stream()
.filter() ← 중간 연산 (lazy, 최종 연산 전까지 실행 안 됨)
.map() ← 중간 연산
.sorted() ← 중간 연산
.collect() ← 최종 연산 (실행 트리거)
중간 연산은 게으르다(lazy). 최종 연산이 호출될 때 한 번에 처리된다.
병렬 스트림
long count = orders.parallelStream()
.filter(o -> o.getAmount() > 100_000)
.count();
ForkJoinPool.commonPool()을 사용한다. CPU 집약적 작업에 유리하지만, I/O 작업이나 상태 공유가 있으면 오히려 느려질 수 있다. 실측이 필요하다.
Optional
null 대신 Optional로 명시적으로 값의 부재를 표현한다.
// null 리턴 (나쁜 예)
public User findById(Long id) {
return userMap.get(id); // null 가능
}
// 호출부에서 null 체크 강요, 잊으면 NPE
// Optional 사용
public Optional<User> findById(Long id) {
return Optional.ofNullable(userMap.get(id));
}
주요 메서드
Optional<User> user = userRepository.findById(id);
// 값이 있으면 처리
user.ifPresent(u -> log.info("사용자: {}", u.getName()));
// 값이 없으면 기본값
User result = user.orElse(new GuestUser());
// 값이 없으면 예외
User result2 = user.orElseThrow(() -> new UserNotFoundException(id));
// 값이 있으면 변환
Optional<String> name = user.map(User::getName);
// Optional 체이닝
String email = userRepository.findById(id)
.map(User::getEmail)
.filter(e -> e.contains("@"))
.orElse("unknown@example.com");
Optional 잘못된 사용
// 안 좋음: isPresent() + get() 조합 → null 체크랑 다를 게 없음
if (user.isPresent()) {
return user.get();
}
// 안 좋음: 필드에 Optional 사용
class User {
private Optional<String> nickname; // 직렬화 문제, 의도와 다름
}
// 안 좋음: 컬렉션을 Optional로 감싸기
Optional<List<User>> users = ...; // 빈 List를 리턴하면 됨
CompletableFuture
비동기 작업을 조합하고 결과를 처리한다.
// 기본 비동기 실행
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
userRepository.findById(userId)
);
// 결과 변환
CompletableFuture<String> nameFuture = userFuture
.thenApply(user -> user.getName()); // 동기 변환
// 비동기 체이닝
CompletableFuture<Order> orderFuture = userFuture
.thenCompose(user ->
CompletableFuture.supplyAsync(() -> orderRepository.findByUserId(user.getId()))
);
병렬 실행 후 합치기
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
userService.findById(userId));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() ->
orderService.findByUserId(userId));
// 두 작업을 병렬로 실행하고 결과 합치기
CompletableFuture<UserWithOrders> result = userFuture.thenCombine(
ordersFuture,
(user, orders) -> new UserWithOrders(user, orders)
);
UserWithOrders data = result.get(5, TimeUnit.SECONDS);
// 여러 Future 모두 완료 대기
CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2, future3);
all.join();
// 하나라도 완료되면
CompletableFuture<Object> any = CompletableFuture.anyOf(future1, future2);
예외 처리
CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> {
if (userId == null) throw new IllegalArgumentException("ID is null");
return userService.findById(userId);
})
.exceptionally(ex -> {
log.error("오류 발생: {}", ex.getMessage());
return null; // 기본값 반환
})
.handle((user, ex) -> {
if (ex != null) return new GuestUser(); // 예외 시 기본값
return user;
});
equals와 hashCode
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
규칙:
equals()가 true면hashCode()도 같아야 한다.hashCode()가 같아도equals()가 false일 수 있다 (해시 충돌).
왜 둘 다 구현해야 하는가?
HashMap, HashSet은 먼저 hashCode()로 버킷을 찾고, 버킷 안에서 equals()로 비교한다. hashCode()만 재정의하면 같은 버킷에 있지만 equals()가 Object 기본값이라 다른 객체로 판단한다. equals()만 재정의하면 다른 버킷에 들어가 아예 찾지 못한다.
Generic
타입 안전성을 컴파일 타임에 보장한다.
// 제네릭 없이
List list = new ArrayList();
list.add("string");
list.add(123);
String s = (String) list.get(1); // ClassCastException 런타임!
// 제네릭 사용
List<String> list = new ArrayList<>();
list.add("string");
// list.add(123); // 컴파일 에러
String s = list.get(0); // 형변환 불필요
와일드카드
// 상한 와일드카드 (공변): Number와 그 하위 타입
public double sum(List<? extends Number> list) {
return list.stream().mapToDouble(Number::doubleValue).sum();
}
// 하한 와일드카드 (반변): Integer와 그 상위 타입
public void addNumbers(List<? super Integer> list) {
list.add(1);
list.add(2);
}
PECS (Producer Extends, Consumer Super):
- 리스트에서 읽기만 →
? extends T - 리스트에 쓰기만 →
? super T
Java 버전별 주요 변경사항
Java 8
- Lambda, Stream API, Optional
default인터페이스 메서드java.time(LocalDate, LocalDateTime)
Java 11
String메서드 추가 (isBlank(),strip(),lines())var(지역 변수 타입 추론, Java 10~)- HTTP Client 표준화
Java 14~16
- Record (불변 데이터 클래스)
- Pattern Matching for
instanceof - Sealed Class
Java 17 (LTS)
// Record
public record UserDto(Long id, String name, String email) {}
// equals, hashCode, toString, getter 자동 생성
// Pattern Matching
if (obj instanceof String s && s.length() > 5) {
System.out.println(s.toUpperCase()); // 형변환 없이 바로 사용
}
// Sealed Class
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
// Switch Expression
String label = switch (status) {
case ACTIVE -> "활성";
case INACTIVE -> "비활성";
case DELETED -> "삭제됨";
};
Java 21 (LTS)
- Virtual Thread (Project Loom): 경량 스레드. 블로킹 I/O도 효율적으로 처리.
- Pattern Matching for switch
// Virtual Thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> callExternalApi());
// 수만 개의 Virtual Thread 생성 가능
}
면접에서 자주 나오는 질문
Q. Stream과 for 루프의 차이는?
Stream은 선언적이고 내부 반복을 사용해 코드가 간결하다. 병렬 처리로 전환이 쉽다. for 루프는 성능 예측이 쉽고 break/continue가 자유롭다. 단순 반복은 for 루프가 빠를 수 있지만, 가독성과 유지보수성에서 Stream이 유리하다.
Q. equals와 hashCode를 같이 재정의해야 하는 이유는?
HashMap, HashSet은 hashCode로 버킷을 찾고 equals로 동등성을 확인한다. hashCode가 다르면 같은 버킷에 없어 equals를 호출하지도 않는다. equals만 재정의하면 Map/Set이 같은 논리적 객체를 다른 것으로 취급한다.
Q. Optional을 남용하면 안 되는 경우는?
파라미터 타입으로 사용하면 호출부가 복잡해진다. 필드 타입으로 쓰면 직렬화 문제가 생긴다. 컬렉션을 Optional로 감싸는 것은 불필요하다. 주로 반환 타입에 사용해 호출부가 null을 명시적으로 처리하게 유도하는 것이 적합하다.
Q. Java Virtual Thread(Java 21)란?
OS 스레드가 아닌 JVM이 관리하는 경량 스레드다. 기존 플랫폼 스레드는 블로킹 I/O 시 OS 스레드가 대기한다. Virtual Thread는 블로킹 시 다른 Virtual Thread로 전환해 훨씬 적은 OS 스레드로 수천~수만 개의 동시 I/O 처리가 가능하다. Webflux 없이도 높은 동시성을 달성할 수 있다.