[내일배움캠프 Spring_3기] 달리기반 1회차 - 객체지향

jiiim_ni·2026년 1월 18일

달리기반 1일차 수업에서는 객체지향에 대해서 배웠다.

일단 오늘 수업 요약 전에 달리기반 첫 수업 소감을 말하자면 튜터님께서 설명을 해주실 때 찰떡 비유와 함께 진행해주셔서 이해가 정말 잘 되었다.

Step1: 카페 주문 시스템

손님(Customer), 바리스타(Barista) 가 있다고 가정
손님이 커피를 주문하면 바리스타가 만들어줌

Barista 클래스

public class Barista {
    // 세상에! 누구나 접근 가능한 public 변수라니요.
    public int beans = 100; // 커피 원두 양 (그램)

    // 커피 만드는 기술(메서드)도 없고, 단순히 재료만 들고 서 있습니다.
}
  • 자기 자신을 전혀 방어하지 못하는 바리스타 - Bad Code
  • beans가 public이면 지나가던 사람이 바리스타 주머니에 손을 넣어서 원두 개수도 세어보고 맘대로 가져갈 수 있다는 뜻

Customer 클래스

public class Customer {

    public void order(String menuName, Barista barista) {
        System.out.println("손님: " + menuName + " 주세요.");
        
        // 여기가 문제입니다!
        // 손님이 직접 바리스타의 원두통을 확인합니다. (???)
        if (barista.beans >= 20) {
            // 심지어 직접 원두를 퍼갑니다.
            barista.beans -= 20; 
            System.out.println("손님: (원두를 직접 갈아서 커피를 만듦) -> " + new Coffee(menuName));
        } else {
            System.out.println("손님: 원두가 부족하네... 못 먹겠다.");
        }
    }
}
  • 남의 물건을 내 것처럼 쓰는 손님 - Bad Code
  • 다른 객체의 상태(field)를 직접 꺼내와서(get), 내 쪽에서 로직을 처리하고(set), 다시 넣어주는 방식
    -> 전형적인 절차지향적 프로그래밍

Main

Barista bariKim = new Barista();
Customer sonnom = new Customer();

// 1. 정상적인(?) 손놈의 강탈 과정
sonnom.order("아이스 아메리카노", bariKim);

// 2. 대참사
// 어떤 나쁜 마음을 먹은 개발자가 나타나서...
bariKim.beans = 0; 
System.out.println("누군가 원두를 다 훔쳐감: " + bariKim.beans);
  • Barista는 자신의 상태를 보호할 방법이 전혀 없음
  • 데이터(beans)와 데이터를 처리하는 로직(커피 만들기)이 서로 다른 곳에 떨어져 있기 때문

객체에게 일을 시키세요!

객체지향의 진짜 핵심은 데이터를 가진 놈이 그 데이터를 처리하게 하는 것 (책임 주도 설계 혹은 캡슐화 라고 함)

  • 바리스타의 원두(beans)는 바리스타만 만질 수 있어야함(private)
  • 손님은 바리스타에게 "커피 줘!"라고 메시지만 보내야함(barista.makeCoffee())

Step2: 캡슐화와 책임

위에 잘못된 코드를 제대로 고쳐보면

Barista 클래스

public class Barista {
    // 1. private으로 외부 접근 차단 (원두는 나만의 거시야~)
    private int beans = 100;

    // 2. 상태를 변경하는 로직을 스스로 수행 (책임 수행)
    public Coffee makeCoffee(String menuName) {
        if (this.beans >= 20) {
            this.beans -= 20;
            System.out.println("바리스타: (쓱싹쓱싹) 커피 만드는 중...");
            return new Coffee(menuName);
        } else {
            System.out.println("바리스타: 원두가 부족합니다!");
            return null; // 간단한 예제를 위해 null 반환
        }
    }
}
  • 이제 beans는 private임
  • 대신 makeCoffee라는 공개된 메서드를 열어줌
  • 커피를 만들어줘 라고 요청하면 바리스타가 스스로 원두를 확인하고 줄임. -> 자율적인 객체

Customer 클래스

public class Customer {

