switch가 식(expression)이 된 순간 — 연산자에서 제어 흐름까지

예전 Java에서 switch를 쓰면 이랬다:

String result;
switch (day) {
    case 1: result = "월"; break;
    case 2: result = "화"; break;
    // ... break 빼먹으면 다음 case로 흘러내림
}

변수 선언 → switch → case마다 대입 → break. 이게 값 하나를 결정하는 데 너무 많은 의식이다. Java 14부터 switch가 값을 반환하는 식이 됐다 — String result = switch(day) { case 1 -> "월"; ... }; 한 줄로. break도 없고, 흘러내림도 없다. (JEP-361)

이 글은 이 변화의 배경이 되는 연산자 함정(&& vs &, 문자열 +)부터, switch expression과 pattern matching for switch까지 풀어간다.

연산자 — 자주 틀리는 세 가지

단락 평가(short-circuit evaluation) — && vs &

논리 연산자 &&||단락 평가를 수행한다. (JLS §15.23, §15.24) 좌변만으로 결과가 확정되면 우변을 아예 실행하지 않는다.

쉽게 말하면: "왼쪽만 봐도 답이 나오면 오른쪽은 안 본다"는 것이다.

null 체크를 생각해 보자. s != nullfalse면 — snull이라는 뜻이니, s.length()를 부르면 NullPointerException이 난다. 하지만 &&는 왼쪽이 false면 오른쪽을 실행 자체를 안 한다. 그래서 null 검사와 길이 검사를 한 줄에 안전하게 쓸 수 있다.

// Java 25
String s = null;
// 단락 평가: s == null이면 우변 평가 안 함 → NullPointerException 발생 안 함
if (s != null && s.length() > 0) {
    System.out.println("유효한 문자열");
} else {
    System.out.println("null이거나 빈 문자열");
}
null이거나 빈 문자열

반면 &|비트 연산자다 — 논리형에 쓰면 양쪽을 모두 평가한다(단락 없음).

// Java 25 — 위험: &는 단락하지 않음
if (s != null & s.length() > 0) {   // s == null이어도 s.length() 실행 → NPE!
}

&&를 쓸 의도로 &를 쓰는 것이 논리 연산 관련 가장 흔한 버그다. IDE에서 경고하지만, 코드 리뷰에서도 자주 놓친다.

문자열 결합 연산자 + — 편리하지만 느린 이유

Java에서 +는 숫자 덧셈과 문자열 결합을 모두 수행한다. (JLS §15.18.1)

// Java 25
String name = "Java";
int version = 21;
String result = "Hello, " + name + " " + version;   // "Hello, Java 25"

이 코드는 실제로 StringBuilder를 생성하여 append를 반복 호출한 뒤 toString으로 변환한다 — 루프 안에서 쓰면 매 반복마다 새 StringBuilder 객체와 임시 문자열을 만든다.

// Java 25 — 루프 안의 + 연산 (성능 문제)
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;   // 매번 새 StringBuilder + 새 String 생성
}

// 올바른 방식
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result2 = sb.toString();

단일 표현식에서의 + 연산은 Java 9+부터 컴파일러가 invokedynamic으로 최적화한다. (JEP-280) 따라서 String s = a + b + c;는 효율적이다. 문제는 루프 안에서의 +=다 — 이 경우 매번 새 객체를 생성한다.

삼항 연산자 — ? :의 타입 규칙

// Java 25
int x = 5;
String result = x > 0 ? "양수" : "음수 또는 0";

삼항 연산자의 두 피연산자 타입이 다를 때, Java는 결과 타입을 통합한다. (JLS §15.25) 이 규칙이 직관과 다를 때가 있다:

// Java 25 — 삼항 연산자 타입 함정
Object obj = true ? Integer.valueOf(1) : "hello";
// 결과 타입: Integer와 String의 공통 조상인 Object/Serializable/Comparable
// 예상과 다를 수 있음

// 더 위험한 경우
int a = 1;
long b = 2L;
var result = true ? a : b;   // result는 int가 아니라 long

switch — 문(statement)에서 식(expression)으로의 진화

전통적 switch 문

Java의 switch는 C 계열 언어의 switch와 같은 구조로 시작했다 — 정수/enum/String 값에 따라 분기하고, break로 빠져나온다. break를 빼먹으면 fall-through(다음 case로 넘어감)가 발생한다.

// Java 25 — 전통적 switch 문 (fall-through 함정)
int day = 3;
String type;
switch (day) {
    case 1:
    case 2:
    case 3:
    case 4:
    case 5:
        type = "평일";   // case 1~5가 모두 여기로 fall-through
        break;
    case 6:
    case 7:
        type = "주말";
        break;
    default:
        type = "알 수 없음";
}

break를 빼먹으면 의도치 않게 다음 case가 실행된다. 이것이 전통적 switch의 가장 큰 버그 원인이었다.

switch expression (Java 14+, JEP-361)

