C
Java/OOP/Lesson 04

객체지향 5원칙 — 클래스·캡슐화·상속·다형성·인터페이스

60분·theory

객체지향 5원칙 — 클래스·캡슐화·상속·다형성·인터페이스

🎯 이 lesson 을 읽고 나면

이 lesson 을 다 읽고 나면 아래 3가지를 자신 있게 할 수 있습니다.

  • ✅ 캡슐화 · 상속 · 다형성 · 추상화 정의 + 코드 예시
  • ✅ SOLID 5원칙 (특히 SRP·OCP·DIP) 면접 답변
  • ✅ 왜 상속 < 컴포지션 인지 한 줄 설명

학습 목표를 체크리스트로 두고 다 답할 수 있게 되면 lesson 을 닫으세요.

객체지향이 뭐냐 — *현실의 사물처럼* 코드를 짜는 방식

핵심 한 줄

객체지향 (OOP) = 현실의 사물을 객체로 표현하고, 그 객체들이 서로 메시지를 주고받으며 일을 처리하는 프로그래밍 방식. Java 의 가장 중심 사상 입니다.

왜 객체지향인가

옛날 C 언어는 함수와 데이터가 따로 였습니다. 사람 의 이름·나이는 변수로, 걷기·말하기 는 함수로 — 분리돼서 큰 프로그램이 되면 뭐가 뭔지 모르게 됐죠.

객체지향은 데이터와 행동을 한 덩어리로 묶어서 다룹니다. "사람" 이라는 클래스 안에 이름·나이 같은 필드 와 걷기·말하기 같은 메서드 가 함께 있습니다. 마치 현실의 사람이 몸 (데이터)행동 을 동시에 가진 것처럼요.

클래스 vs 객체 — 붕어빵 틀과 붕어빵

가장 헷갈리는 두 단어입니다. 비유로 풀어볼게요.

  • 클래스 = 붕어빵 틀. 설계도. "이런 모양의 붕어빵을 만들 거야" 라는 청사진.
  • 객체 = 실제로 만들어진 붕어빵. 1개·2개·100개 만들 수 있고, 각각 독립적입니다.
java
class 사람 {           // 클래스 = 설계도
    String 이름;
    int 나이;
}

사람 a = new 사람();   // 객체 1 = 첫 번째 붕어빵
사람 b = new 사람();   // 객체 2 = 두 번째 (a 와 완전히 별개)

new 키워드가 붕어빵을 굽는 행위 입니다. 메모리 어딘가에 새 공간을 잡고 그 위치를 a 라는 변수에 기록합니다.

캡슐화 — 외부에서 함부로 못 만지게

내 통장 잔액을 아무나 마음대로 바꿀 수 있다면? 큰일 나죠. 그래서 객체지향에서는 외부 직접 접근을 막고 정해진 메서드를 통해서만 데이터를 다루도록 합니다.

java
class 계좌 {
    private int 잔액;       // private → 외부 직접 접근 X

    public void 입금(int 금액) {
        if (금액 <= 0) throw new IllegalArgumentException("0보다 커야 함");
        잔액 += 금액;
    }

    public int 잔액조회() { return 잔액; }
}

private 키워드로 직접 접근을 차단 하고, 입금() 같은 공식 통로 만 열어둡니다. 그 통로 안에서 검증·로깅·동기화 같은 부가 작업을 다 처리할 수 있어요. 이게 캡슐화의 핵심입니다.

상속 — 공통 부분을 재사용

여러 종류의 동물 클래스를 만든다고 해봅시다. 강아지·고양이·새 — 모두 움직이고 먹습니다. 이걸 매번 따로 쓰면 중복입니다.

java
class 동물 {
    void 먹는다() { ... }
    void 움직인다() { ... }
}

class 강아지 extends 동물 {
    void 짖는다() { ... }   // 추가 행동만
}

강아지는 동물의 모든 기능 을 자동으로 물려받고, 자기만의 행동 만 추가하면 됩니다.

다만 상속은 무서운 무기 입니다. 깊게 쌓으면 부모 한 줄 바꾸면 자식 모두 영향 — 변경이 어려워집니다. 그래서 현대 Java 에서는 합성 (composition) 을 선호합니다. 상속보다 포함 — 강아지가 동물을 상속 하는 게 아니라, 강아지가 Movable·Eatable 인터페이스 를 가지는 방식이죠.

다형성 — 같은 인터페이스, 다른 동작

