개요

객체지향이라고 하면 꼭 따라오는 개념이 있다. 유지보수.
유지보수성이란 쉽게 말하면 개발자가 주어진 코드를 유지보수할 때 편하다는 뜻이다.

결국 관리하기 편하게 코드를 짠다는 말인데
처음 프로그래밍을 접하면 기능의 모든 것들을 하나의 클래스 혹은 파일에 작성할 것이다.

따로 파일을 분리해서 작성하는게 익숙하지 않아서 라고 생각한다.

그러다보면 내 코드를 이해하기 힘들어지고 누군가에게 설명하기도 힘들어진다.

이런 방식을 절차 지향 프로그래밍이라고 한다. 초기 프로그래밍 방식은 이러했다고 한다. 그리고 위의 상황이 바로 절차지향 프로그래밍 방식의 단점이다.

코드의 라인이 몇 줄 안된다면 상관이 없겠지만 코드의 라인이 많으면 많을수록 관리하기 어려워진다.
결국 더 이상 개선하는 것을 포기하게 될 지도 모른다.

객체지향이란 클래스를 역할별로 나누고 클래스들끼리 메시지로 협력하게 만드는 프로그래밍 방식이라고 생각한다.

프로그램을 이렇게 설계한다면 마치 레고블럭을 다루듯이 결합할 수도 있고 떼어내어 다른 블럭을 붙일 수가 있다.

객체 지향의 4가지 특징

객체 지향에는 4가지 특징이 있는데 다음과 같다.

  1. 추상화
  2. 다형성
  3. 캡슐화
  4. 상속

추상화

추상화란 불필요한 부분을 제거함으로써 필요한 핵심만 나타낸 것이다. 이는 우리가 사용하는 일반화와 비슷하다.
버스 노선도를 보면 버스가 어디서 출발해 어느 정류장을 거치고 어느 정류장에 도착하는지 나와있다. 사실 실제 동선은 노선도만큼 단순하지 않다.
곧게 뻗지 않고 왼쪽 오른쪽으로 왔다 갔다 하고 유턴도 할 수 있고 주변의 건물들도 있을 것이다. 그러나 이것들은 버스 노선을 나타내는 본질이 아니다.
그렇기 때문에 본질을 제외한 것들을 지워버리는 것이다.

자바에서 추상화를 실현할 수 있는 방법으로는 추상 클래스와 인터페이스가 있다.
인터페이스를 이용해 자동차와 오토바이를 추상화해보면 다음과 같다.

public class Vehicle {
	String model;
	String color;
	int wheels;
	
	void moveForward() {
		System.out.println("앞으로 전진합니다.");
	}
		
	void moveBackword() {
		System.out.println("뒤로 후진합니다.");
	}
	
}

다형성

한 사람이 모임을 나가면 모임원의 역할을 할 것이고
집에서는 한 명의 가족 구성원이 될 것이다.

이 것이 다형성이다.

사람 A = new 회사원();  
or  
사람 A = new 가족();   
or... 

자바에서 다형성을 실현할 수 있는 방법은 두 가지가 있는데 하나는 오버라이드 다른 하나는 오버로딩이다.

오버라이드는 상위 클래스에서 정의된 메소드를 하위 클래스에서 자기 자신의 기능에 맞게 수정하는 것이다. 쉽게 말하면 커스텀이다.

오버로딩이란 같은 이름의 메소드를 여러개 작성하는 것을 말한다.

예를 들어 자바의 print문을 사용할 때를 생각해보면 그 안에 어떤 데이터를 넣어도 동작한다.

실제 println 상세 코드를 들여다보면 이렇게 구현되어있다.

public void println(boolean x) {
	if (getClass() == PrintStream.class) {
			writeln(String.valueOf(x));
	} else {
			synchronized (this) {
					print(x);
					newLine();
			}
	}
}
public void println(char x) {
	if (getClass() == PrintStream.class) {
			writeln(String.valueOf(x));
	} else {
			synchronized (this) {
					print(x);
					newLine();
			}
	}
}
public void println(int x) {
	if (getClass() == PrintStream.class) {
			writeln(String.valueOf(x));
	} else {
			synchronized (this) {
					print(x);
					newLine();
			}
	}
}

이렇게 오버로딩을 활용하면 같은 메소드 이름으로 어떤 타입이 들어와도 처리할 수 있게 된다.

다형성에 대한 정의는 하나가 더 있다.

상위클래스 타입의 참조 변수로 하위 클래스 객체를 참조하는 것

Car = new Car();
MotorBike motorBike = new MotorBike();

Vehicle vehicle  = new Car();

위의 코드를 보면 Vehicle 타입의 변수인 vehicle에 하위 클래스의 인스턴스인 Car를 할당해주고 있다. 다형성을 이용하면 이런식으로 코드를 작성하는 것이 가능해진다.