    public void order(String menuName, Barista barista) {
        System.out.println("손님: " + menuName + " 주세요.");

        // 이제 손님은 원두가 몇 개인지, 어떻게 만드는지 알 필요가 없음
        // 그냥 "만들어줘"라고 요청(Message)만 보냄.
        Coffee coffee = barista.makeCoffee(menuName);

        if (coffee != null) {
            System.out.println("손님: 와! " + coffee + " 나왔다.");
        } else {
            System.out.println("손님: ㅠㅠ");
        }
    }
}
  • 손님은 이제 beans를 궁금해하지 않음
    그냥 커피 주세요 라고 메시지 보낼뿐

정리(무엇이 좋아졌는가?)

  1. 결합도가 낮아짐(Low Coupling) - 바리스타가 로직을 바꿔도 손님 코드는 수정할 필요가 없음
  2. 응집도가 높아짐(High Cohesion) - 원두에 관련된 로직은 모두 Barista 클래스 안에 모여 있음
  3. 데이터 무결성 - 외부에서 beans = 0처럼 악의적인 조작을 할 수 없음

캡슐화

  • 소중한 데이터와 그 데이터를 다루는 메서드를 하나의 캡슐 안에 넣고 외부의 위험으로부터 보호하는 기술

접근 제어자

1) public (=동네 놀이터(누구나))
: 모든 곳에서 접근 가능
2) protected (=우리 집(가족 + 자녀))
: 같은 폴더(패키지) + 자식 클래스까지
3) default (= 내 방(나 혼자))
: 같은 폴더(패키지)에서만 가능
4) private (= 내 비밀 일기장(오직 나만))
: 오직 이 클래스 안에서만

필드(변수)는 무조건 private으로 꼭꼭 숨긴다(Information Hiding)
외부에서 써야 하는 메서드만 Public으로 열어준다

  • 데이터(필드)는 private으로

  • 데이터 조작은 Public 메서드로만

  • 메서드도 캡슐화할 수 있음

  • 캡슐화는 데이터뿐만 아니라, 내부에서만 사용하는 복잡한 로직(메서드)도 숨기는 데 사용됨

  • 복잡하거나 불필요한 내부 로직을 private으로 숨기는 것을 정보 은닉(Information Hiding)이라고 부르며 캡슐화의 핵심 목표

게터(Getter)와 세터(Setter) - private 데이터와의 공식 소통 창구

  • Getter: private 필드의 값을 가져오는(Get) public 메서드
  • Setter: private 필드의 값을 설정(Set) 하는 public 메서드(이 안에서 유효성 검사를 할 수 있음)

요약

  • 접근 제어자로 코드의 보안 등급(Public, private 등)을 설정한다.
  • 캡슐화는 소중한 데이터(필드)와 그 데이터를 다루는 기능(메서드)을 하나의 캡슐로 묶어 보호하는 기술이다.
  • 정보 은닉은 캡슐화의 핵심 목표로, private을 사용해 불필요한 내부 구현을 숨기는 것이다.
  • 실무 원칙: 필드는 Private으로 막고, 소통이 필요한 경우에만 public 게터/세터를 열어준다(단, 값이 바뀌면 안 되는 필드에는 절대 세터를 만들지 말 것)

Step3: 객체 협력과 메시징

Step2에서 손님이 바리스타에게 직접 주문하였음
캐셔를 등장시켜서 협력과 메시징을 진행
손님 -> 캐셔 -> 바리스타

Cashier 클래스

public class Cashier {
    private Barista barista; // 협력(의존) 객체

    // 캐셔는 바리스타를 알고 있어야만 주문을 전달할 수 있죠? (협력 관계)
    public Cashier(Barista barista) {
        this.barista = barista;
    }

    public Coffee takeOrder(String menuName) {
        System.out.println("캐셔: 주문 확인했습니다. (" + menuName + ")");
        
        // 중요! 캐셔가 직접 커피를 만드는 게 아닙니다.
        // 전문가인 바리스타에게 "만들어줘"라고 메시지만 토스(Toss)합니다.
        return barista.makeCoffee(menuName);
    }
}
  • 주문을 받아서 바리스타에게 전달하는 역할(Manager)

Customer

public class Customer {

