Java - 객체 지향 프로그래밍(OOP)

Kwon Yongho·2023년 12월 13일
0

Java

목록 보기
3/6
post-thumbnail

객체 지향 프로그래밍(OOP)

1. 객체 지향 프로그래밍이란?

  • 객체 지향 프로그래밍(Object-Oriented Programming, OOP)이란 컴퓨터 프로그램을 어떤 데이터를 입력받아 순서대로 처리하고 결과를 도출하는 명령어들의 목록으로 보는 시각에서 벗어나 여러 독립적인 부품들의 조합, 즉 객체들의 유기적인 협력과 결합으로 파악하고자 하는 컴퓨터 프로그래밍의 패러다임을 의미한다.
  • 대표적으로 많이 알려진 Java언어를 포함하여 Ruby Python, C++, Objectivc-C, C#, Kotlin 등이 모두 객체지향 요소를 가진 언어이다.

업로드중..

2. OOP의 4가지 특징(A PIE)

  • 추상화(Abstration)
    • 클래스들의 공통적인 특성(변수, 메소드)들을 묶어 표현하는 것
  • 다형성(Polymorphism)
    • 하나의 객체가 다양한 형태로 처리될 수 있는 특성
  • 상속(Inheritance)
    • 연관 있는 클래스들에 대해 공통적인 구성요소를 정의, 이를 대표하는 클래스 정의
  • 캡슐화(Encapsulation)
    • 데이터와 코드의 형태를 외부로부터 알 수 없게 하고, 데이터의 구조와 역할, 기능을 하나의 캡슐 형태로 만드는 방법

3. OOP 설계의 5가지 원칙 (SOLID)

좋은 설계란 시스템에 새로운 요구사항이나 변경사항이 있을 때, 영향을 받는 범위가 적은 구조를 말한다. 그래서 시스템에 예상하지 못한 변경사항이 발생하더라도, 유연하게 대처하고 이후에 확장성이 있는 시스템 구조를 만들 수 있다.

즉, SOLID 객체 지향 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다.

  • 단일 책임 원칙 (SRP : Single Responsibility Principle)
  • 개방 폐쇄 원칙 (OCP : Open/Closed Principle)
  • 리스코프 치환 원칙 (LSP : Liskov’s Substitution Principle)
  • 인터페이스 분리 원칙 (ISP : Interface Segregation Principle)
  • 의존관계 역전 원칙 (DIP : Dependency Inversion Principle)

3-1. 단일 책임 원칙(SRP)

  • 한 클래스는 하나의 책임(기능)만을 가져야 한다.
  • 즉 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는데 집중 되도록 클래스를 따로 여러개 설계하라는 원칙이다.
  • 프로그램의 유지보수성을 높이기 위한 설계 기법

SRP 위배 예제

// 주문 처리 클래스 - 주문 처리와 이메일 발송 두 가지 책임을 가지고 있음
class OrderProcessor {
    public void processOrder(String orderDetails) {
        // 주문 처리 로직...
        sendConfirmationEmail(orderDetails); // 이메일 발송
    }

    // 이메일 발송 메서드
    private void sendConfirmationEmail(String orderDetails) {
        System.out.println("이메일을 발송했습니다: 주문 내용 - " + orderDetails);
    }
}

// 메인 클래스 - 주문 처리 클래스를 사용하여 주문 처리와 이메일 발송
public class Main {
    public static void main(String[] args) {
        OrderProcessor orderProcessor = new OrderProcessor();

        String orderDetails = "상품명: ABC, 가격: $50";
        orderProcessor.processOrder(orderDetails); // 주문 처리와 이메일 발송
    }
}
  • 위 코드에서 OrderProcessor 클래스는 주문 처리이메일 발송 두 가지 책임을 가지고 있습니다. 이렇게 되면 주문 처리 로직의 변경이 이메일 발송에도 영향을 미칠 수 있다.

SRP 준수 예제

