백엔드 면접 질문 25개로 돌아보는 자바 서버 기초

 ・ 13 min

photo by Luca Bravo(https://unsplash.com/@lucabravo?utm_source=templater_proxy&utm_medium=referral) on Unsplash

면접에서 받았던 질문들을 다시 펼쳐보면, 익숙하다고 생각했던 개념도 한 번 더 정리하게 돼요. 트래픽 처리부터 동시성, 캐시, 인덱스, 자료구조까지 한 번에 묶어서 답해볼게요.

트래픽과 성능#

어느 정도 트래픽을 다뤘었나요?#

서비스 규모에 따라 다르겠지만, 일 평균 수십만 요청부터 피크 타임 초당 수백 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 일반 스트림, 언제 어느 쪽이 빠른가요?#

일반 스트림은 단일 스레드라서 데이터가 작거나, 연산이 가볍거나, 순서가 중요할 때 더 빨라요. 병렬화 오버헤드(스레드 분배, 결과 합치기)가 없거든요.

페러럴스트림은 다음 조건이 모두 만족될 때 이점을 가져요.

  1. 데이터 크기가 충분히 큼 (보통 수만 건 이상)
  2. 연산 비용이 큼 (CPU 바운드)
  3. 상태 공유나 순서 의존성이 없음
  4. 분할 가능한 자료구조 (ArrayList, IntStream.range 등 — LinkedList는 비효율)

작은 컬렉션에 parallelStream을 쓰면 오히려 느려져요.

논블로킹 I/O#

논블로킹 I/O가 항상 좋을까요?#

아니에요. 워크로드 특성에 따라 달라요. I/O 대기 시간이 긴 작업이 많고 동시 연결이 많을 때는 분명 유리하지만, CPU 바운드 작업이 많으면 별 차이가 없거나 오히려 손해를 봐요.

논블로킹 I/O의 성능상 단점#

  1. 컨텍스트 스위칭이 줄어드는 만큼 코드 복잡도가 늘어요. 콜백, 리액티브 체인, 에러 전파를 다 신경 써야 해요.
  2. 블로킹 코드 한 줄이 전체를 망칠 수 있어요. 이벤트 루프 스레드가 막히면 다른 요청도 같이 멈춰요.
  3. 디버깅과 스택 추적이 어려워요. 비동기 경계를 넘으면 호출 스택이 끊겨요.
  4. CPU 바운드 작업에는 이점이 없어요. 스레드를 늘려도 코어 수보다 빠를 수는 없어요.

캐시#

캐시 사용 경험#

대표적으로는 Redis, Caffeine, Ehcache 같은 도구를 써요. 사용 사례는 보통 이런 식이에요.

  • 읽기 많은 조회 API의 응답 캐싱
  • 외부 API 호출 결과 캐싱
  • 세션·인증 토큰 저장소
  • 분산 락 구현

캐시는 언제 갱신되나요?#

전략에 따라 달라요.

  • TTL 만료: 일정 시간이 지나면 무효화돼요. 가장 단순해요.
  • Write-Through: DB 쓰기와 동시에 캐시도 갱신해요.
  • Write-Behind: 캐시에 먼저 쓰고 비동기로 DB에 반영해요.
  • Cache-Aside (Lazy Loading): 조회 시 캐시 미스면 DB에서 읽어 채워요. 갱신 시에는 캐시를 무효화하거나 갱신해요.
  • 이벤트 기반 무효화: DB 변경 이벤트(Kafka, CDC)로 캐시를 갱신해요.

캐시와 DB의 데이터 정합성이 깨질 가능성#

깨질 수 있어요. 대표적인 시나리오는 이런 거예요.

  1. DB 갱신 후 캐시 무효화 사이의 짧은 시간에 다른 요청이 옛 데이터를 읽고 캐시에 다시 채울 때
  2. 다중 인스턴스 환경에서 한쪽이 캐시를 갱신하기 전에 다른 쪽이 읽을 때
  3. 트랜잭션 롤백이 일어났는데 캐시는 이미 갱신된 경우
  4. 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로 옮기는 사례가 늘고 있어요.

발생했을 때는 이렇게 대응해요.

  1. -XX:+HeapDumpOnOutOfMemoryError 옵션으로 힙 덤프 자동 저장
  2. MAT(Memory Analyzer Tool) 로 누수 객체 분석
  3. GC 로그로 패턴 확인 (지속 증가 → 누수, 일시적 폭증 → 요청 패턴)
  4. 임시로는 힙 늘리기, 근본적으로는 누수 지점 수정

서버 모니터링은 어떻게 했나요?#

요즘은 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 사용 시 문제점#

  1. 성능 오버헤드: 경합이 심하면 OS 수준 락(중량 락)으로 가서 컨텍스트 스위칭이 잦아져요.
  2. 데드락 위험: 락 획득 순서가 엉키면 멈춰요.
  3. 재진입은 가능하지만 인터럽트 불가: Lock 인터페이스는 lockInterruptibly()를 제공하지만 synchronized는 못 풀어요.
  4. 공정성(fairness) 제어 불가: 어떤 스레드가 먼저 락을 받을지 보장이 없어요.
  5. 타임아웃 불가: 무한 대기할 수 있어요.

대안으로 ReentrantLock, ReadWriteLock, StampedLock을 쓰면 더 세밀하게 제어할 수 있어요.

AtomicInteger를 아는지#

AtomicIntegerCAS(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 성능이 원하는 대로 동작하지 않는 경우#

  1. hashCode()가 균등하게 분포되지 않을 때 — 해시 충돌이 많아져 O(n)에 가까워져요.
  2. equals()와 hashCode()가 일관되지 않을 때 — 들어간 객체를 다시 못 찾아요.
  3. 가변 객체를 키로 썼는데 필드가 바뀌었을 때 — 해시값이 달라져서 검색 실패해요.
  4. 초기 용량이 부족할 때 — 리사이징(rehash)이 자주 일어나요.
  5. 로드 팩터가 너무 높을 때 — 충돌이 늘어요.

자바 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'; // 1

s.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


다른 글
정부지원사업과 투자, 창업자가 꼭 알아야 할 것들 커버 이미지
 ・ 3 min

정부지원사업과 투자, 창업자가 꼭 알아야 할 것들

백엔드 면접 준비를 위한 핵심 개념 학습 정리 커버 이미지
 ・ 9 min

백엔드 면접 준비를 위한 핵심 개념 학습 정리

백엔드 기술 면접에서 자주 나오는 질문 정리 커버 이미지
 ・ 24 min

백엔드 기술 면접에서 자주 나오는 질문 정리