Java 면접 질문
Java 인터뷰는 핵심 언어 기능, 객체 지향 설계, 동시성 이해 및 문제 해결 능력의 깊이를 테스트합니다. 시니어 엔지니어는 문법뿐 아니라 성능, 메모리 및 모범 사례에 대한 추론이 기대됩니다. 이 가이드는 가장 일반적이고 도전적인 Java 인터뷰 질문을 다룹니다.
Java 면접에서 다루는 내용
코어 Java 및 OOP
상속, 다형성, 캡슐화, 추상화, 인터페이스 대 추상 클래스, 객체 생명 주기.
컬렉션 프레임워크
HashMap 내부, ConcurrentHashMap, ArrayList 대 LinkedList, comparator 대 comparable, fail-fast 대 fail-safe 반복자.
동시성 및 멀티스레딩
스레드 생명 주기, synchronized, volatile, 락(ReentrantLock, ReadWriteLock), 실행자, fork/join, 동시 컬렉션.
JVM 내부 및 성능
클래스 로딩, 메모리 모델(힙, 스택, 메타스페이스), 가비지 컬렉션 알고리즘(G1, CMS, ZGC), 프로파일링 및 튜닝.
샘플 Java 면접 질문
- Java에서 추상 클래스와 인터페이스의 차이점을 설명하세요. 각각 언제 사용하나요?좋은 답변이 다루는 것
- 추상 클래스는 인스턴스 변수, 생성자, 일반 메서드를 가질 수 있지만 인터페이스는 (Java 8 이전) 추상 메서드만 가능
- Java 8 이후 인터페이스는 default/static 메서드를 가질 수 있으나 상태는 가질 수 없음
- 추상 클래스는 단일 상속만 가능하지만 인터페이스는 다중 구현 가능
- 추상 클래스는 'is-a' 관계, 인터페이스는 'can-do' 능력에 주로 사용
- 인터페이스는 계약(contract)을 정의하고 추상 클래스는 부분 구현을 제공
샘플 답변 보기
추상 클래스와 인터페이스는 모두 객체 지향 추상화를 지원하지만, 목적과 사용법에 차이가 있습니다. 추상 클래스는 상태(필드)와 생성자를 가질 수 있으며, 일부 메서드를 구현할 수 있어 관련 클래스들의 공통 기반을 제공합니다. 반면 인터페이스는 (Java 8 이후) default/static 메서드를 가질 수 있으나 필드는 가질 수 없고 상수만 선언 가능합니다. 상속 측면에서 클래스는 단일 상속이므로 추상 클래스는 하나만 상속 가능하지만, 인터페이스는 여러 개를 구현할 수 있습니다. 따라서 추상 클래스는 공통 상태와 동작을 공유하는 계층 구조(예: Animal -> Dog)에 적합하고, 인터페이스는 서로 다른 클래스들이 특정 기능을 보장하도록 할 때(예: Comparable, Runnable) 사용합니다. 과거에는 인터페이스가 완전히 추상적이었으나 Java 8부터는 구현을 포함할 수 있어 경계가 모호해졌지만, 상태 유무가 핵심 차이입니다.
- HashMap은 내부적으로 어떻게 작동하나요? 크기 조정이 성능에 어떤 영향을 미치나요?좋은 답변이 다루는 것
- HashMap은 해시 함수로 키의 해시코드를 생성한 후 index 계산(보조 해시 사용)
- 내부적으로 Node<K,V> 배열(bucket)을 사용하며, 충돌 시 LinkedList 또는 TreeNode로 저장
- load factor(기본 0.75)를 초과하면 resize 발생: capacity 2배 증가, 모든 요소 rehash
- resize는 O(n) 연산이지만, 분할 상환으로 평균 O(1) 유지
- resize 시점에 일시적 성능 저하 및 메모리 사용량 증가 발생
샘플 답변 보기
HashMap은 내부적으로 Node<K,V> 배열을 사용하며, put() 호출 시 키의 hashCode()를 받아 보조 해시(hash())로 재분산한 후 (capacity-1) & hash로 버킷 인덱스를 결정합니다. 충돌이 발생하면 해당 버킷에 LinkedList로 연결하고, 일정 개수(8) 이상이거나 트리화 임계치 초과 시 TreeNode(Red-Black Tree)로 변환하여 검색 성능을 개선합니다. size가 threshold(capacity * loadFactor)를 넘으면 resize가 발생하는데, 새로운 capacity는 기존의 2배이며 모든 요소를 새 배열에 다시 배치(rehash)합니다. 이 resize 작업은 O(n)으로 시간이 소요되어 순간적인 지연이 생길 수 있으나, 분할 상환 분석으로 보면 평균적으로 삽입이 O(1)임을 보장합니다. 또한 resize 동안 추가 메모리를 할당하므로 메모리 사용량이 급증할 수 있으며, 특히 큰 HashMap에서 트리거될 때 주의해야 합니다. 올바른 초기 용량과 load factor를 설정하면 불필요한 resize를 줄일 수 있습니다.
- Java에서 스레드 안전한 싱글톤을 작성하세요(이중 확인 락, Bill Pugh 방법).좋은 답변이 다루는 것
- 이중 확인 락(DCL): volatile 키워드로 instance 변수 선언, getInstance()에서 null 체크 후 synchronized 블록
- volatile은 Java 5 이후 JMM에서 재정렬 방지하여 안전하게 동작
- Bill Pugh 방법: inner static holder 클래스 사용, JVM의 클래스 로딩 시점 활용
- DCL은 복잡하지만 지연 초기화 가능, Bill Pugh는 간결하고 성능 좋음
- 두 방법 모두 스레드 안전하지만 DCL은 실수할 가능성 높음
샘플 답변 보기
스레드 안전한 싱글톤을 구현하는 방법 중 대표적인 두 가지는 이중 확인 락(Double-Checked Locking)과 Bill Pugh의 정적 내부 클래스 방식입니다. DCL은 instance 변수를 volatile로 선언하고 getInstance()에서 첫 번째 null 체크 후 synchronized 블록 안에서 다시 null 체크 후 인스턴스를 생성합니다. volatile 키워드는 Java 5 이후 JMM에서 생성자 완료 전 참조가 노출되는 문제를 해결합니다. 단점은 코드가 다소 복잡하고 실수하기 쉽다는 점입니다. Bill Pugh 방식은 private static inner class에 인스턴스를 선언하고, getInstance()에서 그 클래스를 참조하여 JVM의 클래스 로딩 시점에 인스턴스가 생성되도록 합니다. 이 방법은 동기화 없이도 스레드 안전하며 가장 간결합니다. 일반적으로 Bill Pugh 방식을 권장하며, 지연 초기화가 필요하지 않으면 enum 싱글톤도 좋은 선택입니다.
참고 코드java // 이중 확인 락 (Double-Checked Locking) public class SingletonDCL { private static volatile SingletonDCL instance; private SingletonDCL() {} public static SingletonDCL getInstance() { if (instance == null) { // 첫 번째 체크 (락 없음) synchronized (SingletonDCL.class) { if (instance == null) { // 두 번째 체크 (락 내부) instance = new SingletonDCL(); } } } return instance; } } // Bill Pugh (정적 내부 클래스) 방식 public class SingletonBillPugh { private SingletonBillPugh() {} private static class Holder { private static final SingletonBillPugh INSTANCE = new SingletonBillPugh(); } public static SingletonBillPugh getInstance() { return Holder.INSTANCE; } } // 시간 복잡도: getInstance()는 항상 O(1) // 공간 복잡도: O(1) (인스턴스 자체는 constant) - synchronized, ReentrantLock, ReadWriteLock의 차이점은 무엇인가요? 각각의 사용 사례를 제시하세요.좋은 답변이 다루는 것
- synchronized: JVM 수준의 암시적 락, 코드 블록 또는 메서드에 사용, 재진입 가능, 데드락 가능
- ReentrantLock: java.util.concurrent.locks 패키지, 명시적 lock/unlock, tryLock, 인터럽트 가능, 공정성 설정 가능
- ReadWriteLock: 읽기 락(공유)과 쓰기 락(배타) 분리, 읽기 작업이 많을 때 성능 향상
- synchronized는 간단한 동기화에 적합, ReentrantLock은 정교한 제어 필요 시, ReadWriteLock은 읽기 잦고 쓰기 드문 경우
샘플 답변 보기
synchronized는 JVM 레벨에서 지원하는 암시적 락으로, 메서드나 블록에 사용하며 자동으로 unlock 됩니다. 재진입 가능하고 코드가 간결하지만, 타임아웃이나 인터럽트를 지원하지 않으며 공정성을 설정할 수 없습니다. ReentrantLock은 명시적 락으로 lock()과 unlock()을 직접 호출하며, tryLock()으로 타임아웃을 지정하거나 lockInterruptibly()로 인터럽트에 응답할 수 있습니다. 또한 생성자에 boolean을 전달하여 공정 락(fair lock)으로 설정 가능합니다. 단, 항상 finally에서 unlock()을 해야 하므로 실수할 위험이 있습니다. ReadWriteLock은 읽기 작업은 여러 스레드가 동시에 가능하고 쓰기 작업은 배타적인 구조입니다. 읽기 비중이 높고 쓰기가 적은 시나리오에서 synchronized나 ReentrantLock보다 동시성을 크게 높일 수 있습니다. 적절한 사용 사례로는 synchronized는 간단한 카운터나 캐시, ReentrantLock은 복잡한 조건 동기화(Condition)가 필요할 때, ReadWriteLock은 캐시 저장소나 설정 맵 등에서 읽기가 대부분일 때 선택합니다.
- Java 메모리 모델을 설명하세요. volatile은 어떻게 가시성을 보장하나요?좋은 답변이 다루는 것
- JMM은 스레드 간 변수 가시성과 코드 재정렬 규칙을 정의
- volatile은 메모리 장벽을 통해 변수 읽기/쓰기가 메인 메모리에서 직접 이루어지도록 보장
- volatile은 가시성과 재정렬 방지(하지만 원자성은 보장하지 않음)
- synchronized와 달리 volatile은 락을 걸지 않음
- JMM의 happens-before 관계: volatile 변수 쓰기 후 읽기는 happens-before 관계 성립
샘플 답변 보기
Java 메모리 모델(JMM)은 스레드가 변수를 어떻게 읽고 쓸지에 대한 규칙을 정의하며, 각 스레드의 작업 메모리(캐시)와 메인 메모리 간의 동기화를 명시합니다. 주요 개념으로 happens-before 관계가 있어, 순서가 보장되어야 하는 연산들 간의 가시성을 보장합니다. volatile 키워드는 변수에 대한 모든 읽기와 쓰기가 메인 메모리에서 직접 이루어지도록 하여, 한 스레드의 쓰기가 다른 스레드의 읽기에 즉시 보이게 합니다. 또한 volatile은 컴파일러와 CPU의 재정렬을 제한합니다. volatile 변수에 대한 쓰기 전의 모든 쓰기 연산은 메모리 장벽을 통해 이후 volatile 변수를 읽는 스레드에게 보이게 됩니다. 그러나 volatile은 원자성을 보장하지 않습니다. 예를 들어 count++ 연산은 읽기-수정-쓰기의 세 단계이므로 원자적이지 않아 동시성 문제가 발생할 수 있습니다. 따라서 volatile은 플래그 변수나 상태 표시에 적합하고, 원자적 연산이 필요하면 AtomicInteger 등을 사용해야 합니다.
- Java Streams를 사용하거나 사용하지 않고 문자열에서 첫 번째로 반복되지 않는 문자를 찾는 메서드를 작성하세요.좋은 답변이 다루는 것
- Stream 사용: chars()로 IntStream 생성, Collectors.groupingBy로 카운트, filter로 첫 번째 찾기
- Stream 미사용: LinkedHashMap 또는 int[256] 배열로 문자 빈도 계산, 다시 순회하며 첫 번째 반복되지 않는 문자 반환
- 시간 복잡도 O(n), 공간 복잡도 O(1) (ASCII인 경우 256 크기 배열)
- LinkedHashMap은 삽입 순서 유지하므로 첫 번째 반복되지 않는 문자 찾기에 적합
샘플 답변 보기
첫 번째로 반복되지 않는 문자를 찾는 방법은 두 가지 접근 방식으로 작성할 수 있습니다. Stream을 사용하면 문자열을 char의 IntStream으로 변환한 후, Collectors.groupingBy로 각 문자의 등장 횟수를 세고, 다시 stream을 순회하며 count가 1인 첫 문자를 찾을 수 있습니다. 단, 이 방식은 전체 문자열을 두 번 순회하므로 O(n)이지만, groupingBy의 특성상 해시맵을 사용하므로 공간 복잡도는 O(k)(k: 고유 문자 수)입니다. Stream을 사용하지 않는 전통적인 방식은 먼저 각 문자의 빈도를 저장할 배열(ASCII라면 int[256], 유니코드라면 HashMap)을 사용하여 첫 번째 순회로 카운트를 채우고, 두 번째 순회에서 count가 1인 첫 문자를 반환합니다. LinkedHashMap을 사용하면 삽입 순서를 유지하면서 카운트를 저장할 수 있습니다. 둘 다 시간 복잡도는 O(n)이고 공간 복잡도는 O(1) 또는 O(문자셋 크기)입니다.
참고 코드java import java.util.*; import java.util.stream.*; public class FirstNonRepeatingCharacter { // Stream 방식 public static char firstNonRepeatingStream(String s) { Map<Character, Long> freq = s.chars() .mapToObj(c -> (char) c) .collect(Collectors.groupingBy(c -> c, LinkedHashMap::new, Collectors.counting())); return freq.entrySet().stream() .filter(e -> e.getValue() == 1) .map(Map.Entry::getKey) .findFirst() .orElseThrow(() -> new RuntimeException("No non-repeating character")); } // 비Stream 방식 (배열 사용, ASCII 가정) public static char firstNonRepeatingArray(String s) { int[] count = new int[256]; // ASCII for (char c : s.toCharArray()) { count[c]++; } for (char c : s.toCharArray()) { if (count[c] == 1) { return c; } } throw new RuntimeException("No non-repeating character"); } // 시간 복잡도: O(n) // 공간 복잡도: O(1) (배열 방식) / O(k) (Stream 방식, k는 고유 문자 수) } - Java에서 가비지 컬렉션은 어떻게 작동하나요? G1GC와 ZGC를 비교하세요.좋은 답변이 다루는 것
- GC의 기본 단계: Mark(객체 표시), Sweep(제거), Compact(압축) (CMS, G1 등 변형)
- G1GC: 힙을 리전으로 분할, 예측 가능한 일시 중지 시간 목표, Concurrent Marking, Mixed GC
- ZGC: 컬러 포인터와 로드 배리어 사용, 동시 이주, 일시 중지 시간 10ms 미만 목표
- G1GC는 처리량 중시, ZGC는 저지연 중시
- ZGC는 메모리 오버헤드가 더 크고 아직 널리 사용되지 않음
샘플 답변 보기
가비지 컬렉션(GC)은 JVM에서 더 이상 참조되지 않는 객체를 자동으로 회수하는 메커니즘입니다. 기본 과정은 Mark(살아있는 객체 식별), Sweep(죽은 객체 메모리 해제), Compact(파편화 방지를 위한 객체 이동)로 구성됩니다. G1GC(Garbage-First)는 힙을 고정된 크기의 리전으로 나누고, GC 일시 중지 시간 목표를 설정할 수 있습니다. 주로 Young GC와 Mixed GC를 수행하며, CMS보다 예측 가능하고 파편화 문제를 줄입니다. ZGC(Z Garbage Collector)는 초저지연을 목표로, 컬러 포인터(64비트 포인터의 메타데이터 비트)와 로드 배리어를 사용하여 대부분의 작업을 애플리케이션 스레드와 동시에 수행합니다. 결과적으로 일시 중지 시간이 10ms 미만으로 매우 짧습니다. G1GC는 처리량과 힙 크기(수십 GB)에서 균형 잡힌 성능을 제공하는 반면, ZGC는 대용량 힙(수백 GB)에서도 일관된 저지연을 제공하지만 CPU 오버헤드가 더 높고, 아직은 Java 11+에서 실무적 사용이 제한적입니다. 선택은 애플리케이션의 지연 시간 요구사항과 힙 크기에 따라 달라집니다.
- wait-notify(또는 Lock 조건)를 사용하여 사용자 정의 블로킹 큐를 구현하세요.좋은 답변이 다루는 것
- wait-notify 구현: synchronized 블록 내에서 wait(), notifyAll() 사용, while 루프로 조건 검사
- Lock-Condition 구현: ReentrantLock.newCondition(), await(), signalAll() 사용
- 반드시 조건 변수(큐가 가득 찼는지, 비었는지)를 보호하는 while 루프를 사용해야 함
- notify() 대신 notifyAll()을 사용하여 모든 대기 스레드 깨우기 (스레드 기아 방지)
- 시간 복잡도: put/take O(1), 공간: O(capacity)
샘플 답변 보기
사용자 정의 블로킹 큐는 wait-notify 메커니즘 또는 Lock과 Condition을 사용하여 구현할 수 있습니다. 기본 아이디어는 큐가 가득 찼을 때 put()을 호출한 스레드를 대기시키고, take()가 요소를 제거하면 notifyAll()로 깨우는 방식입니다. 반대로 큐가 비었을 때 take()는 대기하고, put()이 요소를 추가하면 깨웁니다. 중요한 점은 조건 검사를 while 루프로 반복해야 한다는 것입니다(스레드가 깨어난 후에도 조건이 만족되지 않을 수 있음). 또한 notify()보다는 notifyAll()을 사용하여 모든 대기 스레드를 깨우는 것이 안전합니다(신호 손실 방지). Lock-Condition을 사용하면 더 세밀한 제어가 가능하며, 두 개의 Condition(notFull, notEmpty)을 사용하여 불필요한 깨움을 줄일 수 있습니다. 각각의 구현은 시간 복잡도 O(1)이며, 공간은 배열 기반 큐의 경우 O(capacity)입니다.
참고 코드java import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BlockingQueueUsingWaitNotify { private final Queue<Integer> queue = new LinkedList<>(); private final int capacity; public BlockingQueueUsingWaitNotify(int capacity) { this.capacity = capacity; } public synchronized void put(int item) throws InterruptedException { while (queue.size() == capacity) { wait(); // 큐가 가득 찼을 때 대기 } queue.add(item); notifyAll(); // 대기 중인 take 스레드 깨움 } public synchronized int take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 큐가 비었을 때 대기 } int item = queue.poll(); notifyAll(); // 대기 중인 put 스레드 깨움 return item; } } class BlockingQueueUsingLockCondition { private final Queue<Integer> queue = new LinkedList<>(); private final int capacity; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BlockingQueueUsingLockCondition(int capacity) { this.capacity = capacity; } public void put(int item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signalAll(); } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int item = queue.poll(); notFull.signalAll(); return item; } finally { lock.unlock(); } } } // 시간 복잡도: put/take O(1) // 공간 복잡도: O(capacity)
준비 방법
- 화이트보드나 온라인 편집기에서 깔끔하고 스레드 안전한 코드를 작성하는 연습을 하세요.
- JVM 내부를 이해하고 메모리 및 GC 동작에 대해 추론할 수 있어야 합니다.
- 컬렉션 프레임워크를 마스터하세요: 각 구현의 장단점을 알아야 합니다.
- Java 8+ 기능을 복습하세요: 스트림, 람다, Optional, CompletableFuture, 새로운 Date/Time API.
- 'HashMap과 ConcurrentHashMap의 차이점은 무엇인가요?'에 대한 깊은 답변을 준비하세요.
자주 묻는 질문
Java 8 기능이 인터뷰에서 중요한가요?
네, 대부분의 인터뷰는 람다, 스트림, Optional, CompletableFuture에 대한 지식을 가정합니다. 이들은 현대 Java 개발에서 많이 사용됩니다.
JVM 튜닝 플래그를 외워야 하나요?
일반적인 GC 플래그(예: -XX:+UseG1GC, -Xms, -Xmx)에 익숙하고 힙 크기 조정 방법을 알아야 합니다. 암기보다 추론이 더 중요합니다.
Java 인터뷰에서 디자인 패턴이 얼마나 자주 나오나요?
흔히, 특히 싱글톤, 팩토리, 빌더, 옵저버 패턴이 자주 나옵니다. 스레드 안전한 싱글톤을 구현하거나 Java 8 람다로 전략 패턴을 설명할 준비를 하세요.
fail-fast와 fail-safe 반복자의 차이점은 무엇인가요?
fail-fast 반복자는 컬렉션이 반복 중에 구조적으로 수정되면 ConcurrentModificationException을 던집니다(예: ArrayList). fail-safe 반복자는 복제본에서 작동합니다(예: ConcurrentHashMap, CopyOnWriteArrayList).
Java 9-17 기능을 인터뷰를 위해 알아야 하나요?
네, 모듈(Jigsaw), 봉인 클래스, 레코드, instanceof 패턴 매칭, 텍스트 블록에 대한 질문을 예상하세요. 적어도 주요 기능은 알고 있어야 합니다.
즉각적인 AI 피드백으로 Java 질문 연습하기
이력서를 업로드하고 맞춤형 모의 면접을 받아 무엇을 개선해야 할지 정확히 확인하세요 — 무료로 시작하세요.