OOP

9 분 소요



Object Oriented Programming

객체 지향 프로그래밍(OOP)은 컴퓨터 프로그래밍의 패러다임 중 하나이다. 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어릐 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 “객체”들의 모임으로 파악하고자 하는 것이다.
[wikipedia] 객체 지향 프로그래밍

OOP는 함수와 로직보다는 데이터와 객체를 기준으로 프로그램을 작성하는 방법입니다. 객체는 고유의 속성과 행위를 가지는 데이터 필드입니다.


Structure of OOP

  • Class: 객체, 속성, 그리고 메서드의 청사진으로 사용되는 사용자가 정의한 데이터 타입입니다.

  • Object: 실제 데이터가 지정된 클래스의 인스턴스입니다.

  • Method: 객체가 할 수 있는 행위들을 설명하는 클래스 내의 함수입니다.

  • Attribute: 클래스 내에 정의된 객체의 상태를 나타내는 정보입니다. 객체는 attribute field에 데이터를 저장할 것입니다.


Principles of OOP

  • 캡슐화(Encapsulation)
    • 캡슐화를 통해 객체 안의 모든 중요한 정보들은 감춰지고, 선택한 정보들만 외부로 노출시킬 수 있습니다. 다른 객체들은 이 감춰진 정보들을 마음대로 수정할 권한이 없고, 공개된 기능과 함수들만 호출할 수 있습니다. 캡슐화를 통해 보안성이 향상되고 의도되지 않은 데이터 부정합을 방지할 수 있습니다.
    • ex. private fields and public getters/setters
  • 추상화(Abstraction)
    • 객체는 사용자 혹은 다른 객체들이 관심있는 내부 메커니즘만 드러내고, 다른 불필요한 구현물들은 감춥니다. 이를 통해 사용자가 관심을 가지지 않아도 될 내부 복잡한 로직들을 숨기고 생각할 필요조차 없게 해줍니다.
    • ex.
      1
      2
      3
      4
      
        abstract class Vehicle {
            protected abstract void drive();
            protected abstract void changeGear();
        }
      
  • 상속성(Inheritance)
    • 상속을 통해 클래스는 다른 클래스의 코드를 재사용할 수 있습니다. 객체 간 관계와 하위 구조가 지정될 수 있습니다. 이로, 개발자들은 고유 체계를 유지하며 공통 로직을 재사용할 수 있게 됩니다.
    • 자바는 기본적으로 단일 상속만 지원하며, 인터페이스에 한해 다중 상속을 할 수 있습니다.
    • ex. class Dog extends Animal {}
  • 다형성(Polymorphism)
    • 객체는 자신의 기능을 공유하기 위해 만들어지고, 이 기능은 여러개의 형태를 가질 수 있습니다. 프로그램은 객체의 호출된 기능이 여러 형태중 어떤 것인지를 파악하고 실행할 것입니다.
    • ex. overloading, overriding


Pros of OOP

  • 모듈성: 캡슐화로 인해 객체들은 자급적으로 문제를 해결할 수 있고 협력적인 개발이 쉽도록 합니다.

  • 재사용성: 상속을 통해 코드는 재사용될 수 있고, 동일한 코드를 새로 짤 필요가 없습니다.

  • 생산성: 모듈성과 재사용성을 통해 새로운 프로그램을 더 빠르게 작성할 수 있습니다.

  • 확장성: OOP를 통해 개발자는 여러 기능들을 하나로 엮어 사용하기 수월해집니다.

  • 보안성: 캡슐화와 추상화를 통해 복잡한 코드는 감춰질 수 있고 소프트웨어 유지보수가 용이해집니다.

  • 유연성: 다형성을 통해 하나의 기능이 해당 클래스에 적응할 수 있습니다. 적절한 다른 객체 또한 동일 인터페이스에 변경 적용될 수 있습니다.


Cons of OOP

  • 소프트웨어 개발의 데이터 구성에만 너무 집중하고, 로직과 알고리즘에 집중도가 떨어지는 경향이 있을 수 있습니다.

  • 절차형보다 작성하기 복잡할 수 있고 컴파일 하는데 더 긴 시간을 가집니다.


