객체 지향 이해하기

진환·2024년 1월 19일
0

※ 2024.01 원티드 프리온보딩 백엔드 챌린지 강의를 기반으로 작성했습니다.

소프트웨어의 가치

소프트웨어는 사람들에게 새로운 가치를 제공한다.
그리고 소프트웨어는 시간이 지남에 따라 변화하는 요구사항에 적응해야 한다.

이를 위해서 개발자가 할 수 있는 일은 당연히 새로운 가치를 제공할 수 있고, 변화하는 요구사항에 적응할 수 있는 소프트웨어를 만드는 것이다.

그럼 변화하는 요구사항에 적응할 수 있는 소프트웨어를 어떻게 만들 수 있을까?
변화하는 요구사항의 특징은 다음과 같다.

  1. 유연성
  2. 확장성
  3. 유지 보수성

위의 3가지 특징을 갖고 있다. 높은 유연성, 확장성, 유지 보수성을 확보하는 방법 중 객체 지향 프로그래밍을 통해 확보하는 방법을 알아보자.


의존

의존이라는 말은 객체 지향 언어를 공부하게 되면 자주 듣게 된다.

의존의 사전적 정의는 다음과 같다.

어떠한 일을 자신의 힘으로 하지 못하고, 다른 어떤 것의 도움을 받아 의지하다.

그렇다면 코드에서는 의존이 어떻게 표현될까?

  1. 객체 참조에 의한 연관 관계
  2. 메서드 반환 타입이나 파라미터로서의 의존 관계
  3. 상속에 의한 의존 관계
  4. 구현에 의한 의존 관계

객체 참조에 의한 연관 관계

public class A {

	private B b;

	public void someMethod() {
		b.someMethod();
	}
}

public class B {

	public void someMethod() {
		...
	}
}

A클래스가 멤버 변수로 B를 갖고 있다.
이때 A클래스는 B클래스에 의존을 하고 있다고 말할 수 있다.


메서드 반환 타입이나 파라미터로서의 의존 관계

public class ClassA {

	public ClassC someMethod(ClassB b) {
		return b.someMethod();
	}
}

public class ClassB {

	public ClassC someMethod() {
		return new ClassC();
	}
}

public class ClassC [
	...
}

ClassAsomeMethod메서드를 보면 반환 타입이 ClassC이고, 파라미터로서 ClassB를 받고 있다. 이렇게 되면 ClassAClassB, ClassC에 의존한다고 할 수 있다. 그리고 ClassB도 마찬가지로 someMethod메서드의 반환 타입이 ClassC이므로 ClassBClassC에 의존하고 있다고 할 수 있다.


상속에 의한 의존 관계

public class SuperClass {

	public void functionInSuper() {
		...
	}
}

public class subClass extends SuperClass {
	
	@Override
	public void functionInSuper() [
		...
	}
}

subClassSuperClass를 상속받고 있다. 이 경우에도 subClassSuperClass에 의존하고 있다.


구현에 의한 의존 관계

public interface InterfaceA {
	void functionInInterfaceA();
}

public class ClassB implements InterfaceA {

	@Override
	public void functionInInterfaceA() {
		...
	}
}

ClassBInterfaceA를 구현하고 있다. 이때도 ClassBInterfaceA를 의존하고 있는 것이다.


의존이 가지는 진짜 의미

의존이 가지는 진짜 의미는 변경 전파 가능성이다.
AB를 의존하고 있을 경우 B에 변경이 있다면 A도 해당 변경에 의해 변경될 가능성이 있는 것이다.
그러므로 우리는 필요한 의존만 유지하며 의존성을 최소화해야 한다.


절차지향 프로그래밍과 객체 지향 프로그래밍의 차이

절차지향

절차지향은 아래와 같은 특징이 있다.

  • 프로시저에 중점을 둔다.
  • 프로그램은 일련의 절차적 단계로 구성되고, 데이터와 프로시저가 별도로 존재한다.

객체 지향

객체 지향은 데이터와 기능이 하나의 객체에 묶여있다.


의존이라는 관점에서의 해석

코드로 의존을 바라보자.
절차 지향은 순서대로 입금, 출금, 잔액 출력의 절차를 진행한다.

public static void main(String[] args) {
	int accountBalance = 10000;
	// 입금
	accountBalance = deposit(accountBalance, 5000);

	// 출금
	accountBalance = withdraw(accountBalance, 3000);

	// 잔액 출력
	System.out.println(accountBalance);
}

public static int deposit(int balance, int amount) {
	return balance + amount;
}

public static int withdraw(int balance, int amount) {
	if (balance < amount) {
		System.out.println("잔액이 부족합니다.");
		return balance;
	} else {
		return balance - amount;
	}
}