// 주문 처리 클래스 - 주문 처리에 대한 책임만을 가지고 있음
class OrderProcessor {
    public void processOrder(String orderDetails) {
        // 주문 처리 로직...
        System.out.println("주문을 처리했습니다: 주문 내용 - " + orderDetails);
    }
}

// 이메일 서비스 클래스 - 이메일 발송에 대한 책임만을 가지고 있음
class EmailService {
    public void sendConfirmationEmail(String orderDetails) {
        System.out.println("이메일을 발송했습니다: 주문 내용 - " + orderDetails);
    }
}

// 메인 클래스 - 주문 처리와 이메일 발송 클래스를 사용하여 주문 처리와 이메일 발송
public class Main {
    public static void main(String[] args) {
        OrderProcessor orderProcessor = new OrderProcessor();
        EmailService emailService = new EmailService();

        String orderDetails = "상품명: ABC, 가격: $50";

        orderProcessor.processOrder(orderDetails); // 주문 처리
        emailService.sendConfirmationEmail(orderDetails); // 이메일 발송
    }
}
  • OrderProcessor 클래스는 주문 처리에만 집중하고, EmailService 클래스는 이메일 발송에만 집중하게 되었다. 이렇게 분리된 코드는 각 클래스의 변경이 다른 클래스에 영향을 미치지 않게 되었다.

SRP 원칙의 책임의 범위

  • 실제 이 원리를 적용해서 직접 클래스를 설계하기는 쉽지 않다.
  • 단일 책임 기준은 살마들마다 생각이 다르고 상황이 달라질 수 있기 때문이다.

3-2. 개방 폐쇄 원칙(OCP)

  • OCP 원칙은 클래스는 '확장에 열려있어야 하며, 수정에는 닫혀있어야 한다' 를 뜻한다.
  • 기능 추가 요청이 오면 클래스를 확장을 통해 손쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화 하도록 프로그램을 작성해야 하는 설계 기법이다.
  • OCP 원칙은 추상화 사용을 통한 관계 구축을 권장을 의미하는 것

OCP 위배 예제

// 도형 클래스 - OCP를 위반한 예제
class Shape {
    String type;

    public Shape(String type) {
        this.type = type;
    }

    public double calculateArea() {
        if (type.equals("Circle")) {
            // 원의 면적 계산 로직...
            return /* 원의 면적 */;
        } else if (type.equals("Rectangle")) {
            // 사각형의 면적 계산 로직...
            return /* 사각형의 면적 */;
        }
        // 다른 도형의 면적 계산 로직도 추가...
        return 0.0;
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        Shape circle = new Shape("Circle");
        double circleArea = circle.calculateArea();
        System.out.println("원의 면적: " + circleArea);

        Shape rectangle = new Shape("Rectangle");
        double rectangleArea = rectangle.calculateArea();
        System.out.println("사각형의 면적: " + rectangleArea);
    }
}
  • Shape 클래스는 도형의 종류에 따라 면적을 계산하는 책임을 가지고 있다. 하지만 새로운 도형이 추가 될 때마다 calculateArea 메서드를 수정해야 하므로 OCP 원칙을 위반하고 있다.

OCP 준수 예제

// 도형 인터페이스 - OCP를 준수한 예제
interface Shape {
    double calculateArea();
}

// 원 클래스 - 도형 인터페이스를 구현
class Circle implements Shape {
    @Override
    public double calculateArea() {
        // 원의 면적 계산 로직...
        return /* 원의 면적 */;
    }
}

// 사각형 클래스 - 도형 인터페이스를 구현
class Rectangle implements Shape {
    @Override
    public double calculateArea() {
        // 사각형의 면적 계산 로직...
        return /* 사각형의 면적 */;
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        Shape circle = new Circle();
        double circleArea = circle.calculateArea();
        System.out.println("원의 면적: " + circleArea);

        Shape rectangle = new Rectangle();
        double rectangleArea = rectangle.calculateArea();
        System.out.println("사각형의 면적: " + rectangleArea);
    }
}
  • 이 코드는 도형에 대한 공통적인 동작을 나타내는 Shape 인터페이스를 도입하였고 각 도형은 위 인터페이스를 구현하여 새로운 도형이 추가될 때마다 기존 코드를 수정할 필요 없이 새로운 도형을 추가하여 확장 할 수 있다.

