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

다콩이·2022년 8월 1일
0

Java

목록 보기
3/4
post-thumbnail

객체지향 프로그래밍(Object Oriented Programming)

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

실생활에서 쓰는 모든 것을 객체라 하며,
객체지향 프로그래밍은 프로그램 구현에 필요한 객체를 파악하고,
각각의 객체들의 역할이 무엇인지 정의하며
객체들 간의 상호작용을 통해 프로그램을 만드는 것을 말한다.


2. 절차적 프로그래밍 vs 객체지향 프로그래밍

고객이 음료수를 자판기에서 꺼내는 같은 상황에 대해서 다음과 같은 차이가 있다.

절차적 프로그래밍

  • 하나의 클래스에 필요한 프로세스를 위한 모든 기능과 절차를 구성
  • 필요한 제품 기능 별 메서드(주스 구매 가능 여부, 주스 꺼내기 등)를 만들고 순차적으로 진행하는 방식
public class PP {
    // 잔여 주스 개수
    static int Orange_juice = 10;
    static int Apple_juice = 20;

    // 오렌지 주스 구매 가능?
    static boolean Orange_possible(int pay) {
        return Orange_juice > 0 && pay >= 500;
    }

    // 오렌지 주스 꺼내기
    static int getOrangeJuice() {
        Orange_juice--;
        return 500;
    }

    // 사과 주스 구매 가능?
    static boolean Apple_possible(int pay) {
        return Apple_juice > 0 && pay >= 300;
    }

    // 사과 주스 꺼내기
    static int getAppleJuice() {
        Apple_juice--;
        return 300;
    }
    // 메인 메소드
    public static void main(String[] args) {

        int customer_changes = 1000;
        String customer_has = null;

        // 오렌지 주스가 먹고싶다
        String want_juice = "Orange juice";

        if(want_juice.equals("Orange juice")) {
            if(Orange_possible(customer_changes)) {
                int changes = getOrangeJuice();
                System.out.println("오렌지 주스가 정상적으로 구매되었습니다");
                customer_has = want_juice;
                customer_changes -= changes;
            }
            else {
                System.out.println("오렌지 주스를 구매하실 수 없습니다");
            }
        }

        else if(want_juice.equals("Apple juilce")) {
            if(Apple_possible(customer_changes)) {
                int changes = getAppleJuice();
                System.out.println("사과 주스가 정상적으로 구매되었습니다");
                customer_has = want_juice;
                customer_changes -= changes;
            }
            else {
                System.out.println("사과 주스를 구매하실 수 없습니다");
            }

        }

        else {
            System.out.println("없는 물품입니다");
        }

        System.out.println("잔액 : " + customer_changes + "\t갖고있는 음료 : " + customer_has);
    }
}

객체지향 프로그래밍

  • 필요한 객체를 구분(고객, 자판기)하고, 각 객체 별로 클래스를 구성
  • 각 객체 별로 필요한 기능(잔액, 주스 판매 가능 여부 등)에 대한 메서드를 따로 구성
// 고객
@Getter
class Customer {

    private int changes;
    private String hasJuice = null;

    public Customer(int changes) {
        this.changes = changes;
    }

    // 잔액 설정
    public void resetting_juice(int changes , String hasJuice) {
        this.changes -= changes;
        this.hasJuice = hasJuice;
    }

    public String toString() {
        return "잔액 : " + changes + "\t갖고있는 음료 : " + hasJuice;
    }
}

// 자판기
@AllArgsConstructor
class Vending_Machine {
    // 자판기에 남아있는 주스 개수
    private int Orange_juice; // 오렌지 주스 가격 : 500
    private int Apple_juice; // 사과 주스 가격 : 300

    // 오렌지 주스 판매가 가능한지 검사
    private boolean Orange_possible(int pay) {
        return Orange_juice > 0 && pay >= 500;
    }

    // 사과 주스 판매가 가능한지 검사
    private boolean Apple_possible(int pay) {
        return Apple_juice > 0 && pay >= 300;
    }