예를 들어 이동수단 외에도 Driver클래스를 구현해야한다고 생각해보자.

public class Driver {
	void drive(Car car) {
		car.moveForward();
		car.moveBackward();
	}
	void drive(MotorBike motorBike) {
		motorBike.moveForward();
		motorBike.moveBackward();
	}
}

public class main {
	public static void main(String[] args) {
	 Car car = new Car();
	 Driver driver = new Driver();
	 
	 driver.drive(car);
	}
}

위의 코드는 오버로딩을 이용하여 매개변수로 들어올 수 있는 이동수단의 종류만큼 메소드를 생성해주고 있다.

그러나 만약 이동수단의 갯수가 늘어난다면??
기차, 배, 비행기….

이렇게 이동수단의 갯수만큼 메소드를 작성해주어야한다.

그러나 이들은 모두 이동수단의 한 종류이고 이동수단이라는 것은 기차, 자동차 등의 것들을 포함하고 있다.

그렇다면 위의 코드를 이런식으로 바꿔볼 수 있다.

public interface Vehicle {
	void moveForward();
	void moveBackward();
}
public class Car implements vihicle {
...
}
public class MotorBike implements vihicle {
...
}

public class Driver {
	void drive(Vihicle vihicle) {
		vehicle.moveForward();
		vehicle.moveBackward();
	}
}

public class main {
	public static void main(String[] args) {
	 Car car = new Car();
	 Driver driver = new Driver();
	 
	 driver.drive(car);
	}
}

이렇게 해서 이동수단이 추가되더라도 Driver클래스를 수정해야하는 번거로움이 없어졌다.
이런 다형성을 활용해볼 수 있는 대표적인 예가 바로 List이다.

자바에서 List는 ArrayList와 LinkedList가 있다.
이들은 List라는 인터페이스에 속해있는데 이를 이용해서 코드를 작성해보면 이렇다.

List list = new ArrayList<>();
//만약 다른 리스트를 사용해야한다면 다음과 같이 변경할 수 있다.
list = new LinkedList<>();
Sample sample = new Sample();
sample.register(list);
...
public void register(List list) {
  ...
}

이렇게 작성한다면 List의 종류를 바꿔야할 때 편하다.

상속

상속은 추상화된 일반적인 클래스를 만들고 이를 이용해 세부사항들을 구현하는 것을 말한다.
예를 들어 위의 자동차 코드에서 더 세부적으로 나누어 스파크, 그랜져.. 등의 클래스를 구현해야한다고 생각해보자.
이들은 자동차니까 중복되는 코드들이 생길 수 밖에 없을 것이다.

이렇게 된다면 단순하고 지겨운 코드들을 수작업으로 작성해줄 수 밖에 없을 것이다. -> 뭔가 대책이 있을 것 같다는 느낌이 올 것이다.
이럴 때 상속을 이용해서 문제를 해결할 수 있다.

public class Car {
	String model;
	String color;
	int wheels;
	
	void moveForward() {
    System.out.println("앞으로 전진합니다.");
	}
		
	void moveBackword() {
	  System.out.println("뒤로 후진합니다.");
	}
	
}
public class Genesis extends Car {
  boolean isConvertible;
 
  public void dialGear() {
    System.out.println("다이얼을 움직입니다.");
 }
}

알아두면 좋은 것은 상속될 때 public과 protected 접근 제어자만 상속된다.
상속관계에 있으면 다형성도 같이 생긴다. 예를 들어 위의 코드 예제의 경우…

Car car = new Car();
Car genesis = new Genesis();

genesis.moveForward();

Genesis 클래스의 메소드에는 dialGear라는 메소드가 있지만 부모타입인 Car로 선언해 두었기 때문이다.
비록 부모 타입으로 자식 객체를 선언하면 그 실제 객체는 힙 메모리에 존재하는데 컴파일러는 변수의 타입만 보고 그 변수를 통해 호출할 수 있는 메소드를 제한한다.

올바른 상속이란 메소드를 재사용하기 보다 필드에 대한 재사용이 되어야하고 메소드를 재사용하려면 다른 방법을 사용하는 것이 좋다.

부모타입이 할 수 있는 일은 자식 타입도 할 수 있어야한다.

이쯤되면 느껴지는 것이 있다.
여러가지 객체지향의 특징이 있지만 결국 추상화된 상위 클래스 타입과 이를 구현한, 상속받은 하위 클래스를 혼용하여 사용하는 것.

어렵게 말하면 상위 클래스 타입의 참조변수로 하위 클래스 객체를 참조하는 것이라고 할 수 있겠다.

캡슐화

