Service 계층 assert 표준은 Service 클래스 안에서 assert 문을 어디에 쓰고 어디에 쓰지 않을지 정한 글이다. 그 글은 도달 불가 분기(제대로 돌면 실행이 절대 오지 않아야 하는 갈래)를 assert가 아니라 throw new AssertionError로 막기로 했고, 근거는 이 대비였다.

도달 불가 분기의 assert false 는 -ea 가 꺼지면 조용히 통과하고,
throw new AssertionError 는 -ea 와 무관하게 항상 멈춘다.

-ea는 프로그램을 실행할 때 「assert 문을 실제로 검사하라」고 켜는 옵션이고, 주지 않으면 꺼져 있다. assert false;는 「여기 오면 무조건 실패」라는 뜻이지만 -ea가 꺼져 있으면 그 줄은 아무 일도 하지 않는다. 그러면 버그로 도달 불가 분기에 들어왔는데도 프로그램이 멈추지 않고 그 뒤를 계속 실행한다. 아래 결과에서 이 경우 메서드가 0을 돌려주고 정상 종료하는 것이 그 장면이다. 반면 throw new AssertionError()는 옵션과 상관없이 예외를 던지므로 그 자리에서 멈춘다.


주장 — assert false는 -ea 여부로 동작이 갈린다

도달 불가 분기에 쓴 assert false 는 -ea 가 꺼지면 조용히 통과하고 켜지면 멈춘다.

근거 종류는 1(기술 사실)이다.


반증 조건 — 컴파일이 안 되거나 동작이 반대면 반증이다

여기서 「두 형태」는 아래 설계 섹션에서 만드는 switch 문과 switch 표현식이다. 같은 도달 불가 분기를 두 가지 문법으로 적어 각각 시험한다.

  • refuted — 두 형태 중 하나가 컴파일되지 않거나, -ea를 껐는데 멈추거나, 켰는데 통과한다
  • supported — 두 형태 모두 컴파일되고 -ea 끄면 통과·켜면 멈춘다
  • inconclusive — 대조군(throw new AssertionError)이 -ea와 무관하게 멈추지 않는다

대조군은 「-ea와 상관없이 항상 멈춘다」는 답을 이미 아는 코드다. 그것마저 안 멈추면 실험 장치(실행 옵션이나 코드)가 잘못됐다는 뜻이라, assert false 쪽 결과도 믿을 수 없어 판정을 내리지 않는다.

「컴파일되지 않는다」를 반증에 넣은 이유는 그 형태에서는 주장이 성립할 자리가 없기 때문이다. 위험이 있다고 말하려면 그 코드가 존재할 수 있어야 한다.


설계 — 같은 분기를 switch 문과 switch 표현식 두 형태로 만든다

// 형태 1 — switch 문
switch (kind) { case A: ...; break; default: assert false; }

// 형태 2 — switch 표현식
int v = switch (kind) { case A -> 1; default -> { assert false; } };

위 두 줄은 모양만 보이려고 줄인 것이다. kind는 갈래를 고를 값, A는 그 값 중 하나를 가리키는 자리 표시다. 실제 실험 코드는 이 블로그 저장소의 proofs/assert-false-switch-expression/app/variants/ 폴더에 있다(사이트에는 싣지 않고 저장소에만 둔다). 형태 1을 실제로 돌린 코드는 이렇다.

static int classify(int kind) {
    int v = 0;
    switch (kind) {
        case 1:
            v = 10;
            break;
        default:
            assert false;
    }
    return v;
}
// main 에서 classify(9) 를 불러 돌아온 값을 출력한다

v를 0으로 시작하고, kind가 1이면 10을 넣는다. main은 classify(9)로 불러 일부러 default에 들어가게 한다. -ea가 켜져 있으면 assert false;에서 AssertionError가 나 멈춘다. 꺼져 있으면 그 줄이 아무 일도 안 하므로 v는 처음 값 0 그대로 return v;까지 가서 0이 돌아온다. 결과에 나오는 「0 반환」이 이 값이다. 형태 2는 같은 메서드를 return switch (kind) { case 1 -> 10; default -> { assert false; } };로 쓴 것이다.

한 줄씩 따라가기. 형태 1은 예전부터 쓰던 switch 문이다 — case A: 뒤에 콜론을 쓰고, 분기가 끝나면 break로 빠져나오며, 분기 자체는 아무 값도 돌려주지 않는다. 형태 2는 자바 14부터 쓸 수 있는 switch 표현식이다 — case A ->처럼 화살표(->)를 쓰고, switch (...) { ... } 전체가 int v = 에 대입되는 하나의 값으로 계산된다. default -> { assert false; }처럼 화살표 뒤에 중괄호 블록을 쓰면 그 블록 안에서 값을 하나 만들어 돌려주거나(yield) 예외를 던져야 한다 — 블록이 끝까지 실행됐는데 아무 값도 내놓지 않으면 컴파일이 안 된다. 그래서 assert false;만 들어 있는 형태 2는 “검사만 하고 값은 안 준다”는 뜻이 되어, 컴파일러가 이 자리를 막는다. -ea를 켜면 assert false;도 예외를 던지지만, 컴파일은 실행보다 먼저 일어나고 그때는 나중에 어떤 실행 옵션을 줄지 알 수 없다. 아래 결과가 보여 주듯 컴파일러는 assert false;를 「예외를 던진다」로 쳐 주지 않았다.

블록이 값을 주는 모양과 예외를 던지는 모양은 이렇다. 화살표 뒤가 값 하나(case 1 -> 10)나 throw 한 문장이면 중괄호 없이 바로 쓸 수 있다. 둘 다 JDK 21에서 컴파일되는 것을 확인했다.