    public int buy(String kind, int pay) {
        if(kind.equals("Orange juice")) {
            if(Orange_possible(pay)) {
                Orange_juice--;
                System.out.println("오렌지 주스가 정상적으로 구매되었습니다");
                return 500;
            }
            System.out.println("오렌지 주스를 구매하실 수 없습니다");
            return 0;
        }
        else if(kind.equals("Apple juice")) {
            if(Apple_possible(pay)) {
                Apple_juice--;
                System.out.println("사과 주스가 정상적으로 구매되었습니다");
                return 300;
            }
            System.out.println("사과 주스를 구매하실 수 없습니다");
            return 0;
        }
        System.out.println("없는 물품입니다");
        return 0;
    }
}
  • 메인 메서드에서 각 클래스에 대해 해당 클래스 타입의 객체명(customer, vm)을 선언하고, 값을 넣어줌으로써 클래스를 인스턴스화해 사용
  • 필요한 기능에 대해 클래스를 통해 메서드(buy, resetting_juice)를 호출한 후 매개변수를 입력함으로써 변수 생성
public class OOP {
    public static void main(String[] args) {

        Customer customer = new Customer(1000);
        Vending_Machine vm = new Vending_Machine(10, 3);

        // 오렌지 주스가 먹고 싶다
        String want_juice = "Orange juice";

        int pay = vm.buy(want_juice, customer.getChanges());

        // 구매 실패시
        if(pay == 0) {
            customer.resetting_juice(pay, null);
        }
        // 구매 성공시
        else {
            customer.resetting_juice(pay, want_juice);
        }
        System.out.println(customer);
    }
}

장단점 비교


3. 객체지향의 특징

캡슐화(Encapsulation)

  • 객체의 속성(data fields)과 행위(methods)를 하나로 묶고, 실제 구현 내용 일부를 외부에 감추어 은닉
  • 접근 제어자(public, private, protected 등)를 통해 실제로 구현되는 부분을 외부에 드러나지 않도록 정보를 은닉할 수 있음.
// 모든 패키지, 모든 클래스에서 접근 가능
class publicA {
    public void run() {
        B b = new B();
        b.publicMethod();
    }
}

// 동일한 패키지, 모든 클래스에서 또는 다른 패키지에서 자식 클래스일 경우 접근 가능
class protectedA extends B {
    public void run() {
        protectedMethod();
    }
}

// 동일한 패키지 내에서 접근 가능
class defaultA {
    void defaultMethod() {
    }
}

// 동일한 클래스 내에서 접근 가능
class privateA extends B {
    public void run() {
        B b = new B();
        // 접근 불가능
        b.privateMethod();
        privateMethod();
    }
}

class B {
    public void publicMethod() {
        System.out.println("public 메소드 접근");
    }
    void defaultMethod() {
        System.out.println("default 메소드 접근");
    }
    protected void protectedMethod() {
        System.out.println("protected 메소드 접근");
    }
    private void privateMethod() {
        System.out.println("private 메소드 접근");
    }
}

추상화(Abstraction)

  • 불필요한 정보는 숨기고, 중요한 정보만을 표현함으로써 공통의 속성이나 기능을 묶어 이름을 붙이는 것
abstract class Car {
    public abstract void move();
}

class SuperCar extends Car{
    public void move() {
        System.out.println("빠르게 달린다");
    }
}

class ElectricCar extends Car{
    public void move() {
        System.out.println("전기로 달린다");
    }
}

public class Abstraction {
    public static void main(String[] args) {
        SuperCar sc = new SuperCar();
        ElectricCar ec = new ElectricCar();

        sc.move();
        ec.move();
    }
}

상속(Inheritance)

  • 부모클래스의 속성과 기능을 그대로 이어받아 사용
class Student {
    public final String name;

    public Student(String name) {
        this.name = name;
    }

    public void study() {
        System.out.println(name + "가 공부합니다.");
    }
}

// Student 클래스를 상속 받아 Girl 클래스 생성
class Girl extends Student {
    public Girl(String name) {
        super(name);
    }
    // study() 메서드를 재정의해 사용
    public void study() {
        System.out.println(name + "가 열심히 공부합니다.");
    }

    public void walk() {
        System.out.println(name + "가 걷습니다.");
    }
}

public class Inheritance {
    public static void main(String[] args) {
        final Girl girl = new Girl("영희");

        girl.study();
        girl.walk();
    }
}


다형성(Polymorphism)

  • 한 객체가 여러가지 타입을 가질 수 있는 것
  • 역할(인터페이스)구현(클래스)를 명확히 구분해 다형성을 구현
  • 클라이언트는 구현 대상의 역할(인터페이스)만 알고 내부구조를 알지 않아도 됨
  • 클라이언트는 구현 대상의 내부 구조의 변경, 대상 자체의 변경에 영향을 받지 않음
  • 대표적으로 오버로딩(Overloading), 오버라이딩(Overriding)이 있다.