위 코드를 객체 지향으로 바꾸게 되면

public class BankAccount {

	private int balance;

	public BankAccount(int balance) {
		this.balance = balance;
	}

	public void deposit(int amount) {
		this.balance += amount;
	}

	public void withdraw(int amount) {
		if (balance < amount) {
			System.out.println("잔액이 부족합니다.");
		} else {
			this.balance -= amount;
		}
	}

	public void printBalance() {
		System.out.println(balance);
	}
}

public class Main {

	public static void main(String[] args) {
		BankAccount bankAccount = new BankAccount(10000);

		// 입금
		bankAccount.deposit(5000);

		// 출금
		bankAccount.withdraw(3000);

		// 잔액 출력
		bankAccount.printBalance();
	}
}

위의 코드처럼 나타낼 수 있는데 BankAccount클래스로 객체화 하여 이 객체는 잔액이라는 상태와 입금, 출금, 잔액 출력이라는 기능을 갖고 있다.

그렇다면 여기서 VIP에게는 이체 수수료가 면제된다는 요구사항이 추가됐다고 가정해보자.

절차지향의 경우는 아래와 같을 것이다.

public static void main(String[] args) {
	int accountBalance = 10000;
	int bankFee = 1000;
	boolean isVIP = true;
	// 입금
	accountBalance = deposit(accountBalance, 5000);

	// 수수료 적용
	if (!isVIP) {
		accountBalance = applyBankFee(accountBalance, bankFee);
	}

	// 출금
	accountBalance = withdraw(accountBalance, 3000);

	// 수수료 적용
	if (!isVIP) {
		accountBalance = applyBankFee(accountBalance, bankFee);
	}


	// 잔액 출력
	System.out.println(accountBalance);
}

public static int deposit(int balance, int amount) {
	return balance + amount;
}

public static int withdraw(int balance, int amount) {
	if (balance < amount) {
		System.out.println("잔액이 부족합니다.");
		return balance;
	} else {
		return balance - amount;
	}
}

public static int applyBankFee(int balance, int fee) {
	return balance - fee;
}

요구사항이 추가되면 실제 실행되는 코드에 변경이 생기게 된다.

반면에 객체 지향 코드를 보면

public class BankAccount {

	private int balance;
	private boolean isVIP;
	private int bankFee = 1000;

	public BankAccount(int balance, boolean isVIP) {
		this.balance = balance;
		this.isVIP = isVIP;
	}

	public void deposit(int amount) {
		this.balance += amount;
		if (!isVIP) {
			applyBankFee();
		}
	}

	private void applyBankFee() {
		this.balance -= bankFee;
	}

	public void withdraw(int amount) {
		if (balance < amount + (isVIP ? 0 : bankFee)) {
			System.out.println("잔액이 부족합니다.");
		} else {
			this.balance -= amount;
			if (!isVIP) {
				applyBankFee();
			}
		}
	}

	public void printBalance() {
		System.out.println(balance);
	}
}

public class Main {

	public static void main(String[] args) {
		BankAccount bankAccount = new BankAccount(10000, true); // 기본 잔액과 VIP 고객 여부

		// 입금
		bankAccount.deposit(5000);

		// 출금
		bankAccount.withdraw(3000);

		// 잔액 출력
		bankAccount.printBalance();
	}
}

BankAccount에 요구사항에 맞는 변화가 생기게 되고 실제 실행되고 있는 코드는 거의 변하지 않았음을 알 수 있다.

이를 통해서 알 수 있는 것은 객체 지향적인 설계를 통해서 의존을 다룰 수 있고 이를 통해 변경이 전파되는 것을 최소화할 수 있다.

객체는 자체적으로 상태와 행동을 갖고 있고, 이를 외부에서 '어떠한 행동을 할 줄 안다.'의 시선으로 바라볼 뿐이라 내부에서 어떻게 행동을 하는지 모르기 때문에 객체 밖으로 변경이 전파되지 않는 것이다.

그렇다면 객체 지향 설계가 의존을 다루는 핵심은 무엇일까?


객체 지향 핵심 이해하기

객체 지향 프로그래밍의 선구자인 앨런 케이는 다음의 3가지를 객체 지향의 핵심이라고 말한다.

  • Message Passing
  • Encapulation
  • Dynamic Binding

Message Passing

클라이언트와 서버의 입장에서 말을 하면,
클라이언트는 자신의 목적을 달성하기 위해 어떤 API를 호출해야 하는지 알고 있지만, 서버가 구체적으로 어떻게 일을 하는지 모른다.
서버는 그저 들어온 요청에 맞는 자신이 알고있는, 할 수 있는 일을 할 뿐이다.
클라이언트와 서버를 객체로 표현하면,