캡슐화란 내부의 세부사항을 외부로부터 감추는 것이다.
우리가 알고있는 캡슐 약과 비슷하다. 캡슐로 감싸서 외부로부터의 오염을 막고 우리는 알약이 어떤 성분으로 구성되어있는지 알 필요 없이 약효를 기대하며 잘 챙겨먹기만 하면 된다.

자바에서의 캡슐화도 마찬가지다. 외부로부터 코드를 숨겨서 내부 데이터와 로직을 보호할 수 있고 만약 오류가 생긴 부분이 해당 함수 혹은 클래스라면 그 때 내부를 들여다보고 수정하면 된다.(유지보수성)

자바에서 캡슐화를 실현할 수 있는 방법은 두 가지가 있다.

  • 접근 제어자
  • Getter/Setter

자바를 사용해서 기능을 구현하면 내부 필드를 private로 하고 외부에서 접근하려고 했을 때 getter/setter를 이용해서 접근하곤 한다.

만약 내부 필드값을 validate하고 싶다면 setter에서 체크를 해주면 된다. 이렇게 내부 데이터를 보호할 수 있다.

이런 목적 말고도 다른 목적이 하나 더 있다.
바로 적절하게 책임을 분배하는 것이다.

예를 들어 하나의 로직이 있고 이 로직을 많은 곳에서 사용한다고 해보자.

만약 로직이 노출되어있다면 로직을 수정해야할 때 이 로직이 쓰인 모든 부분을 직접 체크하며 하나 하나 바꿔줘야한다.

이 로직을 클래스에 담아서 사용한다면 로직을 수정해야할 때 한 곳만 수정하면 된다.
이 것 또한 캡슐화의 목적이다.

public class Purchase {
	private Product product;
	
	public Purchase(Product product) {
		this.product = product;
	}
	
	public void purchase() {
		product.printCount();
		product.printPrice();
		System.out.prinln(product.getCount() * product.getPrice());
	}
}

purchase 메소드에 로직이 노출되어있는 모습이다. 이것을 product에게 맡긴다면…

public class Product {
	...
	public void purchase() {
		System.out,println(this.count);
		System.out.println(this.price);
		System.out.prinln(product.getCount() * product.getPrice());
	}
}

public class Purchase {
  ...
	public void purchase() {
		product.purchase();
	}
}

이렇게 된다.
로직을 수정해야한다면 그냥 product내부에 들어가서 수정하면 purchase로직이 사용되는 모든 부분이 자동으로 수정된다.

또 다른 예로는

