[LG U+ 유레카 4기] WEEK 01 - Java 기초 객체지향언어 (4)

Soohwan Lim·2026년 4월 10일

유레카부트캠프

목록 보기
6/31
post-thumbnail

Singleton, Interface, Enum, Factory 패턴, 예외처리


1. 오늘의 학습 흐름

  • Singleton 패턴: 존재 이유, 구현 방식 3가지, Thread Safe 이슈
  • abstract 클래스와 abstract 메서드
  • Interface: 다중 상속, Marker Interface, Coupling/Cohesion
  • Enum: 상수 관리의 진화, Enum 메서드
  • Factory 패턴 (Simple Factory Method): 생성 패턴 입문
  • 예외처리: Exception 계층 구조, try-catch-finally, throws, throw

2. Singleton 패턴

왜 필요한가

강사님이 서버-클라이언트 예시로 설명해주셨다. 네트워크 소켓 연결이나 DB 커넥션 풀처럼 딱 하나만 있어야 하는 자원이 있다. 이런 걸 매번 new로 생성하면?

  • 객체 생성 자체가 JVM에서 제일 비싼 연산이다 (DAY 03에서 배운 내용)
  • DB 커넥션 같은 건 연결에 시간도 걸리고 자원도 많이 먹는다
  • 여러 개가 생기면 데이터 정합성 문제도 생긴다

그래서 "이 클래스에서 객체는 딱 하나만 만들어지도록 보장"하는 패턴이 Singleton이다. java.lang.Runtime이 대표적인 표준 라이브러리 Singleton이다.

구현 규칙 3가지

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;
    }
}
  • Lazy: LazyHolder 클래스는 getInstance()가 처음 호출되기 전까지 JVM에 로드되지 않는다
  • Thread Safe: JVM 클래스 로딩 메커니즘 자체가 thread-safe하게 설계되어 있어 synchronized 없이도 안전하다
  • 성능: 초기화 이후 동기화 오버헤드 없음
// 같은 객체인지 검증
MySingleton s1 = MySingleton.getInstance();
MySingleton s2 = MySingleton.getInstance();
System.out.println(s1.hashCode());  // 1829164700
System.out.println(s2.hashCode());  // 1829164700 → 동일

Thread Safe가 뭔데?

아직 스레드를 배우지 않았으니 간단하게만. 자바 서버는 여러 클라이언트 요청을 동시에 처리하는데, 각 요청이 별도의 스레드(Thread) 로 돌아간다. Thread Safe란 여러 스레드가 동시에 같은 코드에 접근해도 문제없는 상태다. 스레드는 나중에 제대로 배우니 지금은 이 정도만.

면접 포인트

"Singleton을 설명하고 Thread Safe하게 구현하는 방법을 말해보세요."

LazyHolder 패턴. static nested class는 참조 전까지 JVM에 로드되지 않고, 클래스 로딩 자체가 JVM에 의해 thread-safe하게 처리된다. synchronizedvolatile 없이 Lazy + Thread Safe + 고성능 세 가지를 동시에 달성한다.


3. abstract 클래스와 메서드

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();
}

4. Interface

기본 구조

// 선언
[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();
}

클래스는 단일 상속, Interface는 다중 상속

클래스가 다중 상속을 막은 이유는 다이아몬드 문제(두 부모에게 같은 메서드를 물려받을 때 발생하는 충돌과 모호성) 때문이다. 부모 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;

Marker Interface

Cloneable, Serializable처럼 메서드가 아무것도 없는 인터페이스다. "이 클래스가 특정 책임/능력을 가지고 있음"을 표시(mark) 하는 용도다. JVM이나 프레임워크가 이 마크를 확인해서 동작 방식을 결정한다. clone()을 쓰려면 반드시 Cloneable을 구현해야 하고, 안 하면 CloneNotSupportedException이 발생한다.

Coupling & Cohesion

오늘 수업에서 나온 개념. Interface 설계 원칙이기도 하다.

  • Coupling(결합도): 낮을수록 좋다. 모듈 간 의존성이 낮아야 수정이 쉽다
  • Cohesion(응집도): 높을수록 좋다. 하나의 요소가 밀접하게 연관된 책임만 가질수록 좋다
    정처기에 나오는거 내공외제스자 우논시절통순기..

