채용 시즌이 다가오면 개발자들의 가장 큰 관문 중 하나는 기술 면접이다. 특히 Java를 주력으로 사용하는 엔지니어라면, 단순히 개념을 아는 것을 넘어서 정확한 구현과 세부적인 이해가 요구된다. 많은 지원자가 스프링의 의존성 주입이나 JVM 메모리 모델에 대해 설명할 수 있지만, 막상 백보드에 버블 정렬을 작성하라고 하면 반복문의 범위 조건을 잘못 설정하거나, 싱글턴 패턴을 설명하면서 스레드 안정성을 간과하는 경우가 적지 않다. 이러한 사소해 보이는 실수들이 최종 합격 여부를 좌우하기도 한다.
이 글은 단순한 면접 질문 나열이 아니라, 실제 현업에서 자주 언급되며 실수하기 쉬운 주제들을 중심으로 깊이 있게 다룬다. 알고리즘 구현부터 디자인 패턴, 동시성 문제까지, "왜 이렇게 해야 하는가"에 초점을 맞춰 설명한다.
1. 알고리즘 구현: 반복문 경계와 예외 상황 처리
인터뷰에서 정렬 및 탐색 알고리즘은 기본 중의 기본이지만, 그만큼 철저한 검증이 필요하다. 대부분의 지원자는 알고리즘의 로직을 설명할 수 있으나, 코드 레벨에서의 견고함 — 즉, 입력 값이 비어 있거나, 하나뿐인 원소를 가진 배열, 이미 정렬된 데이터 등 — 을 고려하지 않는 경우가 많다.
1.1 버블 정렬: 성능 최적화와 안정성의 의미
버블 정렬은 인접한 두 요소를 비교하며 큰 값을 뒤로 밀어내는 방식이다. 아래는 가장 기초적인 구현이다.
public class BasicBubbleSort {
public static void sort(int[] array) {
if (array == null || array.length <= 1) return;
int length = array.length;
for (int i = 0; i < length - 1; i++) {
for (int j = 0; j < length - 1 - i; j++) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
}
이 코드는 동작하지만, 최선의 경우(이미 정렬된 배열)에도 O(n²)의 시간 복잡도를 유지한다. 이는 비효율적이며, 인터뷰에서는 반드시 개선 여지를 묻는다.
개선 포인트: early termination 도입
한 번의 전체 순회에서 어떤 요소도 교환되지 않았다면, 더 이상 정렬할 필요가 없다는 의미다. 이를 플래그 변수로 추적하면 최선의 경우 시간 복잡도를 O(n)로 줄일 수 있다.
public class OptimizedBubbleSort {
public static void sort(int[] array) {
if (array == null || array.length <= 1) return;
int length = array.length;
boolean exchanged;
for (int i = 0; i < length - 1; i++) {
exchanged = false;
for (int j = 0; j < length - 1 - i; j++) {
if (array[j] > array[j + 1]) {
swap(array, j, j + 1);
exchanged = true;
}
}
if (!exchanged) break;
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
여기서 추가로 눈여겨볼 점은 length - 1 - i라는 내부 루프의 종료 조건이다. 이는 배열 인덱스의 범위를 초과하지 않도록 막아주는 핵심 로직이며, 실수하기 쉬운 부분이다.
안정성(stability)에 대한 이해
버블 정렬은 안정적인 정렬 알고리즘이다. 즉, 값이 같은 원소들 사이의 상대적 순서가 정렬 후에도 유지된다. 이는 > 대신 >=을 사용했을 때 깨지게 된다. 만약 동일한 값일 때도 교환한다면, 먼저 나오던 원소가 뒤로 밀려 상대 위치가 바뀌므로 안정성이 상실된다. 인터뷰에서 "어떻게 하면 안정성을 깰 수 있겠는가?"라는 질문에 이처럼 답할 수 있다면, 알고리즘에 대한 깊은 이해를 보여줄 수 있다.
1.2 이진 탐색: 정수 오버플로와 중복 요소 처리
이진 탐색은 정렬된 배열에서 특정 값을 빠르게 찾는 대표적인 알고리즘이다. 일반적으로 중간 인덱스를 다음과 같이 계산한다.
// 위험한 방식
int mid = (left + right) / 2;
문제는 left와 right가 모두 큰 값일 때, left + right가 정수형의 최댓값을 초과하여 음수로 바뀔 수 있다는 점이다. 이는 인덱스 범위를 벗어나는 오류를 유발한다.
안전한 중간값 계산 방법
이 문제를 해결하려면 다음과 같이 작성해야 한다.
// 안전한 방식 #1
int mid = left + (right - left) / 2;
// 또는 비트 연산 활용 (JDK 내부에서도 사용됨)
int mid = (left + right) >> 1;
첫 번째 방식은 덧셈이 아닌 뺄셈을 사용하므로 오버플로 가능성이 제거된다. 두 번째는 부호 없는 우측 시프트를 통해 나누기 연산을 효율화한 것으로, JDK의 Arrays.binarySearch()에서도 실제로 사용된다.
중복 원소가 있을 때의 동작
배열에 중복된 값이 존재할 경우, 이진 탐색은 그 중 어떤 인덱스를 반환할까? 표준 이진 탐색은 특정 인덱스를 보장하지 않으며, 첫 번째 혹은 마지막 등장 위치를 찾기 위해서는 추가적인 로직이 필요하다.
예를 들어, 첫 번째로 등장하는 위치를 찾기 위해선 일치할 경우에도 계속 왼쪽을 탐색하도록 수정해야 한다.
public static int findFirstOccurrence(int[] arr, int target) {
int left = 0, right = arr.length - 1;
int result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
result = mid;
right = mid - 1; // 계속 왼쪽 탐색
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
인터뷰에서는 "중복된 숫자 중 첫 번째 인덱스를 찾는 방법"을 물을 수 있으므로, 단순한 존재 여부 확인을 넘어서는 구현 능력을 갖추는 것이 중요하다.
2. 싱글턴 패턴: 스레드 안전성과 리플렉션 공격
싱글턴 패턴은 객체를 단 하나만 생성해야 할 때 사용되지만, 여러 스레드 환경에서 올바르게 작동하도록 구현하는 것은 생각보다 까다롭다.
2.1 지연 초기화와 동시성 문제
흔히 사용되는 지연 초기화 방식은 다음과 같다.
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
하지만 이 코드는 멀티스레드 환경에서 문제가 생긴다. 두 스레드가 동시에 instance == null을 판단하면, 두 번의 인스턴스 생성이 발생할 수 있다.
해결책: double-checked locking
성능 저하 없이 스레드 안정성을 확보하려면 다음과 같이 작성한다.
public class ThreadSafeSingleton {
private static volatile ThreadSafeSingleton instance;
private ThreadSafeSingleton() {}
public static ThreadSafeSingleton getInstance() {
if (instance == null) {
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new ThreadSafeSingleton();
}
}
}
return instance;
}
}
여기서 중요한 점은 volatile 키워드의 사용이다. 이는 인스턴스 생성 과정에서의 지시어 재배치(instruction reordering)를 막아, 다른 스레드가 아직 초기화되지 않은 객체를 참조하는 일을 방지한다.
2.2 리플렉션과 역직렬화 공격
위 구현도 리플렉션을 통한 공격에는 취약하다. 예를 들어, 다음 코드로 기존의 private 생성자를 강제 호출할 수 있다.
Constructor<ThreadSafeSingleton> c =
ThreadSafeSingleton.class.getDeclaredConstructor();
c.setAccessible(true);
ThreadSafeSingleton second = c.newInstance();
이를 막기 위해 생성자 내부에서 두 번째 호출을 감지하고 예외를 던질 수 있다.
private ThreadSafeSingleton() {
if (instance != null) {
throw new IllegalStateException("이미 인스턴스가 생성되었습니다.");
}
}
또한, 역직렬화 시 새로운 인스턴스가 생성되는 것을 막기 위해 readResolve() 메서드를 제공해야 한다.
private Object readResolve() {
return instance;
}
가장 안전한 방법은 열거형(enum)을 사용하는 것이다. Effective Java에서 제안된 방식으로, 직렬화와 리플렉션 모두에 내성을 가진다.
public enum SingletonEnum {
INSTANCE;
public void doSomething() {
// 비즈니스 로직
}
}
이 방식은 단순하고, 직관적이며, JVM 차원에서 보장되기 때문에 면접에서 언급하면 매우 좋은 인상을 줄 수 있다.