오버로딩(Overloading)

  • 한 클래스 내에 같은 이름을 가진 메서드가 있더라도 매개변수의 개수 또는 타입이 다르면 같은 이름을 사용해 메소드 정의 가능
  • 메서드명이 같아야 한다.
  • 매개변수의 개수 또는 타입이 달라야 한다.
// Test 클래스 내에 여러 test 메서드를 만들고, 매개변수의 타입과 개수를 다르게 해 다양한 메서드 생성
class Test {
    void test() {
        System.out.println("No parameters");
    }

    void test(int param) {
        System.out.println("int " + param);
    }

    void test(String param) {
        System.out.println("String " + param);
    }

    void test(int param1, int param2) {
        System.out.println("two " + param1 + ", " + param2);
    }
}

public class Polymorphism {
    public static void main(String[] args) {

        Test obj = new Test();

        obj.test();
        obj.test(1);
        obj.test("one");
        obj.test(10, 20);
    }
}

오버라이딩(Overriding)

부모 클래스로부터 상속받은 메서드를 자식 클래스에서 재정의
부모와 자식 클래스의 메서드 구성요소가 동일해야 한다.
메서드명, 매개변수의 개수와 타입, 리턴 타입이 모두 같아야 한다.

// Student 클래스를 만들고 study 메서드를 만들어 Girl 클래스에서 이를 상속
class Student {
    public final String name;

    public Student(String name) {
        this.name = name;
    }

    public void study() {
        System.out.println(name + "가 공부합니다.");
    }
}

// study 메서드를 재정의해 부모 클래스와 조건 내에서 완전히 똑같지 않아도 메서드 사용 가능
class Girl extends Student {
    public Girl(String name) {
        super(name);
    }
    // study() 메서드를 재정의해 사용
    public void study() {
        System.out.println(name + "가 열심히 공부합니다.");
    }

    public void walk() {
        System.out.println(name + "가 걷습니다.");
    }
}

4. 객체지향의 5가지 설계원칙(SOLID)

단일 책임 원칙(SRP, Single Responsibility Principle)

  • 하나의 모듈은 한 가지 책임을 가져야 한다는 것으로, 모듈이 변경되는 이유가 한가지여야 함을 의미

사용자의 정보(email, password)를 받아
데이터베이스에 저장하는 로직(UserService)이 있다고 가정

해당 로직에는 비밀번호를 암호화하는
알고리즘이 포함되어 있음

해당 클래스는 정보를 데이터베이스에 저장하는 기능과
비밀번호를 암호화하는 기능이 함께 있어
단일 책임을 가지고 있지 않음

@Service
@RequiredArgsConstructor
public class UserService{
    private final UserRepository userRepository;

    public void addUser(final String email, final String pw) {
        final StringBuilder sb = new StringBuilder();

        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
            sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }

        final String encryptedPassword = sb.toString();
        final User user = User.builder()
                .email(email)
                .password(encryptedPassword).build();

        userRepository.save(user);
    }
}

단일 책임 원칙을 준수하기 위해
비밀번호를 암호화 하는 기능은 따로 구성

이로써 각 모듈이 변경되는 이유가 한 가지가 되게 함

// 암호화 알고리즘 클래스
@Component
public class SimplePasswordEncoder {

    public String encryptPassword(final String pw) {
        final StringBuilder sb = new StringBuilder();

        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
            sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }
        return sb.toString();
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final SimplePasswordEncoder passwordEncoder;

    public void addUser(final String email, final String pw) {
        final String encryptedPassword = passwordEncoder.encryptPassword(pw);

        final User user = User.builder()
                .email(email)
                .password(encryptedPassword).build();

        userRepository.save(user);
    }
}

개방 폐쇄 원칙(OCP, Open-Closed Principle)

  • 확장에 대해 열려있고 수정에 대해서는 닫혀있어야 한다는 원칙
  • 확장에 대해 열려 있다: 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다: 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.

사용자 정보를 저장하는 로직(UserService)에서
암호화 알고리즘 방법을 변경해야 하는 이슈가 있을 때, UserService에서
암호화 알고리즘을 다시 수정해야 하고,
이것은 수정에 대해 닫혀있지 않음

@Component
public static class SHA256PasswordEncoder_ {

    private final static String SHA_256 = "SHA-256";

    public String encryptPassword(final String pw)  {
        final MessageDigest digest;
        try {
            digest = MessageDigest.getInstance(SHA_256);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException();
        }

        final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));

        return bytesToHex(encodedHash);
    }

    private String bytesToHex(final byte[] encodedHash) {
        final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);

        for (final byte hash : encodedHash) {
            final String hex = Integer.toHexString(0xff & hash);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }

        return hexString.toString();
    }
}