default -> { int x = 20; yield x; }      // 값을 준다 — switch 전체의 값이 20
default -> throw new AssertionError();   // 던진다 — 대조군이 쓴 모양

먼저 javac로 컴파일하고, 컴파일된 형태만 -ea(assert 검사를 켠다)와 -da(assert 검사를 끈다)로 두 번 실행한다. 컴파일은 .java 파일을 .class 파일로 바꾸는 단계이고, 실행은 그 .class를 java 명령으로 돌리는 별개의 단계다. 실행 옵션은 java -ea 클래스이름, java -da 클래스이름처럼 java 뒤에 붙인다. 대조군은 같은 자리에 throw new AssertionError()를 둔 두 형태다. JDK 21에서 프레임워크 없이 돌렸다.


결과 — switch 표현식은 컴파일 단계에서 막혔다

AssertExpression   컴파일 실패 (에러 1)
  error: switch rule completes without providing a value
    default -> { assert false; }
  (switch rules in switch expressions must either provide a value or throw)

AssertStatement    -ea → AssertionError (exit 1)   ·   -da → 통과, 0 반환
ThrowStatement     -ea → AssertionError            ·   -da → AssertionError
ThrowExpression    -ea → AssertionError            ·   -da → AssertionError

한 줄씩 따라가기. 이름 넷은 각각 형태 2(switch 표현식)에 assert false를 넣은 것(AssertExpression), 형태 1(switch 문)에 assert false를 넣은 것(AssertStatement), 그리고 같은 자리에 throw new AssertionError()를 넣은 대조군을 switch 표현식(ThrowExpression)과 switch 문(ThrowStatement)으로 만든 것이다. 컴파일 실패 (에러 1)의 1은 에러가 한 개 났다는 개수다. (exit 1)은 프로그램이 정상적으로 끝나지 않았다는 종료 코드다. -da → 통과, 0 반환의 0은 종료 코드가 아니라 설계에서 본 classify(9)가 돌려준 값이다. 즉 AssertStatement는 -ea를 켜면 AssertionError가 나서 비정상 종료(exit 1)하고, -da를 켜면(=assert를 끄면) 아무 일도 없이 지나가 0을 돌려주고 정상 종료(종료 코드 0)한다는 뜻이다. ThrowStatement와 ThrowExpression 줄에는 종료 코드를 줄여 적었다. 실험 스크립트가 남긴 원본 기록 result.md(실험 폴더 proofs/assert-false-switch-expression/ 안에 있다)를 보면 네 번 모두 exit=1이다. AssertExpression은 실행까지 가지도 못하고 컴파일 단계에서 이미 에러가 난다. 그 에러 문구 switch rule completes without providing a value는 「switch의 이 갈래(rule)가 값을 내놓지 않은 채 끝난다」는 뜻이고, 괄호 안의 줄은 「switch 표현식의 갈래는 값을 주거나 예외를 던져야 한다」는 뜻이다. 앞에서 설명한 형태 2의 문제를 컴파일러가 그대로 말한 것이다.

판정은 refuted다. 반증 조건의 첫 항목(둘 중 하나가 컴파일되지 않는다)에 걸렸다.


예상과 달랐던 점 — 주장이 뒤집힌 게 아니라 범위가 좁혀졌다

switch 문에서는 주장이 정확히 그대로 관측됐다. -ea를 켜면 멈추고 끄면 조용히 통과하며 0을 돌려준다. 표준이 경계한 위험이 그 형태에서는 실재한다.

switch 표현식에서는 그 위험이 생길 자리가 없다. assert false만 쓴 분기는 값을 돌려주지 않아 컴파일이 거부된다. 더 눈에 띄는 것은 진단 문구(컴파일러가 내놓은 에러 메시지)다.

switch rules in switch expressions must either provide a value or throw

컴파일러가 「값을 주거나 던져라」라고 말한다. 표준이 정한 답(throw new AssertionError())을 컴파일러가 이미 강제하고 있다는 뜻이다.


이 결과가 표준에 남기는 것 — 규칙은 살고 근거의 범위가 줄어든다

규칙(도달 불가 분기에는 AssertionError를 던진다)은 흔들리지 않는다. 오히려 표현식 쪽에서는 컴파일러가 같은 답을 강제한다. 바뀌어야 하는 것은 근거를 어디까지 일반화해 적었는가다. 지금 표준 글(Service 계층 assert 표준)은 switch 문인지 표현식인지 형태를 가리지 않고 말한다.

규칙은 바로 고치지 않는다. 실험이 틀렸을 수도 있기 때문이다. 대신 표준 글의 그 원칙 아래에 > 반증:으로 시작하는 인용 줄을 달아 이 결과를 보이게 하고(마크다운에서 줄 앞에 >를 붙이면 인용 블록으로 보인다. 이 블로그는 실험 결과를 원칙에 달 때 이 모양을 쓴다), GitHub 이슈를 하나 열어 「기존 규칙의 근거와 실험 결과가 어긋난다」는 뜻의 kind: 모순 표시를 붙인 안건 이슈로 다룬다. 거기서 문장에 형태 조건을 붙일지 정한다.


정리

  • switch 문에서는 주장대로다. -ea 끄면 통과(0 반환), 켜면 AssertionError
  • switch 표현식에서는 assert false가 컴파일되지 않는다. 위험이 생길 자리가 없다
  • 컴파일러 진단이 「값을 주거나 던져라」라고 말한다. 표준의 답과 같다
  • 대조군은 두 형태 모두 -ea와 무관하게 멈췄다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기