0. 서론
OOP, 객체지향이란 무엇일까?
처음 프로그래밍, 코딩을 처음 접했을 때 가장 생소했던 단어중 하나이다. 객체지향? 객체라는 요소를 이용해서 모델링이니...뭐니... 하는 아리송한 말로만 들려왔다. 지금 와서 내가 이해한 객체지향에 대해서 간단하게 정리를 해보는 시간을 가지고자 한다.
1. OOP가 뭔데?
OOP, 즉 객체지향 프로그래밍이란, 프로그래밍 개발방법론 중 하나로, 이런식으로 "개발해야한다~" 라는 의미이다. OOP에 대해서 찾아보면, 사람이 직관적으로 이애하기 쉽고, 유지보수를 용이하게 한다는 설명을 찾을 수 있다. 하지만 이를 이해하기 위해서 몇가지 알아야할 지식들이 있다.
객체
간단하게 말하자면, 아래와 같다.
- 객체는 현실의 무언가에 대응하는 개념이다.
- class는 객체를 표현하는 하나의 수단이다.
조금 복잡하게 말하자면 아래와 같다.
- 다른 객체와 협력(collaboration) 하는 역할(role)을 맡고 있는 대상
- 역할(role)을 맡으면 임무를 수행할 책임(responsibility)가 생긴다.
- 책임을 다하기 위해 데이터와 프로세스를 가지고 있다.
- 협력(Collaboration)
- 시스템의 목표를 달성하기 위해 여러 객체가 참여하여 행동하는 것이다.
- 예를들어 치킨집을 개발한다고 하면, "치킨을 튀겨서 손님에게 배달해야 한다." 라는 목표가 있을 것이다.
- 책임(responsibility)
- 협력 속에서 본인이 수행해야할 임무의 내용을 알고, 수행하는 것이다.
- 예를들어 "치킨을 튀길 객체는 치킨을 맛있게 조리해야할 책임이 있다."라는 책임이 있다.(조리하는 법을 알고, 조리할줄 알아야한다는 책임이 있다.)
- 역할(role)
- 동일한 목적을 가진 책임의 묶음이다.
- 예를들어 "치킨을 조리할 책임을 가지는 역할은 요리사"라는 치킨 조리 등등의 같은 동일한 책임을 모두 가지고 있다.
- 메세지
- 객체는 메세지를 통해 다른 객체에게 책임을 다하라고 요구한다.
- 무엇을 할지만 요구하고, 어떻게 하는지는 신경쓰지 않아도 된다.
정리하자면, 치킨집을 개발한다고 한다면, "치킨을 손님에게 배달하기"라는 협력을 완수하기 위해, 치킨 가게라는 객체는 치킨을 튀기는 책임(역할)을 수행하고, 배달원 객체는 치킨을 손님에게 배달하는 책임(역할)을 수행한다.
예시
// 자바에서는 메서드를 호출해서 메세지를 보낸다.
class ChickenShop {
public void cookChicken() {
// 치킨을 호출받아. 요리를 한다.
}
public void deliverChicken() {
// 치킨을 전달한다.
}
위의 내용들을 종합해서 예시 코드를 작성해보자.
java에서는 클래스를 통해서 객체를 표현한다. ChickenShop이라는 객체를 만든다.
java에서는 메서드를 호출해서 메세지를 보낸다. 그러므로 요리하고 배달하는 메서드를 만든다.
이렇게 하면, 무엇을 할지를 메세지로 요구하고, 어떻게 하는지는 메서드 안에서 자율적으로 한다.
한마디로 객체는 책임을 수행하라고 요구받지만, 어떻게 처리할지는 자율에 맡겨진다.
2. 데이터 중심의 설계 VS 객체 지향적 설계
데이터 중심의 설계
class ChickenShop {
private Chef chef = new Chef();
private Phone Phone = new Phone();
private void processChickenOrder() {
Menu orderMenu = phone.getMenu();
List<Menu> possibleMenuList = chef.getMenuList();
Driver driver = new Driver();
if (possibleMenuList.contains(orderMenu)) {
Chicken chicken = chef.cook(orderMenu);
driver.setChicken(chicken);
driver.setDestination(order.getDestination());
driver.deliver();
}
}
}
위와같이 코드를 짜본다.
전화기에서 메뉴를 가져와서 치킨메뉴를 만들고, 요리사가 가능한요리인지 체크하고, 요리사가 요리해서 driver에게 전달하고, driver에게 목적지를 주고 배달하는 코드이다. 언뜻 봐서는 ChickenShop은 전체적인 협력을 주관하는 책임, chef는 요리하는 책임, 전화기는 전화받는 책임이 있으니 객체지향적이라고 볼 수 있으나, 데이터 지향적인 설계라고 볼 수 있다.
getter,setter가 과도하게 사용되었다.
- 서로 알고 있는 객체가 너무 많아지므로, 결합도가 높아진다.
- 예를들어 위의 코드에서 phone.getMenu의 타입이 변경된다고 하면, 메뉴판을 가져올 chef.getMenuList, chef.cook(orderMenu) 등등 이렇게 결합도가 높으면 작은 변경에도 수정되야할 부분들이 너무 많다.(유지보수에 안좋음)
데이터를 처리하는 작업과 데이터가 분리되어 응집도가 낮아진다.
- 하나의 일을 여러 객체가 같이 하고 있다는 이유로, 여러가지로 변경되어야 할 것이 많다.
- 예를들어 위의 코드에서 메뉴를 받아오는 방식이 변경되면 수정, Chef의 요리방식이 바뀌면 수정...등등
책임 주도 개발
그러면 어떻게 해야 객체지향적으로 개발을 할 수 있을까?
방법으로는 책임 주도 개발이 있다.
책임 주도 개발
- 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.(협력 파악)
- 시스템 책임을 더 작은 책임으로 분할한다.(책임을 만든다.)
- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
- 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.
협력을 파악해보자
> 치킨 주문을 받아 손님에게 배달해야 한다.
시스템 책임을 더 작은 책임으로 분할해보자 .
> 메세지를 생성한다.
- 치킨을 주문 받는다.
- 치킨을 요리한다.
- 치킨을 손님에게 배달한다.
분할된 책임을 할 수 있는 적절한 객체나 역할을 찾아서 책임을 할당한다.
> 치킨 주문을 받는다 > 치킨 가게에 할당
다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
> 치킨 가게 > 요리사에게 치킨 요리 메세지 > 치킨 배달하라는 메세지를 배달원에게 보낸다.
해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 된다.
> 책임 할당 > 메세지를 준다 > 메소드로 표현된다.
ChickenShop객체안에 takeOrder(주문받기), 그리고 요리를 하도록 Chef에게 시킨다. 그리고 chef도 driver에게 배달을 하도록 시킨다.
class ChickenShop {
private Chef chef = new Chef();
public void takeOrder() {
chef.cook();
}
}
ChickShop은 주문만 받아서 Chef에게 요리하라고만 시킨다.
이렇게 간소화 되기 때문에, 알고있는 정보도 적고, 수행하는 기능도 적으므로, 수정이 된다고 하면 ChickShop이나 Chef만 고치면 된다.
Class Chef {
private Driver driver = new Driver();
private Chicken chicken;
public void Cook() {
chicken = new FriedChicken();
driver.deliver(chicken);
}
}
Chef 또한 치킨을 튀기는 책임을 하고 driver에게 배달을 넘긴다.
이부분 역시 수정할 거리가 생기면 chef나 driver만 고치면 된다.
3. OOP이론의 4가지
OOP이론에는 4가지가 있다.
- Encapsulation(캡슐화)
- Inheritance(상속)
- Abstraction(추상화)
- Polymorphism(다형성)
Encapsulation(캡슐화)
Encapsulation(캡슐화)는 데이터, 데이터에 활용하는 함수를 캡슐 혹은 컨테이너에 두는 것을 의미합니다.
캡슐화를 하지 않는다면?
public class Entrepreneur {
public String firstName = "Elon";
public String lastName = "Musk";
public int shares = 18000000;
public String company = "TSLA";
public double calculateNetWorth() {
double sharePrice = getSharePrice(this.company);
return this.shares * sharePrice;
}
private double getSharePrice(String company) {
// 해당 회사의 주식 가격을 가져오는 로직
return 400.0; // 임의의 값으로 가정
}
public static void main(String[] args) {
Entrepreneur elon = new Entrepreneur();
double netWorth = elon.calculateNetWorth();
System.out.println("Elon Musk's net worth is: $" + netWorth);
}
}
위의 코드는 이름, 주식의 수량, 회사 이름을 가진 EntrePreneur클래스이다. calculateNetWorth라는 함수는 재산을 계산하도록한다. 이 함수는 그의 주식의 수량에 주가를 곱해서 계산한다. getSharPrices는 회사에 따라서 주식 가격을 로직인데 간단하게 하기위해 일단 400을 리턴하게 한다. 메인함수에서는 해당 함수들을 사용한다. elon에 Entrepreneur 객체를 생성하고, netWorth에 elon에 calculateNetWorth라는 함수에 담아서 실행한다.
하지만 코드를 위와같이 짜게되면, 여러가지 문제가 생길 수 있다.
public class Entrepreneur {
public String firstName = "Elon"; // 캡슐화되지 않은 public 필드
public static void main(String[] args) {
Entrepreneur elon = new Entrepreneur();
elon.firstName = "Hacker"; // 외부에서 firstName을 Hacker로 변경
System.out.println("Entrepreneur's first name: " + elon.firstName); // 출력 결과: Hacker
}
}
1. 데이터 무결성 위협: 필드에 직접 접근하여 잘못된 데이터를 설정할 수 있다. 이와 같은 이유로 중요한 정보들이 외부에서 쉽게 접근되고 수정될 수 있어서 보안에 취약해질 수 있다.
public class Entrepreneur {
public int shares = 18000000; // 캡슐화되지 않은 public 필드
public void updateShares(int newShares) {
this.shares = newShares; // shares를 외부에서 변경
}
public static void main(String[] args) {
Entrepreneur elon = new Entrepreneur();
System.out.println("Shares before update: " + elon.shares); // 출력 결과: 18000000
elon.updateShares(20000000); // 외부에서 shares를 20000000으로 변경
System.out.println("Shares after update: " + elon.shares); // 출력 결과: 20000000
}
}
2. 유지보수 어려움: 코드의 어디서든 필드를 직접 접근하고 변경할 수 있어서 디버깅과 수정이 어려워질 수 있다. 저렇게 새로운 값을 넣으려면 매번 하나하나의 필드를 변경해야 한다.
캡슐화를 적용한다면
public class Entrepreneur {
private String firstName;
private String lastName;
private int shares;
private String company;
public Entrepreneur(String firstName, String lastName, int shares, String company) {
this.firstName = firstName;
this.lastName = lastName;
this.shares = shares;
this.company = company;
}
private double getSharePrice(String company) {
// 해당 회사의 주식 가격을 가져오는 로직
return 400.0; // 임의의 값으로 가정
}
public double calculateNetWorth() {
double sharePrice = getSharePrice(this.company);
return this.shares * sharePrice;
}
public static void main(String[] args) {
Entrepreneur elon = new Entrepreneur("Elon", "Musk", 17700000, "TSLA");
double netWorth = elon.calculateNetWorth();
System.out.println("Elon Musk's net worth is: $" + netWorth);
}
}
Java에서는 접근 제어자인 private를 사용하여 클래스 내부의 필드를 캡슐화한다.
생성자에서 클래스의 인스턴스, 즉 , private 필드들 초기화 해준다.
이를 통해 외부에서 직접적으로 접근할 수 없으며, Entrepreneur elon = new Entrepreneur("Elon", "Musk", 17700000, "TSLA");와 같이 객체를 생성하는 것은 코드의 유지보수 측면에서 매우 유용하면서 가독성도 좋다.
간접적으로는 calculateNetWorth 메서드를 통해 순자산을 계산할 수 있도록 구현된다.
Inheritance(상속)
상속 덕분에 코드를 더 작은단위, Class로 쪼개고, 작은단위를 사용,재사용을 할 수 있게 된다.
// Entrepreneur 클래스
public class Entrepreneur {
private String firstName;
private String lastName;
private int shares;
private String company;
public Entrepreneur(String firstName, String lastName, int shares, String company) {
this.firstName = firstName;
this.lastName = lastName;
this.shares = shares;
this.company = company;
}
}
// Actor 클래스
public class Actor {
private String firstName;
private String lastName;
private int oscars;
private int age;
public Actor(String firstName, String lastName, int oscars, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.oscars = oscars;
this.age = age;
}
}
기업가(Entrepreneur)라는 클래스 외에 배우(Actor)라는 클래스가 존재한다고 해보자.
두 클래스에는 공통적으로 firstName, lastName이라는 속성이 있다. 상속이라는 개념이 없다면, 위의 코드처럼 매번 저렇게 별도의 클래스에 별도의 속성을 매번 정의해주어야 한다.
// Person 클래스
public class Person {
private String firstName;
private String lastName;
public Person(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String sayHi() {
return "Hi, my name is " + this.firstName + " " + this.lastName;
}
}
// Entrepreneur 클래스
public class Entrepreneur extends Person {
private int shares;
private String company;
public Entrepreneur(String firstName, String lastName, int shares, String company) {
super(firstName, lastName);
this.shares = shares;
this.company = company;
}
// elon.sayHi()로 호출할 수 있는 sayHi() 메소드
public String sayHi() {
return super.sayHi(); // 부모 클래스(Person)의 sayHi() 메소드 호출
}
}
위의 코드처럼 중복되는 firstName, lastName을 Person이라는 클래스로 옮기고, 배우, 기업가 클래스가 Person클래스를 상속해서 사용하도록 해보자. (extends Person)
클래스를 확장하게 되며, 자식클래스는 부모클래스의 모든 속성과 메소드를 '상속' 혹은 수신하게 된다. 위의 코드에서 처럼 sayHi라는 메서드가 Person에 존재한다면, 기업가 클래스에서도 sayHi를 사용할 수 있다.
이처럼 '상속'은 클래스들을 작게 쪼개고 분할한 다음 레고처럼 클래스를 구성할 수 있도록 해준다.
Abstraction(추상화)
C++의 아버지 Bjarne Strousrup은 추상화를 '구현 세부 정보를 숨기는 일반 인터페이스를 지정하는 행위' 라고 정의한다.
자동차를 운전할 때 우리는 각종 인터페이스를 사용한다. 휠, 페달, 드라이버, 핸들 등등.. 제조사에 의해서 사용자에게 노출되어 있다. 이를 이용해 자동차를 조종할 수 있다.
하지만, 자동차를 구현하는 세부정보는 노출되어 있지 않다. 휠을 움직이면, 모터안의 어디가 움직이고.. 기름이 들어가서... 등등의 일련의 과정들 말이다. 한마디로 우리는 자동차가 어떻게 동작하는지 세부정보를 모르고도 자동차를 움직일 수 있었다는 것이다.
import java.util.ArrayList;
import java.util.List;
public class BetterArray {
private List<String> items;
public BetterArray() {
this.items = new ArrayList<>();
}
public List<String> getItems() {
return new ArrayList<>(this.items);
}
public void addItem(String item) {
this.items.add(item);
}
public void removeItem(String itemToDelete) {
this.items.removeIf(item -> item.equals(itemToDelete));
}
public void modifyItem(String itemToChange, String newValue) {
int index = this.items.indexOf(itemToChange);
if (index != -1) {
this.items.set(index, newValue);
}
}
}
BetterArray라는 클래스를 만들고, 내부에는 배열이 있는데 노출된 인터페이스(각종 메서드들)를 만든다.
이 클래스 안에는 배열 내부의 항목을 가져오거나, 추가, 제거, 등을 하는 메서드가 있다. 누가 BatterArray클래스를 사용한다면 사용할 수 있는 노출된 인터페이스들이다.
public class Main {
public static void main(String[] args) {
BetterArray arr = new BetterArray();
arr.addItem("I love");
arr.modifyItem("I love", "Typescript");
}
}
외부 사용자들은 위처럼 BatterArray를 사용한다.
하지만 이 클래스를 사용하면서 modifyItem 메서드안에서 indexOf~~ item.set.. 등등의 일련의 과정을 모르고도 그냥 값을 주입해서 사용할 수 있다.
이렇게 추상화를 사용하는 가장 큰 장점은, 메소드를 수정해서 내부작업이 바뀌어도, 사용하는 사람은 바꿀게 없다. 그냥 평소대로 값을 입력해서 사용한다. 내부 작업이 바뀌든 말든 modifyitem에는 itemToChange, newValue를 넣어서 사용한다 라는 인터페이스는 유지되고 있기 때문이다.
Polymorphism(다형성)
poli는 다각형처럼 여러개를 의미한다. morphism은 형태, 모양을 의미한다. 즉, Polymorphism은 여러개의 형태라는 뜻을 가지고 있다. 이를 이해하기 위해 '상속'에서 부모클래스의 속성이 자식들에게 상속되는 것을 알아야 한다.
// Person 클래스
public class Person {
public String sayHi() {
return "hi";
}
public String sayBye() {
return "Bye";
}
}
// 한국인 클래스 (Person 클래스를 상속받음)
public class Korean extends Person {
@Override
public String sayHi() {
return "안녕";
}
}
// 미국인 클래스 (Person 클래스를 상속받음)
public class American extends Person {
// 따로 sayHi() 메소드를 오버라이드하지 않았으므로 부모 클래스의 sayHi() 메소드를 사용함
}
위의 코드에서는 한국인, 미국은은 둘 다 Perosn클래스를 확장해서 사용중이다.
이 클래스들의 객체는 sayHi 메서드를 사용할 수 있다.
여기서 다형성이 등장하는데, 만약 한국인 클래스에서 sayHi메서드를 고쳐버리고 싶다고 해보자.
위의 코드처럼 sayHi 메서드를 재정의 하는데 이걸 메서드'오버라이딩'이라고 한다. sayHi() 라는 같은 이름의 메서드를 사용하지만 다른 구현방식으로 사용하는 것이다. 여기서 중요한 점은, Personn의 sayHi() 에서 return값이 string형식이므로 한국인 클래스의 sayHi() 역시 string을 리턴해야한다. 만약 int형을 반환하려고하면 에러가 생길 것이다.
한마디로, 부모에서 메소드를 오버라이딩 할 수는 있지만, 작동 규칙이 정해져있다. 핵심은 그대로지만 구현방식이나 모양은 달라질 수 있는 것이다.
'Backend > 개념,공부' 카테고리의 다른 글
Nginx란? (1) | 2023.12.28 |
---|---|
Redis? (0) | 2023.12.08 |
데이터베이스 - 데이터베이스(DB), MariaDB (0) | 2023.06.18 |