    // 이제 손님은 Barista를 알 필요가 없습니다. (느슨한 결합, Loose Coupling)
    public void order(String menuName, Cashier cashier) {
        
        System.out.println("손님: " + menuName + " 주세요!");
        
        // 손님은 캐셔만 믿고 주문합니다.
        Coffee coffee = cashier.takeOrder(menuName);

        if (coffee != null) {
            System.out.println("손님: (홀짝) 음~ " + coffee + " 맛있다.");
        } else {
            System.out.println("손님: (아쉽) 다음에 올게요.");
        }
    }
}
  • 메시지 전송(Messaging)
  • 코드에서 Barista라는 단어가 아예 사라짐 -> 손님 객체는 이제 바리스타가 교체되든, 로봇으로 바뀌든 신경 쓸 필요가 없어짐 -> 유지보수가 엄청나게 쉬워진다는 뜻

Main

Barista chulsoo = new Barista();
Cashier younghee = new Cashier(chulsoo); // "영희야, 주문 들어오면 철수한테 말해줘"
Customer gildong = new Customer();

// 길동이는 영희(캐셔)에게만 말합니다.
gildong.order("따뜻한 라떼", younghee);

[실행결과]

손님: 따뜻한 라떼 주세요!
캐셔: 주문 확인했습니다. (따뜻한 라떼)
바리스타: (원두 갈갈갈) 맛있게 만들어 드릴게요!
손님: (홀짝) 음~ ☕ 따뜻한 라떼 맛있다.

객체지향은 혼자 하는 게 아니다.

  1. 캡슐화(Encapsulation): 내 데이터는 내가 관리한다.(Barista.beans)
  2. 협력과 메시징(Collaboration & Messaging): 내가 못하는 건 남에게 부탁한다.(Cashier -> Barista)
  3. 느슨한 결합(Loose Coupling): 서로에 대해 너무 많이 알지 않는다(Customer는 Barista를 모름)

Step4: 인터페이스와 다형성

만약 캐셔에게 이제부터는 오전엔 철수(사람)랑 일하고, 오후엔 이 로봇이랑 일해 라고 한다면 과연 캐셔는 이 지시를 따를 수 있을까?

public class Cashier {
    // ❌ 문제점: 'Barista'(사람) 클래스에 꽉 묶여 있음 (Tight Coupling)
    private Barista barista; 

    public Cashier(Barista barista) {
        this.barista = barista;
    }
}
  • 캐셔는 Barista라는 특정 클래스만 알고 있음

해결책 - 인터페이스(계약서)

// CoffeeMaker.java
public interface CoffeeMaker {
    // "누구든 이 명찰을 달려면, 커피 만드는 기능은 꼭 있어야 해!"
    Coffee makeCoffee(String menuName);
}

이 문제를 해결하기 위해 "커피를 만드는 존재"라는 공통점을 뽑아서 인터페이스(Interface)를 만듬

그리고 사람과 로봇이 이 명찰을 달게 함

// 사람
public class Barista implements CoffeeMaker{...}

// 로봇
public class RobotBarista implements CoffeeMaker{...}

다형성

public class Cashier {
    // ⭕ 해결: 구체적인 클래스가 아니라 '인터페이스'에 의존함 (Loose Coupling)
    private CoffeeMaker coffeeMaker;

    public Cashier(CoffeeMaker coffeeMaker) {
        this.coffeeMaker = coffeeMaker; // 사람도 OK, 로봇도 OK!
    }
}

이제 캐셔는 상대가 사람인지 로봇인지 알 필요가 없음
그저 커피를 만들 줄 아는 무언가(CoffeeMaker)라면 누구든 환영

// 오전: 사람 투입
Cashier morning = new Cashier(new Barista());
morning.takeOrder("라떼"); 
// -> 🧔🏻‍♂️ 바리스타: (핸드드립) 뚝딱뚝딱...

// 오후: 로봇 투입
Cashier afternoon = new Cashier(new RobotBarista());
afternoon.takeOrder("라떼");
// -> 🤖 로봇: 삐리릭! 고압 추출 모드...

똑같은 takeOrder()를 줄 뿐인데 누가 일하느냐에 따라 결과가 달라짐 -> 이것이 다형성

다형성 추가 내용

