CS 기초 노트

Java, 백엔드에서 알아야 할 것들 정리

2026. 03. 18.
CSLanguageJavaJVM

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 모델 위에 서 있고, 그 위에서 @TransactionalThreadLocal이 자연스럽게 얹혀 있다