Java 14부터 switch가 값을 반환하는 식이 됐다. (JEP-361) 화살표(->) 케이스 라벨을 쓰고, 각 케이스가 값을 반환한다. fall-through가 없다.

// Java 25 — switch expression
int day = 3;
String type = switch (day) {
    case 1, 2, 3, 4, 5 -> "평일";
    case 6, 7 -> "주말";
    default -> "알 수 없음";
};
System.out.println(type);   // 평일

핵심 차이:

  • case 1, 2, 3, 4, 5 — 쉼표로 여러 값을 나열 (Java 14+)
  • -> — 화살표 케이스. break 불필요, fall-through 없음
  • switch가 값을 반환 (세미콜론으로 문장 종료)
  • 여러 줄 실행이 필요하면 { } 블록 + yield:
// Java 25 — yield로 블록에서 값 반환
String type = switch (day) {
    case 1, 2, 3, 4, 5 -> {
        String msg = "평일 " + day;
        yield msg;   // 블록에서 값 반환
    }
    case 6, 7 -> "주말";
    default -> "알 수 없음";
};

yield는 Java 13에서 도입된 키워드다 (yield는 제한된 식별자 — 변수명으로 쓸 수 있으나 switch expression의 yield와 혼동되므로 권장하지 않음).

switch expression의 exhaustiveness

switch expression은 모든 가능한 값을 처리해야 한다 — 누락 시 컴파일 에러. (JLS §15.28)

// Java 25 — 컴파일 에러: default 없고 모든 case를 안 덮음
// enum이나 sealed class가 아닌 int는 default가 필수
String type = switch (day) {
    case 1 -> "월";
    // 컴파일 에러: switch expression이 모든 값을 처리하지 않음
};

이 제약 덕분에 새 case를 잊는 버그를 컴파일 타임에 잡을 수 있다. enum과 sealed class에서 특히 강력하다.

Pattern matching for switch (Java 21, JEP-441)

Java 21에서 switch는 단순 값 비교를 넘어 타입 패턴(type pattern)으로 분기할 수 있게 됐다. (JEP-441) 이것이 switch를 근본적으로 바꾼다.

// Java 25 — pattern matching for switch
Object obj = "Hello, Java 25";
String result = switch (obj) {
    case Integer i -> "정수: " + i;
    case String s  -> "문자열: " + s;
    case Double d  -> "실수: " + d;
    case null      -> "null입니다";
    default        -> "기타: " + obj;
};
System.out.println(result);   // 문자열: Hello, Java 25

전통적 instanceof 체인과의 비교

// Java 25 — 전통적 방식 (장황함)
String describe(Object obj) {
    if (obj instanceof Integer) {
        Integer i = (Integer) obj;   // 명시적 캐스트
        return "정수: " + i;
    } else if (obj instanceof String) {
        String s = (String) obj;     // 명시적 캐스트
        return "문자열: " + s;
    } else if (obj == null) {
        return "null입니다";
    } else {
        return "기타: " + obj;
    }
}

// Java 25 — switch pattern matching (간결)
String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "정수: " + i;
        case String s  -> "문자열: " + s;
        case null      -> "null입니다";
        default        -> "기타: " + obj;
    };
}

null 케이스 — 직접 처리 가능

전통적 switch에서 selector가 null이면 NullPointerException이 발생했다. pattern matching switch에서는 case null으로 직접 처리할 수 있다.

// Java 25 — null 케이스 직접 처리
String result = switch (obj) {
    case String s when s.isEmpty() -> "빈 문자열";   // guard (when절)
    case String s                   -> "문자열: " + s;
    case Integer i when i > 100     -> "큰 정수: " + i;  // guard
    case Integer i                   -> "정수: " + i;
    case null                       -> "null입니다";   // null 직접 처리
    default                          -> "기타";
};

when 절(guard) — 패턴에 조건 추가

when 키워드로 case 패턴에 추가 조건을 건다. (JEP-441)

// Java 25 — when 절로 정제
case String s when s.length() > 10 -> "긴 문자열";
case String s                       -> "짧은 문자열";

when 절이 없으면 모든 String이 같은 case로 처리된다. when이 있으면 위에서부터 순서대로 평가한다.

sealed class와 switch — 완전성 보장

sealed class와 pattern matching switch의 조합이 가장 강력하다:

// Java 25 — sealed class + switch
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double height) implements Shape {}

double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.w() * r.h();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // default 불필요! sealed class의 모든 허용 타입을 처리했으므로
    };
}

sealed class는 허용된 하위 타입을 컴파일 타임에 알 수 있으므로, default 없이도 exhaustiveness가 보장된다. 새 하위 타입을 추가하면 switch가 컴파일 에러를 발생시킨다 — 누락된 case를 즉시 발견할 수 있다.

반복문 — enhanced for와 labeled break