object

위의 그림처럼 뒤에서 무슨 일이 일어나는지 관심이 없고 그저 응답을 주고, 받을 뿐이다.

커피 주문하는 코드를 예시로 보면,

public class Customer {

	public void order(Barista barista, String coffeeType) {
    	barista.makeCoffee(coffeeType);
	}
}

public class Barista {

	public void makeCoffee(String coffeeType) {
		System.out.printf("%s 커피를 만드는 중입니다.", coffeeType);
	}
}

public void main(String[] args) {
	Customer customer = new Customer();
	Barista barista = new Barista();
	customer.order(barista, "아메리카노"); // 메시지 패싱 예시
}

커피를 주문하라는 메시지(order)를 Customer에게 보내고 Customer는 커피를 만들라는 메시지(makeCoffee)를 Barista에게 전달한다.
이렇듯 메시지는 명령 + 인자로 이루어져있다. 코드상에 CustomerBarista가 어떻게 커피를 만드는지 전혀 알지 못한다. 그저 만들라는 메시지를 보내고 커피를 받기만 하면 된다. 그리고 메시지를 전달받은 Barista는 누가 메시지를 보냈는지 중요하지 않고 그저 메시지를 전달받고 커피를 만들 뿐이다.


Encapsulation

Encapsulation은 캡슐화라는 의미로, 객체의 내부 상태와 동작을 외부로부터 숨기는 방법이다.

캡슐화를 통해서 다음과 같은 이점을 얻을 수 있다.


낮은 결합도

낮은 결합도를 얻을 수 있는데 이는 변경을 더 쉽게 할 수 있음을 뜻한다.

높은 결합도의 코드의 예시는 다음과 같다.

class HighCouplingClass {
	int data = 10;
}

class AnotherHighCouplingClass {
	int data;

	public AnotherHighCouplingClass(HighCouplingClass hc) {
		this.data = hc.data;
	}
}

HighCouplingClass hc = new HighCouplingClass();
AnotherHighCouplingClass ahc = new AnotherHighCouplingClass(hc);

위의 코드를 보면 AnotherHighCouplingClassHighCouplingClass의 인스턴스를 직접 참조하고 있다.

낮은 결합도의 코드의 예시를 보면,

class LowCouplingClass {
	int data = 10;
}

class AnotherLowCouplingClass {
	int data;

	public AnotherLowCouplingClass(int data) {
		this.data = data;
	}
}

LowCouplingClass lc = new LowCouplingClass();
AnotherLowCouplingClass alc = new AnotherLowCouplingClass(lc.data);

이번에는 AnotherLowCouplingClassLowCouplingClass 인스턴스를 직접 참조하는 것이 아니라 int 타입의 데이터를 받고 있다.

여기서 만약 코드의 변경이 있다고 하고, 높은 결합도의 코드의 경우에는

class HighCouplingClass {
	String data = "10";
}

class AnotherHighCouplingClass {
	int data;

	public AnotherHighCouplingClass(HighCouplingClass hc) {
		this.data = Integer.parseInt(hc.data);
	}
}

HighCouplingClass hc = new HighCouplingClass();
AnotherHighCouplingClass ahc = new AnotherHighCouplingClass(hc);

위의 코드처럼 되는데 HighCouplingClassdata 변수의 타입을 String으로 바꿨을 뿐인데, 이를 사용하는 AnotherHighCouplingClass의 생성자 내부의 코드도 변경이 일어나는 것을 볼 수 있다. 이처럼 객체 참조를 하게 되면 의존이 전파될 수 있다.

낮은 결합도를 가진 코드의 경우에는

class LowCouplingClass {
	String data = 10;
}

class AnotherLowCouplingClass {
	int data;

	public AnotherLowCouplingClass(int data) {
		this.data = data;
	}
}

LowCouplingClass lc = new LowCouplingClass();
AnotherLowCouplingClass alc = new AnotherLowCouplingClass(Integer.parseInt(lc.data));

위처럼 LowCouplingClass에서 변경이 일어나도 AnotherLowCouplingClass에서는 변경이 일어나지 않는 것을 볼 수 있다.


자율적인 객체

소통은 인터페이스로 하고, 구현은 클래스 내부에서 마음대로 할 수 있게 된다.

public class Car {

	private int speed;
	private int fuelLevel;

	public void accelerate() {
		speed += 1;
	}

	public void brake() {
		speed -= 1;
	}

	public void turnLeft() {
		// turn left...
	}

	public void turnRight() {
		// turn right...
	}
}