SOLID

SOLID는 Robert C. Martin이 제시한 다섯가지 객체 지향 디자인 원칙들의 앞 글자를 따온 단어입니다. 이 원칙들은 프로그램이 확장되어도 유지보수 및 관리를 용이하게 도웁니다. 또한, code smells를 방지하고, 리팩토링이 용이하며, agile 소프트웨어 개발이 용이하도록 해줍니다.

1. Single-Responsibility Principle(SRP)

클래스는 단 하나만의 이유로만 변경되어야 하고, 이는 클래스가 단 하나의 일만 수행해야 한다는 것을 뜻합니다.

SRP를

  1. 많은 팀이 동일한 프로젝트에서 작업하고, 각자 다른 이유로 동일한 클래스를 수정할 수 있습니다. 이는 호환성 불이치를 일으킬 수 있습니다.

  2. 버전 컨트롤이 용이해집니다. 하나의 클래스는 하나의 작업만 수행하기 때문에 github commit에서 용도를 파악하기 쉬워집니다.

  3. Merge conflict가 줄어듭니다. 하나의 클래스는 하나의 기능만 수행하므로 클래스에 수정이 있다 하더라도 그 기능 내에서 일 것입니다.

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Invoice {

	private Book book;
	private int quantity;
	private double discountRate;
	private double taxRate;
	private double total;

	public Invoice(Book book, int quantity, double discountRate, double taxRate) {
		this.book = book;
		this.quantity = quantity;
		this.discountRate = discountRate;
		this.taxRate = taxRate;
		this.total = this.calculateTotal();
	}

	public double calculateTotal() {
	        double price = ((book.price - book.price * discountRate) * this.quantity);

		double priceWithTaxes = price * (1 + taxRate);

		return priceWithTaxes;
	}

	public void printInvoice() {
            System.out.println(quantity + "x " + book.name + " " +          book.price + "$");
            System.out.println("Discount Rate: " + discountRate);
            System.out.println("Tax Rate: " + taxRate);
            System.out.println("Total: " + total);
	}

        public void saveToFile(String filename) {
	// Creates a file with given name and writes the invoice
	}
}

위 Invoice 클래스는 SRP를 위배하고 있습니다.

  1. printInvoice method
    하나의 클래스는 단 하나의 이유에서만 수정되어야 한다고 했습니다. Invoice 클래스의 가장 핵심 기능은 총액을 계산하는 calculateTotal 메서드입니다. 그러나 출력할 송장의 형식을 변경하기 위해서는 이 printInvoice method를 수정해야 하고, 핵심 기능 수정이 아닌 다른 이유로 수정되어야 합니다.

  2. saveToFile method
    saveToFile의 영속 로직과 다른 메서드들의 비즈니스 로직이 하나의 클래스에 포함되어있습니다. 이는 지양되어야 하며, 파일 뿐만이 아니라 database, api call 등 다른 영속 행위들도 마찬가지 입니다.

이를 해결하기 위해 Invoice 클래스의 printInvoice 메서드와 saveToFile 메서드를 분리해내 각자의 기능을 수행하는 클래스들을 만들면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InvoicePrinter {
    private Invoice invoice;

    public InvoicePrinter(Invoice invoice) {
        this.invoice = invoice;
    }

    public void print() {
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
        System.out.println("Discount Rate: " + invoice.discountRate);
        System.out.println("Tax Rate: " + invoice.taxRate);
        System.out.println("Total: " + invoice.total + " $");
    }
}
1
2
3
4
5
6
7
8
9
10
11
public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }
}

2. Open-Closed Principle(OCP)

클래스들은 확장에 열려있고 수정에는 닫혀있어야 합니다.

수정은 존재하는 클래스의 코드를 수정함을 뜻하고, 확장은 새로운 기능을 추가하는 것을 의미합니다.

즉, 우리는 이미 존재하는 다른 코드들을 건드리지 않으면서 새로운 기능을 추가해야 한다는 의미입니다. 다른 코드를 건드리며 기능을 추가할 경우, 많은 버그들을 일으킬 수 있습니다.

