if (order.status == 2) { ... } // ① 매직 넘버. 2 가 뭔지 코드만 봐서는 모른다
public static final int STATUS_PAID = 2;
if (order.status == STATUS_PAID) { ... } // ② 이름은 생겼지만 status 에 99 를 넣어도 컴파일된다
public enum OrderStatus { CREATED, PAID, SHIPPED }
if (order.status == OrderStatus.PAID) { ... } // ③ status 에는 세 값만 올 수 있다. 오타는 컴파일 오류②와 ③의 차이가 핵심입니다. int 상수는 이름만 붙인 숫자라서 타입이 여전히 int 입니다. 상태 필드에 나이를 넣어도 컴파일러는 모릅니다. enum 은 새로운 타입이라 OrderStatus 변수에는 OrderStatus 값만 들어갑니다.
enum 은 문법 설탕이 아니라 진짜 클래스입니다. 컴파일러가 이렇게 바꿉니다.
public final class OrderStatus extends Enum<OrderStatus> {
public static final OrderStatus CREATED = new OrderStatus("CREATED", 0); // 상수 하나 = 인스턴스 하나, 클래스 로드 때 생성
public static final OrderStatus PAID = new OrderStatus("PAID", 1);
private OrderStatus(String name, int ordinal) { ... } // 생성자가 private 이라 외부에서 더 못 만든다
public static OrderStatus[] values() { ... }
public static OrderStatus valueOf(String name) { ... }
}여기서 따라오는 성질이 있습니다.
| 성질 | 이유 | 결과 |
|---|---|---|
== 비교 가능 |
상수당 인스턴스가 하나뿐 | equals 대신 == 를 써도 되고, null 이어도 NPE 가 안 난다 |
switch 가능 |
컴파일러가 상수 목록을 안다 | 화살표 switch 식에서 상수를 빠뜨리면 컴파일 오류 |
| 필드·메서드·생성자 가능 | 클래스니까 | 상태에 설명·코드·동작을 붙일 수 있다 |
| 상속 불가, 인스턴스 생성 불가 | final 클래스 + private 생성자 | "다섯 개뿐" 이 보장된다 |
기본 제공 메서드는 넷을 기억하면 됩니다. name() 은 선언한 이름 문자열, ordinal() 은 선언 순서(0부터), values() 는 전체 배열, valueOf(String) 은 이름으로 찾기(없으면 IllegalArgumentException).
상태마다 화면 표시용 설명과 DB 저장용 코드가 필요하면 필드로 넣습니다.
public enum OrderStatus {
CREATED("주문 접수", 10), PAID("결제 완료", 20), SHIPPED("배송 중", 30), DELIVERED("배송 완료", 40), CANCELLED("취소", 90);
private final String label;
private final int code;
OrderStatus(String label, int code) { this.label = label; this.code = code; } // 상수 목록 뒤에 세미콜론, 그다음 필드·생성자
public static OrderStatus fromCode(int code) { for (OrderStatus s : values()) if (s.code == code) return s; throw new IllegalArgumentException(...); }
}DB 에는 code 를 저장하고 ordinal() 은 절대 저장하지 않습니다. ordinal() 은 선언 순서라서 나중에 상수를 중간에 하나 끼우면 기존 데이터의 의미가 전부 밀립니다. name() 을 문자열로 저장하는 것은 괜찮지만 이름을 바꾸면 깨지므로, 바뀌지 않을 별도 코드를 두는 것이 실무 관례입니다.
동작도 넣을 수 있습니다. next() 처럼 자기 상태에 따라 분기하는 메서드는 enum 안에 두면 상태 전이 규칙이 한곳에 모입니다. 상수마다 동작이 완전히 다르면 추상 메서드를 선언하고 상수별로 구현합니다(예제 2 의 PaymentMethod). 결제 수단별 if-else 가 사라지고, 새 수단을 추가할 때 빠뜨린 곳이 컴파일 오류로 드러납니다.
enum 을 키로 쓰는 Map 은 HashMap 대신 EnumMap 을 씁니다. 내부가 ordinal() 인덱스의 배열이라 해시 계산이 없고, 출력 순서가 상수 선언 순서로 고정됩니다. 상태별 집계, 요일별 설정처럼 "키가 enum 전체" 인 경우 항상 이쪽입니다.
EnumSet 도 같은 원리로 비트 연산이라 contains 가 매우 빠르며, EnumSet.of(CREATED, PAID) 처럼 "취소 가능한 상태 집합" 을 표현할 때 씁니다.
pkg/
├── PkgMain.java (패키지 없음, 기본 패키지)
└── shop/
└── Product.java → 첫 줄에 package shop;규칙은 셋입니다. 파일의 package 선언과 폴더 경로가 일치해야 하고, 클래스의 완전한 이름은 shop.Product 이며, 다른 패키지에서 쓰려면 import shop.Product; 를 하거나 완전한 이름으로 씁니다.
실무에서는 com.company.project.order.service 처럼 도메인 역순 + 프로젝트 + 계층으로 깊게 나눕니다. 이 레슨 사이트의 다른 예제들이 패키지를 안 쓰는 것은 javac *.java && java Main 한 줄로 실행하려는 편의이고, 실무 코드에 기본 패키지는 없습니다.
import 는 세 가지가 있습니다.
| 형태 | 뜻 | 주의 |
|---|---|---|
import java.util.List; |
클래스 하나 | 가장 명확. IDE 가 자동으로 넣어 준다 |
import java.util.*; |
패키지 안 클래스 전부 | 하위 패키지는 포함 안 됨. 이름 충돌 시 컴파일 오류 |
import static java.lang.Math.max; |
정적 멤버 | Math.max 를 max 로. 테스트 코드의 assertEquals 가 이것 |
이름 충돌은 실무에서 한 번은 겪습니다. java.util.Date 와 java.sql.Date 를 한 파일에서 둘 다 쓰면 하나는 import 하고 다른 하나는 java.sql.Date 처럼 완전한 이름으로 써야 합니다. java.lang 패키지(String, Math, System)는 import 없이 항상 보입니다.
패키지는 접근 제어와 연결됩니다. 접근 제어자를 안 쓴 멤버(default)는 같은 패키지 안에서만 보입니다. 05 레슨의 캡슐화 표에서 protected 와 default 가 "같은 패키지" 를 기준으로 갈리는 이유가 여기 있고, 예제 5 에서 internalCode() 를 밖에서 부르면 컴파일 오류가 나는 것으로 확인합니다.
인스턴스 필드는 객체마다 따로 있습니다. static 필드는 클래스에 하나만 있고 모든 객체가 공유합니다. 지금까지 만든 객체 수, 설정값, 캐시처럼 "객체가 아니라 클래스 전체의 것" 이 static 입니다.
public class Counter {
private static int created = 0; // Counter 클래스에 하나
private final int id; // Counter 객체마다 하나
public Counter() { id = ++created; }
public static int created() { return created; } // 객체 없이 Counter.created() 로 호출
}static 메서드는 인스턴스 필드에 접근할 수 없습니다. Counter.created() 를 부를 때 "어느 객체의 id" 인지 정해지지 않았기 때문입니다. main 이 static 이라 그 안에서 인스턴스 메서드를 바로 못 부르고 new 를 먼저 하는 이유도 같습니다. 유틸 메서드(Math.max, Arrays.toString)처럼 객체 상태가 필요 없는 것이 static 메서드의 자리입니다.
static { ... } 블록은 클래스가 처음 로드될 때 딱 한 번 실행됩니다. 설정 파일 읽기, 룩업 테이블 초기화에 씁니다. 예제 3 에서 첫 new Counter() 직전에 한 번만 찍히는 것을 볼 수 있습니다.
final 은 붙는 자리에 따라 뜻이 다릅니다.
| 자리 | 뜻 | 예 |
|---|---|---|
| 변수·필드 | 한 번 대입하면 다시 못 바꿈 | final int id; 생성자에서 딱 한 번 대입 |
| 메서드 | 하위 클래스가 오버라이드 못 함 | 상속 레슨에서 다룸 |
| 클래스 | 상속 못 함 | String, 모든 enum, record |
가장 흔한 오해는 final = 불변 입니다. final List<String> items 는 "items 변수가 다른 리스트를 가리킬 수 없다" 는 뜻이지, 리스트 안의 내용은 add 로 바뀝니다. 배열도 요소는 바뀝니다. 내용까지 얼리려면 List.copyOf 같은 불변 컬렉션이나 불변 클래스(String, BigDecimal)를 써야 합니다. 예제 4 가 이 둘을 나란히 보여줍니다.
public static final 이 상수인 이유는 이제 분리됩니다. static 으로 클래스에 하나만 두고, final 로 바뀌지 않게 하고, 이름은 MAX_RETRY 처럼 대문자와 밑줄로 씁니다. 지역 변수에도 final 을 붙일 수 있고, 람다에서 바깥 변수를 쓸 때 "사실상 final" 이어야 한다는 규칙이 고급 레슨에서 나옵니다.