부모 클래스 타입의 변수로, 자식 클래스 객체를 다룰 수 있다

  • 코드의 유연성과 확장성이 올라감

  • 다형성: 하나의 타입(부모)으로 여러 가지 형태(자식)의 객체를 다루는 기술

  • 핵심 원리: 부모 타입의 변수에 자식 객체를 담을 수 있음

  • 장점: 코드의 중복을 줄이고, 유연하고 확장성 있는 프로그램을 만들 수 있음

  • 주요 활용처:

    • 배열/컬렉션 관리: 부모 타입의 배열 하나로 모든 자식 객체를 관리
    • 매개변수: 부모 타입의 매개변수로 모든 자식 객체를 받을 수 있음
    • 리턴 타입: 상황에 따라 다른 자식 객체를 유연하게 반환

인터페이스

  • 추상 클래스(Abstract Class): 아직 미완성된 부분이 있는 설계도. 몇몇 기능은 이미 완성되어 있지만(일반 메서드), 가장 핵심적인 기능은 비워두는 것(추상 메서드)
    • abstract 키워드를 붙여서 만듬
    • 미완성 설계도라서, 객체를 직접 생성할 수 없음
    • 반드시 이 설계도를 상속받는 자식 클래스가 미완성된 부분(추상 메서드)을 오버라이딩해서 완성해야만 함
  • 인터페이스는 한 단계 더 나아간 '완벽한 뼈대(명세서)'
    내부 구현은 단 하나도 없고, 규칙(메서드 목록)만 정의

추상 클래스 vs 인터페이스

  • "~은 ~의 한 종류다" 라는 IS-A 관계가 자연스럽고, 자식들이 공통된 데이터나 메서드를 공유해야 한다면 추상 클래스
    (예: 포유류, 조류, 어류는 모두 '동물'의 한 종류)
  • "~는~를 할 수 있다" 라는 CAN-DO 관계가 자연스럽고, 서로 관련 없는 클래스들에게 공통된 기능을 붙여주고 싶다면 인터페이스를 쓰면됨(예: 비행기, 새, 슈퍼맨은 모두 '날 수 있다')

실무에서는 보통 인터페이스를 훨씬 더 많이 사용함. 인터페이스를 통해 객체의 역할을 정의하고, 그 역할에 맞는 구현체를 갈아 끼우는 방식(다형성)이 프로그램을 훨씬 더 유연하게 만들어주기 때문

요약

  • 추상화 : 복잡한 내부를 숨기고, 핵심 기능만 노출시켜 코드를 단순하고 유연하게 만드는 기술
  • 추상 클래스 : IS-A 관계. 미완성 설계도. 자식들이 공통된 속성과 기능을 공유할 때 사용(extends, 단일 상속)
  • 인터페이스 : CAN-DO 관계. 부품 규격서. 클래스에 특정 '기능'을 부여할 때 사용(implements, 다중 구현 가능)
  • 다중 구현과 다형성 : 인터페이스의 핵심. 한 객체가 여러 역할을 수행하게 하고, 역할(인터페이스 타입)을 기준으로 코드를 유연하게 작성할 수 있게 함

Step5: DIP와 의존성 주입

// Step 4의 Main 코드
CoffeeMaker robot = new RobotBarista();
Cashier cashier = new Cashier(robot); 
  • Main 메서드는 "프로그램을 실행하는 곳"인데, 여기서 로봇을 만들고 캐셔에게 연결해주는 조립(Assembly) 작업까지 다 하고 있음 -> 마치 손님이 식당 주방에 들어와서 재료를 다듬고 있는 꼴

객체지향의 설계 원칙 중 하나: 쓰는 놈과 만드는 놈을 나누자

  • 사용 영역(Usage): 실제 빨래를 돌리고, 커피를 파는 곳(Main, Customer, Cashier)
  • 구성 영역(Configuration): 부품을 조립하고 세팅하는 곳

해결책: Context

  • 객체를 생성하고 조립하는 별도의 클래스를 만듬
    -> OrderContext(또는 Config, Factory 등)
public class OrderContext {
    // 오전반 세팅: 사람 + 캐셔 조립
    public static Cashier configMorningShift() {
        return new Cashier(new Barista()); // 여기서 '주입(Injection)' 해줌!
    }

    // 오후반 세팅: 로봇 + 캐셔 조립
    public static Cashier configAfternoonShift() {
        return new Cashier(new RobotBarista()); 
    }
}