같은 메서드 호출이 객체에 따라 다른 결과 를 내는 것이 다형성입니다.

java
동물 a = new 강아지();
동물 b = new 고양이();
a.소리();   // "멍멍"
b.소리();   // "야옹"

ab 모두 동물 타입이지만, 소리() 메서드는 실제 객체의 타입 에 따라 다르게 동작합니다. 런타임 에 결정되는 거죠.

이 덕분에 List<동물> 한 리스트에 강아지·고양이를 섞어 담고 반복문 하나 로 처리할 수 있습니다. 동작은 각자 알아서 합니다.

인터페이스 vs 추상 클래스 — 자주 혼동되는 둘

둘 다 직접 생성 못 하는 추상 타입입니다. 차이는:

  • 인터페이스역할. "비교할 수 있다 (Comparable)"·"반복할 수 있다 (Iterable)" 같은 능력 명세. 다중 구현 가능 (implements A, B, C).
  • 추상 클래스공통 골격. "이런 메서드들은 다 구현했지만 일부는 자식이 채워야 함". 단일 상속만 (extends A).

규칙: 역할만 정의 하고 싶으면 인터페이스, 공통 코드 + 일부 추상 이면 추상 클래스. Java 8 부터 인터페이스도 default 메서드로 구현을 가질 수 있어 경계가 흐려졌지만, 역할 vs 골격 의 직관은 여전히 유효합니다.

한 번 정리

객체지향은 복잡한 프로그램을 작은 단위 (클래스) 로 쪼개서 관리 하는 방식입니다. 핵심 5원칙:

원칙한 줄
클래스·객체설계도·실체. new 로 객체 생성
캡슐화private + getter/setter 로 직접 접근 차단
상속extends 로 공통 부분 재사용 (남용 금지)
다형성같은 메서드, 다른 동작. List<동물> 가능
인터페이스역할 정의. 다중 구현 가능

> 💡 실무 현장: Java 백엔드는 95% OOP. 함수형·반응형은 보조 도구. OOP 가 가장 기본 무기입니다.

예외 처리 — 에러를 *우아하게* 다루는 법

예외가 뭐냐

프로그램이 예상 못 한 상황 에 부딪혔을 때 — 파일이 없다·DB 연결이 끊겼다·0 으로 나눴다 — Java 는 예외 (Exception) 라는 객체를 던집니다 (throw). 누군가 받지 (catch) 않으면 프로그램이 터집니다.

옛 C 처럼 에러 코드를 리턴 하지 않고 별도 경로로 에러를 전파 하는 메커니즘이죠.

두 종류의 예외 — Checked vs Unchecked

이게 Java 의 특이한 점 입니다. 다른 언어에는 거의 없는 구분.

  • Checked: 컴파일러가 반드시 처리하라고 강제. IOException·SQLException 등. try-catch 또는 throws 선언 필수.
  • Unchecked (RuntimeException): 처리 권장이지만 강제 X. NullPointerException·IllegalArgumentException 등.

이 구분은 오랫동안 논쟁 입니다. Spring·최근 라이브러리는 Checked 를 거의 안 씁니다 — 강제 처리가 코드를 더럽힌다고 보거든요. Kotlin·C# 은 아예 Unchecked 만 있습니다.

try-catch-finally

java
try {
    int x = Integer.parseInt(input);
} catch (NumberFormatException e) {
    log.error("파싱 실패: {}", input, e);
    throw new BusinessException("잘못된 입력 형식");
} finally {
    cleanup();    // 성공·실패 *상관없이* 항상 실행
}

finally 는 자원 정리에 썼지만, try-with-resources (Java 7+) 가 더 깔끔합니다:

java
try (BufferedReader r = new BufferedReader(new FileReader(f))) {
    return r.readLine();
}   // r.close() 자동 호출

AutoCloseable 을 구현한 자원은 블록 끝나면 자동 close. DB 연결·파일·네트워크 소켓 모두 이 패턴이 표준입니다.

흔한 함정 4가지

1. catch (Exception e) { } — 빈 catch: 가장 무서운 안티패턴. 에러를 조용히 삼킵니다. 디버깅 지옥의 시작. 최소한 로그라도 남기세요.

2. e.printStackTrace(): 표준 출력으로 stack trace 가 흩어집니다. production 에서는 logger 로:

java
catch (Exception e) {
    log.error("주문 생성 실패: userId={}", userId, e);  // 구조화된 로그
}

