Java, 백엔드에서 알아야 할 것들 정리
Java는 백엔드에서 여전히 가장 흔하게 쓰이는 언어다. 문법만으로는 절반도 이해가 안 되고, JVM에서 뭐가 일어나는지까지 봐야 실무 판단이 붙는다.
이 글은 그 관점에서 항목별로 짧게 정리한다.
- 언어 : OOP · Collection · Stream/Lambda · Exception
- 런타임 : JVM 메모리 구조 · GC
- 동시성 : Thread/ExecutorService · 동시성 문제 · Spring에서의 요청 처리
1. Java OOP
Java는 모든 것이 클래스 안에서 시작한다.
public class Order {
private final Long id;
private OrderStatus status;
public Order(Long id) {
this.id = id;
this.status = OrderStatus.CREATED;
}
public void pay() {
if (status != OrderStatus.CREATED) throw new IllegalStateException();
status = OrderStatus.PAID;
}
}객체지향의 4대 요소를 문법으로 그대로 지원한다.
캡슐화 : private 필드 + public 메서드
상속 : class Child extends Parent
다형성 : Parent p = new Child(); p.method();
추상화 : abstract class, interface
Java 8 이후로는 인터페이스에도 default 메서드가 들어와서
"인터페이스는 껍데기, 클래스는 구현" 이라는 구분이 조금 흐려졌다.
public interface Payable {
void pay();
default boolean isFree() { return false; } // 8+ default 메서드
}Java OOP에서 실무적으로 자주 걸리는 지점.
- equals / hashCode를 잘못 구현해서 Set/Map이 이상하게 동작
- 상속을 남발해서 부모 클래스가 커지고, 자식이 부모에 강결합
- 필드를 public으로 열어서 캡슐화가 무너짐
2. Collection
Java Collection 프레임워크의 큰 그림.
Iterable
└─ Collection
├─ List (순서 O, 중복 O) → ArrayList, LinkedList
├─ Set (순서 X, 중복 X) → HashSet, LinkedHashSet, TreeSet
└─ Queue (선입선출) → ArrayDeque, PriorityQueue
Map (키-값) → HashMap, LinkedHashMap, TreeMap
자주 쓰는 것 위주로 감각.
ArrayList : 인덱스 접근 O(1), 뒤에 추가 amortized O(1)
LinkedList : 앞뒤 추가/삭제 O(1), 랜덤 접근 O(n)
HashMap : 평균 O(1) get/put, 순서 보장 X
LinkedHashMap : HashMap + 삽입 순서 유지
TreeMap : 키 정렬, get/put O(log n)
실무에서 자주 쓰는 패턴.
List<User> users = new ArrayList<>();
Map<Long, User> byId = new HashMap<>();
Set<String> seen = new HashSet<>();주의할 점
HashMap은 스레드 안전하지 않다 → 동시 접근하면 무한 루프까지 갈 수 있음- 스레드 안전이 필요하면
ConcurrentHashMap - Java 9+의
List.of(...),Map.of(...)는 불변 컬렉션
3. Stream / Lambda
Java 8부터 들어온 함수형 스타일.
List<String> names = users.stream()
.filter(u -> u.getAge() >= 20)
.map(User::getName)
.toList();핵심 개념 세 가지.
1. Stream : 데이터 파이프라인 (한 번 쓰면 재사용 X)
2. Lambda : (x) -> f(x) 형태의 익명 함수
3. Method ref: User::getName == u -> u.getName()
주의할 점.
- Stream은 "한 번" 소비되면 끝. 재사용하려면 다시 stream() 열기
- 무거운 연산에서 parallelStream()은 만병통치약이 아님
→ 스레드 풀 공유, 순서 뒤바뀜, 오버헤드 이슈 있음
- Optional은 반환 타입으로만 쓰고, 필드나 파라미터로 남발하지 않기
컬렉션 → 컬렉션 변환에서 특히 강력하다.
Map<Long, List<Order>> byUser = orders.stream()
.collect(Collectors.groupingBy(Order::getUserId));4. Exception
Java 예외는 크게 두 갈래.
Throwable
├─ Error (JVM 레벨 문제, OOM 등 — 잡지 말 것)
└─ Exception
├─ Checked Exception (IOException, SQLException 등)
│ → 반드시 try-catch 또는 throws 선언
└─ RuntimeException (NullPointerException, IllegalArgumentException 등)
→ 컴파일러가 강제하지 않음
Checked / Unchecked 구분은 Java의 특이한 지점이다.
// Checked: throws로 선언하거나 잡아야 함
public String read(File f) throws IOException {
return Files.readString(f.toPath());
}
// Unchecked: 컴파일러가 강제하지 않음
public void divide(int a, int b) {
if (b == 0) throw new IllegalArgumentException();
// ...
}실무에서의 판단.
- 라이브러리/프레임워크 경계 → 도메인 Exception으로 감싸서 던짐
- Controller / GlobalExceptionHandler에서 최종 처리
- Checked Exception을 남발하면 시그니처가 오염됨
→ 요즘은 RuntimeException 상속을 선호하는 편
5. JVM 메모리 구조
JVM은 크게 이런 영역들로 나뉜다.
┌─────────────────────────────────────────┐
│ Method Area (Metaspace) │ ← 클래스 정보, static 변수
├─────────────────────────────────────────┤
│ Heap │ ← new 로 만든 객체
│ ├─ Young Generation │
│ │ ├─ Eden │
│ │ └─ Survivor (S0, S1) │
│ └─ Old Generation │
├─────────────────────────────────────────┤
│ Stack (스레드마다 1개) │ ← 지역 변수, 메서드 프레임
├─────────────────────────────────────────┤
│ PC Register (스레드마다 1개) │
│ Native Method Stack (스레드마다 1개) │
└─────────────────────────────────────────┘
핵심 감각.
- 객체는 Heap에 산다
- 지역 변수는 Stack에 산다 (참조는 stack, 실제 객체는 heap)
- 클래스 자체의 메타정보는 Metaspace에 산다 (Java 8부터 PermGen → Metaspace)
- Heap이 부족하면 OutOfMemoryError: Java heap space
- Metaspace가 부족하면 OutOfMemoryError: Metaspace
- Stack이 깊어지면 StackOverflowError
6. GC 기본
GC는 더 이상 참조되지 않는 객체를 알아서 회수한다.
기본 아이디어.
객체는 대부분 "만들자마자 죽는다" (Weak Generational Hypothesis)
→ 새로 만든 애들만 자주 청소하면 됨
→ Young Generation을 자주, Old를 가끔
전형적인 흐름.
1. 객체 생성 → Eden
2. Eden 꽉 참 → Minor GC → 살아있는 애만 Survivor로
3. Survivor에서 몇 번 살아남으면 → Old로 승격
4. Old 꽉 참 → Major GC (또는 Full GC) — 비쌈
대표적인 Collector.
- Serial GC : 싱글 스레드 (테스트/저사양)
- Parallel GC : 멀티 스레드 (처리량 위주)
- G1 GC : Java 9+ 기본, 지연 시간과 처리량 균형
- ZGC / Shenandoah : 매우 큰 힙, 짧은 stop-the-world 필요할 때
실무에서 알아둘 것.
- "GC 튜닝"의 첫 걸음은 힙 크기(-Xms, -Xmx)와 GC 로그 확인
- GC 로그는 -Xlog:gc* (Java 9+)
- 응답 지연이 튄다 → Full GC pause 의심
- 메모리 누수는 GC로 해결되지 않는다 (참조가 살아있는 게 문제)
7. Thread / ExecutorService
Java 스레드의 두 가지 사용 방식.
저수준: Thread 직접 다루기
Thread t = new Thread(() -> {
System.out.println("hello");
});
t.start();
t.join();실무: ExecutorService 사용
ExecutorService pool = Executors.newFixedThreadPool(10);
Future<String> f = pool.submit(() -> "result");
System.out.println(f.get());
pool.shutdown();왜 ExecutorService를 쓰나.
- 스레드 생성/소멸 비용을 줄이려고 풀링
- 태스크 큐로 부하 조절
- Future/CompletableFuture로 비동기 결과 다루기
주요 풀의 특성.
newFixedThreadPool(n) : 스레드 n개 고정
newCachedThreadPool() : 필요 시 무제한 생성 (위험)
newSingleThreadExecutor() : 순차 실행 보장
Executors.newVirtualThreadPerTaskExecutor() ← Java 21+ 가상 스레드
8. 동시성 문제
여러 스레드가 같은 데이터를 만지면 생기는 문제들.
Race Condition (경쟁 조건)
int count = 0;
// 여러 스레드가 count++ 를 동시에 하면
// 최종 값이 실제 호출 수보다 작을 수 있음count++는 실제로는 세 단계다.
1. count 값을 읽는다
2. +1 을 한다
3. 다시 count에 쓴다
두 스레드가 동시에 1을 읽으면, 둘 다 2를 쓰고 결과는 2가 된다 (원래는 3이어야 함).
해결 도구들
// 1. synchronized
private synchronized void increase() { count++; }
// 2. Atomic 변수
private AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
// 3. Lock
private ReentrantLock lock = new ReentrantLock();
lock.lock();
try { count++; } finally { lock.unlock(); }
// 4. ConcurrentHashMap 같은 스레드 안전 컬렉션그 외 알아둘 것.
- Deadlock : A가 B의 락을, B가 A의 락을 서로 기다림
- Livelock : 서로 양보만 하다가 진전이 없음
- Starvation: 특정 스레드가 계속 자원을 못 받음
- Visibility : 한 스레드의 변경이 다른 스레드에 안 보임 → volatile 또는 락 필요
9. Spring에서 Thread가 요청을 처리하는 방식
Spring MVC(Tomcat 기반)의 기본 동작.
┌─────────────┐ HTTP 요청 ┌──────────────────┐
│ Client │ ────────────▶ │ Tomcat Acceptor │
└─────────────┘ └────────┬─────────┘
│
요청 하나 = 스레드 하나 할당
│
┌────────▼─────────┐
│ Worker Thread Pool│
│ (기본 200개 등) │
└────────┬─────────┘
│
DispatcherServlet
│
Controller → Service → Repository
│
응답
핵심.
- 요청 하나가 스레드 하나를 "점유"한다 (Thread-per-request)
- Controller → Service → Repository가 모두 같은 스레드
- Service의 @Transactional은 이 스레드에 묶여 있음
- ThreadLocal (SecurityContext, TransactionSynchronizationManager 등)이 여기서 살아남
문제.
- DB나 외부 API를 기다리는 동안에도 스레드가 "붙잡혀 있음"
- 스레드가 부족해지면 요청이 큐에 쌓이고 지연이 튐
이걸 해결하려는 방향이 두 가지 있다.
1. Spring WebFlux (Reactive) : 이벤트 루프 기반
2. Virtual Thread (Java 21+) : 스레드는 그대로 늘리는데 값이 싸짐
정리
- Java는 언어 문법 + JVM 런타임 + 동시성 모델을 세트로 봐야 실무 판단이 붙는다
- 언어 : OOP, Collection, Stream/Lambda, Exception
- 런타임 : Heap/Stack/Metaspace 구조와 GC의 세대 개념
- 동시성 : Thread/ExecutorService로 병렬 처리 + synchronized/Atomic/Lock으로 공유 상태 보호
- Spring MVC는 Thread-per-request 모델 위에 서 있고, 그 위에서
@Transactional과ThreadLocal이 자연스럽게 얹혀 있다