들어가며

https://geniusjun4663.tistory.com/80
[모던 자바 인 액션] 자바 생태계로 Deep Dive - (1)
들어가며자바 공부를 계속 진행하다 보면 우리가 흔히 쓰는 일반 for문이 아니라 람다 표현식, 스트림, 컬렉션 API 등으로 이루어진 코드를 많이 접하게 된다. 더불어 AI들도 특히 Stream()문법으로
geniusjun4663.tistory.com
저번 글에 이어서 자바 생태계로 Deep Dive 해보자!
본문으로
8️⃣ 9️⃣ 컬렉션 API와 람다 표현식으로 코드 구성하기
ConcurrentHashMap은 HashMap보다 동시성에 친화적이다?
이 둘의 차이는 동시성과 비동시성의 차이로 멀티스레딩 상황에서 주의해서 써야한다고 정도만 알고 있었는데, 이번기회에 책 덕분에 정리해본다.
HashMap은 기본적으로 Thread-Safe하지 않다. HashMap은 내부적으로 배열(table) + 연결 구조(버킷 체인) 로 동작하는데, 여러 스레드가 동시에 put()/remove()를 수행하면 구조를 바꾸는 작업이 충돌할 수 있다. Collection 인터페이스에서 synchronizedMap() 메서드를 지원하긴 하지만 이조차도 Map전체를 통째로 Lock하기 때문에, 동시성 문제는 발생하지 않더라도 한 스레드가 쓰기 작업 중이면 다른 모든 스레드는 대기하는 식의 성능 저하가 일어난다. (안전해지긴 하지만, 동시 요청이 많아질수록 병목이 심해진다.)
즉 HashMap은 동시에 수정하는 상황을 고려한 방어장치가 없다.
자바 8에서 제공하는 ConcurrentHashMap은 Thread-Safe하다. ConcurrentHashMap은 가능한 한 Lock을 안 쓰거나, 쓰더라도 ‘아주 좁은 범위’만 잠근다.
- 읽기(get): 거의 lock-free (대부분 잠금 없이 읽음)
- 버킷의 헤더를 volatile 성격으로 안전하게 읽고 그 뒤 체인/트리를 따라가며 찾는다.
- 쓰기와 충돌하더라도 구조가 깨지지 않도록 설계되어 있다.
- 쓰기(put): 필요한 버킷만 잠그거나, 먼저 CAS(Compare-And-Swapm 내가 예상한 값이 아직 그대로일 때만 바꾸는)로 “자리 선점”을 시도한다.
- 해당 인덱스 버킷이 비었으면 → CAS로 “내가 먼저 넣을게” 하고 한번에 성공시키려 한다. (lock 없이)
- 이미 누가 있으면 → 그 버킷만 synchronized로 잠그고 체인/트리를 갱신한다.
HashMap과 ConcurrentHashMap 방식을 아래 그림처럼 정리할 수도 있겠다.
// HashMap
table (배열)
+-----+-----+-----+-----+-----+
| 0 | 1 | 2 | 3 | 4 |
+-----+-----+-----+-----+-----+
| | |
v v v
[A] [B]->[C] (empty)
// ConcurrentHashMap
table
+-----+-----+-----+-----+-----+
| 0 | 1 | 2 | 3 | 4 |
+-----+-----+-----+-----+-----+
🔒 🔒
(0번 버킷만) (3번 버킷만)
이러한 이유로 멀티스레드 환경에서나, 동시에 접근하는 flow가 있다면 꼭 ConcurrentHashMap을 사용해야겠다!
🧐 자바 컴파일러가 앞으로 해결해야 할 문제, 람다의 스택 트레이스
버그가 발생했을 시 우리는 에러 목록을 확인 할 수 있는 스택 트레이스를 보곤 한다. 아래 예제를 보자. 아래 코드는 고의적으로 문제를 일으키도록 구현한 간단한 코드다.
import java.util.*;
public class Debugging{
public static void main(String[] args) {
List<Point> points = Arrays.asList(new Point(12, 2), null);
points.stream().map(p -> p.getX()).forEach(System.out::println);
}
아래는 런타임 에러시 우리가 많이 보는 스택트레이스이다. 중간에 보면 이상한 lambda$main$0 에러를 확인할 수 있다.
Exception in thread "main" java.lang.NullPointerException
at Debugging.lambda$main$0(Debugging.java:6) // 여기 $0 은 무슨 의미일까?
at Debugging$$Lambda$5/284720968.apply(Unknown Source)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline .java:193)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Splitera
이는 람다 표현식 내부에서 에러가 발생했음을 가리킨다. 람다 표현식은 이름이 없으므로(익명클래스 구현) 컴파일러가 람다를 참조하는 이름을 만들어낸 것이다. 클래스에 여러 람다 표현식이 있을 때는 꽤 골치 아픈일이 벌어질 것이다.(이거 뭐 람다식의 순서를 따로 카운팅 할수도 없고..)
따라서 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다는 점을 염두에 두자. 이는 미래의 자바 컴파일러가 개선해야 할 부분이다.
1️⃣1️⃣ null 대신 Optional 클래스
자바로 프로그램을 개발하면 한 번이라도 NullPointerException을 겪어봤을 것이다. 나도 객체 값에 null이 들어올 수 있으니 조심해야지, if ( 객체 == null) 일때 따로 처리하는 로직을 만들며.. 늘 개발 했던 거 같다.
1965년 토니 호어라는 영국 컴퓨터과학자는 "구현하기가 쉬웠기 때문에 null을 도입"해서 프로그래밍 언어를 만들었고, 그 철학이 여러 명령형 언어들에 퍼지게 되면서 자바에도 null 이라는 값을 참조 및 예외로 구현하게끔 설계되었다. 여러해가 지난 후 호어는 당시 null 및 예외를 만든 결정을 가리켜 "십억 달러짜리 실수" 라고 표현했다고 한다 ㅋㅋㅋ.
나도 언어 설계자의 입장에서 생각해보게 된다. 값에 빈 값이 들어올 수 있는데, 그것을 null이라고 칭하며 null이 참조되었을때 예외를 터뜨리는 방식이 최선일까? 매번 if(객체 == null) {뒤처리} 로직은 반복될테고, null은 사실 아무 의미도 없는데 말이다!
자바 8이후로는 null을 참조했을 시 예외를 던지는 방식의 로직을 지양하기 위해, Optional<T> 클래스를 제공한다.
Optinal 클래스의 의미는 어떤 객체가 null일때 발생하는 예외를 잡는것이 아닌, 그 객체가 있을 수도 있고 없을 수도 있다는 의미를 명시적으로 나타낸다. -> 즉 null이 들어올 수도 있는데, 그것을 예외로 처리하지 않는다.
좀 더 자세히 말하자면 값이 있으면 그 값을 반환하고, 값이 없으면 싱글톤 인스턴스를 반환하는 정적 팩토리 메서드인 Optinal.empty 메서드로 Optinal를 반환한다. Optional과 null은 의미상으론 빈값으로써 비슷하지만, Optinal은 예외를 발생시키지 않는 빈 값이므로 활용점이 무궁무진하다.
// 기존 방법(null 사용)
public User findUserById(Long id) {
User user = userRepository.get(id);
if (user == null) {
return null;
}
return user;
}
// 이를 사용하는 쪽에선 항상 이렇게 체크하고 써야하는 불편함
User user = findUserById(1L);
if (user != null) {
System.out.println(user.getName());
}
위의 코드를 아래처럼 쓸 수 있다. 값이 없는 상황을 Optianl 메서드로 간단하게 처리가능 하다.
public Optional<User> findUserById(Long id) {
User user = userRepository.get(id);
if (user == null) {
return Optional.empty(); // 싱글톤 빈 Optional 반환
}
return Optional.of(user);
}
// 예외를 무서워 할 필요 없다!
Optional<User> optionalUser = findUserById(1L);
optionalUser.ifPresent(user ->
System.out.println(user.getName())
);
// 아래처럼 있는지 없는지를 Optianl 메서드인 isPresent로 간단하게 확인 가능하다.
public Optional<Insurance> nullSafeFindCheapestInsurance( Optional<Person> person, Optional<Car> car)
{
if (person.isPresent() && car.isPresent()){
return Optional.of(findCheapestInsurance(person.get(), car.get()));
}
}
이렇게 Optinal을 활용하면 사용자는 메서드의 시그니처만 보고도 Optinal값이 사용되거나 반환되는지를 예측할 수 있으므로 더 깔끔한 API를 설계할 수 있다고 말한다.
도메인 모델에 Optinal을 사용했을 때 데이터를 직렬화 할 수 없는 이유?
자바 언어 아키텍처인 브라이언 고츠는 Optinal의 용도가 Optional은 “이 메서드는 값이 없을 수도 있습니다” 를 호출자에게 명확하게 알리기 위한 도구라고 못박았다. 즉 아래와 같은 필드변수가 있다면 User를 못 찾을 수도 있다.
public Optional<User> findUser(Long id)
Optional 클래스는 필드 형식으로 사용할 것을 가정하지 않았으므로 Serializable 인터페이스를 구현하지 않는다. 따라서 우리 도메인 모델에 Optional을 사용한다면 직렬화 serializable 모델을 사용하는 도구나 프레임워크에서 문제가 생길 수 있다.
그래서 예전에 Optinal 필드로 인해 JPA 라이브러리 중 하나인 Jackson 직렬화 문제를 겪었던 적이 있었다. (엔티티 필드에는 Optianl은 지양하자.)
이와 같은 단점에도 불구하고 여전히 객체 그래프 에서 일부 또는 전체 객체가 null일 수 있는 상황(person.getCar().getInsurance().getName() 이런 체이닝 관계에서 널 에러가 터지기 쉬움)이라면 더욱 Optinal이 매력적이다. 직렬화 모델이 필요하다면 다음 예제에서 보여주는 것처럼 Optional로 값을 반환받을 수 있는 메서드를 추가하는 방식을 권장한다.
public class Person {
private Car car;
public Optional getCarAsOptional() {
return Optional.ofNullable(car);
}
}
1️⃣3️⃣ 자바가 디폴트 메서드를 선택한 이유?
아래의 인터페이스에 새로운 메서드를 추가해보자.
interface MyService {
void doSomething();
}
아래처럼 추가했다. 인터페이스를 구현중인 구현체들은 모든 추상메서드를 구현해야하기에, 컴파일 에러가 날 것이다.
interface MyService {
void doSomething();
void newMethod(); // 추가
}
특히 자바 8이전의 라이브러리 개발자들이 힘겹게 개발하여 배포해도, 새로운 기능을 추가하면 그 구현체들이 모두 컴파일 에러가 나는 불편함이 있었다. 즉 라이브러리의 업데이트가 너무 어려웠다. 자바 8에서는 이를 해결하기 위해 default 메서드를 제공한다.
interface MyService {
void doSomething();
default void newMethod() {
System.out.println("기본 동작");
}
}
이렇게 default 키워드를 추가하여, 새로운 메서드를 추가하여 업데이트한다면 기존 구현 클래스의 수정없이 업데이트가 가능하다.
이렇게 자바 8부터는 default 키워드를 추가하여 기존 코드 깨지지 않게 인터페이스를 확장할 수 있는 방법을 열어준 것이다.
아래 로직을 보면 구현체 클래스에서는 새로 추가 구현을 안해도 default메서드는 손쉽게 사용가능하다.
class MyServiceImpl implements MyService {
public void doSomething() {
...
}
}
MyService service = new MyServiceImpl();
service.newMethod(); // 사용 가능
실제로 Collection, List, Map 등 새로운 기능이 생겼을때 default메서드로 추가중이다. 대표적으로 forEach()메서드는 자바측에서 추후에 default 메서드로 추가한 것이다!(이런 과거 있었다니...!)
🧐 자바가 동시성 문제를 해결해온 흐름(Future → CompletableFuture → Virtual Thread)
자바는 오랫동안 “동시성”을 점진적으로 개선해왔다. 처음에는 단순히 결과를 나중에 받는 기능만 있었고, 이후에는 비동기 조합, 그리고 최근에는 아예 동시성 모델 자체를 바꾸는 방향으로 진화했다.
1️⃣ Future — 결과를 나중에 받는다
Java 5에서 등장한 Future 의 목적은 단순했다. “지금 실행하지 말고, 나중에 결과를 받자.”... 직관적인 기능이다!
아래 예제를 보자.
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Hello";
});
String result = future.get(); // 여기서 대기 (blocking)
코드가 직관적이지만, 문제는 아래와 같다.
- get()은 blocking
- 콜백 없음
- 작업 조합 불가
- 예외 처리 번거로움
결국 Future는 비동기처럼 보이지만 결국 기다려야 하는 구조 또는 비동기 실행 결과를 담는 박스... 라고 정리할 수 있겠다.
2️⃣ CompletableFuture — “비동기를 조합한다”
Java 8에서 등장한 CompletableFuture 는 완전히 다른 철학을 갖는다. “비동기 작업을 연결하고 조합하자.”
또한 아래 로직처럼 비동기 로직을 체이닝으로 연결하는 것이 가장 큰 장점이다/
CompletableFuture
.supplyAsync(() -> "hello")
.thenApply(String::toUpperCase)
.thenAccept(System.out::println);
CompletableFuture<String> f1 = supplyAsync(() -> callA());
CompletableFuture<String> f2 = supplyAsync(() -> callB());
f1.thenCombine(f2, (a, b) -> a + b);
API 2개 병렬 호출하여 CPU 병렬 작업을 이룬다.
→ 서비스 응답 속도 단축
하지만 점점 코드는 아래처럼 가독성이 나빠지고 디버깅이 어려워진다. 스택 트레이스도 추적하기 힘들뿐더러 트랜잭션 관리가 까다로워진다.
f1.thenCompose(...)
.thenCombine(...)
.exceptionally(...)
.handle(...)
3️⃣ Virtual Thread — “비동기 구조를 없애자”
Java 21에서 정식 도입된 Virtual Thread은 방향이 완전히 다르다.
이전까지는 블로킹은 나쁜 것이다~ 라고 생각하며 여태것 여러가지 인터페이스와 라이브러리를 혼용해서 non-blocking 구조를 만들기 위해 스레드의 주도권 이리저리 넘겨가며 정말 애썻다.(Future, CompletableFuture, Reactor, RxJava) 하지만 Virtual Thread는
"blocking 자체가 문제가 아니라, 무거운 OS thread가 문제였다."
Thread.startVirtualThread(() -> {
callApi(); // 그냥 blocking 코드
});
이런 느낌이 끝이다. 체이닝도 없고, 콜백도 없고, 조합도 없다. 겉으로 보면 그냥 동기 코드처럼 보인다. 하지만 내부에서는 완전히 다르게 동작한다.
예를 들어 callApi()가 외부 서버에 HTTP 요청을 보낸다고 가정해보자.
String result = callApi(); // 네트워크 응답 기다림
여기서 callApi()가 I/O 대기 상태에 들어가면,
- Virtual Thread는 일시 정지(suspend) 된다.
- 그 위에 올라가 있던 OS Thread는 즉시 반납된다.
- 그 OS Thread는 다른 Virtual Thread를 실행한다.
즉, 기다리는 동안 OS Thread를 점유하지 않는다.
그래서 수천, 수만 개의 요청이 와도 OS Thread는 몇 개만 있어도 충분하다.
마무리하며
이렇게 하여 모던 자바 인 액션이라는 명저를 읽으며 인상깊었던 부분을 모조리 정리해봤다..! 굉장히 두꺼워서 처음에는 엄두가 안나던 책이었지만, 그 모든 것을 읽고 난 후의 지금 나는 자바와 절친이 된 기분이다! 특히 자바가 살아남기 위해 멀터코어 CPU를 적극적으로 활용하기 위해 선택하고 발전해온 코드 설계자들의 선택이 경이롭다.
좋은 프로그램은 역할과 책임을 적절히 분배받은 객체들이 서로 메시지 기반으로 소통하며 계획된 유스케이스들을 문제없이 수행해내는 것이 좋은 프로그램이라고 생각하는데, 프로그래밍 구현 같은 부분은 시대의 흐름에 따라 함수형 프로그래밍으로 가는 건 어쩔 수 없지 않았나 싶다.
나도 앞으로 개발자들이 많이 사용할 라이브러리나 코드 쪽의 로우레벨 개발에 참여해보고 싶다는 생각도 든다.
역시 명저를 읽으면 그 뜻을 모두 헤아리진 못하더라도, 시선이 넓어지는 것 같다. 앞으로 자바를 볼 때 조금 더 넓은 시각으로 바라볼 수 있겠다! 앞으로도 여러 분야에서 유명하단 책들은 꼭꼭 읽어봐야겠다. 자바야! 너 참 좋다~!
앞으로도 개발 서적 꾸준히 읽어봐야곘다.
'Book' 카테고리의 다른 글
| [모던 자바 인 액션] 자바 생태계로 Deep Dive - (1) (0) | 2026.02.22 |
|---|---|
| [객체지향의 사실과 오해] 객체지향을 조금 더 알게 해준 고마운 책 (1) | 2026.01.22 |