3. 한 줄에 두 가지 일: try 블록 안에서 서로 다른 종류의 작업 을 묶으면 어디서 에러났는지 모릅니다. 작은 try 여러 개가 낫습니다.

4. 도메인 의미를 살리지 않은 예외: throw new RuntimeException("error") 보다 throw new InsufficientBalanceException() 같은 의미 있는 이름 이 디버깅·문서에 큰 차이를 만듭니다.

한 번 정리

예외 처리는 방어 코드 도배 가 아닙니다. 정상 흐름 vs 예외 흐름 을 명확히 분리하고, 의미 있는 메시지 + 로깅 으로 추후 추적 가능하게 만드는 게 핵심입니다.

제네릭 — *타입을 변수처럼* 다루기

왜 제네릭이 필요한가

Java 5 이전엔 List 가 모든 타입을 담을 수 있었습니다. 편리해 보이지만 런타임에 ClassCastException 으로 폭발하는 사고가 잦았죠.

java
// 옛날
List names = new ArrayList();
names.add("홍길동");
names.add(42);            // 사고! Integer 가 들어감
String x = (String) names.get(1);   // 런타임 ClassCastException

제네릭컴파일 타임에 타입을 검증 하는 장치입니다.

java
List<String> names = new ArrayList<>();
names.add("홍길동");
names.add(42);            // 컴파일 에러! ✅

런타임 폭발 → 컴파일러가 미리 잡습니다. 타입 안전성 이 핵심 가치입니다.

기본 사용법

java
public <T> T findById(Class<T> type, Long id) {
    // T 가 무엇이든 같은 로직
    return em.find(type, id);
}

User u = findById(User.class, 42L);
Order o = findById(Order.class, 100L);

<T>타입 매개변수 — 함수의 매개변수와 비슷한 개념. 컴파일러가 호출 시점에 어떤 타입인지 추론합니다.

Bounded Type — T 의 조건

java
public <T extends Number> double sum(List<T> list) {
    double total = 0;
    for (T n : list) total += n.doubleValue();
    return total;
}

<T extends Number> = T 는 Number 또는 그 자손 만 가능. Integer·Long·Double 같은 숫자 타입만 통과.

Wildcard — ? 의 의미

가장 헷갈리는 부분입니다.

  • List<? extends Number> — Number 의 어떤 자손 (정확히 모름). 읽기만 가능, 쓰기 X.
  • List<? super Integer> — Integer 의 어떤 부모 (정확히 모름). 쓰기는 가능 (Integer 만), 읽기는 Object 로만.

PECS 원칙 — Producer Extends, Consumer Super:

  • 데이터를 꺼내 쓰는 곳 (생산자) → extends
  • 데이터를 넣기만 하는 곳 (소비자) → super

> 외울 필요 없음. 제네릭 라이브러리 만들 때 만 깊이 봅니다. 사용자 입장에선 List<User> 같은 단순 사용이 99%.

Type Erasure — 런타임엔 사라진다

Java 제네릭의 기묘한 특성. 컴파일 후엔 모든 <T> 가 사라지고 Object 로 변환됩니다. 그래서:

  • new T() 불가 (런타임에 T 가 뭔지 모름) → Class<T> 인자로 받아야
  • T[] 배열 생성 불가
  • instanceof List<String> 불가 → instanceof List<?>

이게 Java 가 한 번 결정해서 못 되돌리는 디자인 부채 입니다. C# 의 Reified Generics 처럼 됐으면 더 좋았겠지만, 호환성 때문에 못 바꿉니다.

한 번 정리

제네릭은 컴파일 타임 타입 안전 을 위한 도구입니다. List<User> 같은 기본 사용은 반드시. Wildcard·Bounded 는 라이브러리 작성 시 깊이 들어가면 됩니다.

enum + 어노테이션 — Java 의 *메타 도구*

enum — 그냥 상수가 아니다

다른 언어에선 enum 이 단순 상수 모음 입니다. Java 의 enum 은 훨씬 강력 — 각 값이 객체 이고, 메서드·필드·인터페이스 구현 까지 가능합니다.

java
public enum 결제상태 {
    PENDING("결제대기", 0),
    PAID("결제완료", 1),
    REFUNDED("환불완료", 2);

    private final String 이름;
    private final int 코드;

    결제상태(String 이름, int 코드) {
        this.이름 = 이름;
        this.코드 = 코드;
    }

    public String 이름() { return 이름; }
}