다른 코드를 건드리지 않으며 기능을 추가하는 것이 어떤 것인지 예제를 통해 확인해보겠습니다.

위에서 분리 생성한 InvoicePersistence 클래스에 데이터베이스 영속 메서드를 추가해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }

    public void saveToDatabase() {
        // Saves the invoice to mysql database
    }
}

기능은 추가되었지만, 안타깝게도 확장에 용이한 형식으로 되지는 않았습니다. saveToDatabase 메서드는 mysql에는 저장할 수 있지만 mongoDB에는 저장할 수 없습니다. 이 문제를 해결하기 위해 InvoicePersistence 클래스를 인터페이스로 변경하고 영속 방식에 따라 이 interface를 상속받는 클래스를 생성할 것입니다.

1
2
3
4
interface InvoicePersistence {

    public void save(Invoice invoice);
}
1
2
3
4
5
6
7
public class DatabasePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to DB
    }
}
1
2
3
4
5
6
7
public class FilePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to file
    }
}

DatabasePersistence와 FilePersistence는 서로의 save 메서드 로직을 건드리지 않고 각자의 기능을 수행할 수 있습니다. 추후 MongoDB와 같은 다른 영속 방식을 사용하고 싶다면 같은 방법으로 새로운 클래스를 추가하면 됩니다.

그런데 왜 굳이 인터페이스를 생성하고 그를 상속받는 여러개의 클래스들을 만들어야 할까요?

추후에 PersistenceManager라는 persistence 객체들을 관리하는 클래스를 만들었습니다. Persistence 객체들이 같은 interface를 상속받고 있다면 다형성의 도움을 받아 하나의 변수로 persistence 객체를 관리할 수 있고, 이는 유연성을 극대화할 수 있습니다.

1
2
3
4
5
6
7
public class PersistenceManager {
    InvoicePersistence invoicePersistence;

    public PersistenceManager(InvoicePersistence invoicePersistence) {
        this.invoicePersistence = invoicePersistence;
    }
}

3. Liskov Substitution Principle(LSP)

하위 클래스는 상위 클래스로 언제든 교체될 수 있어야 합니다.

이는, B라는 객체가 클래스 A를 상속받고 있을 때, 클래스 A를 요구하는 기능에 클래스 B를 넘겨주어도 이상이 없어야 한다는 뜻입니다. 이것은 상속의 성질을 생각해보면 당연한 말이지만, 종종 이를 이를 위배하여 버그를 발생하는 코드가 생길 수 있습니다.

유명한 Rectangle - Square 예제를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// import lombok getter and setter

@Getter
@Setter
class Rectangle {

	protected int width, height;

	public Rectangle() {
	}

	public Rectangle(int width, int height) {
		this.width = width;
		this.height = height;
	}