Interface를 보면 그 클래스가 어떤 책임을 가지고 있는지 바로 알 수 있다. SuperMan implements Flyer, Serializable만 봐도 "날 수 있고, 직렬화 가능하다"는 게 보인다. Interface가 책임을 명시적으로 드러내는 도구가 되는 셈이다.

Java 8 이후 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 접근 불가
    }
}

5. Enum

Interface 상수의 문제점

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) { }  // 컴파일 에러!

Enum 주요 메서드

메서드설명리턴 타입
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에 속성과 생성자 추가

enum Season {
    SPRING("봄"), SUMMER("여름"), FALL("가을"), WINTER("겨울");

    private String season;

    private Season(String season) {  // 생성자는 반드시 private
        this.season = season;
    }
    public String getSeason() { return season; }
}

Enum + Interface 조합

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;

6. Factory 패턴 (Simple Factory Method)

왜 필요한가

객체 생성 코드가 여기저기 흩어져 있으면, 나중에 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 클래스로, PhoneSmartPhone이 상속받는다. FactoryTest에서는 Product 타입으로만 다루기 때문에 실제 어떤 객체가 들어오는지 모른다. 다형성 + Factory 패턴의 조합이다.

실제로 Spring Framework가 이 생성 패턴을 대규모로 도와주는 프레임워크다. Spring의 ApplicationContext가 사실상 거대한 Factory다. 나중에 스프링 배울 때 다시 보면 이해가 훨씬 빠를 것 같다.


7. 예외처리 (Exception Handling)

예외 계층 구조

Throwable
├── Error (시스템 수준)
│   ├── OutOfMemoryError   → heap 메모리 부족
│   ├── StackOverFlowError → Stack 메모리 부족
│   └── NoSuchMethodError  → main()이 없는데 실행한 경우
└── Exception (S/W 수준)
    ├── CheckedException   → 반드시 예외 처리 강제 (컴파일러가 체크)
    │   ├── FileNotFoundException
    │   ├── IOException
    │   └── SQLException
    └── RuntimeException (UncheckedException) → 선택적 처리
        ├── ArrayIndexOutOfBoundsException
        ├── NullPointerException
        ├── ClassCastException
        ├── NumberFormatException
        ├── ArithmeticException
        └── IndexOutOfBoundsException

구분 기준이 명확하다. 개발자 실수는 디버그로 잡고, 사용자 실수나 외부 환경 문제는 예외처리로 잡는다.

  • CheckedException: 컴파일러가 처리를 강제. 파일, 네트워크, DB처럼 외부 자원 접근 시 항상 발생 가능
  • UncheckedException(RuntimeException): 처리 강제 없음. 대부분 개발자가 사전에 막을 수 있는 것들

try-catch-finally

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와 throw: 예외 위임

// 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으로, 비정상 결과는 예외로 전달 → 메서드의 계약이 명확해진다.

Error 처리

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은 다음 주에 이어서 배운다.


8. 질문

Spring Framework가 Factory 패턴을 도와주는 프레임워크라고 배웠는데, 그게 구체적으로 어떤 의미인가?

오늘 ProductFactory.java에서 new SmartPhone()을 반환하는 코드를 봤다. 바꿀 때 한 줄만 수정하면 된다는 게 장점이었다.

근데 Spring의 IoC(제어의 역전)는 이보다 한 단계 더 나아간다. 어떤 객체를 만들지를 코드가 아닌 설정(XML 또는 어노테이션)으로 결정한다. 즉 ProductFactory.java를 수정하지 않아도, 설정만 바꾸면 다른 객체가 주입된다. 개발자가 객체 생성을 제어하는 게 아니라 Spring이 대신 해주는 것이다.

지금 배우는 Factory 패턴이 Spring DI(Dependency Injection)의 개념적 토대다. 지금 당장 깊게 파지 않더라도 "왜 이런 구조가 필요한가"라는 맥락을 갖고 Spring을 배우면 이해 속도가 다를 것 같다.


9. 키워드 정리

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 로딩 시점


10. 내일의 목표

  • GSAT준비
  • 정처기 실기 - 기출
profile
developer

0개의 댓글