DIP: 의존관계 역전 원칙

// Main은 이제 '누가' 오는지 전혀 몰라도 됩니다.
// 그냥 "오전반 세팅 줘!"라고 공장에다 주문만 하면 끝.
Cashier cashier = OrderContext.configMorningShift();

1) High Level(Cashier): 구체적인 것(Barista)에 의존하지 않고, 추상적인 것(CoffeeMaker)에 의존
2) Low Level(Barista): 추상적인 것(CoffeeMaker)을 구현
3) 조립가(OrderContext): 이 둘을 밖에서 연결(Injection) 해 줌.

-> 이전에는 캐셔가 바리스타를 직접 데려왔다면(new Barista), 이제는 외부(Context)에서 바리스타를 캐셔에게 주입(Injection)해줌.
이것을 의존성 주입(Dependency Injection, DI)이라고 부름


SOLID 원칙

S: 단일 책임 원칙(Single Responsibility Principle)

  • 클래스는 단 하나의 책임만 가져야 한다.
  • 하나의 클래스는 하나의 기능, 하나의 역할만 전문적으로 수행해야 한다는 원칙. 여러 가지 일을 동시에 하는 만능 클래스는 좋지 않음

왜 좋을까?

1) 이해하기 쉬움 - 클래스가 하는 일이 명확해서 코드를 파악하기 편함
2) 수정이 안전 - 하나의 기능을 수정해도 다른 기능에 영향을 줄 확률이 크게 줄어듬
3) 재사용하기 좋음 - 필요한 기능만 쏙쏙 가져다 쓰기 편함

O: 개방-폐쇄 원칙(Open/Closed Principle)

  • 소프트웨어 요소는 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 함
  • 새로운 기능을 추가할 때, 기존 코드를 뜯어고치는 게 아니라, 새로운 코드를 추가하는 방식으로 만들어야 한다는 원칙

왜 좋을까?

1) 안정적 - 이미 잘 동작하던 기존 코드를 건드리지 않으니, 새로운 기능을 추가하다가 기존 기능이 고장날 위험이 없음
2) 유연함 - 새로운 요구사항이 생겨도 겁내지 않고 유연하게 대처할 수 있음

L: 리스코프 치환 원칙(Liskov Substitution Principle)

  • 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야함
  • 자식 클래스는 부모 클래스의 역할을 완벽하게 해낼 수 있어야 한다는 원칙. 즉, 부모 클래스가 들어갈 자리에 자식 클래스를 넣어도, 프로그램이 아무 문제 없이 똑같이 동작해야 함.

왜 좋을까?

1) 신뢰성이 높아짐 - 클래스들 간의 관계가 예측 가능해져서 프로그램 전체의 안정성이 올라감
2) 다형성을 잘 활용할 수 있음 - OCP에서 본 것처럼, 부모 타입 하나로 다양한 자식 타입을 믿고 다룰 수 있게 됨

I: 인터페이스 분리 원칙(Interface Segregation Principle)

  • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다
  • 하나의 거대하고 뚱뚱한 인터페이스보다는 기능별로 잘게 쪼갠 날씬한 인터페이스 여러 개를 만드는 것이 더 좋다는 원칙

왜 좋을까?

1) 결합도가 낮아짐 - 클래스가 자기에게 필요 없는 기능에 의존하지 않게 되어 시스템이 깔끔해짐
2) 수정이 쉬워짐 - 인터페이스가 작고 명확하면, 수정이 필요할 때 영향을 받는 범위를 최소화할 수 있음

D: 의존성 역전 원칙(Dependency Inversion Principle)

  • 고수준 모듈은 저수준 모듈에 의존해서는 안된다. 둘 다 추상화에 의존해야 한다,
  • 세부적인 구현(저수준 모듈)에 직접 의존하지 말고, 공통적인 약속(추상화)에 의존하라는 원칙

왜 좋을까?

1) 유연성과 확장성이 극대화됨 - 부품(클래스)을 갈아 끼우기가 매우 쉬워짐
2) 테스트하기 쉬워짐 - 실제 객체 대신 테스트용 가짜 객체(Mock Object)를 쉽게 연결해서 테스트할 수 있음

0개의 댓글