OCP 원칙을 따른 JDBC

  • OCP 원칙의 가장 잘 따르는 예시가 바로 자바의 데이터베이스 인터페이스인 JDBC이다.
  • 만일 자바 애플리케이션에서 사용하고 있는 데이터베이스를 MySQL에서 Oracle로 바꾸고 싶다면, 복잡한 하드 코딩 없이 그냥 connection 객체 부분만 교체해주면 된다.

3-3. 리스코프 치환 원칙(LSP)

  • LSP 원칙은 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다.
  • LSP는 다형성 원리를 이용하기 위한 원칙 개념으로 보면 된다.
  • 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미하는 것이다.

LSP 위배 예제

class Bird {
    public void fly() {
        System.out.println("새가 날아갑니다.");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        System.out.println("펭귄은 날지 못해요.");
    }

    public void swim() {
        System.out.println("펭귄이 수영해요.");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Penguin();
        bird.fly(); // 예상 결과: 펭귄은 날지 못해요.
        
        // 컴파일러는 fly 메서드는 있지만 swim 메서드는 없다고 판단하여 에러 발생
        // ((Penguin) bird).swim(); // 컴파일 에러
    }
}
  • 이 예제에서는 Penguin 클래스가 Bird 클래스를 상속받았지만,Penguin에만 있는 swim메서드를 사용하려고 하면 컴파일러에서는 Bird 타입으로 선언된 변수에서 swim메서드를 찾을 수 없다고 판단하여 에러가 발생

LSP 준수 예제

interface Bird {
    void fly();
}

interface Swimmer {
    void swim();
}

class Penguin implements Bird, Swimmer {
    @Override
    public void fly() {
        System.out.println("펭귄은 날지 못해요.");
    }

    @Override
    public void swim() {
        System.out.println("펭귄이 수영해요.");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Penguin();
        bird.fly(); // 예상 결과: 펭귄은 날지 못해요.

        // 형변환 없이 인터페이스에 정의된 메서드 호출 가능
        Swimmer swimmer = (Swimmer) bird;
        swimmer.swim(); // 예상 결과: 펭귄이 수영해요.
    }
}
  • 이 예제에서는 Penguin 클래스가 BirdSwimmer 인터페이스를 모두 구현하고 있습니다. 이렇게 하면 Penguin 객체를 Bird 인터페이스로 사용할 때 fly 메서드만 사용하고, Swimmer 인터페이스로 형변환하여 swim 메서드를 사용할 수 있습니다.

Collection 인터페이스
Collection 타입의 객체에서 자료형을 LinkedList에서 전혀 다른 자료형 HashSet으로 바꿔도 add() 메서드를 실행하는데 있어 원래 의도대로 작동되기 때문에 LSP 원칙을 잘 지켰다 할 수 있다.

3-4. 인터페이스 분리 원칙(ISP)

  • ISP 원칙은 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
  • SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다.
  • ISP 원칙은 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이 목표

ISP 위배 예제

// 큰 인터페이스
interface Car {
    void drive(); // 주행
    void refuel(); // 주유
    void enableAutonomousMode(); // 자율 주행 모드 전환
    void disableAutonomousMode(); // 자융 주행 모드 해제
}

// 자동차 클래스 - 큰 인터페이스를 구현
class AutomatedCar implements Car {
    @Override
    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }

    @Override
    public void refuel() {
        System.out.println("자동차를 주유합니다.");
    }

    @Override
    public void enableAutonomousMode() {
        System.out.println("자동차가 자율 주행 모드로 전환합니다.");
    }

    @Override
    public void disableAutonomousMode() {
        System.out.println("자동차가 자율 주행 모드를 해제합니다.");
    }
}