각 enum 값에 추가 정보 를 가질 수 있고, 메서드도 정의할 수 있습니다. 상태 머신·Strategy 패턴 에 자주 씁니다.

java
public enum 할인 {
    NONE { public int 적용(int 가격) { return 가격; } },
    TEN  { public int 적용(int 가격) { return (int)(가격 * 0.9); } },
    VIP  { public int 적용(int 가격) { return (int)(가격 * 0.7); } };
    public abstract int 적용(int 가격);
}

int 최종 = 할인.VIP.적용(10000);   // 7000

if-else 도배 대신 각 enum 이 자기 행동 을 갖는 패턴입니다.

어노테이션 — 코드에 메타데이터 붙이기

@Override·@Deprecated·@SuppressWarnings — 이 @ 로 시작하는 게 모두 어노테이션입니다. 코드 자체 가 아니라 코드에 대한 정보 를 표시합니다.

java
@Override         // 부모 메서드 오버라이드 확인 (컴파일러 검증)
public String toString() { return ...; }

@Deprecated       // 사용 자제 (IDE 경고)
public void oldApi() { ... }

Spring 의 핵심은 어노테이션

Java 백엔드를 한다면 어노테이션이 코드의 80% 를 차지합니다. Spring 의 거의 모든 마법은 어노테이션 기반:

  • @Component·@Service·@Repository — 빈 등록
  • @Autowired — 의존성 주입
  • @RestController + @GetMapping("/users") — 웹 라우팅
  • @Transactional — 트랜잭션 자동 관리
  • @Entity + @Id — JPA 매핑
java
@RestController
@RequestMapping("/api/users")
public class UserController {
    @Autowired
    private UserService service;

    @GetMapping("/{id}")
    public User get(@PathVariable Long id) {
        return service.findById(id);
    }
}

어노테이션 그 자체아무것도 안 합니다. Spring 같은 프레임워크가 런타임에 어노테이션을 읽고 그에 맞는 동작 (라우팅·DI·트랜잭션) 을 자동으로 수행합니다.

한 번 정리

  • enum = 타입 안전한 상수 + 각자 객체처럼 메서드·필드 가능
  • 어노테이션 = 메타데이터. 직접 실행 X. 프레임워크·도구가 읽고 동작

이 둘이 Java 의 선언형 (declarative) 스타일을 가능하게 합니다. "이렇게 동작해라" 명령이 아니라 "이런 것이다" 선언만 하면 도구가 알아서.

💻 📌 OOP 실전 코드 — 외울 필요 없음, 패턴만 기억
// ============================================
// 1. 빌더 패턴 — 검증 가능한 객체 생성
// ============================================
public class 사용자 {
    private final String 이메일;
    private final String 이름;
    private int 포인트;

    private 사용자(Builder b) {
        if (!b.이메일.contains("@"))
            throw new IllegalArgumentException("이메일 형식 X");
        this.이메일 = b.이메일;
        this.이름 = b.이름;
        this.포인트 = b.포인트;
    }

    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private String 이메일, 이름;
        private int 포인트 = 0;

        public Builder 이메일(String x) { this.이메일 = x; return this; }
        public Builder 이름(String x) { this.이름 = x; return this; }
        public Builder 포인트(int x) { this.포인트 = x; return this; }
        public 사용자 build() { return new 사용자(this); }
    }
}

// 사용
사용자 u = 사용자.builder().이메일("[email protected]").이름("홍길동").build();

// ============================================
// 2. Strategy 패턴 — enum 으로 자연스럽게
// ============================================
public enum 배송방식 {
    일반 { public int 요금(int 무게) { return 3000; } },
    당일 { public int 요금(int 무게) { return 10000 + 무게 * 100; } },
    무료 { public int 요금(int 무게) { return 0; } };
    public abstract int 요금(int 무게);
}

int 비용 = 배송방식.당일.요금(2);    // 10200

// ============================================
// 3. 제네릭 Repository — 재사용 가능한 베이스
// ============================================
public abstract class Repository<T, ID> {
    public abstract T findById(ID id);
    public abstract List<T> findAll();
    public abstract T save(T entity);
}

public class UserRepository extends Repository<User, Long> {
    @Override public User findById(Long id) { /* ... */ }
    // ...
}

// ============================================
// 4. 예외 처리 — 의미 있게
// ============================================
public class 잔액부족Exception extends RuntimeException {
    public 잔액부족Exception(String msg) { super(msg); }
}

