[JAVA] Queue는 왜 안되고 Queue는 왜 될까?

“왜 Queue는 안 되는데 Queue는 되지?"에 대한 이유를 설명합니다.


Queue<int>는 안 되고 Queue<Integer>는 될까?

Java 제네릭을 쓰다 보면 한 번씩 멈칫하게 되는 질문입니다. “왜 Queue<int>는 안 되는데 Queue<Integer>는 되지?” 이 글은 그 이유를 **타입 소거(type erasure)**와 참조 타입만 허용하는 제네릭 규칙에서 차근차근 풀어 설명합니다. 실전 성능 팁과 BFS 같은 알고리즘 코드 패턴도 함께 담았습니다.


정리

  • 제네릭 타입 인자(T)는 참조 타입만 가능합니다. (T extends Object가 암묵적 전제)
  • 타입 소거로 인해 런타임에는 제네릭 정보가 지워지고, 메서드 시그니처가 사실상 Object 기반으로 동작합니다.
  • int 같은 원시 타입(primitive)Object가 아니므로 제네릭 인자로 쓸 수 없습니다.Queue<int> 금지
  • 배열은 참조 타입이므로 Queue<int[]>는 가능합니다. (배열 자체는 객체)
  • 알고리즘 큐에는 int[] 또는 record/class로 상태를 묶어 넣으면 박싱 없이 빠르고 메모리 친화적입니다.

1) 제네릭은 왜 쓰나?

컴파일 타임에 타입을 체크해 타입 안전성을 높이고, 캐스트를 없애 가독성유지보수성을 올리기 위해서입니다. 제네릭에 대해 정리한 블로그 글 참고

// 제네릭 없음: 캐스트 필요
List list = new ArrayList();
list.add("hi");
String x = (String) list.get(0);

// 제네릭 사용: 컴파일 타임에 체크, 캐스트 제거
List<String> list2 = new ArrayList<>();
list2.add("hi");
String y = list2.get(0);

2) 타입 소거(type erasure)란?

자바의 제네릭은 런타임에 사라집니다. 컴파일러가 제네릭 코드를 검사·보정한 뒤, 실행 시점에는 타입 매개변수를 지운(Object로 대체한) 형태로 동작합니다.

개념적으로 다음과 같습니다.

// 원본
class Box<T> {
  void put(T x) { /* ... */ }
  T get() { /* ... */ }
}

// (개념적) 컴파일 후 - T가 지워지고 Object 중심으로
class Box {
  void put(Object x) { /* ... */ }
  Object get() { /* ... */ }
}

컴파일러가 캐스트 삽입오토박싱/언박싱으로 타입 안전을 보정해 줍니다(필요 시 브리지 메서드도 생성).


3) T는 왜 ‘참조 타입’만 될 수 있나?

자바 언어 규칙상 **모든 타입 매개변수는 암묵적으로 T extends Object**로 취급됩니다.

  • Integer, String, MyClass 같은 참조 타입(reference type)Object의 하위 타입이므로 OK.
  • int, double, boolean 같은 원시 타입(primitive)Object가 아니므로 제네릭 타입 인자로 금지됩니다.

즉, Queue<int>언어 차원에서 성립하지 않습니다.

포인트: 타입 소거 후의 세계는 Object 중심이라, 그 세계로 들어올 수 있는 타입(=참조 타입)만 제네릭 인자가 될 수 있습니다.


4) 그렇다면 Queue<Integer>는 왜 되나?

Integerint래퍼 클래스(참조 타입)입니다. 제네릭 인자로 쓸 수 있고, q.offer(1)처럼 쓰면 오토박싱이 자동으로 일어나 int -> Integer가 됩니다.

Queue<Integer> q = new ArrayDeque<>();
q.offer(1);      // int가 Integer로 오토박싱
int v = q.poll(); // Integer가 int로 언박싱

단, 이 과정은 객체 할당/GC 비용이 들 수 있습니다. 대량 연산에서는 체감될 수 있어요.


5) Queue<int[]>는 왜 가능한가?

배열은 항상 참조 타입입니다. 원소가 원시 타입이든 말든, 배열 자체는 힙 객체니까 제네릭 인자로 사용 가능해요.

Queue<int[]> q = new ArrayDeque<>();
q.offer(new int[]{1,2,3}); // 배열 참조를 넣음
int[] arr = q.poll();

6) 성능·메모리 관점: 오토박싱을 피하자

  • Queue<Integer>원소마다 Integer 객체가 생길 수 있어

    • 오토박싱/언박싱 비용
    • 객체 헤더 + 포인터 오버헤드
    • GC 부담 이 발생합니다.
  • 알고리즘(특히 BFS/DFS, 다익스트라 등)에서는 박싱을 피하는 게 유리합니다.

