Java static·final·예외·컬렉션·접근 제어
static과 final의 제한 대상, Java 예외의 분류와 처리 흐름, List·Set·Map의 차이, 접근 제한자의 범위를 구분한다. 참조를 바꾸지 못하는 것과 객체 내용을 바꾸지 못하는 것은 다르다.
static과 final의 제한 대상, Java 예외의 분류와 처리 흐름, List·Set·Map의 차이, 접근 제한자의 범위를 구분한다. 참조를 바꾸지 못하는 것과 객체 내용을 바꾸지 못하는 것은 다르다.
그림으로 확인하기

static: 클래스에 연결된 멤버
static 필드와 인스턴스 필드
필드가 static이면 그 필드는 특정 객체 하나에 속하지 않고 클래스에 속한다. 객체가 0개이거나 여러 개여도 로드된 해당 클래스에는 static 필드가 하나 존재한다.
class Counter {
static int total;
int own;
}
Counter a = new Counter();
Counter b = new Counter();
a.total++;
a.own++;
b.total++;
b.own++;
문법상 객체 참조를 통해 a.total처럼 정적 필드에 접근할 수 있지만, 정적 멤버라는 사실이 흐려지므로 일반적으로 Counter.total처럼 클래스 이름으로 접근한다.
상태는 다음처럼 나뉜다.
| 대상 | Counter.total | a.own | b.own |
|---|---|---|---|
| 초기값 | 0 | 0 | 0 |
| 위 코드 실행 후 | 2 | 1 | 1 |
total은 두 객체가 공유하지만 own은 객체마다 별도다. 시험에서 객체 수를 세는 문제는 먼저 필드 선언에 static이 있는지 표시해야 한다.
static 메서드에는 현재 객체가 없다
인스턴스 메서드는 특정 객체를 대상으로 호출되므로 실행 중 this가 현재 객체를 가리킨다. static 메서드는 특정 객체 없이 호출되므로 this와 super를 사용할 수 없다.
class Account {
static int count;
int balance;
static int getCount() {
return count;
}
int getBalance() {
return balance;
}
}
다음 형태는 컴파일되지 않는다.
class Account {
int balance;
static int invalid() {
// return balance; // 인스턴스가 정해지지 않음
// return this.balance; // static 문맥에는 this가 없음
return 0;
}
}
정적 메서드가 인스턴스 필드나 인스턴스 메서드를 사용하려면 대상 객체를 명시적으로 받아야 한다.
static int readBalance(Account account) {
return account.balance;
}
핵심은 “정적 메서드가 인스턴스 멤버를 절대 사용할 수 없다”가 아니다. 현재 객체를 자동으로 갖지 않으므로 어떤 객체를 사용할지 명시해야 한다는 뜻이다.
정적 초기화
정적 필드 초기화식과 static 초기화 블록은 클래스 초기화 과정에서 실행된다. 같은 클래스 안에서는 일반적으로 소스에 적힌 순서대로 정적 필드 초기화식과 정적 초기화 블록을 처리한다.
class Config {
static int first = init("first", 1);
static {
System.out.println("block");
}
static int second = init("second", 2);
static int init(String name, int value) {
System.out.println(name);
return value;
}
}
처음으로 클래스 초기화가 필요한 적극적 사용이 발생하면 위 초기화가 한 번 수행된다. 다만 컴파일 시간 상수의 단순 사용처럼 클래스 초기화를 일으키지 않을 수 있는 예외가 있으므로, 모든 static 필드 참조가 반드시 초기화 블록을 실행한다고 단정하면 안 된다.
static 메서드는 오버라이딩되지 않는다
하위 클래스가 상위 클래스의 같은 시그니처 static 메서드를 선언하면 인스턴스 메서드 오버라이딩이 아니라 정적 메서드 숨김이다. 호출할 메서드는 실제 객체 형이 아니라 컴파일 시점의 형을 기준으로 정해진다. 이 규칙의 자세한 호출 판정은 앞 이론의 오버로딩·오버라이딩과 연결된다.
final: 적용 대상에 따라 달라지는 제한
final 변수는 한 번만 대입한다
지역 변수, 매개변수, 필드에 final을 붙이면 해당 변수는 한 번 대입된 뒤 다른 값을 다시 대입할 수 없다.
final int limit = 3;
// limit = 4; // 컴파일 오류
선언과 동시에 초기화하지 않은 final 변수를 빈 final 변수(blank final variable) 라고 한다. 인스턴스의 빈 final 필드는 모든 생성 경로에서 한 번 대입되어야 한다.
class Member {
final int id;
Member(int id) {
this.id = id;
}
}
생성자에서 대입한 뒤 다시 바꾸는 것은 허용되지 않는다. static final 빈 필드는 정적 초기화식이나 정적 초기화 블록 등 허용된 초기화 경로에서 정확히 한 번 대입해야 한다.
기본형 final과 참조형 final
기본형 변수는 값 자체를 저장한다. 기본형 final 변수는 저장된 값을 다시 바꿀 수 없다.
final int score = 100;
참조형 변수는 객체를 가리키는 참조값을 저장한다. 참조형 final 변수는 다른 객체를 가리키도록 재대입할 수 없지만, 현재 가리키는 객체의 상태 변경까지 자동으로 막지는 않는다.
import java.util.ArrayList;
import java.util.List;
final List<String> names = new ArrayList<>();
names.add("Kim"); // 가능
names.set(0, "Lee"); // 가능
// names = new ArrayList<>(); // 컴파일 오류
배열도 객체이므로 같은 원리가 적용된다.
final int[] values = {1, 2};
values[0] = 9; // 가능
// values = new int[]{3, 4}; // 컴파일 오류
따라서 다음 세 문장을 구분해야 한다.
final참조: 변수의 재대입을 막는다.- 변경 불가 컬렉션: 컬렉션을 통한 구조 변경 연산을 막는다.
- 불변 객체: 외부에서 관찰되는 객체 상태가 생성 후 바뀌지 않도록 전체 설계한다.
final 메서드와 final 클래스
class Parent {
final void fixed() { }
}
final 메서드는 하위 클래스에서 오버라이딩할 수 없다. 다만 이름이 같아도 매개변수 목록이 다른 메서드를 새로 오버로딩하는 것은 별개의 문제다.
final class Utility { }
final 클래스는 하위 클래스를 만들 수 없다. 클래스의 모든 객체가 자동으로 불변이라는 뜻은 아니다. final 클래스 안에 변경 가능한 필드와 변경 메서드가 있으면 객체 상태는 바뀔 수 있다.
Java 예외 계층과 검사 규칙
기본 계층
예외와 오류는 Throwable 계층으로 표현된다.
Throwable
├─ Error
└─ Exception
├─ RuntimeException
└─ 그 밖의 일반적인 검사 예외
시험에서는 다음처럼 분류한다.
| 분류 | 대표 관계 | 컴파일 시 처리·선언 의무 | 일반적인 의미 |
|---|---|---|---|
| 검사 예외 | Exception의 하위이지만 RuntimeException 계열은 아님 | 있음 | 파일·네트워크 등 호출자가 처리하거나 전달할 수 있는 실패를 API 계약에 나타냄 |
| 실행 예외 | RuntimeException과 그 하위 | 없음 | 잘못된 인자, 널 참조, 잘못된 형 변환 등 실행 중 계약 위반이나 프로그램 오류를 자주 표현 |
| 오류 | Error와 그 하위 | 없음 | JVM 자원 고갈 등 일반 애플리케이션이 보통 복구 대상으로 삼지 않는 심각한 문제 |
검사 예외라고 반드시 복구 가능하고, 실행 예외라고 절대 잡으면 안 된다는 뜻은 아니다. 분류의 핵심은 컴파일러의 처리·선언 검사 여부다.
catch (Exception e)는 Exception 계열을 잡지만 그 형제 계층인 Error까지 잡지는 않는다. Throwable을 잡으면 둘 다 포함될 수 있지만 일반적인 애플리케이션 코드에서 넓게 잡는 것은 신중해야 한다.
throw와 throws
throw는 실행문에서 실제 예외 객체 하나를 발생시킨다.
if (amount < 0) {
throw new IllegalArgumentException("amount");
}
throws는 메서드나 생성자 선언부에서 밖으로 전달될 수 있는 예외 형을 적는다.
static String readText() throws java.io.IOException {
return "data";
}
검사 예외가 메서드 밖으로 전달될 가능성이 있으면 해당 메서드는 다음 중 하나를 해야 한다.
- 적절한
catch에서 처리한다. throws절에 그 예외 또는 상위 형을 선언하여 호출자에게 전달한다.
비검사 예외도 문법상 throws에 적을 수 있지만 컴파일러가 선언을 의무화하지 않는다. throws를 적었다고 메서드가 호출될 때마다 반드시 예외가 발생하는 것도 아니다.
try·catch·finally의 제어 흐름
예외가 발생하지 않은 경우
try {
System.out.println("try");
} catch (RuntimeException ex) {
System.out.println("catch");
} finally {
System.out.println("finally");
}
System.out.println("after");
예외가 없으면 try → finally → 이후 문장 순서로 진행한다.
try
finally
after
예외가 발생한 경우
try 안에서 예외가 발생하면 남은 문장은 실행하지 않는다. 현재 try의 catch를 위에서부터 검사하여 실제 예외 객체와 대입 호환되는 첫 번째 처리기를 선택한다.
try {
System.out.println("start");
Integer.parseInt("x");
System.out.println("end");
} catch (NumberFormatException ex) {
System.out.println("number");
} finally {
System.out.println("cleanup");
}
start
number
cleanup
end는 실행되지 않는다.
catch 순서
하위 예외 형을 먼저, 넓은 상위 형을 뒤에 둔다.
try {
// 작업
} catch (java.io.FileNotFoundException ex) {
// 더 구체적인 처리
} catch (java.io.IOException ex) {
// 더 넓은 처리
}
반대로 IOException을 먼저 두면 뒤의 FileNotFoundException 처리기는 도달할 수 없으므로 컴파일 오류다.
여러 예외를 같은 방식으로 처리하면 다중 catch를 사용할 수 있다.
try {
// 작업
} catch (java.io.IOException | java.sql.SQLException ex) {
System.out.println(ex.getMessage());
}
다중 catch의 대안 형끼리는 상하위 관계일 수 없다. 예를 들어 IOException | FileNotFoundException처럼 한쪽이 다른 쪽의 하위 형이면 허용되지 않는다.
finally는 언제 실행되는가
finally는 try 또는 catch가 정상 종료되거나 return, break, continue, 예외 발생 등으로 갑자기 종료될 때에도 일반적으로 실행된다. 그래서 잠금 해제나 임시 상태 복구 같은 정리 작업에 사용할 수 있다.
하지만 “어떤 상황에서도 무조건 실행된다”는 설명은 정확하지 않다. JVM 프로세스가 강제로 종료되거나 가상 머신이 비정상적으로 중단되는 상황에서는 실행이 보장되지 않는다.
finally 안에서 새 예외를 던지거나 return하면 앞서 발생한 예외나 반환값을 가릴 수 있다.
static int dangerous() {
try {
return 1;
} finally {
return 2;
}
}
위 메서드의 결과는 2다. 그러나 이런 코드는 원래 흐름과 예외를 숨기므로 사용하지 않는 것이 안전하다.
예외를 잡지 못하면 상위 호출자로 전파된다
현재 메서드에서 일치하는 catch를 찾지 못하면 메서드 실행이 갑자기 끝나고 호출자 쪽으로 예외가 전달된다. 호출 스택을 따라 올라가며 처리기를 찾고, 끝까지 찾지 못하면 해당 스레드의 미처리 예외가 된다.
finally가 있다면 각 스택 프레임을 빠져나가는 과정에서 해당 정리 블록이 수행될 수 있다. 따라서 출력 문제는 “예외 발생 지점 → 현재 catch 탐색 → finally → 호출자 전파” 순서로 추적한다.
Java 컬렉션 계층의 기본 구조
Collection과 Map
대표 구조는 다음처럼 이해한다.
Iterable
└─ Collection
├─ List
├─ Set
└─ Queue / Deque
Map ← Collection의 하위가 아닌 별도 인터페이스
Collection<E>는 여러 원소를 한 그룹으로 다루는 계층의 루트 인터페이스다. Map<K, V>은 키와 값의 대응을 다루므로 이 계층에 속하지 않는다.
다만 Map은 내용을 컬렉션 형태로 볼 수 있는 뷰를 제공한다.
keySet(): 키의Setvalues(): 값의CollectionentrySet(): 키-값 쌍의Set
값은 중복될 수 있으므로 values()의 반환형은 Set이 아니다.
Collection과 Collections
java.util.Collection<String> values;
java.util.Collections.sort(list);
Collection: 원소 그룹의 공통 연산을 정의하는 인터페이스Collections: 정렬, 역순, 변경 불가 뷰 생성 같은 정적 도우미 메서드를 제공하는 클래스
끝의 s 유무만 다른 별개의 대상이다.
제네릭 원소 형
컬렉션 제네릭에는 기본형을 직접 사용할 수 없다.
// List<int> invalid; // 컴파일 오류
java.util.List<Integer> valid;
또한 제네릭은 기본적으로 불공변이다.
java.util.List<Integer> integers = new java.util.ArrayList<>();
// java.util.List<Number> numbers = integers; // 컴파일 오류
Integer가 Number의 하위 타입이어도 List<Integer>가 List<Number>의 하위 타입이 되는 것은 아니다.
List·Set·Map 선택 기준
핵심 계약 비교
| 인터페이스 | 핵심 계약 | 중복 | 위치·순서 | 대표 연산 |
|---|---|---|---|---|
List<E> | 순서가 있는 원소 열과 정수 인덱스 | 일반적으로 허용 | 있음 | get, set, add, remove |
Set<E> | 같은 원소를 둘 이상 보관하지 않음 | 허용하지 않음 | 구현체에 따라 다름 | add, contains, remove |
Map<K,V> | 고유한 키 하나에 값 하나를 대응 | 키 중복 불가, 값 중복 가능 | 구현체에 따라 다름 | put, get, containsKey, remove |
Set.add(value)는 동등한 원소가 이미 있으면 보통 false를 반환하고 구조를 바꾸지 않는다. Map.put(key, value)는 같은 키가 있으면 새 엔트리를 하나 더 만드는 것이 아니라 기존 값을 교체하며 이전 값을 반환할 수 있다.
List
List는 원소의 순서와 위치를 보존하고 인덱스로 접근한다. 같은 값이 여러 번 들어갈 수 있다.
java.util.List<String> list = new java.util.ArrayList<>();
list.add("A");
list.add("B");
list.add("A");
System.out.println(list.get(1)); // B
정수 래퍼를 저장한 목록에서는 remove 오버로드를 주의한다.
java.util.List<Integer> numbers =
new java.util.ArrayList<>(java.util.List.of(10, 20, 30));
numbers.remove(1); // 인덱스 1의 값 20 제거
numbers.remove(Integer.valueOf(30)); // 값 30 제거
Set
Set은 동등한 원소를 중복 보관하지 않는다. 일반적인 해시 기반 Set은 equals와 hashCode를 이용해 중복 여부를 판단한다.
java.util.Set<String> set = new java.util.HashSet<>();
System.out.println(set.add("A")); // true
System.out.println(set.add("A")); // false
Set이라는 인터페이스 이름만으로 순회 순서를 단정할 수 없다. 순서 보장 여부는 구현체 계약을 확인해야 한다.
Map
Map은 키를 이용해 값을 저장하고 검색한다.
java.util.Map<String, Integer> scores = new java.util.HashMap<>();
scores.put("Kim", 80);
scores.put("Lee", 90);
scores.put("Kim", 95);
마지막 put은 Kim 키의 값을 80에서 95로 바꾼다. 키는 하나지만 값 95가 다른 키에도 저장되는 것은 가능하다.
get(key)가 null을 반환하면 키가 없을 수도 있고, 해당 키에 실제로 null 값이 연결되어 있을 수도 있다. 구현체가 널 값을 허용하는 경우 두 상태를 구분하려면 containsKey(key)도 확인한다.
대표 컬렉션 구현
| 구현체 | 중심 특성 |
|---|---|
| ArrayList | 배열 기반 목록, 인덱스 접근에 적합 |
| LinkedList | 연결 구조 기반 목록, 원소 탐색과 연결 변경 비용을 구분 |
| HashSet·HashMap | 해시 기반, 반복 순서를 보장하지 않음 |
| LinkedHashSet·LinkedHashMap | 일반적인 사용에서 삽입 순서 유지 |
| TreeSet·TreeMap | 정렬 기준에 따라 원소 또는 키의 순서 유지 |
List는 같은 값을 여러 번 저장할 수 있다. Set는 같은 것으로 판단되는 원소의 중복을 허용하지 않는다. Map은 키마다 하나의 값을 대응시키며, 같은 키에 새 값을 넣으면 보통 기존 값을 교체한다. Map의 값은 중복될 수 있다.
해시 기반 컬렉션에서는 객체의 동등성 판단과 해시값이 함께 사용된다. 같은 것으로 비교되는 객체는 같은 해시값을 가져야 한다. 여기서 내부 버킷 구조나 충돌 처리 구현까지 외울 필요는 없다.
final List<String> list는 list가 다른 객체를 가리키도록 재대입하지 못한다는 뜻이다. 가리키는 목록이 변경 가능하다면 add나 remove는 가능하다.
접근 제어
다음은 일반적인 최상위 클래스의 멤버 접근 범위를 비교한 것이다.
| 접근 제한 | 같은 클래스 | 같은 패키지 | 다른 패키지의 하위 클래스 | 그 밖의 클래스 |
|---|---|---|---|---|
| public | 가능 | 가능 | 가능 | 가능 |
| protected | 가능 | 가능 | 상속 관계와 접근 방식의 조건 아래 가능 | 불가 |
| 생략: 패키지 접근 | 가능 | 가능 | 불가 | 불가 |
| private | 가능 | 불가 | 불가 | 불가 |
접근 제한자를 생략하면 패키지 접근이며, default라는 키워드를 붙이는 방식이 아니다. protected는 같은 패키지에서도 사용할 수 있으므로 “자식 클래스만 접근 가능”이라고 설명하면 부정확하다. 다른 패키지의 하위 클래스에서는 임의의 상위 클래스 객체를 통해 모든 protected 멤버에 자유롭게 접근할 수 있는 것은 아니다.
캡슐화는 내부 상태를 감추고 필요한 연산만 공개하여 올바른 상태를 유지하게 하는 것이다. private 필드에 대해 무조건 모든 setter를 공개하는 것이 목적은 아니다. 상위 메서드를 오버라이딩할 때는 접근 범위를 더 좁힐 수 없다.
자동 자원 정리의 기본
try-with-resources는 파일 같은 자원을 try 문에 선언하고 사용 후 자동으로 닫도록 돕는 문법이다. 일반 finally를 이용한 정리와 마찬가지로 성공·실패 경로에서 자원이 남지 않게 하는 데 목적이 있다. 예외 처리와 자원 해제의 역할은 서로 구분한다.
자원 종료 순서와 반환값의 구분
try-with-resources에는 AutoCloseable 계약에 맞는 자원을 사용한다. 여러 자원을 순서대로 초기화해 모두 성공했다면 종료 시에는 선언·초기화의 역순으로 close를 호출한다. 예를 들어 자원 A 다음 B를 열면 B, A 순서로 닫는다. 모든 자원이 초기화되기 전에 실패하면 성공적으로 초기화된 자원만 종료 대상이 된다.
return x는 반환할 값을 먼저 정하고 finally를 거쳐 호출자에게 전달한다. finally에서 지역 변수 x의 값만 바꾸어도 이미 정한 기본형 반환값이 바뀌지는 않는다. 다만 finally가 새로 return하거나 예외를 던지면 앞선 반환·예외를 덮을 수 있으므로, finally에서는 불필요한 제어 흐름 변경을 피한다.