
Singleton, Interface, Enum, Factory 패턴, 예외처리
강사님이 서버-클라이언트 예시로 설명해주셨다. 네트워크 소켓 연결이나 DB 커넥션 풀처럼 딱 하나만 있어야 하는 자원이 있다. 이런 걸 매번 new로 생성하면?
그래서 "이 클래스에서 객체는 딱 하나만 만들어지도록 보장"하는 패턴이 Singleton이다. java.lang.Runtime이 대표적인 표준 라이브러리 Singleton이다.
1. 해당 클래스 타입의 속성을 static으로 선언
2. 생성자를 private으로 선언 ← 외부에서 new 못 하게 차단
3. 객체를 반환하는 메서드를 public static으로 선언
방식 1: Eager Initialization
public class MySingleton {
private static MySingleton instance = new MySingleton(); // 클래스 로드 시 즉시 생성
private MySingleton() {}
public static MySingleton getInstance() { return instance; }
}
Thread Safe하지만 getInstance()를 한 번도 안 호출해도 객체가 생긴다. 가볍고 반드시 쓰이는 객체라면 괜찮다.
방식 2: Lazy Initialization - 멀티스레드 환경에서 쓰면 안 된다
public class MySingleton {
private static MySingleton instance;
private MySingleton() {}
public static MySingleton getInstance() {
if (instance == null) {
instance = new MySingleton(); // 처음 호출될 때 생성
}
return instance;
}
}
Thread A와 Thread B가 동시에 instance == null을 확인하면 둘 다 new MySingleton()을 실행해서 인스턴스가 두 개 만들어진다. 이게 Race Condition이고, Singleton이 깨진다.
방식 3: LazyHolder (Bill Pugh) - Best Practice
public class MySingleton {
private static class LazyHolder {
private static final MySingleton instance = new MySingleton();
}
private MySingleton() {}
public static MySingleton getInstance() {
return LazyHolder.instance;
}
}
LazyHolder 클래스는 getInstance()가 처음 호출되기 전까지 JVM에 로드되지 않는다synchronized 없이도 안전하다// 같은 객체인지 검증
MySingleton s1 = MySingleton.getInstance();
MySingleton s2 = MySingleton.getInstance();
System.out.println(s1.hashCode()); // 1829164700
System.out.println(s2.hashCode()); // 1829164700 → 동일
아직 스레드를 배우지 않았으니 간단하게만. 자바 서버는 여러 클라이언트 요청을 동시에 처리하는데, 각 요청이 별도의 스레드(Thread) 로 돌아간다. Thread Safe란 여러 스레드가 동시에 같은 코드에 접근해도 문제없는 상태다. 스레드는 나중에 제대로 배우니 지금은 이 정도만.
"Singleton을 설명하고 Thread Safe하게 구현하는 방법을 말해보세요."
LazyHolder 패턴. static nested class는 참조 전까지 JVM에 로드되지 않고, 클래스 로딩 자체가 JVM에 의해 thread-safe하게 처리된다. synchronized나 volatile 없이 Lazy + Thread Safe + 고성능 세 가지를 동시에 달성한다.
abstract는 속성에는 쓸 수 없고 메서드와 클래스에만 사용 가능하다. static, final과는 함께 못 쓴다. static은 고정된 구현, final은 오버라이딩 금지인데, abstract는 구현을 비워두고 반드시 오버라이딩을 요구하니 서로 모순이기 때문이다.
public abstract void bark(); // 선언부만, 구현부 없음. 세미콜론으로 끝남
sub 클래스에서 반드시 오버라이딩해야 한다. 안 하면 컴파일 에러. 추상 클래스는 직접 객체 생성 불가, 다형성으로는 사용 가능하다.
Animal[] animals = new Animal[2]; // 배열 생성은 OK
animals[0] = new Dog();
animals[1] = new Duck("채리필터", "오리날다", 1997);
for (Animal animal : animals) {
animal.bark(); // Virtual Invocation → 각자의 구현 호출
animal.special();
}
// 선언
[public] interface 인터페이스명 [extends 인터페이스1, 인터페이스2, ...] { }
// 구현
class 클래스명 extends 부모클래스 implements 인터페이스1, 인터페이스2, ... { }
Interface는 상수와 추상 메서드로만 구성된다. 명시하지 않아도 컴파일러가 자동 추가한다.
interface Trans {
int INIT = 10; // 컴파일 시 → public static final int INIT = 10;
void start(); // 컴파일 시 → public abstract void start();
void stop();
}
클래스가 다중 상속을 막은 이유는 다이아몬드 문제(두 부모에게 같은 메서드를 물려받을 때 발생하는 충돌과 모호성) 때문이다. 부모 A, B가 같은 이름의 메서드를 가지고 있을 때 자식이 어느 쪽을 써야 할지 모호해진다. Interface는 구현이 없는 추상 메서드만 가지고 있어서 이 문제가 발생하지 않는다. 자식이 직접 구현하면 되니까.
// SuperMan은 Human을 상속하면서 Flyer, Serializable 인터페이스를 동시에 구현
class SuperMan extends Human implements Flyer, Serializable {
public void fly() { System.out.println("하늘을 날아서 지구를 구해야지"); }
public void stop() { System.out.println("그만 움직이고 쉬어야징!!"); }
}
// 다형성: SuperMan 객체를 여러 타입으로 참조 가능
SuperMan clark = new SuperMan("클라크");
Human h = clark;
Trans t = clark;
Flyer f = clark;
Cloneable, Serializable처럼 메서드가 아무것도 없는 인터페이스다. "이 클래스가 특정 책임/능력을 가지고 있음"을 표시(mark) 하는 용도다. JVM이나 프레임워크가 이 마크를 확인해서 동작 방식을 결정한다. clone()을 쓰려면 반드시 Cloneable을 구현해야 하고, 안 하면 CloneNotSupportedException이 발생한다.
오늘 수업에서 나온 개념. Interface 설계 원칙이기도 하다.
Interface를 보면 그 클래스가 어떤 책임을 가지고 있는지 바로 알 수 있다. SuperMan implements Flyer, Serializable만 봐도 "날 수 있고, 직렬화 가능하다"는 게 보인다. Interface가 책임을 명시적으로 드러내는 도구가 되는 셈이다.
Java 8부터 default 메서드와 static 메서드가 추가됐다. Java 9부터는 private 메서드도 된다.
interface Flyer extends Cloneable, Trans {
void fly(); // 추상 메서드 (구현 강제)
private void test() { // Java 9+: interface 내부 공통 로직
System.out.println("interface의 private 함수");
}
public default void defaultMethod() { // 구현 클래스가 오버라이딩 선택 가능
test();
System.out.println("interface default method");
}
public static void staticMethod() { // 인터페이스명.메서드() 로 직접 호출
System.out.println(INIT);
// test() 호출 불가 - static에서는 private/default 접근 불가
}
}
Interface로 상수를 관리하면 이런 문제가 생긴다.
interface TVCommand { static int MAX_SOUND = 50; ... }
interface PhoneCommand { static int PHONE_LIST = 50; }
// TVCommand와 PhoneCommand의 값이 우연히 같으면 비교가 true가 되버린다
if (TVCommand.MAX_SOUND == PhoneCommand.PHONE_LIST) {
System.out.println("TV 명령과 Phone 명령이 같게 됨."); // 이게 출력됨!
}
전혀 다른 도메인의 상수인데 값이 같다는 이유로 같다고 판단된다. Enum은 이걸 타입 자체로 구별한다.
enum ETVCommand { MAX_SOUND, VOLUME_UP, VOLUME_DOWN, MUTE, CHANNEL_UP, CHANNEL_DOWN }
enum EPhoneCommand { CALL, PHONE_LIST }
// 타입이 달라서 컴파일 에러 → 비교 자체가 불가능
// if (ETVCommand.MAX_SOUND == EPhoneCommand.PHONE_LIST) { } // 컴파일 에러!
| 메서드 | 설명 | 리턴 타입 |
|---|---|---|
name() | 열거 객체의 문자열 이름 | String |
ordinal() | 열거 객체의 순번 (0부터) | int |
compareTo() | 순번 차이 비교 | int |
valueOf(String) | 문자열로 열거 객체 반환 | enum |
values() | 모든 열거 객체를 배열로 반환 | enum[] |
Season s = Season.SUMMER;
System.out.println(s.getSeason()); // "여름" (커스텀 필드)
System.out.println(s.name()); // "SUMMER"
System.out.println(s.ordinal()); // 1 (0부터 시작)
enum Season {
SPRING("봄"), SUMMER("여름"), FALL("가을"), WINTER("겨울");
private String season;
private Season(String season) { // 생성자는 반드시 private
this.season = season;
}
public String getSeason() { return season; }
}
Enum은 Enum 클래스를 암묵적으로 상속하기 때문에 다른 클래스를 상속받을 수 없다. 하지만 Interface는 구현 가능하다. 이걸 활용하면 Enum을 다형성으로 쓸 수 있다.
public interface Command { }
public enum PhoneCommand implements Command { CALL, PHONE_LIST }
public enum TVCommand implements Command { MAX_SOUND, VOLUME_UP, ... }
// Command 타입으로 다형성 적용
Command command = PhoneCommand.CALL;
객체 생성 코드가 여기저기 흩어져 있으면, 나중에 Phone 대신 SmartPhone으로 바꿔야 할 때 new Phone()을 전부 찾아서 고쳐야 한다. 강사님이 말씀하신 대로 Factory 패턴은 객체 생성을 한 곳에 모아두는 것이다.
// ProductFactory.java - 객체 생성 책임을 여기에만
public class ProductFactory {
public static Product getInstance() {
return new SmartPhone(); // Phone → SmartPhone 교체 시 여기만 수정
}
}
// 사용하는 쪽은 전혀 수정 안 해도 됨
Product p = ProductFactory.getInstance();
p.power();
p.service(PhoneCommand.CALL);
Product는 abstract 클래스로, Phone과 SmartPhone이 상속받는다. FactoryTest에서는 Product 타입으로만 다루기 때문에 실제 어떤 객체가 들어오는지 모른다. 다형성 + Factory 패턴의 조합이다.
실제로 Spring Framework가 이 생성 패턴을 대규모로 도와주는 프레임워크다. Spring의 ApplicationContext가 사실상 거대한 Factory다. 나중에 스프링 배울 때 다시 보면 이해가 훨씬 빠를 것 같다.
Throwable
├── Error (시스템 수준)
│ ├── OutOfMemoryError → heap 메모리 부족
│ ├── StackOverFlowError → Stack 메모리 부족
│ └── NoSuchMethodError → main()이 없는데 실행한 경우
└── Exception (S/W 수준)
├── CheckedException → 반드시 예외 처리 강제 (컴파일러가 체크)
│ ├── FileNotFoundException
│ ├── IOException
│ └── SQLException
└── RuntimeException (UncheckedException) → 선택적 처리
├── ArrayIndexOutOfBoundsException
├── NullPointerException
├── ClassCastException
├── NumberFormatException
├── ArithmeticException
└── IndexOutOfBoundsException
구분 기준이 명확하다. 개발자 실수는 디버그로 잡고, 사용자 실수나 외부 환경 문제는 예외처리로 잡는다.
try {
// 예외 발생 가능 코드
for (int i = 0; i < 4; i++) {
System.out.println(greeting[i]); // i=3에서 ArrayIndexOutOfBoundsException
}
} catch (Exception e) {
System.out.println(e.getMessage()); // 예외 메시지 출력
} finally {
// try가 수행되면 항상 실행 (예외 발생 여부 무관)
// IO close, Network 종료 등 반드시 해야 하는 작업
}
finally가 실행되지 않는 유일한 경우는 System.exit()으로 JVM 자체를 종료할 때다. return문 안에서도 finally는 실행된다.
ExcepTest2.java에서 NumberFormatException 처리 코드가 주석처리된 부분이 있다. 주석을 풀지 않으면 숫자가 아닌 문자열 입력 시 예외가 처리되지 않고 위로 던져진다. 이게 의도된 코드인지 아닌지 파악하는 게 실무 디버깅의 시작이다.
// throws: 이 메서드는 예외를 직접 처리하지 않고 호출한 곳으로 던진다
public static int mod(int a, int b) throws Exception {
if (b == 0) throw new Exception("0으로 나눌 수 없습니다."); // throw: 예외 객체 생성해서 던짐
return a / b;
}
// CheckedException은 반드시 처리해야 함
try {
System.out.println(MyUtil.mod(256, 0));
} catch (Exception e) {
System.out.println(e.getMessage());
}
// throws ArithmeticException → UncheckedException이라 호출부에서 처리 강제 없음
public static int div(int a, int b) {
if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다.");
return a / b;
}
System.out.println(MyUtil.div(256, 4)); // 예외처리 없이도 컴파일 OK
예외 위임의 목적 세 가지.
1. 오류 발생 지점에서 처리하면 처리 방법이 고정되어 버린다. 호출하는 쪽마다 다르게 처리하려면 위임해야 한다.
2. 여러 메서드에서 동일한 예외 처리가 필요하면 한 곳에 모아서 중복을 없앤다.
3. 정상 결과는 return으로, 비정상 결과는 예외로 전달 → 메서드의 계약이 명확해진다.
try {
Employee[] emps1 = new Employee[2111111111]; // OutOfMemoryError 유발
Employee[] emps2 = new Employee[2111111111];
Employee[] emps3 = new Employee[2111111111];
} catch (Throwable e) {
System.out.println("메모리가 부족합니다.");
}
System.out.println("main end.....");
일부 Error는 Throwable로 잡을 수 있다. 하지만 StackOverFlowError 등 일부는 잡아도 의미 없는 경우가 많다. Error는 일반적으로 복구 불가능한 상황이므로 catch보다는 사전에 발생하지 않도록 설계하는 게 원칙이다.
사용자 정의 Exception은 다음 주에 이어서 배운다.
Spring Framework가 Factory 패턴을 도와주는 프레임워크라고 배웠는데, 그게 구체적으로 어떤 의미인가?
오늘 ProductFactory.java에서 new SmartPhone()을 반환하는 코드를 봤다. 바꿀 때 한 줄만 수정하면 된다는 게 장점이었다.
근데 Spring의 IoC(제어의 역전)는 이보다 한 단계 더 나아간다. 어떤 객체를 만들지를 코드가 아닌 설정(XML 또는 어노테이션)으로 결정한다. 즉 ProductFactory.java를 수정하지 않아도, 설정만 바꾸면 다른 객체가 주입된다. 개발자가 객체 생성을 제어하는 게 아니라 Spring이 대신 해주는 것이다.
지금 배우는 Factory 패턴이 Spring DI(Dependency Injection)의 개념적 토대다. 지금 당장 깊게 파지 않더라도 "왜 이런 구조가 필요한가"라는 맥락을 갖고 Spring을 배우면 이해 속도가 다를 것 같다.
Singleton LazyHolder Bill Pugh Thread Safe Race Condition abstract Interface Marker Interface Coupling Cohesion default 메서드 Enum ordinal() name() values() Factory 패턴 Simple Factory Method IoC 맛보기 CheckedException UncheckedException try-catch-finally throws throw Throwable clone() equals() vs == Objects.equals() System.gc() Static Nested Class 로딩 시점