권장 1) int[]로 상태 묶기 (가볍고 빠름)

// {r, c, breakUsed, dist}
ArrayDeque<int[]> q = new ArrayDeque<>();
q.offer(new int[]{0, 0, 0, 1});
int[] cur = q.poll();
int r = cur[0];

권장 2) record로 가독성 ↑ (Java 16+)

record State(int r, int c, int b, int d) {}
ArrayDeque<State> q = new ArrayDeque<>();
q.offer(new State(0, 0, 0, 1));
State s = q.poll();

팁: 큐 구현체는 **ArrayDeque**가 일반적으로 LinkedList보다 빠르고 메모리 효율적입니다.


7) 실전 FAQ

Q1. “런타임에 진짜 Queue<Object>로 동작하나요?” 개념적으로는 그와 유사합니다(타입 소거). 실제로는 컴파일러가 캐스트/브리지 메서드 등으로 타입 안전을 맞춰 줍니다. 핵심은 런타임에 타입 인자 정보가 없고 Object 중심으로 호출된다는 점입니다.

Q2. 그렇다면 왜 컴파일러가 Queue<int>도 자동으로 Queue<Integer>로 바꿔주지 않나요? 언어 규칙상 제네릭 인자는 참조 타입만 허용합니다. 타입 인자 자체를 바꾸는 묵시적 변환은 설계상 모호성과 함정(예: T가 원시로 선언됐는데 실제론 참조로 다뤄짐)을 낳기에 금지됩니다. 명시적으로 Integer를 써 주세요.

Q3. 원시 타입 컬렉션이 꼭 필요합니다. 방법이 없나요? 표준 라이브러리는 제공하지 않지만, 전용 라이브러리가 있습니다.

  • fastutil (IntArrayList, IntOpenHashSet, …)
  • HPPC (High Performance Primitive Collections)
  • Eclipse Collections (primitive collections) 대량 데이터·고성능 시나리오에서 유용합니다.

8) BFS 예시: 박싱 없이 깔끔하게

아래는 “벽을 한 번만 부술 수 있는” BFS 패턴입니다. int[]로 상태를 묶어 박싱을 회피합니다.

import java.io.*;
import java.util.*;

public class Main {
    static final int[] dr = {1, -1, 0, 0};
    static final int[] dc = {0, 0, 1, -1};

    public static void main(String[] args) throws Exception {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        int N = Integer.parseInt(st.nextToken());
        int M = Integer.parseInt(st.nextToken());

        int[][] map = new int[N][M];
        for (int i = 0; i < N; i++) {
            String line = br.readLine();
            for (int j = 0; j < M; j++) map[i][j] = line.charAt(j) - '0';
        }

        boolean[][][] visited = new boolean[N][M][2];
        ArrayDeque<int[]> q = new ArrayDeque<>();
        q.offer(new int[]{0, 0, 0, 1}); // r, c, breakUsed, dist
        visited[0][0][0] = true;

        int ans = -1;
        while (!q.isEmpty()) {
            int[] cur = q.poll();
            int r = cur[0], c = cur[1], b = cur[2], d = cur[3];
            if (r == N - 1 && c == M - 1) { ans = d; break; }
            for (int k = 0; k < 4; k++) {
                int nr = r + dr[k], nc = c + dc[k];
                if (nr < 0 || nr >= N || nc < 0 || nc >= M) continue;
                if (map[nr][nc] == 0) {
                    if (!visited[nr][nc][b]) { visited[nr][nc][b] = true; q.offer(new int[]{nr, nc, b, d + 1}); }
                } else if (b == 0 && !visited[nr][nc][1]) {
                    visited[nr][nc][1] = true; q.offer(new int[]{nr, nc, 1, d + 1});
                }
            }
        }
        System.out.println(ans);
    }
}

9) 한눈에 요약 체크리스트

  • 제네릭 인자 = 참조 타입만 (암묵적 T extends Object)
  • 런타임 = 타입 소거 (제네릭 정보 없음, Object 중심)
  • Queue<int> ❌, Queue<Integer>
  • Queue<int[]> ⭕ (배열은 참조 타입)
  • 성능 중요: 박싱 피하기 (가능하면 int[]/record 사용)
  • 큐 구현체는 보통 ArrayDeque 추천

마무리

Queue<int>가 금지되는 이유는 단순히 “문법이 그렇다”가 아니라, 타입 소거라는 실행 모델참조 타입만 허용하는 제네릭 설계가 맞물린 결과입니다. 오늘부터는 알고리즘에서 박싱 없는 상태 표현으로 깔끔하고 빠른 코드를 써 보세요!