// 일반 차량 클래스 - 큰 인터페이스를 구현
class RegularCar implements Car {
    @Override
    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }

    @Override
    public void refuel() {
        System.out.println("자동차를 주유합니다.");
    }

    // 자율 주행을 지원하지 않는데 해당 메서드를 사용함
    @Override
    public void enableAutonomousMode() {
        System.out.println("자율 주행 미지원 차량");
    }

    // 자율 주행을 지원하지 않는데 해당 메서드를 사용함
    @Override
    public void disableAutonomousMode() {
        System.out.println("자율 주행 미지원 차량");
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        Car automatedCar = new AutomatedCar();
        automatedCar.drive();
        automatedCar.enableAutonomousMode();
        automatedCar.refuel();

        Car regularCar = new RegularCar();
        regularCar.drive();
        regularCar.enableAutonomousMode(); // 실제로는 사용되지 않아야 하는 메서드
        regularCar.refuel();
    }
}
  • 자율 주행이 가능한 차량(AutomatedCar)과 가능하지 않은 차량(RegularCar)이 같은 기능을 가지고 있는 Car 인터페이스를 구현하고 있지만 RegularCarenableAutonomousModedisableAutonomousMode가 필요 없음에도 메서드를 구현하고 있습니다. 이는 필요하지 않은 메소드를 의존하는 것으로 ISP 위반한 예제로 볼 수 있습니다.

ISP 준수 예제

// 작은 인터페이스들
interface Drivable {
    void drive(); // 주행
}

interface Refuelable {
    void refuel(); // 주유
}

// 자율 주행을 지원하는 차량
interface AutonomousSupport {
    void enableAutonomousMode(); // 자율 주행 모드 전환
    void disableAutonomousMode(); // 자율 주행 모드 해제
}

// 자동차 클래스 - 필요한 작은 인터페이스들을 구현
class AutomatedCar implements Drivable, Refuelable, AutonomousSupport {
    @Override
    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }

    @Override
    public void refuel() {
        System.out.println("자동차를 주유합니다.");
    }

    @Override
    public void enableAutonomousMode() {
        System.out.println("자동차가 자율 주행 모드로 전환합니다.");
    }

    @Override
    public void disableAutonomousMode() {
        System.out.println("자동차가 자율 주행 모드를 해제합니다.");
    }
}

// 자율 주행을 지원하지 않는 차량
class RegularCar implements Drivable, Refuelable {
    @Override
    public void drive() {
        System.out.println("자동차가 주행합니다.");
    }

    @Override
    public void refuel() {
        System.out.println("자동차를 주유합니다.");
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        // 자율 주행을 지원하는 차량
        AutomatedCar autonomousCar = new AutomatedCar();
        autonomousCar.drive();
        autonomousCar.enableAutonomousMode();
        autonomousCar.disableAutonomousMode();
        autonomousCar.refuel();

        // 자율 주행을 지원하지 않는 차량
        RegularCar regularCar = new RegularCar();
        regularCar.drive();
        regularCar.refuel();
    }
}
  • 위 예제는 Drivable, Refuelable, AutonomousSupport로 기능을 각각의 interface로 분리하여 인터페이스의 단일 책임 원칙을 지켰고, 클라이언트의 사용 목적과 용도에 맞는 기능만 구현 했기 때문에 ISP를 준수한 예제로 볼 수 있습니다.

3-5. 의존관계 역전 원칙(DIP)

  • Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙
  • 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻
  • 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는, 변화하기 어려운 것 거의 변화가 없는 것에 의존하라는 것

DIP 위배 예제

  • 게임에는 여러 무기들이 존재하고 캐릭터는 무기를 갈아 낄 수 있습니다.
// 검 구현체
class Sword {
    public void attackWithSword() {
        System.out.println("검으로 공격합니다.");
    }
}

// 활 구현체
class Bow {
    public void attackWithBow() {
        System.out.println("활로 공격합니다.");
    }
}