    public int getArea() {
		return width * height;
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Square extends Rectangle {

	public Square() {}

	public Square(int size) {
		width = height = size;
	}

	@Override
	public void setWidth(int width) {
		super.setWidth(width);
		super.setHeight(width);
	}

	@Override
	public void setHeight(int height) {
		super.setHeight(height);
		super.setWidth(height);
	}
}

정사각형은 직사각형의 한 종류이므로 위와 같이 Rectangle을 상속받는 Square를 만들었습니다. 정사각형의 성질인 너비와 높이가 같아야함을 지키기 위해 setWidth와 setHeight를 오버라이딩하였습니다.

위 두 클래스들이 의도대로 동작하는지 확인하기 위해 테스트 클래스를 만들었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Test {

   static void getAreaTest(Rectangle r) {
      r.setWidth(5);
      r.setHeight(4);
      System.out.println("Expected area of 20, got " + r.getArea());
   }

   public static void main(String[] args) {
      Rectangle rc = new Rectangle(2, 3);
      getAreaTest(rc);

      Rectangle sq = new Square();
      sq.setWidth(5);
      getAreaTest(sq);
   }
}

11번 줄의 직사각형을 위한 테스트는 정상적으로 20을 출력할 것입니다. 그러나 15번째 줄의 정사각형을 위한 테스트는 예상한 20이 아닌 16을 출력할 것입니다.

하위 클래스인 Square는 상위 클래스인 Rectangle의 메서드인 getArea()를 원하는 대로 수행하지 못하였고 Liskov substitution 원칙을 위배했습니다.

현실 세계의 Is-a 관계는 이처럼 종종 컴퓨터 세계에서는 성립되지 않을 수 있습니다. 위 예제의 경우에는 Rectangle과 Square의 상하 관계를 끊고, 두 도형 모두 Shape 혹은 Polygon과 같은 클래스를 만들어 상속받는 것이 바람직해 보입니다.

4. Interface Segregation Principle(ISP)

클라이언트가 자신과 관련이 없는 인터페이스는 구현하지 않아야 합니다.

인터페이스는 하나의 포괄적인 용도를 가지기보다는 여러 특수한 용도를 가지는 것이 좋습니다.

예제를 통해 간단히 확인해보겠습니다.

1
2
3
4
5
6
public interface Guitar {
    void tune();
    void play();
    void changeStrings();
    void connectAmp();
}

위와 같이 조율, 연주, 줄 교체, 앰프 연결 기능이 있는 일반적인 기타 인터페이스가 있습니다.

그런데 한 과학자가 절대로 조율이 필요 없고 선 교체도 필요가 없고 앰프 또한 필요가 없는 엄청난 기타를 만들었습니다. 일단 기타는 기타이니 Guitar 인터페이스를 상속받았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class SuperAwesomeGuitar implements Guitar {

    @Override
    void tune() {
        // do noting
    }

    @Override
    void play() {
        // play the super awesome guitar
    }

    @Override
    void changeStrings() {
        // do noting
    }

    @Override
    void connectAmp() {
        // do noting
    }
}

위와 같이 우리의 새로운 멋진 기타는 굳이 필요 없는 세개의 메서드를 구현해야만 합니다. 이런 불필요한 구현을 없애기 위해 연주 기능만 있는 기타 interface를 상속받아야 합니다.

1
2
3
public interface PlayableGuitar {
    void play();
}
1
2
3
4
public interface StringChangeableGuitar {
    void tune();
    void changeStrings();
}
1
2
3
4
public interface AmpConnectableGuitar {
    void tune();
    void changeStrings();
}
1
2
3
4
5
6
7
8
class SuperAwesomeGuitar implements Guitar {

    @Override
    void play() {
        // play the super awesome guitar
    }

}

위와 같이 기능에 따라 인터페이스를 분리하고 상속받아, 실제로 사용할 메서드들만 골라 구현하도록 해야합니다.

5. Dependency Inversion Principle(DIP)

클래스는 구체화 클래스에 의존해서는 안되고 추상화에 의존해야 합니다.

일반적으로 인터페이스, 추상 클래스와 같은 추상화가 구현체 클래스들보다 변경이 적습니다. 안정된 소프트웨어를 위해 변경이 자주 일어나는 구체화 클래스에 의존하지 않고 변경이 적은 추상화에 의존해야 합니다.

유명한 예제로 자동차 - 스노우 타이어 예제가 있습니다. 눈이 많이 내리는 겨울 자동차는 스노우타이어에 의존하여 주행해야 합니다. 그러나 눈이 내리지 않는 계절이 온다면, 자동차는 더이상 스노우 타이어가 아닌 일반 타이어에 의존해야 합니다. 이렇게 변경되기 쉬운 클래스에 의존한다면 자동차는 수시 교체로 인한 변경에 노출될 것입니다.

따라서 자동차는 스노우 타이어나 일반 타이어같은 구현체에 의존하지 않고, 타이어라는 interface에 의존되어야 합니다.

Robert C Martin의 말에 따르면, 옛날 전통적인 소프트웨어 개발 메서드에서는 상위 모듈이 하위 모듈에 의존하는 경우가 많았다고 합니다. OOP애서는 이것이 역전되어 ‘inversion’이라는 용어를 사용했다고 합니다.



참고

[TechTarget] object-oriented programming(OOP)
[freeCodeCamp] The SOLID Principles of Object-Oriented Programming Explained in Plain English
[stack overflow] What is difference between the Open/Closed Principle and the Dependency Inversion Principle?

태그:

카테고리:

업데이트:

댓글남기기