public void 출금(Long 계좌Id, int 금액) {
    var 계좌 = 계좌Repo.findById(계좌Id)
        .orElseThrow(() -> new EntityNotFoundException("계좌 없음"));
    if (계좌.잔액() < 금액) {
        throw new 잔액부족Exception("부족: 현재 " + 계좌.잔액());
    }
    계좌.출금(금액);
}

SOLID 5원칙 — bad / good 코드로 비교

SOLID 가 뭐냐

5가지 객체지향 설계 원칙 의 머리글자. Robert C. Martin (Uncle Bob) 이 정리. 면접에서 반드시 한 번은 묻습니다.

S — Single Responsibility (단일 책임)

한 클래스는 한 가지 일만. 변경 사유가 1개여야 함.

java
// ❌ 나쁨 — 사용자 정보 + 이메일 발송 + DB 저장이 한 클래스에
class User {
    void save() { /* DB */ }
    void sendWelcomeEmail() { /* SMTP */ }
}

// ✅ 좋음 — 책임 분리
class User { /* 데이터만 */ }
class UserRepository { void save(User u) { } }
class EmailService { void sendWelcome(User u) { } }

O — Open/Closed (개방/폐쇄)

확장엔 열려있고 수정엔 닫혀있게. 새 기능 추가 시 기존 코드 수정 없이 가능해야.

java
// ❌ 새 결제 수단마다 if-else 추가
if (type.equals("CARD")) { ... } else if (type.equals("KAKAO")) { ... }

// ✅ 인터페이스 + 구현체 — 새 수단은 새 클래스로
interface PaymentMethod { void pay(int amount); }
class CardPayment implements PaymentMethod { ... }
class KakaoPayment implements PaymentMethod { ... }

L — Liskov Substitution (리스코프 치환)

자식이 부모를 완전히 대체 가능해야. 부모를 쓰는 코드에 자식을 넣어도 문제없이 동작.

java
// ❌ 정사각형이 직사각형을 상속하면 깨짐
class Rectangle { void setWidth(int w); void setHeight(int h); }
class Square extends Rectangle {
    void setWidth(int w) { super.setWidth(w); super.setHeight(w); }  // 부모 동작 변경
}

상속 대신 인터페이스 분리 가 정답이 많습니다.

I — Interface Segregation (인터페이스 분리)

큰 인터페이스를 잘게 쪼개라. 안 쓰는 메서드까지 구현 강제 X.

java
// ❌ 거대한 인터페이스
interface Worker { void work(); void eat(); void sleep(); }

// ✅ 역할별 분리
interface Workable { void work(); }
interface Eatable { void eat(); }

D — Dependency Inversion (의존성 역전)

구체 클래스가 아니라 인터페이스에 의존. Spring 의 DI 가 이 원칙의 실천판.

java
// ❌ 구체 클래스에 의존
class UserService { MySQLRepository repo = new MySQLRepository(); }

// ✅ 인터페이스에 의존 — 테스트·DB 교체 자유로움
class UserService {
    private final UserRepository repo;
    UserService(UserRepository repo) { this.repo = repo; }
}

외울 필요 없습니다 — 원칙은 결과 입니다

좋은 코드를 짜다 보면 자연스럽게 따라가는 원리 입니다. 이름을 외우려 하지 말고 각 원칙의 의도 만 기억하세요.

☕ 직접 실행 — class · 상속 · 다형성

캡슐화 · 상속 · 다형성을 한 번에. 동물 계층 예제.
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ 실행하기 버튼을 눌러보세요
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 AI 에게 이렇게 요청해보세요

이 lesson 의 개념을 알면 AI 에게 구체적으로 지시할 수 있습니다. 막연한 "고쳐줘" 가 아니라 어휘를 가진 요청 — 그게 토큰 절약의 출발점입니다.

  • "이 클래스를 SOLID 원칙 (특히 SRP) 위반 여부 검사해서 분리해줘"
  • "이 객체 생성 코드에 빌더 패턴 적용해줘"
  • "이 상속 구조를 인터페이스 분리로 리팩토링해줘"

왜 이게 토큰을 줄이나

개념을 모를 땐 AI 답변을 받고도 "그게 뭐예요?" 를 다시 물어야 합니다. 그 "다시 물음" 이 토큰을 잡아먹습니다. 개념 한 번 익혀두면 대화가 한 번에 끝납니다.

객체지향 5원칙 — 클래스·캡슐화·상속·다형성·인터페이스 - Java