Car 객체와 소통하려는 다른 객체는 오직 노출된 인터페이스(현재의 경우에는 메서드)만을 통해서 소통이 가능하다.
만일 차가 브레이크를 밟으면 로그를 남긴다는 요구사항이 새로 생길 경우 brake메서드 내부에 로깅을 하는 코드만 작성하면 되므로 외부에 변경을 전파하지 않게 된다.


Dynamic Binding

동적 바인딩(Dynamic Binding)은 런타임 시점에 참조 변수와 실제 객체 타입을 확인하여 함수를 호출하는 방식이다.
동적 바인딩은 다형성이 적용된 코드에서 발생하는 하나의 현상으로 볼 수 있는데 다형성이란 하나의 참조 변수로 여러 개의 객체를 참조할 수 있는 특성을 뜻한다.

객체 지향에서 다형성을 사용하면 다른 객체에게 보내는 메시지가 실제로 어떤 메서드를 호출할 지 런타임에 결정된다.

polymorphism

위의 그림은 다형성을 나타내는 그림인데 코드로 나타내면 아래와 같다.

public class Coffee {
	private String name;
	public Coffee(String name) {
		this.name = name;
	}
}

public class CoffeeMachine implements CoffeeMaker {

	@Override
	public Coffee makeCoffee(String coffeeType) {
		System.out.printf("커피머신이 %s 커피를 만드는 중입니다.", coffeeType);
		return new Coffee(coffeeType);
	}
}

public class HandDrip implements CoffeeMaker {

	@Override
	public Coffee makeCoffee(String coffeeType) {
		System.out.printf("핸드드립으로 %s 커피를 만드는 중입니다.", coffeeType);
		return new Coffee(coffeeType);
	}
}

public interface CoffeeMaker {

	Coffee makeCoffee(String coffeeType);
}

public class Customer {
	public void order(Barista barista, String coffeeType) {
		LocalDateTime now = LocalDateTime.now();
		barista.makeCoffee(now, coffeeType);
	}
}
public class Barista {

	private CoffeeMaker determineCoffeeMaker(LocalDatetime now) {
		if (now.hour < 8 || now.hour > 20) {
			return new CoffeeMachine();
		} else {
			return new HandDrip();
		}
	}

	public Coffee makeCoffee(LocalDateTime now, String coffeeType) {
		System.out.printf("%s 커피를 만드는 중입니다.", coffeeType);
		CoffeeMaker coffeeMaker = determineCoffeeMaker(now);
		return coffeeMaker.makeCoffee(coffeeType);
	}
}

public static void main(String[] args) {
	Customer customer = new Customer();
	Barista barista = new Barista();
	customer.order(barista, "아메리카노");
}

Customer가 커피를 주문할 경우 Barista는 커피머신으로 커피를 탈지, 핸드드립으로 커피를 탈지 실제로 타는 시간에 따라 결정되는 것을 볼 수 있다.
이처럼 다형성을 이용하면 런타임에 실제 메시지를 결정할 수 있게 된다.


객체의 협력, 책임, 역할 개념 이해하기

객체는 Message Passing을 통해 요청하고 협력한다.

message

CustomerBarista에게 커피를 만드는 것을 요청했다.
이는 CustomerBarista가 커피를 만들 수 있다는 것을 알고 있음을 뜻하고, Customer커피를 만들도록 시켰다는 것을 뜻한다.
그리고 Barista자신이 커피를 만들 수 있다는 것을 알고 있고, 할 수 있다는 것이다.

이로써 Customer의 책임은

  • Barista가 무엇을 하는지 아는 것
  • Barista가 할 줄 아는 것을 시키는 것

Barista의 책임은

  • 커피를 만드는 방법을 아는 것
  • 커피를 만드는 것

이렇게 볼 수 있다. 위처럼 책임은 객체가 무엇을 할 수 있는지, 객체가 무엇을 아는지에 따라 달라진다.

그리고 객체는 무언가를 할 줄 아는 객체에게 그에 대한 책임을 할당하게 된다.
단, 어떻게 할 줄 아는지는 모르고, 그저 할 줄 안다는 것만 안다.

makeCoffee라는 메시지의 책임이 왜 Barista에게 할당되었는지에 대해 생각해보면 Barista가 커피를 만드는 방법을 알기 때문이다.

그리고 CoffeeMaker가 커피를 만드는 역할을 하게 된다.
위에서 CoffeeMakerCoffeeMachine이 될 수도, HandDrip이 될 수도 있었는데 이렇듯 역할은 구체적인 객체를 바꿔 끼울 수 있는 슬롯을 뜻한다.

profile
끄적끄적

0개의 댓글