Java의 assert는 개발자들 사이에서 “있지만 거의 쓰지 않는” 기능으로 취급받습니다. 왜일까요?
실제로 assert는 강력한 개발 도구이지만, 기본적으로 비활성화되어 있고, 그 의도가 명확하지 않으면 남용될 수 있기 때문입니다. 이 글에서는 assert를 올바르게 이해하고, 언제 써야 하고 언제 피해야 하는지 명확히 구분하는 방법을 알아봅시다.
Assert의 기본 개념
Assert는 무엇인가?
Assert는 특정 조건이 true인지 확인하는 검증 도구입니다:
// 기본 형태
assert 조건식;
// 메시지와 함께
assert 조건식 : "에러 메시지";
조건이 false일 때 java.lang.AssertionError가 발생합니다. 이름에 Exception이 아니라 Error가 붙어 있다 — Exception이 아니라 Error 계열이라서, catch (Exception e)로는 잡히지 않는다. 보통 잡지 않고 프로그램을 멈추게 두는 쪽이다.
핵심 특징
-ea 옵션 필요"] A --> C["Zero 오버헤드
운영환경"] A --> D["AssertionError 발생
회복 불가능"] A --> E["개발/테스트 용도
검증 도구"] style B fill:#4a5568,stroke:#e2e8f0 style C fill:#4a5568,stroke:#e2e8f0 style D fill:#4a5568,stroke:#e2e8f0 style E fill:#4a5568,stroke:#e2e8f0
위 그림의 “-ea 옵션”은 자바 프로그램을 실행할 때 붙여서 assert 검사를 켜는 JVM 옵션이다. 실제로 어디에 치는지는 바로 아래 명령 예시에서 본다. “운영환경”은 실제 사용자가 쓰는 서비스가 돌아가는 운영 환경을 말한다. “Zero 오버헤드“는, assert가 꺼져 있으면(기본값) 그 검증 코드가 실행 시점에 아예 돌지 않아서 속도에 전혀 영향을 주지 않는다는 뜻이다. 켜져 있을 때만 조건을 검사하는 비용이 든다. “회복 불가능”은 try/catch로 물리적으로 못 잡는다는 뜻이 아니다. assert가 실패했다는 건 코드 어딘가에 이미 예상하지 못한 논리 오류가 있다는 신호라서, 그 자리에서 잡아 프로그램을 정상 상태로 되돌리려 하지 말고 즉시 멈춰서 원인을 고쳐야 한다는 뜻이다.
“기본적으로 비활성화” 라는 점이 가장 중요합니다.
# Assert가 작동하지 않음 (기본값)
java MyClass
# Assert가 활성화됨
java -ea MyClass
# 특정 패키지만 활성화
java -ea:com.mycompany... MyClass
한 줄씩 따라가기. 이 세 줄은 IDE의 실행 버튼 대신 터미널에 직접 치는 명령이다(bash는 그 터미널에서 명령을 받아 실행해 주는 셸의 이름이라 코드 블록에 붙은 표시일 뿐이다). IDE는 실행 버튼을 누르면 컴파일까지 대신 해 주지만, 터미널에서는 먼저 javac MyClass.java로 컴파일해 MyClass.class를 만들어 둔 뒤에 아래 명령을 친다. java MyClass는 MyClass의 main을 실행하고, 옵션이 없으니 assert 문은 전부 그냥 지나간다. java -ea MyClass는 -ea를 붙여서 모든 assert 문이 조건을 검사하게 한다. java -ea:com.mycompany... MyClass는 com.mycompany 패키지에만 켠다 — 패키지 이름 끝의 점 세 개(...)는 오타가 아니라 문법이고, 「그 패키지와 그 아래 하위 패키지 전부」라는 뜻이다(Oracle java 명령 문서).
Assert vs 예외 처리: 명확한 경계 그리기
Assert와 예외 처리의 가장 큰 차이는 “데이터의 출처”입니다.
내가 생성/통제"] A --> C["외부 데이터
API/File/Network"] B --> D["Assert 사용"] C --> E["예외 처리 사용"] style D fill:#48bb78,stroke:#e2e8f0 style E fill:#f56565,stroke:#e2e8f0
위 그림의 외부 데이터 예시 중 API는 다른 프로그램이 기능을 빌려 쓰게 정해 둔 창구를 뜻한다. 외부 API가 응답으로 주는 값은 내 코드 밖에서 오므로 내가 그 값을 통제할 수 없고, 그래서 Assert가 아니라 예외 처리로 검증한다. File은 디스크에 있는 파일에서 읽은 값, Network는 다른 컴퓨터가 인터넷 너머로 보내 온 값이다. 둘 다 내 코드가 만든 값이 아니라는 점에서 API 응답과 같다.
Assert를 사용해야 할 경우
원칙: 내가 완전히 통제할 수 있는 코드 내에서만 사용합니다.
// ✅ 올바른 Assert 사용
// 1. 내가 호출하는 private 메서드
private void processOrder(Order order) {
assert order != null : "Order는 null이 아니어야 함";
// ... 처리 로직
}
// 2. 생성자에서의 불변식 검증
private User(String name, int age) {
assert name != null && !name.isEmpty() : "이름은 비어있으면 안 됨";
assert 0 <= age && age <= 150 : "나이는 0~150 사이여야 함";
this.name = name;
this.age = age;
}
// 3. 알고리즘 후 결과 검증
public void sort(int[] arr) {
// 정렬 로직...
// 불변식: 배열이 정렬되어 있어야 함
for (int i = 0; i < arr.length - 1; i++) {
assert arr[i] <= arr[i + 1] : "배열이 정렬되지 않음";
}
}
한 줄씩 따라가기. 1번은 processOrder가 나만 호출하는 private 메서드라, 그 메서드에 들어오는 order가 null이 아니어야 한다는 전제를 assert로 적어 둔다. 2번은 생성자가 끝난 뒤 name과 age가 항상 올바른 값이어야 한다는 불변식을 검증한다 — 생성자를 통하지 않고는 객체가 만들어질 수 없다. 그런데 이 생성자는 private라서 User 클래스 안의 코드만 부를 수 있다. 바깥에서 들어온 값은 User 안의 공개 메서드(아래 「예외 처리를 사용해야 할 경우」의 createUser 같은 것)가 먼저 if로 검사한 뒤에 이 생성자에 넘긴다고 가정한 예시다. 그래서 이 조건이 깨진다면 그 검사를 빠뜨린, 내가 쓴 다른 코드에 실수가 있다는 뜻이다. 이 글의 코드 조각들은 서로 따로 쓴 예시라서, 뒤에 나오는 테스트·Controller 예시의 new User(...)는 이 private 생성자가 아니라 밖에서 부를 수 있는 생성자를 가진 User를 가정한다. 3번은 sort가 끝난 직후 배열이 항상 오름차순으로 정렬돼 있어야 한다는 불변식을 검증한다 — 정렬 로직을 내가 직접 짰으므로, 결과가 틀렸다면 그 로직 자체에 버그가 있다는 신호다.
예외 처리를 사용해야 할 경우
원칙: 외부 데이터나 다른 개발자가 호출할 가능성이 있는 경우입니다.
// ✅ 올바른 예외 처리
// 1. Public API 메서드
public User createUser(String name, int age) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("이름은 비어있으면 안 됨");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("나이는 0~150 사이여야 함");
}
return new User(name, age);
}
// 2. API 요청 처리
@PostMapping("/users")
public User createUserFromApi(@RequestBody UserDto dto) {
// API 요청은 신뢰할 수 없는 외부 데이터
if (dto == null) {
throw new BadRequestException("요청 본문이 비어있음");
}
// ... 검증 로직
}
// 3. File I/O
public List<User> loadUsersFromFile(String filename) {
try {
File file = new File(filename);
if (!file.exists()) {
throw new FileNotFoundException(filename);
}
// ... 파일 읽기
} catch (IOException e) {
throw new RuntimeException("파일 읽기 실패", e);
}
}
한 줄씩 따라가기. 1번 예시의 createUser는 누구나 호출할 수 있는 공개 메서드라, 들어온 name·age가 올바른지 if로 직접 확인하고 아니면 IllegalArgumentException을 던진다. 2번 예시의 @PostMapping은 “POST 방식으로 /users에 온 요청만 이 메서드가 받는다”는 표시이고, @RequestBody는 요청과 함께 온 JSON을 UserDto 객체로 바꿔서 dto 인자에 담아 준다는 표시다. UserDto는 요청 데이터만 담아 옮기는 그릇인 DTO다. 이렇게 들어온 데이터는 내가 통제할 수 없는 외부 입력이므로 dto == null인지부터 확인한다. 여기서 던지는 BadRequestException은 특정 프레임워크가 기본으로 주는 클래스가 아니라, 요청이 잘못됐을 때 요청을 보낸 쪽에게 「보낸 쪽 잘못」을 뜻하는 400 상태 코드로 응답하려는 의도로 이 예제가 직접 만들었다고 가정한 예외다. 3번 예시의 I/O는 파일 읽기처럼 프로그램이 메모리 밖과 데이터를 주고받는 일이다. 이 예시는 파일이 실제로 있는지(file.exists()) 먼저 보고, 없으면 FileNotFoundException을 던진다. FileNotFoundException은 IOException을 상속한 한 종류라서, 방금 던진 이 예외도 바로 아래 catch (IOException e)가 다시 잡는다. 파일을 읽다 생기는 다른 IOException도 같은 catch로 온다. 거기서 new RuntimeException("파일 읽기 실패", e)로 새 예외를 만들어 던지는데, 두 번째 인자 e는 처음 예외를 원인 예외로 품게 하는 자리다. IOException을 그대로 던지지 않는 이유는, 이 메서드 선언에 throws IOException이 없기 때문이다 — IOException은 Checked 예외라서 throws를 적지 않은 메서드는 그대로 밖으로 던질 수 없고, RuntimeException은 그런 강제가 없다. 이렇게 새 예외 안에 처음 예외를 넣어 던지는 것을 「감싼다」고 한다. 부르는 쪽은 RuntimeException을 받지만, 그 안에 원래의 FileNotFoundException이 남아 있어 진짜 원인을 볼 수 있다.
현실의 경계선: 애매한 경우들
실무에서는 명확한 흑과 백만 존재하지 않습니다. 다음과 같은 경계선상의 경우들을 살펴봅시다:
경우 1: 같은 팀 개발자가 호출하는 메서드
public class StringUtils {
public static String normalize(String input) {
// 같은 팀 개발자가 호출
// assert? 예외?
assert input != null && !input.isEmpty()
: "정규화할 문자열은 비어있으면 안 됨";
return input.trim().toLowerCase();
}
}
결론: Assert 사용 가능합니다. 팀 개발자가 -ea 옵션으로 테스트하면 오류를 빠르게 발견할 수 있습니다.
앞의 「예외 처리를 사용해야 할 경우」는 다른 개발자가 호출할 가능성이 있으면 예외를 쓰라고 했다. 그런데도 이 public 메서드에 assert를 허용하는 건, 여기서 「다른 개발자」가 같은 팀이라 -ea를 켜고 테스트한다는 팀 약속(아래 원칙 1)을 함께 지키기 때문이다. 그 약속 안에서는 null을 넘기는 실수가 테스트 단계에서 AssertionError로 드러난다. 대가도 있다 — 운영 환경에서는 assert가 꺼져 있으므로, 테스트에서 못 잡은 null이 들어오면 assert 줄은 그냥 지나가고 바로 다음 줄 input.trim()에서 NullPointerException이 난다. 팀 밖의 누군가가 부를 수 있는 메서드라면 이 대가를 받아들일 수 없으므로 예외 처리로 돌아간다.
경우 2: 테스트 코드에서의 호출
@Test
void testUserCreation() {
// 테스트 코드는 내부 코드
User user = new User("John", 25);
// 이 생성자는 assert를 가질 수 있음
// 테스트 프레임워크는 보통 -ea로 실행됨
assertThat(user.getName()).isEqualTo("John");
}
한 줄씩 따라가기. @Test는 이 메서드가 테스트 코드 하나라는 표시이고, 테스트 도구가 이 표시가 붙은 메서드를 자동으로 찾아 실행한다. assertThat(user.getName()).isEqualTo("John")은 이 글에서 계속 다루는 자바의 assert 키워드와 이름이 비슷하지만 다른 것이다 — AssertJ 같은 테스트 라이브러리가 제공하는 일반 메서드 호출이고, -ea 옵션과 상관없이 항상 동작한다. 반면 주석에 적힌 “이 생성자는 assert를 가질 수 있음”은 User 생성자 안에 있는 자바 키워드 assert를 가리키고, 이 키워드는 지금까지 설명한 대로 -ea가 켜져 있어야 동작한다. 코드 주석의 「테스트 프레임워크는 보통 -ea로 실행됨」은 테스트를 돌려 주는 도구가 이 옵션을 대신 켜 준다는 말이다. 확인해 보니 Maven으로 테스트를 돌릴 때 쓰는 Surefire 플러그인과 Gradle의 test 작업은 따로 설정하지 않아도 기본으로 assert를 켠다(Surefire 문서의 enableAssertions 기본값 true, Gradle Test 문서의 enableAssertions 기본값 true).
결론: Assert 사용 가능합니다. 테스트 코드는 같은 팀에서 작성/유지보수하므로 내부로 간주됩니다.
경우 3: 검증 계층을 통과한 후
@Controller
public class UserController {
@PostMapping("/users")
public User createUser(@RequestBody @Valid UserDto dto) {
// @Valid로 이미 검증됨
// 이 후는 안전한 데이터
// 이 지점에서는 assert 사용 가능
assert dto.getName() != null;
assert dto.getAge() >= 0;
return new User(dto.getName(), dto.getAge());
}
}
한 줄씩 따라가기. 이 예시의 애너테이션들은 자바 서버를 만드는 틀인 Spring이 읽고 처리해 준다. @Controller는 이 클래스가 외부 요청을 받는 창구라는 표시다. 헤딩의 「검증 계층」은 외부에서 들어온 값을 내부 코드로 넘기기 전에 검사하는 단계(여기서는 @Valid, 앞 예시에서는 if-throw)를 말한다. @Valid는 UserDto에 붙여 둔 검증 규칙(예: “이 필드는 비면 안 된다”)을 이 메서드가 시작되기도 전에 검사하라는 표시다. 규칙을 어기면 createUser 코드는 아예 실행되지 않고, 요청을 보낸 쪽에게 바로 에러 응답이 나간다 — Spring 문서에 따르면 기본 동작은 MethodArgumentNotValidException이 나고 400 상태 코드로 응답하는 것이다. 그러므로 assert dto.getName() != null; 줄에 실행이 도달했다는 것 자체가 “@Valid를 이미 통과한 안전한 값”이라는 뜻이 된다. 그래서 이 지점부터는 외부 데이터를 다시 의심할 필요 없이 assert로 넘어갈 수 있다. 메서드가 User를 반환하면 그 값이 응답으로 어떻게 바뀌어 나가는지는 이 글의 주제가 아니라 다루지 않는다.
아래 결론의 「방어 코드」는 운영 환경에서도 늘 돌면서 잘못된 값을 막는 if-throw 검사를 말한다. assert는 운영 환경에서 꺼지므로 방어 코드가 아니라, 「여기서는 이게 당연히 참이다」라는 내 전제를 적어 두고 개발·테스트 때만 확인하는 장치다. 그래서 assert를 두 줄 넣은 것과 「방어 코드는 더 필요 없다」는 말은 어긋나지 않는다.
결론: 검증 계층 통과 후는 assert 영역입니다. 더 이상의 방어 코드는 필요하지 않습니다.
@Valid/if-throw"] B --> |검증 실패| C["BadRequestException"] B --> |검증 성공| D["내부 처리"] D --> |논리 오류| E["AssertionError
assert 체크"] D --> |정상| F["결과 반환"] style C fill:#f56565,stroke:#e2e8f0 style E fill:#ed8936,stroke:#e2e8f0 style F fill:#48bb78,stroke:#e2e8f0
위 그림은 지금까지의 예시를 한 줄로 이은 것이다. 「검증 실패」 칸의 BadRequestException은 「예외 처리를 사용해야 할 경우」 2번처럼 if-throw로 직접 검사할 때 이 글이 가정한 예외이고, @Valid로 검사했다면 그 자리에 Spring의 MethodArgumentNotValidException이 온다. 어느 쪽이든 결과는 400 응답이다. 검증을 통과한 뒤의 내부 처리에서 assert가 깨지면, 그건 외부 데이터 탓이 아니라 내 코드의 논리 오류라서 AssertionError로 간다.
Assert 올바르게 사용하기: 실무 가이드
번호는 처음 쓸 때 매긴 그대로 두었다 — 원칙 1 다음이 원칙 3이고 원칙 2는 이 글에 없다.
원칙 1: 반드시 -ea 옵션으로 개발/테스트
Assert를 사용하려면 팀 차원에서 다음을 설정해야 합니다:
IDE 설정 (IntelliJ):
- Run → Edit Configurations → VM options에
-ea추가 (VM options는 프로그램을 실행하는 JVM에 넘길 옵션을 적는 칸이다)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-ea</argLine>
</configuration>
</plugin>
test {
jvmArgs += '-ea'
}
CI/CD 파이프라인:
# CI 안에서 프로그램을 띄워 시험할 때 반드시 -ea 포함 (운영 배포에는 붙이지 않는다)
java -ea -jar application.jar
한 줄씩 따라가기. Maven 조각은 XML로 적은 설정이고, 프로젝트 맨 위 폴더의 pom.xml 안 <build> → <plugins> 아래에 넣는다. groupId와 artifactId는 어떤 플러그인인지 가리키는 이름이고, 여기서는 테스트를 실행해 주는 maven-surefire-plugin이다. 그 설정에 <argLine>-ea</argLine>를 추가해, 테스트를 돌릴 때마다 자동으로 -ea 옵션이 켜지게 한다. Gradle에서는 프로젝트 맨 위 폴더의 build.gradle 파일에 test { jvmArgs += '-ea' }를 적어 같은 일을 한다 — 테스트를 실행하는 JVM에 -ea를 추가하라는 뜻이다. 다만 앞의 「경우 2」에서 본 대로 Surefire와 Gradle test는 기본값이 이미 assert 켜짐이라, 이 두 조각은 기본값을 바꾸지 않는다는 것을 설정 파일에 눈에 보이게 적어 두는 역할이다. IntelliJ에서 main을 직접 실행하거나 java 명령으로 실행할 때는 이런 기본값이 없으므로 직접 넣어야 한다.
CI/CD 조각의 java -ea -jar application.jar는 테스트 도구가 아니라 jar로 묶은 프로그램 자체를 실행하는 명령이다(-jar는 「이 jar 파일 안의 main을 실행하라」는 옵션이다). 그래서 이 줄은 CI 안에서 프로그램을 띄워 시험해 보는 단계에만 쓴다. 실제 사용자가 쓰는 운영 환경에 배포할 때는 -ea를 붙이지 않는다 — 붙이면 앞에서 말한 「운영환경 Zero 오버헤드」가 사라진다.
원칙 3: 불변식 검증에 활용
Assert의 가장 강력한 사용법은 알고리즘 불변식 검증입니다:
public int binarySearch(int[] sortedArr, int target) {
// 전제조건: 배열은 정렬되어 있어야 함
assert isSorted(sortedArr) : "배열이 정렬되어 있지 않음";
int left = 0, right = sortedArr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (sortedArr[mid] == target) {
return mid;
} else if (sortedArr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 사후조건: 못 찾고 끝났다면 범위가 비어 있어야 한다 (left가 right를 정확히 1 넘음)
assert left == right + 1;
return -1;
}
// 배열이 정렬되어 있는지 확인
private boolean isSorted(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
return false;
}
}
return true;
}
한 줄씩 따라가기. assert isSorted(sortedArr) : "배열이 정렬되어 있지 않음";은 이 메서드가 시작되기 전에 이미 참이어야 하는 전제조건을 검사한다 — 이진 탐색은 배열이 정렬돼 있다는 가정 위에서만 올바르게 동작하므로, 이 가정이 깨지면 그 뒤의 모든 비교가 무의미해진다. left와 right는 지금 찾아보는 범위의 양 끝 인덱스이고, while 루프는 그 범위가 남아 있는 동안 가운데 값(mid)을 target과 비교해 가며 범위를 절반씩 좁혀 나간다 — 가운데 값이 target보다 작으면 target은 더 오른쪽에 있다는 뜻이므로 left를 mid + 1로 옮기고, 크면 right를 mid - 1로 옮긴다. mid를 (left + right) / 2가 아니라 left + (right - left) / 2로 구하는 건, 배열이 아주 커서 left + right가 int 범위(약 21억)를 넘으면 정수 오버플로로 음수가 나오기 때문이다. 두 식은 평소에는 같은 값을 내고, 뒤의 식은 더하기 전에 차이만 반으로 나눠서 범위를 넘지 않는다(left = 2,000,000,000, right = 2,100,000,000으로 돌려 보면 앞의 식은 -97483648, 뒤의 식은 2050000000이 나왔다). 값을 찾으면 그 자리에서 바로 mid를 반환한다. 루프 아래의 주석과 assert는 메서드가 끝날 때 지켜져야 하는 사후조건을 적어 둔 것이다. 루프는 left <= right가 거짓이 될 때 끝나고, 한 번에 left나 right가 1칸씩만 넘어가므로, 못 찾고 루프를 빠져나왔다면 항상 left == right + 1이다 — target이 모든 값보다 작든(right == -1, left == 0), 모든 값보다 크든(left == sortedArr.length), 값들 사이 어딘가에 있든 마찬가지다.
이 assert는 처음에 assert left == sortedArr.length || right == -1;이었다. 그런데 이 조건은 target이 배열의 양 끝 바깥에 있는 경우만 맞고, 값들 사이에 있는데 배열에 없는 경우(예: {1, 3, 5}에서 2를 찾기)에는 틀린다. -ea를 켜고 돌려 보니 2와 4를 찾을 때 정상 입력인데도 AssertionError가 났다. 사후조건을 잘못 적으면 assert가 버그를 잡는 대신 멀쩡한 코드를 멈춰 세운다는 예이기도 해서, 세 경우 모두에 맞는 left == right + 1로 고쳤다(같은 입력으로 돌려 보니 0·2·4·6 모두 통과했다).
원칙 4: Fail Fast 철학
Fail Fast는 잘못된 상태를 발견한 자리에서 바로 멈추는 방식이다. Assert가 true여야 한다면, false 상황에서는 빠르게 실패해야 합니다:
// ❌ 나쁜 예: 조용히 실패
private void transfer(Account from, Account to, int amount) {
assert from != null;
assert to != null;
// null인 경우 여기서 NullPointerException 발생
int balance = from.getBalance();
// ... 이후 로직이 실행될 수 없음
}
// ✅ 좋은 예: AssertionError로 명확하게 실패
private void transfer(Account from, Account to, int amount) {
assert from != null : "발신자 계정이 null";
assert to != null : "수신자 계정이 null";
assert amount > 0 : "이체 금액은 양수여야 함";
// 여기 도달 시 모든 전제조건이 만족됨
from.withdraw(amount);
to.deposit(amount);
}
한 줄씩 따라가기. 두 예시 모두 assert문 자체는 기본값에서 똑같이 비활성화된다 — 운영 환경에서는 어느 쪽이든 그 줄이 그냥 건너뛰어진다. 그런데도 차이가 나는 곳이 둘 있다. 첫째, 검사하는 전제조건 목록 자체가 다르다 — 나쁜 예는 amount > 0을 아예 검사하지 않는다. 그래서 원칙 1처럼 -ea를 켠 개발·테스트 환경에서도 음수 이체 같은 실수를 끝내 못 잡는다. 좋은 예는 그 조건을 명시해 뒀으므로 같은 환경에서 조건이 깨지면 그 자리에서 바로 걸린다. 둘째, 메시지의 유무다 — -ea가 켜진 환경에서 나쁜 예의 assert from != null;이 실패하면 아무 설명 없는 AssertionError만 뜨고, 어떤 값이 왜 잘못됐는지는 스택 트레이스를 다시 뒤져야 안다. 좋은 예는 : "발신자 계정이 null"처럼 메시지를 달아서 실패한 자리에서 바로 원인이 드러난다. 그리고 운영 환경(assert 비활성)에서는 나쁜 예의 null 검사가 아무 일도 안 하므로, from이 실제로 null이면 assert 때문이 아니라 바로 다음 줄 from.getBalance()가 null을 그대로 참조하다 저절로 NullPointerException을 던지는 것뿐이다. “조용히 실패”는 이렇게 우연한 자동 예외에 기대는 것과, 애초에 걸러낼 장치가 없는 amount 쪽을 함께 가리킨다.
Java는 assert가 기본적으로 비활성화된 이유
이것은 Java의 철학과 관련이 있습니다:
1. 성능 철학
Java는 운영 환경 성능을 최우선으로 합니다. Assert 체크로 인한 성능 저하를 완전히 제거하려면 기본 비활성화가 필수입니다.
2. 명시적 의도
Assert를 기본 비활성화함으로써 개발자에게 “나는 이 코드를 검증하겠다”는 의도를 명시하게 합니다. 실수로 사용되는 것을 방지합니다.
내가 이 말로 뜻한 것은 이렇다 — assert 검사는 누군가 -ea를 일부러 붙인 실행에서만 돈다. 그래서 개발자가 켜겠다고 정하지 않은 곳(대표적으로 운영 환경)에서 assert 검사가 의도치 않게 돌아 프로그램을 멈추는 일이 없다. 이 절의 세 가지 이유는 내 해석이고, Java 설계자가 남긴 문서에서 확인한 것은 아니다.
3. 설계 철학의 차이
- Python/C·C++: Assert를 기본 도구로 간주 (기본 활성화, 끄려면 따로 옵션을 준다)
- Java: Assert를 선택적 도구로 간주 (활성화 선택)
다른 언어와의 비교:
# Python: 기본 활성화 (python -O로 제거 가능)
assert condition, "메시지"
// C++: 기본 활성화 (#define NDEBUG로 제거)
assert(condition);
// Java: 기본 비활성화 (java -ea로 활성화)
assert condition : "메시지";
한 줄씩 따라가기. 세 언어 모두 assert가 있고, 차이는 기본값이 켜짐인지 꺼짐인지다. Python은 그냥 실행하면 assert가 돌고, python -O로 실행하면 assert 문을 아예 코드로 만들지 않는다(Python 언어 문서). C와 C++의 assert는 매크로라서, 소스에서 <assert.h>(C++은 <cassert>)를 불러오기 전에 #define NDEBUG로 NDEBUG라는 이름을 정의해 두면 assert가 아무 일도 안 하는 코드로 바뀐다(C·C++ 표준 라이브러리 설명 사이트 cppreference.com). 둘 다 「켜진 게 기본이고 끄려면 따로 손을 쓴다」는 점에서, 「꺼진 게 기본이고 켜려면 -ea를 준다」는 Java와 반대다.
결론 및 체크리스트
Assert를 올바르게 사용하기 위한 최종 체크리스트입니다:
Assert 사용 전 확인 사항
✅ Assert를 사용해도 좋은 경우:
- 내가 완전히 통제하는 메서드인가?
- 알고리즘의 불변식을 검증하는 것인가?
- 이미 검증 계층을 통과한 데이터인가?
- 팀에서 -ea 옵션으로 개발/테스트하기로 약속했는가?
❌ Assert를 피해야 하는 경우:
- Public API 메서드인가?
- 외부 API, 파일, 네트워크 데이터인가?
- 사용자 입력이나 설정값인가? (설정값은 코드 밖의 설정 파일이나 환경 변수에 사람이 적어 넣는 값이라, 내 코드가 만든 값이 아니다)
- -ea 옵션이 보장되지 않는가?
실무 팁
번호는 처음 쓸 때 매긴 그대로 두었다 — 1번 다음이 3번이고 2번은 이 글에 없다.
- 팀 규약 수립: “우리 팀은 디버그 빌드에서 -ea를 항상 활성화한다” (디버그 빌드는 운영 배포용이 아니라 개발·테스트 때 돌리려고 빌드해 실행하는 것을 말한다)
- 코드 리뷰: PR에서 assert 사용이 적절한지 검토
- 문서화: 메서드의 전제조건과 사후조건을 명확히 주석으로
당신의 경험을 공유해보세요
질문: 당신의 프로젝트에서 assert를 활용하고 있나요?
- 팀 차원에서 -ea를 강제하고 있나요?
- Assert와 예외 처리를 어떻게 구분하고 있나요?
- 혹시 assert로 인해 겪은 문제가 있나요?
댓글로 당신의 경험과 의견을 공유해주세요! 다른 개발자들의 실무 경험이 정말 도움이 됩니다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기