public int doSomething(CalculateCommand command) {
	CalculateOperator operator = command.getCalculateOperator();
	int num1 = command.getNum1();
	int num2 = command.getNum2();

	int result = 0;

	if(operator.equals(CalculateOperator.SUM) {
		result = num1 + num2;
	} else if(operator.equals(CalculateOperator.SUB) {
		result = nu1 - num2;
	} else if(operator.equals(CalculateOperator.MUL) {
		result = sum1 * sum2;
	} else if(operator.equals(CalculateOperator.DIV) {
		result = num1 / num2;
	}

	// 그 이후의 로직들
}

이렇게 짤 수 있다. 그러나 연산자가 null인 경우와 0으로 나누는 예외를 처리하지 않았기 때문에 수정해보면…

public int doSomething(CalculateCommand command) {
	// ...
	if(operator != null && operator.equals(CalculateOperator.SUM) {
		result = num1 + num2;
	} else if(operator != null && operator.equals(CalculateOperator.SUB) {
		result = nu1 - num2;
	} else if(operator != null && operator.equals(CalculateOperator.MUL) {
		result = sum1 * sum2;
	} else if(operator != null && operator.equals(CalculateOperator.DIV) {
		if(num2 == 0){
			// 예외
		} else{
			result = num1 / num2;
		}
	}

	// 그 이후의 로직들
}

캡슐화를 적용하기 전에 우선 반복된 if문 사용을 줄여보면 곳곳에 operator가 Null인지 체크하는 로직이 보인다. 공통 로직이고 만약 Null이라면 연산할 수 없고 나눗셈 두 번째 숫자가 0인 경우도 마찬가지다.
다음과 같이 중첩된 if문을 사용하지 않고 빠르게 처리해준다. 이런 패턴을 얼리 리턴 패턴이라고 한다.

if(operator == null) {
	// 에외
}
if(나눗셈이면서  번째 숫자가 0) {
	//예외
}

// 연산 수행 로직

그러나 생각해보면 연산을 어느 클래스에서 또 얼마나 할지 모르는데 지금 상황이라면 매 번 연산을 할 때마다 예외 상황에 대한 검사를 해야한다.
애초에 계산해야하는 정보를 전달 받은 객체가 체크하도록 하면 예외처리하는 코드는 한 번만 작성하면 될 것이다.

public class CalculateCommand {
	private CalculateOperator operator;
	private int num1;
	private int num2;

	public CalculateCommand(CalculateOperator operator, int num1, int num2) {
		if(operator == null) {
	// 에외
		}
		if(나눗셈이면서  번째 숫자가 0) {
			//예외
		}
	}
}

그리고 getNum1과 같은 메소드는 만약 CalculatorCommand의 필드 명이 바뀌거나 삭제되면 이를 사용하고 있는 doSomething메소드에서 빨간 줄이 생긴다. 이를 스탬프 결합도라고 한다.
그냥 이 필드들을 가지고 있는 곳에서 처리한 뒤 그 결과만 doSomething에서 사용하면 된다. 따라서 getter를 사용하지 않고 이 정보를 담고있는 CalculateCommand내부에서 자신이 가지고 있는 필드 값을 가지고 연산하여 그 결과만 반환해주면 doSomething메소드는 자신이 처리해야하는 일에만 집중할 수 있고 유지보수도 간편해진다.

구체적인 정보들이 노출되어있다 -> 유지보수의 어려움
적절히 책임을 분산하여 구체적인 정보들을 드러나지 않는다. -> 효율적인 설계

어떤 값을 넣어줄 때 접근 제어자를 private로 적용하여 setter를 사용하는 경우도 마찬가지다. 구체적인 필드가 노출되어있기 때문에 일반적으로 유지보수를 어렵게 만든다.
따라서 생성자를 적극 활용하여 값을 넣어주는 것이 좋다.

응집도, 결합도

응집도와 결합도라는 개념이 있다. 이는 객체지향 시스템을 평가하는 지표라고 할 수 있다.
응집도는 얼마나 연관성있게 하나의 클래스가 작성되었는지, 결합도는 각 클래스들끼리 얼마나 밀접하게 연관되어있는지를 나타낸다.

결합도는 쉽게 말하면 코드상에 구체적으로 작성할수록 높아지고(유지보수가 어려워지고)
모호하게 작성할수록 낮아진다.(유지보수가 쉬워진다.)

위의 코드들을 보면 알 수 있다.

객체지향 접근법

  1. 도메인을 구성하는 객체에는 어떤 것들이 있는지 고민
  2. 객체들 간의 관계를 고민
  3. 동적인 객체를 정적인 타입으로 추상화해서 도메인 모델링하기
  4. 협력을 설계
  5. 객체를 포괄하는 타입에 적절한 책임을 할당
  6. 구현하기

계산기 프로그램

객체지향을 실습해보기 위해 계산기 프로그램을 작성해보자.
기본 설계부터 시작한다.

CLI 프로그램은 우리가 한 번쯤은 봤을 법한 프로그램으로 콘솔창에 값을 입력하여 상호작용하는 방식의 인터페이스를 가진 프로그램을 말한다.

쉽게 말해 윈도우의 명령 프롬프트를 CLI라고 한다.

그럼 사용자에게 안내를 주기 위해 어떠한 화면을 출력해야한다.
물론 그래픽을 사용할 수 없기 때문에 글자와 공백으로 화면을 출려한다.
예를 들면 다음과 같다.

*** 계산기 프로그램 ***
*** 이용해주셔서 감사합니다 ****
숫자 1: 1
1을 입력하셨습니다!
연산자 : +
+를 입력하셨습니다!

보이는 것과 같이 사용자의 입력에 따라 달라지는 부분과 그렇지 않은 부분이 존재한다.
이를 매번 System.out.println(““)과 같이 작성하자니 불편하다.
그리고 계산 로직과 화면을 출력하는 역할이 같이 있게되면 오류가 발생하더라도 전체 코드를 봐야하고 코드를 알아보기도 힘들어진다.

따라서 실제 로직을 수행하는 클래스와 화면을 출력하는 정적인 클래스를 나눈다는 아이디어를 생각할 수 있다. 이를 MVC패턴이라고 한다.

일단 로직을 먼저 구성해보자.

public class Calculator {
	public static int calculate(int operand1, String operator, int operand2) {
		return operand1 + operand2;
	}
}

나는 코드를 작성하는 초기에 이렇게 하드코딩하는 것을 좋아한다.
우선 간단히 구현할 수 있고 객체들간의 연결 관계를 생성하고 테스트하기 쉽기 때문이다.

public class Calculator {
	public static int calculate(int operand1, String operator, int operand2) {
		switch(operator) {
			case '+': 
				return operand1 + operand2;
			case '-':
				return operand1 - operand2;
			case '*':
				return operand1 * operand2;
			case '/': 
				return operand1 / operand2;
	}
}