// 게임 캐릭터 클래스 - 구체적인 무기 구현체에 직접 의존
class GameCharacter {
    private Sword sword; // 구체적인 무기 타입에 직접 의존

    public GameCharacter() {
        this.sword = new Sword();
    }

    public void setSword(Sword sword) {
        this.sword = sword;
    }

    public void attack() {
        if (sword != null) {
            sword.attackWithSword();
        } else {
            System.out.println("무기가 없어서 공격할 수 없습니다.");
        }
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        // 캐릭터 생성
        GameCharacter character = new GameCharacter();

        // 검으로 공격
        character.attack();

        // 활로 무기 변경 후 공격 (DIP 위반)
        character.setSword(new Bow()); // DIP 위반: 구체적인 무기 구현체에 직접 의존
        character.attack(); // 이 부분에서 문제가 발생할 수 있음
    }
}
  • 이 예제에서 GameCharacter 클래스는 Sword 클래스에 직접 의존하고 있습니다. 이렇게 되면 새로운 무기를 추가하거나 변경하려면 GameCharacter 클래스의 코드를 수정해야 하므로 DIP를 위반하는 예시입니다.

DIP 준수 예제

// 무기 인터페이스
interface Weapon {
    void attack();
}

// 검 구현체
class Sword implements Weapon {
    @Override
    public void attack() {
        System.out.println("검으로 공격합니다.");
    }
}

// 활 구현체
class Bow implements Weapon {
    @Override
    public void attack() {
        System.out.println("활로 공격합니다.");
    }
}

// 게임 캐릭터 클래스 - Weapon에 의존
class GameCharacter {
    private Weapon weapon;

    public GameCharacter(Weapon weapon) {
        this.weapon = weapon;
    }

    public void setWeapon(Weapon weapon) {
        this.weapon = weapon;
    }

    public void attack() {
        if (weapon != null) {
            weapon.attack();
        } else {
            System.out.println("무기가 없어서 공격할 수 없습니다.");
        }
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        // 캐릭터 생성
        GameCharacter character = new GameCharacter(new Sword());

        // 검으로 공격
        character.attack();

        // 활로 무기 변경 후 공격
        character.setWeapon(new Bow());
        character.attack();
    }
}
  • 이 예제에서는 GameCharacter 클래스는 Weapon 인터페이스에 의존하고 있습니다. 이렇게 함으로써 GameCharacter 클래스는 구체적인 무기 구현에 의존하는 것이 아니라, Weapon 인터페이스에 의존하게 되어 새로운 무기를 추가하거나 변경할 때 기존 코드를 수정하지 않고 확장할 수 있습니다.

4. OOP 장단점

4-1. 장점

1) 코드의 재사용성 용이
- 상속을 통해 프로그래밍 시, 코드의 재사용률을 높일 수 있습니다.

2) 생산성 향상
- 잘 설계된 클래스를 통해 독립적인 객체를 활용함으로써, 개발의 생산성을 향상할 수 있습니다.

3) 자연적인 모델링
- 일상생활에서 쓰는 개념을 그대로 객체라는 구조로 표현하여 개발함으로써, 생각한것을 그대로 구현할 수 있습니다.

4) 유지보수의 용이성
- 프로그램을 변경할 때 수정, 추가를 하더라도 캡슐화를 통해 주변 코드에 영향이 적기 때문에 유지보수가 용이합니다.

4-2. 단점

1) 실행 속도가 느림.
-  객체지향언어(C++, JAVA 등)는 상대적으로 실행 속도가 느립니다.

2) 프로그램 용량이 큼
- 객체 단위로 프로그램을 많이 만들다보면, 불필요한 정보들이 같이 삽입될 수 있고, 이는 프로그램의 용량 증가로 이어질 수 있습니다.

3) 설계에 많은 시간 소요
- 클래스별로, 객체별로 설계하고, 상속 등의 구조 또한 설계하여야 하기 때문에, 설계단계부터 많은 시간이 소모됩니다.

참고

0개의 댓글