면접에서 받았던 질문들을 다시 펼쳐보면, 익숙하다고 생각했던 개념도 한 번 더 정리하게 돼요. 트래픽 처리부터 동시성, 캐시, 인덱스, 자료구조까지 한 번에 묶어서 답해볼게요.
트래픽과 성능#
어느 정도 트래픽을 다뤘었나요?#
서비스 규모에 따라 다르겠지만, 일 평균 수십만 요청부터 피크 타임 초당 수백 RPS 정도의 환경을 기준으로 이야기해보는 게 좋아요. 중요한 건 절대 수치보다 그 트래픽에서 어떤 병목을 만났고 어떻게 풀었는지예요.
면접관이 듣고 싶은 건 "DB 커넥션 풀이 부족해서 늘렸다"가 아니라, "쿼리 슬로우 로그를 확인해 인덱스를 추가했고 그래도 안 되어 캐시를 도입했다" 같은 단계적 접근이거든요.
대용량 트래픽을 감당하기 위해 흥미 있었던 아티클이나 공부 내용#
대표적으로 추천할 수 있는 자료는 이런 것들이 있어요.
- 우아한형제들 기술블로그의 대규모 트래픽 처리 사례
- Netflix Tech Blog의 회복 탄력성 패턴 (Hystrix, Circuit Breaker)
- Martin Fowler의 CQRS, Event Sourcing 글
- High Scalability 사이트의 아키텍처 케이스 스터디
핵심 키워드는 수평 확장, 비동기 처리, 캐시 계층 분리, 비관/낙관적 락의 트레이드오프 정도로 정리할 수 있어요.
동시성과 병렬 처리#
WebFlux와 ExecutorService 비교#
WebFlux는 리액티브 스트림 기반의 논블로킹 모델이에요. 적은 수의 이벤트 루프 스레드로 많은 요청을 처리하죠. 반면 ExecutorService는 스레드 풀 기반의 명시적 작업 실행기예요. 작업 단위를 직접 스레드 풀에 맡기는 방식이고요.
| 구분 | WebFlux | ExecutorService |
|---|---|---|
| 모델 | 논블로킹 / 이벤트 루프 | 블로킹 / 스레드 풀 |
| 적합한 작업 | I/O 바운드, 긴 대기 | CPU 바운드, 명시적 비동기 |
| 학습 곡선 | 가파름 | 완만함 |
| 디버깅 | 스택 트레이스 추적 어려움 | 직관적 |
WebFlux는 전 구간이 논블로킹이어야 효과가 나오고, 중간에 블로킹 코드가 끼면 오히려 성능이 떨어질 수 있어요.
Virtual Thread는 어디에 들어오나요?#
Java 21 LTS부터 정식 출시된 Virtual Thread(Project Loom) 가 이 그림을 많이 바꿨어요. 핵심은 "기존 동기 코드를 그대로 쓰면서도 논블로킹 수준의 처리량"을 낼 수 있다는 점이에요.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> userClient.findById(id));
}Virtual Thread는 OS 스레드에 1:1로 묶이지 않고, 블로킹 호출이 일어나면 캐리어 스레드에서 잠시 떨어져 나갔다가 깨어나요. 그래서 I/O 대기 동안 OS 스레드를 점유하지 않아요.
| 구분 | WebFlux | Virtual Thread |
|---|---|---|
| 코드 스타일 | 리액티브 체인(Mono/Flux) |
평범한 동기 코드 |
| 학습 곡선 | 가파름 | 거의 없음 |
| 디버깅 | 어려움 | 일반 스택 트레이스 |
| 성숙도 | 오래됨 | 21~25 LTS에서 빠르게 성숙 |
다만 Virtual Thread도 만능은 아니에요. synchronized 블록 안에서 블로킹 I/O를 호출하면 캐리어 스레드가 핀(pin) 되어 가상 스레드 이점이 사라져요. Java 24부터 이 핀 문제는 많이 완화됐지만, 라이브러리 호환성과 ThreadLocal 사용 패턴은 여전히 점검해야 해요.
요즘은 "새 프로젝트에서 WebFlux를 꼭 골라야 하는가?"에 대한 답이 예전과 달라졌어요. 단순한 I/O 다중화가 목적이라면 Virtual Thread가 더 단순한 선택이 되는 경우가 많아요.
페러럴스트림에서 스레드 설정만 바꿔도 되지 않나요?#
parallelStream()은 기본적으로 공용 ForkJoinPool(commonPool) 을 사용해요. 시스템 프로퍼티로 병렬도를 조정할 수는 있어요.
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "8");문제는 공용 풀이 JVM 전체에서 공유된다는 점이에요. 한 곳에서 무거운 작업을 돌리면 다른 곳도 영향을 받아요. 그래서 격리가 필요하면 별도 ForkJoinPool을 만들어 그 안에서 실행하는 방식이 더 안전해요.
ForkJoinPool customPool = new ForkJoinPool(4);
customPool.submit(() -> list.parallelStream().map(...).toList()).get();페러럴스트림 vs ExecutorService, 어떨 때 효율적인가요?#
용도가 달라요.
- parallelStream: 데이터 컬렉션을 CPU 바운드 연산으로 변환할 때 유리해요. 짧고 균일한 작업이 많을 때 좋아요.
- ExecutorService: 작업 단위가 명확하고, 스케줄링·결과 수집·취소를 직접 제어하고 싶을 때 좋아요. I/O 작업이나 작업별 시간 차이가 클 때 적합해요.
요약하자면, 데이터 처리 파이프라인이면 parallelStream, 독립적인 비동기 작업 실행이면 ExecutorService예요.
페러럴스트림 vs 일반 스트림, 언제 어느 쪽이 빠른가요?#
일반 스트림은 단일 스레드라서 데이터가 작거나, 연산이 가볍거나, 순서가 중요할 때 더 빨라요. 병렬화 오버헤드(스레드 분배, 결과 합치기)가 없거든요.
페러럴스트림은 다음 조건이 모두 만족될 때 이점을 가져요.
- 데이터 크기가 충분히 큼 (보통 수만 건 이상)
- 연산 비용이 큼 (CPU 바운드)
- 상태 공유나 순서 의존성이 없음
- 분할 가능한 자료구조 (ArrayList, IntStream.range 등 — LinkedList는 비효율)
작은 컬렉션에 parallelStream을 쓰면 오히려 느려져요.
논블로킹 I/O#
논블로킹 I/O가 항상 좋을까요?#
아니에요. 워크로드 특성에 따라 달라요. I/O 대기 시간이 긴 작업이 많고 동시 연결이 많을 때는 분명 유리하지만, CPU 바운드 작업이 많으면 별 차이가 없거나 오히려 손해를 봐요.
논블로킹 I/O의 성능상 단점#
- 컨텍스트 스위칭이 줄어드는 만큼 코드 복잡도가 늘어요. 콜백, 리액티브 체인, 에러 전파를 다 신경 써야 해요.
- 블로킹 코드 한 줄이 전체를 망칠 수 있어요. 이벤트 루프 스레드가 막히면 다른 요청도 같이 멈춰요.
- 디버깅과 스택 추적이 어려워요. 비동기 경계를 넘으면 호출 스택이 끊겨요.
- CPU 바운드 작업에는 이점이 없어요. 스레드를 늘려도 코어 수보다 빠를 수는 없어요.
캐시#
캐시 사용 경험#
대표적으로는 Redis, Caffeine, Ehcache 같은 도구를 써요. 사용 사례는 보통 이런 식이에요.
- 읽기 많은 조회 API의 응답 캐싱
- 외부 API 호출 결과 캐싱
- 세션·인증 토큰 저장소
- 분산 락 구현
캐시는 언제 갱신되나요?#
전략에 따라 달라요.
- TTL 만료: 일정 시간이 지나면 무효화돼요. 가장 단순해요.
- Write-Through: DB 쓰기와 동시에 캐시도 갱신해요.
- Write-Behind: 캐시에 먼저 쓰고 비동기로 DB에 반영해요.
- Cache-Aside (Lazy Loading): 조회 시 캐시 미스면 DB에서 읽어 채워요. 갱신 시에는 캐시를 무효화하거나 갱신해요.
- 이벤트 기반 무효화: DB 변경 이벤트(Kafka, CDC)로 캐시를 갱신해요.
캐시와 DB의 데이터 정합성이 깨질 가능성#
깨질 수 있어요. 대표적인 시나리오는 이런 거예요.
- DB 갱신 후 캐시 무효화 사이의 짧은 시간에 다른 요청이 옛 데이터를 읽고 캐시에 다시 채울 때
- 다중 인스턴스 환경에서 한쪽이 캐시를 갱신하기 전에 다른 쪽이 읽을 때
- 트랜잭션 롤백이 일어났는데 캐시는 이미 갱신된 경우
- TTL 동안 DB가 바뀐 경우 옛 데이터가 그대로 노출돼요
완화책으로는 DB 트랜잭션 커밋 후에 캐시를 무효화하기, 분산 락으로 동시 갱신 막기, 버전 키로 옛 캐시 무시하기 같은 방법이 있어요.
JVM과 모니터링#
OOM이 발생하지 않게 하려면#
사전 예방 측면에서는 이런 걸 해요.
- 힙 크기와 GC 알고리즘을 워크로드에 맞게 설정 (
-Xmx,-Xms,-XX:+UseG1GC등) - 메모리 누수 방지: 정적 컬렉션에 무한정 쌓지 않기, ThreadLocal 정리하기, 캐시에 크기 제한 두기
- 대용량 데이터 스트리밍 처리: 파일·DB 결과를 한 번에 메모리에 올리지 않기
- 커넥션·리소스 누수 방지: try-with-resources 사용
GC 알고리즘 선택도 워크로드에 따라 달라져요.
- G1GC: 대부분의 일반 서버 워크로드 기본값. 균형 잡힌 처리량/지연.
- ZGC: Java 15부터 production-ready. 수십 GB~TB급 힙에서도 sub-millisecond pause를 목표로 해요. Java 21에선 Generational ZGC(
-XX:+UseZGC -XX:+ZGenerational)가 도입돼서 처리량까지 따라잡았어요. - Shenandoah: ZGC와 비슷한 저지연 목표를 가진 OpenJDK GC.
- Parallel GC: 처리량이 중요하고 지연은 덜 중요한 배치 작업.
응답 지연이 중요한 트래픽 많은 서비스라면 G1 → Generational ZGC로 옮기는 사례가 늘고 있어요.
발생했을 때는 이렇게 대응해요.
-XX:+HeapDumpOnOutOfMemoryError옵션으로 힙 덤프 자동 저장- MAT(Memory Analyzer Tool) 로 누수 객체 분석
- GC 로그로 패턴 확인 (지속 증가 → 누수, 일시적 폭증 → 요청 패턴)
- 임시로는 힙 늘리기, 근본적으로는 누수 지점 수정
서버 모니터링은 어떻게 했나요?#
요즘은 OpenTelemetry(OTel) 가 사실상 표준이에요. 메트릭·로그·트레이스 세 가지 시그널을 하나의 SDK/프로토콜(OTLP)로 보내고, 백엔드는 골라 쓰는 구조예요.
계층별로 정리하면 이래요.
- 수집(SDK·에이전트): OpenTelemetry Java Agent / Micrometer Tracing
- 메트릭 백엔드: Prometheus + Grafana, CloudWatch
- 로그 백엔드: Loki, Elasticsearch, OpenSearch
- 트레이스 백엔드: Grafana Tempo, Jaeger
- 상용 APM: DataDog, New Relic, Grafana Cloud (모두 OTLP 수신 지원)
- 국내 도구: Pinpoint, Scouter도 여전히 쓰이지만 신규 시스템은 OTel 기반으로 가는 추세예요
- 알림: Grafana Alert, PagerDuty, Slack 연동
핵심은 트래픽·에러율·응답시간·자원 사용률(USE/RED 메트릭) 을 한 화면에서 볼 수 있게 만들고, traceId로 로그·메트릭·트레이스를 한 번에 점프할 수 있게 묶어 두는 거예요.
synchronized와 원자 연산#
synchronized 설명#
synchronized는 자바의 모니터 락 기반 동기화 키워드예요. 메서드나 블록에 붙이면 같은 모니터 객체를 잡은 스레드만 들어올 수 있어요.
public synchronized void increment() {
count++;
}내부적으로는 객체 헤더의 Mark Word에 락 정보가 기록되고, JVM은 상황에 따라 편향 락 → 경량 락 → 중량 락 으로 단계적으로 승격시켜요.
synchronized 사용 시 문제점#
- 성능 오버헤드: 경합이 심하면 OS 수준 락(중량 락)으로 가서 컨텍스트 스위칭이 잦아져요.
- 데드락 위험: 락 획득 순서가 엉키면 멈춰요.
- 재진입은 가능하지만 인터럽트 불가:
Lock인터페이스는lockInterruptibly()를 제공하지만 synchronized는 못 풀어요. - 공정성(fairness) 제어 불가: 어떤 스레드가 먼저 락을 받을지 보장이 없어요.
- 타임아웃 불가: 무한 대기할 수 있어요.
대안으로 ReentrantLock, ReadWriteLock, StampedLock을 쓰면 더 세밀하게 제어할 수 있어요.
AtomicInteger를 아는지#
AtomicInteger는 CAS(Compare-And-Swap) 연산을 활용한 락 프리 정수 클래스예요. 내부적으로 Unsafe.compareAndSwapInt(또는 최신 버전에서는 VarHandle)을 호출해요.
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();락을 잡지 않고 CPU의 원자적 명령어 하나로 처리하기 때문에 가벼운 카운터에 적합해요.
AtomicInteger를 직접 구현한다면#
기본 아이디어는 CAS 루프예요.
public class MyAtomicInteger {
private volatile int value;
private static final VarHandle VALUE;
static {
try {
VALUE = MethodHandles.lookup()
.findVarHandle(MyAtomicInteger.class, "value", int.class);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
public int incrementAndGet() {
int prev, next;
do {
prev = value;
next = prev + 1;
} while (!VALUE.compareAndSet(this, prev, next));
return next;
}
}핵심은 volatile로 가시성 보장 + CAS로 원자성 보장 + 실패 시 재시도예요.
AtomicInteger를 언제 쓰고 언제 안 쓰나요?#
쓰기 좋은 경우는 이래요.
- 단일 변수에 대한 카운팅, 플래그
- 경합이 가벼운 환경
피해야 하는 경우도 있어요.
- 경합이 매우 심하면 CAS 실패 재시도가 폭증해 오히려 느려져요. 이럴 땐
LongAdder를 써요. 내부적으로 셀을 분리해 경합을 줄여요. JDK 내부에서는 셀에@jdk.internal.vm.annotation.Contended를 붙여 false sharing(같은 캐시 라인 공유로 인한 성능 저하)을 막아요. - 여러 변수의 일관성이 필요할 땐 락이나 트랜잭션이 더 적합해요.
자료구조: HashMap, HashSet#
HashSet, HashMap을 언제 쓰나요?#
평균 O(1) 조회·삽입이 필요할 때 써요. HashMap은 키-값 매핑, HashSet은 중복 없는 집합이고요. 내부적으로 HashSet은 HashMap을 감싼 구현이에요.
적합하지 않은 경우는 이런 상황이에요.
- 순서 유지 필요:
LinkedHashMap,LinkedHashSet - 정렬 필요:
TreeMap,TreeSet(O(log n)) - 동시성 필요:
ConcurrentHashMap - null 키 불가:
Hashtable,ConcurrentHashMap은 null 불허
HashSet 성능이 원하는 대로 동작하지 않는 경우#
- hashCode()가 균등하게 분포되지 않을 때 — 해시 충돌이 많아져 O(n)에 가까워져요.
- equals()와 hashCode()가 일관되지 않을 때 — 들어간 객체를 다시 못 찾아요.
- 가변 객체를 키로 썼는데 필드가 바뀌었을 때 — 해시값이 달라져서 검색 실패해요.
- 초기 용량이 부족할 때 — 리사이징(rehash)이 자주 일어나요.
- 로드 팩터가 너무 높을 때 — 충돌이 늘어요.
자바 8 이후에는 한 버킷의 충돌이 일정 수를 넘으면 트리(Red-Black Tree) 로 변환되어 O(log n)을 보장하지만, 근본적으로는 좋은 hashCode를 짜는 게 우선이에요.
ThreadLocal#
Java에서 ThreadLocal 사용해본 적 있나요?#
네, 사용해요. 대표적인 사례는 이래요.
- 요청별 컨텍스트 저장: 인증 정보, 트랜잭션 ID, MDC(로그 컨텍스트)
- SimpleDateFormat 같은 비스레드세이프 객체 재사용
- Spring의
RequestContextHolder,TransactionSynchronizationManager도 내부적으로 ThreadLocal을 써요
주의할 점은 반드시 remove()로 정리해야 한다는 거예요. 스레드 풀 환경에서는 스레드가 재사용되기 때문에, ThreadLocal 값이 다음 요청까지 살아남아 보안 사고나 메모리 누수로 이어질 수 있어요.
MySQL 인덱스#
MySQL 인덱스는 무엇이고 언제 사용하나요?#
인덱스는 테이블 데이터에 접근 속도를 빠르게 하기 위한 별도의 자료구조예요. 책의 목차처럼 원하는 행을 빠르게 찾도록 도와줘요.
쓰면 좋은 경우는 이런 거예요.
- WHERE 절에 자주 등장하는 컬럼
- JOIN의 키 컬럼
- ORDER BY, GROUP BY 컬럼
- 카디널리티가 높은 컬럼 (값의 종류가 많은 컬럼)
MySQL 8.0 이후로는 인덱스 자체도 옵션이 늘었어요.
- Descending Index: 내림차순 정렬을 인덱스 단계에서 처리
- Invisible Index: 옵티마이저에는 안 보이게 만들어 놓고 안전하게 영향도 테스트
- Functional Index:
JSON_EXTRACT(...)나LOWER(col)같은 표현식에 인덱스 가능 - Multi-Valued Index (JSON 배열): JSON 배열 안의 값을 인덱싱
인덱스가 많으면 많을수록 좋을까요?#
아니에요. 인덱스는 쓰기 비용을 늘려요. INSERT, UPDATE, DELETE 시 모든 인덱스를 같이 갱신해야 해요. 또 디스크 공간도 차지하고, 옵티마이저가 잘못된 인덱스를 선택할 수도 있어요.
원칙은 꼭 필요한 쿼리 패턴에만 최소한으로 만드는 거예요.
인덱스의 자료구조, B+ 트리#
MySQL InnoDB는 B+ 트리를 사용해요. B+ 트리의 특징은 이래요.
- 모든 실제 데이터(또는 PK)는 리프 노드에만 존재
- 리프 노드는 양방향 연결 리스트로 이어져 있어 범위 검색이 빠름
- 균형 트리라 모든 리프까지의 깊이가 같아요
- 디스크 I/O 단위(페이지)에 최적화되어 있어 한 번의 I/O로 많은 키를 읽어요
InnoDB의 PK 인덱스는 클러스터드 인덱스라서 리프에 실제 행이 저장돼요. 보조 인덱스는 리프에 PK 값을 저장하고, 그 PK로 다시 클러스터드 인덱스를 탐색해요(이게 back to base, 또는 lookup).
charAt 타입 이슈#
charAt이 반환하는 타입과 받는 타입이 다를 때 오류가 발생할 수 있는 경우#
String.charAt(int)은 char를 반환해요. 그런데 char는 자바에서 부호 없는 16비트 정수처럼 취급되기 때문에, int로 자동 승격되면서 의도치 않은 결과가 나올 수 있어요.
String s = "1";
int x = s.charAt(0); // 49 (문자 '1'의 ASCII)
int y = s.charAt(0) - '0'; // 1s.charAt(0)을 그대로 더하면 숫자 1이 아니라 49가 들어가요. 또 비교할 때도 함정이 있어요.
char c = '1';
if (c == 1) { ... } // false (49 != 1)
if (c == '1') { ... } // true또 한 가지 큰 함정은 유니코드 보충 문자(서로게이트 페어) 예요. 이모지나 일부 한자는 char 두 개로 표현되기 때문에 charAt(i) 한 번으로는 온전한 문자를 못 가져와요.
String s = "𝕏"; // U+1D54F
s.length(); // 2
s.charAt(0); // 서로게이트 절반만 반환
s.codePointAt(0); // 119887, 온전한 코드 포인트문자 단위로 다뤄야 한다면 codePointAt, String.codePoints(), Character.toChars() 같은 API를 써야 해요.
마무리#
면접 질문은 단순히 답을 외우는 게 아니라, 실제로 그 상황에서 어떤 트레이드오프가 있는지 설명할 수 있어야 해요. 같은 질문이라도 "왜 그렇게 동작하는지"와 "언제 다른 선택지를 고를지"까지 답할 수 있으면 깊이가 달라져요.
오늘 정리한 25개 질문 중에 흐릿했던 게 있다면, 코드로 직접 작은 예제를 돌려보면서 다시 확인해보세요.
Difficulties increase the nearer we get to the goal.
— Johann Wolfgang von Goethe