@Service
@RequiredArgsConstructor
public static class UserService_notocp {

    private final UserRepository userRepository;
    // UserService에서 암호화 알고리즘을 다시 수정해야 하고, 이것은 수정에 대해 닫혀있지 않음
    private final SHA256PasswordEncoder_ passwordEncoder;

    public void addUser(final String email, final String pw) {
        final String encryptedPassword = passwordEncoder.encryptPassword(pw);

        final User user = User.builder()
                .email(email)
                .password(encryptedPassword).build();

        userRepository.save(user);
    }
}

해당 로직에서 변하지 않는 것은 사용자를 추가할 때
암호화가 필요한 것이고,
변할 수 있는 것은 구체적인 암호화 정책.

UserService에서는 암호화 알고리즘을 알 필요가 없음

따라서 암호화 자체에 대한 내용은 interface로 추상화한 후,
UserService가 구체적인 암호화 클래스에 의존하지 않고
PasswordEncoder라는 인터페이스에 의존하도록 하면
개방 폐쇄 원칙을 준수

// 암호화 추상화
public interface PasswordEncoder {
    String encryptPassword(final String pw);
}

@Component
public static class SHA256PasswordEncoder implements PasswordEncoder {
    private final static String SHA_256 = "SHA-256";

    @Override
    public String encryptPassword(final String pw) {
        final MessageDigest digest;
        try {
            digest = MessageDigest.getInstance(SHA_256);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException();
        }

        final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));

        return bytesToHex(encodedHash);
    }

    private String bytesToHex(final byte[] encodedHash) {
        final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);

        for (final byte hash : encodedHash) {
            final String hex = Integer.toHexString(0xff & hash);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }

        return hexString.toString();
    }
}

@Service
@RequiredArgsConstructor
public static class UserService_ocp {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public void addUser(final String email, final String pw) {
        final String encryptedPassword = passwordEncoder.encryptPassword(pw);

        final User user = User.builder()
                .email(email)
                .password(encryptedPassword).build();

        userRepository.save(user);
    }
}

리스코프 치환 원칙(LSP, Liskov Substitution Principle)

  • 부모 객체와 이를 상속한 자식 객체가 있을 때, 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다는 원칙

정수가 들어왔을 때 0이거나 양수일 때 “good”을 출력하고,
그렇지 않을 때 에러를 발생시키는 메서드를 가진 Parents 클래스를 생성.

Children 클래스에 Parents를 상속받아 메서드를 오버라이드하고,
정수가 0이 아니어야 하는 조건을 추가한다고 가정

class Parents {
    void method(int data) {
        if (data < 0) {
            throw new RangeException((short) data, "음수가 아니어야 합니다.");
        }
        System.out.println("good");
    }
}

class Children extends Parents{
    @Override
    public void method(int data) {
        if (data <= 0) {
            throw new RangeException((short) data, "0보다 커야 합니다");
        }
    }
}

Parents 클래스에서는 0을 허용했기에 에러가 발생하면 안되지만,
Children 클래스에서 0에 대한 조건을 추가했기 때문에 에러 발생.
자식 객체가 부모 객체를 완전히 대체하지 못함
-> 리스코프 치환 원칙을 만족하기 위해서는 자식 클래스는 부모 클래스가 따르는 조건을 그대로 따라야 함

public class NotLSP {
    public static void main(String[] args) {
        Parents parents = new Children();
        parents.method(0);
    }
}


인터페이스 분리 원칙(ISP, Interface Segregation Principle)

  • 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것

통화, 메세지 이외에 무선충선, AR, 생체인식 등
다양한 기능을 포함하는 스마트폰 객체가 있다고 가정.

public class NotISP {
    abstract public class SmartPhone {
        // 통화
        public void call(String number) {
            System.out.println(number + " 통화 연결");
        }
        // 문자
        public void message(String number, String text) {
            System.out.println(number + ": " + text);
        }
        // 무선충전
        public void wirelessCharge() {
            System.out.println("무선 충전");
        }
        // AR
        public void ar() {
            System.out.println("AR 기능");
        }
        // 생체인식
        abstract public void biometrics();
    }

스마트폰 클래스를 상속받아 S20을 구현하면,
객체의 동작 모두가 필요하므로
인터페이스 분리 원칙 만족

