Assertion은 운영에서 꺼야 할까: Java·Python·TypeScript·Go의 실제 차이

반응형

Assertion은 외부 입력을 검증하는 기능이 아니라 개발자가 이미 참이어야 한다고 믿는 내부 불변식(invariant)을 확인하는 장치다. 운영 환경에서 켜거나 끌 수 있는 언어도 있지만, 프로그램의 올바른 동작이 그 설정에 의존해서는 안 된다.

Java의 assert, Python의 assert, TypeScript의 type assertion과 assertion function, Go의 panic은 이름이나 쓰임이 비슷해 보여도 실행 의미가 다르다. preprod와 production 정책을 정하기 전에 언어별 차이를 분리해야 한다.

먼저 나눌 것: 입력 검증과 내부 불변식

할인율을 받는 함수를 예로 들면 다음 두 검사는 성격이 다르다.

  • 0 <= discount <= 100: 외부 caller가 지켜야 할 public contract이므로 항상 검사해야 한다.
  • 계산 뒤 0 <= result <= original: 앞의 검증과 계산식이 맞다면 참이어야 하는 내부 불변식이다.

첫 번째를 assertion으로 처리하면 assertion이 비활성화됐을 때 잘못된 입력이 통과한다. 인증·권한, 금액, array boundary와 API request validation도 같은 이유로 assertion에 맡기면 안 된다.

목적 실패의 의미 적절한 처리
외부 입력 검증 caller가 contract를 위반 validation error, 명시적 exception·error 반환
복구 가능한 runtime 실패 network, file, dependency 문제 retry·fallback 여부를 판단할 typed error
내부 불변식 확인 code나 설계의 가정이 깨짐 assertion, test failure, 제한적인 panic

Java: assert는 기본적으로 비활성화될 수 있다

Java assertion은 조건이 false일 때 AssertionError를 던진다. JVM 실행 시 -ea 또는 -enableassertions로 전체나 package·class별 assertion을 활성화할 수 있다. 비활성화 상태에서는 조건식과 message 식이 평가되지 않는다.

final class Pricing {
    static long applyDiscount(long priceCents, int percent) {
        if (priceCents < 0 || priceCents > Long.MAX_VALUE / 100) {
            throw new IllegalArgumentException("price out of range");
        }
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("percent out of range");
        }

        long result = priceCents - (priceCents * percent / 100);
        assert result >= 0 && result <= priceCents
                : "discount calculation invariant failed";
        return result;
    }
}

public method의 입력 contract는 IllegalArgumentException으로 항상 유지하고, assert는 계산 뒤의 programmer assumption만 확인한다. assertion 식에는 상태 변경이나 반드시 실행돼야 하는 함수 호출을 넣지 않는다.

java -ea Application
java -ea:com.example.billing... Application

두 번째 형식의 ...은 해당 package와 subpackage를 뜻한다. production에서 assertion을 켤지 여부는 비용과 failure policy를 측정해 정할 수 있지만, AssertionError를 일반적인 사용자 오류처럼 catch해 계속 진행하는 설계는 피한다.

Python: -O에서는 assert가 제거된다

Python의 다음 두 코드는 일반 실행에서는 비슷하게 보일 수 있다.

assert condition, "message"
if __debug__:
    if not condition:
        raise AssertionError("message")

그러나 python -O로 실행하면 __debug__가 false가 되고 assert code가 생성되지 않는다. 따라서 request validation이나 권한 검사에 assert를 쓰면 optimized mode에서 보안과 의미가 바뀐다.

def apply_discount(price_cents: int, percent: int) -> int:
    if price_cents < 0:
        raise ValueError("price_cents must be non-negative")
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")

    result = price_cents - price_cents * percent // 100
    assert 0 <= result <= price_cents
    return result

여기서도 입력 오류는 ValueError, 내부 계산 가정은 assert로 분리했다. production에서 -O를 사용한다면 assertion이 없어져도 return value와 error contract가 같아야 한다. 성능을 위해 무조건 -O를 선택하기보다 실제 workload에서 효과와 debugging 손실을 함께 측정해야 한다.

TypeScript: type assertion과 runtime assertion은 다르다

TypeScript의 value as User 같은 type assertion은 compiler에게 type 정보를 제공할 뿐, 생성된 JavaScript에서 값을 검사하지 않는다.