Java의 반복문은 for, enhanced for(for-each), while, do-while이다. 핵심은 enhanced for와 중첩 루프에서의 labeled break다.

enhanced for (for-each)

// Java 25
int[] nums = { 1, 2, 3, 4, 5 };
for (int n : nums) {
    System.out.print(n + " ");
}
1 2 3 4 5

enhanced for는 배열과 Iterable에 작동한다. 인덱스가 필요 없을 때 권장된다. 인덱스가 필요하면 전통적 for를 쓴다.

labeled break / continue — 중첩 루프 탈출

// Java 25 — labeled break로 중첩 루프 탈출
int[][] matrix = {
    { 1, 2, 3 },
    { 4, 5, 6 },
    { 7, 8, 9 }
};
int target = 5;
boolean found = false;

search:
for (int i = 0; i < matrix.length; i++) {
    for (int j = 0; j < matrix[i].length; j++) {
        if (matrix[i][j] == target) {
            System.out.println("찾음: (" + i + ", " + j + ")");
            found = true;
            break search;   // 외부 루프까지 한 번에 탈출
        }
    }
}

labeled break는 Java에서 유일하게 레이블(label:)을 쓰는 곳이다. goto가 없는 Java에서 다중 루프 탈출을 위한 안전한 대안이다. 다만 남용하면 흐름을 따라가기 어려워지므로, 메서드로 추출하여 return으로 대체하는 것이 더 깔끔할 수 있다.

실습 — switch expression과 pattern matching 체감하기

사전: Java 25 설치 확인.

// Java 25 — SwitchDemo.java
public class SwitchDemo {
    sealed interface Shape permits Circle, Rectangle {}
    record Circle(double radius) implements Shape {}
    record Rectangle(double width, double height) implements Shape {}

    public static void main(String[] args) {
        Object[] items = { 42, "Hello", 3.14, null, new Circle(5.0) };

        for (Object item : items) {
            String desc = switch (item) {
                case Integer i when i > 100 -> "큰 정수: " + i;
                case Integer i              -> "정수: " + i;
                case String s when s.isBlank() -> "빈 문자열";
                case String s               -> "문자열: " + s;
                case Double d               -> "실수: " + d;
                case Circle c               -> "원(반지름=" + c.radius() + ")";
                case Rectangle r            -> "사각형(" + r.width() + "x" + r.height() + ")";
                case null                   -> "null";
                default                     -> "알 수 없음: " + item;
            };
            System.out.println(desc);
        }
    }
}
java SwitchDemo.java
정수: 42
문자열: Hello
실수: 3.14
null
원(반지름=5.0)

확인할 것: 하나의 switch expression이 타입 패턴(Integer, String, Double, Circle), guard(when), null 케이스, default를 모두 처리한다. instanceof 체인이나 방대한 if-else 없이 타입 안전한 분기가 이루어진다.

요약 — 이 글의 결론

  • &&&는 다르다. 논리 연산에는 단락 평가(&&)를, 비트 연산에만 &를 쓴다. 혼동하면 NPE가 발생한다.
  • 루프 안의 += 문자열 결합은 매 반복마다 새 객체를 만든다. 루프에서는 StringBuilder를 쓴다.
  • switch는 식(expression)이 됐다. Java 14+에서 값을 반환하고, fall-through가 없으며, 모든 값을 처리해야 한다(exhaustiveness). 이 제약이 누락된 case를 컴파일 타임에 잡는다.
  • pattern matching for switch(Java 25)는 타입 안전한 분기를 가능하게 한다. instanceof 체인과 명시적 캐스트가 사라진다. when 절로 조건을 추가하고, case null로 NPE를 원천 차단한다.
  • sealed class + switch가 최강의 조합이다. 모든 하위 타입을 컴파일 타임에 알 수 있으므로, default 없이도 완전성이 보장된다. 새 타입 추가 시 switch가 컴파일 에러로 알려준다.

생각해 볼 문제

  1. boolean result = obj instanceof String s && s.length() > 0;에서 s의 스코프는 어디까지인가? (flow typing을 조사해 보자.)
  2. switch expression에서 case Integer icase int i의 차이는 무엇인가? 둘 다 컴파일되는가?
  3. 삼항 연산자 char c = true ? 'a' : 65;의 결과 타입은 무엇인가? (JLS §15.25의 타입 규칙을 읽어보자.)
  4. enhanced for 루프 내에서 ConcurrentModificationException이 발생하는 조건은 무엇인가? 이를 방지하려면 어떤 컬렉션을 써야 하는가?
  5. var x = switch (day) { case MONDAY -> 1; default -> "Monday"; };의 결과 타입은 무엇으로 추론되는가? 컴파일이 될까?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 06. inheritance and polymorphism  (0) 2026.07.12
Java - 05. class and object  (0) 2026.07.12
Java - 04. array and string  (0) 2026.07.12
Java - 02. types  (0) 2026.07.10
Java - 01. intro  (0) 2026.07.10