    public class S20 extends SmartPhone {
        @Override
        public void biometrics() {
            System.out.println("S20 생체인식 기능");
        }
    }

S2 스마트폰의 경우
무선충전, AR, 생체인식과 같은 기능을 지원하지 않음.

하지만, 부모 객체인 스마트폰에 이러한 인터페이스가 포함되어 있으므로
필요하지도 않은 기능을 구현해야 하는 낭비가 발생

    public class S2 extends SmartPhone {

        @Override
        public void wirelessCharge() {
            System.out.println("지원 불가능한 기기");
        }

        @Override
        public void ar() {
            System.out.println("지원 불가능한 기기");
        }

        @Override
        public void biometrics() {
            System.out.println("지원 불가능한 기기");
        }
    }
}

인터페이스 분리 원칙을 만족하기 위해
스마트폰 클래스에는 모든 스마트폰에 공통으로 적용되는 기능만 구현

// 모든 스마트폰에 공통으로 적용되는 기능만 구현
    public class SmartPhone {
        // 통화
        public void call(String number) {
            System.out.println(number + " 통화 연결");
        }
        // 문자
        public void message(String number, String text) {
            System.out.println(number + ": " + text);
        }
    }

나머지 스마트폰의 특성에 따라 달라지는 기능들은
각각 인터페이스로 구현해 필요에 따라 해당 기능만을 상속하도록 함

public class ISP {
	// 각각 인터페이스로 구현해 필요에 따라 해당 기능만을 상속하도록 함
    // 무선충전
    public interface WirelessChargable {
        void wirelessCharge();
    }
    // AR
    public interface ARable {
        void ar();
    }
    // 생체인식
    public interface Biometricsable {
        void biometrics();
    }
	// 모든 기능이 지원 가능한 S20 클래스에는 스마트폰 클래스를 상속받은 후 기타 기능들은 인터페이스를 상속받음으로써 구현
    public class S20 extends SmartPhone implements WirelessChargable, ARable, Biometricsable {
        @Override
        public void wirelessCharge() {
            System.out.println("무선충전 기능");
        }

        @Override
        public void ar() {
            System.out.println("AR 기능");
        }

        @Override
        public void biometrics() {
            System.out.println("생체인식 기능");
        }
    }
    
    //필수 기능만 지원 가능한 S2 클래스는 스마트폰 클래스만 상속받음
    public class S2 extends SmartPhone {
        @Override
        public void message(String number, String text) {
            System.out.println("In S2");
            super.message(number, text);
        }
    }
}

이로써 스마트폰에 적합한 인터페이스를 제공하도록 해 인터페이스 분리 원칙 만족


의존 역전 원칙(DIP, Dependency Inversion Principle)

  • 추상화에 의존하며 구체화에는 의존하지 않는 설계 원칙

결제서비스를 제공하는 로직(PayService)이 있다고 가정
다양한 결제수단이 추가될수록 PayService에 해당 결제수단을 추가해야 하므로
이는 구체화에 의존하는 설계

class SamsungPay {
    String payment() {
        return "samsung";
    }
}

class KakaoPay {
    String payment() {
        return "kakao";
    }
}

public class PayService {
    private SamsungPay samsungPay;
    private KakaoPay kakaoPay;

    public void setSamsungPay(final SamsungPay samsungPay) {
        this.samsungPay = samsungPay;
    }

    public void setKakaoPay(final KakaoPay kakaoPay) {
        this.kakaoPay = kakaoPay;
    }

    public String SSpayment() {
        return samsungPay.payment();
    }

    public String KKPayment() {
        return kakaoPay.payment();
    }
}

Pay라는 공통부분을 추상화하고 각 결제수단은 이를 상속함으로써
PayService가 구체적인 결제수단(SamsungPay, KakaoPay 등)에 의존하지 않도록 설정

public interface Pay {
    String payment();
}

class SamsungPay implements Pay {
    @Override
    public String payment() {
        return "samsung";
    }
}

class KakaoPay implements Pay {
    @Override
    public String payment() {
        return "kakao";
    }
}

public class PayService {
    private Pay pay;

    public void setPay(final Pay pay) {
        this.pay = pay;
    }

    public String payment() {
        return pay.payment();
    }
}

소스코드
https://github.com/chIorophyII/OOP

참고
https://mangkyu.tistory.com/194
https://st-lab.tistory.com/151
https://blog.itcode.dev/posts/2021/08/15/liskov-subsitution-principle
https://pizzasheepsdev.tistory.com/9

0개의 댓글