const user = payload as User; // runtime validation이 아니다

외부 JSON에 이 문법을 적용해도 누락된 field나 잘못된 type은 그대로 남는다. 반면 assertion function은 실제 조건을 확인하고 실패 시 throw하도록 구현할 수 있다.

function invariant(
  condition: unknown,
  message: string,
): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function applyDiscount(priceCents: number, percent: number): number {
  if (
    !Number.isSafeInteger(priceCents) ||
    priceCents < 0 ||
    priceCents > Number.MAX_SAFE_INTEGER / 100
  ) {
    throw new RangeError("priceCents out of range");
  }
  if (!Number.isInteger(percent) || percent < 0 || percent > 100) {
    throw new RangeError("percent out of range");
  }

  const result = priceCents - Math.floor(priceCents * percent / 100);
  invariant(
    Number.isSafeInteger(result) && result >= 0 && result <= priceCents,
    "discount calculation invariant failed",
  );
  return result;
}

asserts condition signature는 조건이 통과한 뒤 type narrowing을 돕는다. 실제 안전성은 함수 본문의 runtime check에서 나온다. bundler가 assertion call을 자동으로 제거한다고 가정해서도 안 된다. 제거하려면 build 설정과 side effect를 명시적으로 검토해야 한다.

console.assert()는 debugging output 용도와 runtime별 동작 차이가 있어 business validation이나 process-failure policy로 쓰기 어렵다. Node.js에서 강제 검사 semantics가 필요하면 node:assert나 명시적으로 throw하는 함수를 사용하되, 외부 입력에는 별도의 schema·validation을 둔다.

Go: built-in assert가 없고 panic은 error 반환과 다르다

Go에는 일반적인 assert keyword나 함수가 없다. caller가 처리할 수 있는 실패는 error로 반환하고, test에서는 testing package로 결과를 확인하는 것이 기본 흐름이다.

package capacity

import "fmt"

func Reserve(capacity, requested int) (int, error) {
	if capacity < 0 || requested < 0 {
		return 0, fmt.Errorf("capacity and requested must be non-negative")
	}
	if requested > capacity {
		return 0, fmt.Errorf("requested exceeds capacity")
	}

	remaining := capacity - requested
	if remaining < 0 { // 앞의 검증이 맞다면 도달할 수 없는 invariant
		panic("negative remaining capacity")
	}
	return remaining, nil
}

panic은 일반적인 validation error를 대신하는 assertion 함수가 아니다. 현재 goroutine의 stack을 unwind하며, recover는 deferred function 안에서만 panicking sequence를 멈출 수 있다. HTTP server처럼 요청 하나의 panic이 process 전체로 번지지 않게 하는 boundary가 있을 수 있지만, 복구 후 원래 함수의 다음 줄부터 계속되는 것은 아니다.

library는 가능한 한 caller가 판단할 error를 반환하고, panic은 package 내부의 실제 불변식 위반이나 process가 안전하게 계속될 수 없는 상황에 제한한다.

preprod와 production 정책은 어떻게 정할까

환경별로 가장 먼저 지킬 원칙은 같다.

  1. validation과 보안 check는 모든 환경에서 동일하게 실행한다.
  2. assertion이 켜지거나 꺼져도 정상 입력의 결과와 외부 contract가 달라지지 않게 한다.
  3. test와 preprod에서는 assertion·property test로 불변식을 적극적으로 확인한다.
  4. production assertion은 성능 비용, 민감 정보가 포함되지 않은 message, process 종료·격리 정책을 함께 검토한다.
  5. assertion failure는 단순 사용자 오류가 아니라 code defect 신호로 관측한다.

“개발에서는 켜고 운영에서는 끈다”는 한 줄 규칙은 네 언어를 설명하지 못한다. Java와 Python은 비활성화 가능성이 의미에 직접 영향을 주고, TypeScript의 type assertion은 애초에 runtime check가 아니며, Go는 명시적 error와 panic의 경계를 설계해야 한다.

외부 request validation의 구체적인 예는 Spring @Valid 입력값 검증에서 이어서 볼 수 있다. 불변식을 이해하기 쉬운 코드로 표현하는 기준은 간결하고 읽기 쉬운 코드 작성과도 연결된다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글