객체지향이란, 객체들간의 상호작용으로 프로그램을 작성하는 것을 목적으로 하는 프로그래밍 방식입니다.
객체지향은 절차지향과 항상 비교군에 있는 대표적인 프로그래밍 방식입니다.
객체지향 프로그래밍을 Object Oriented Programming의 약자인 OOP 라고도 부릅니다.
절차지향이 아니라고 해서 절차가 존재하지 않는다는 뜻은 아닙니다.
절차는 존재하되, 객체들간의 상호작용이 추가된 개념으로 이해하면 됩니다.
그럼 객체들간의 상호작용이 가능한 객체지향 프로그래밍을 왜 사용할까요?
객체 지향을 사용하면, 프로젝트를 구조적이고 효율적이게 만들 수 있도록 합니다.
객체지향이 어떻게 프로젝트를 구조적이고 효율적일 수 있도록 하는지 알기 위해
결제 서비스를 만드는 상상을 해보겠습니다.
우리는 결제 서비스를 개발하기 위해, 대한민국에 존재하는 모든 카드사에서 결제가 가능한 프로그램을 개발하고자 합니다.
우선 절차지향의 대표적인 언어중 하나인 C언어로 개발을 시작합니다.
절차지향과 객체지향의 극단적 비교를 위해... 멍청한 코드를 만들어보겠습니다.
각 카드사별 결제에 필요한 API 호출이 다를 것이기에
각 카드사별 결제 함수가 필요할 것입니다.
void hyundai_card_payment_service() {
// 현대카드 결제
}
void kookmin_card_payment_service() {
// 국민카드 결제
}
void hana_card_payment_service() {
// 하나카드 결제
}
.
.
.
위 코드처럼, 모든 카드사의 결제를 정상적으로 처리하기 위해
카드사 개별 함수를 구현해야 합니다.
이렇게 프로그램을 제작한다면 어떤 문제가 있을까요?
여러가지가 있겠지만 대표적인 이슈는 다음과 같습니다.
if (strcmp(card_type, "현대카드") == 0) {
hyundai_card_payment_service();
} else if (strcmp(card_type, "국민카드") == 0) {
kookmin_card_payment_service();
} else if (strcmp(card_type, "하나카드") == 0) {
hana_card_payment_service();
}
.
.
.
쏟아지는 조건문 ...
물론 절차지향에서도 이러한 문제점을 개선할 방법이 분명 존재하지만,
복잡하고 거대한 프로젝트를 설계할수록, 지속적으로 이러한 문제에 부딛힐 것입니다.
위 절차지향에서 발생했던 문제점들을 객체지향 언어인 코틀린을 사용한다면
어떻게 개선할 수 있을까요?
우선 객체지향 프로그래밍의 큰 특징으로 다음이 있습니다.
우선 추상화, 우린 다시 카드 결제사의 개발자가 되어 결제 흐름을 구성해보겠습니다.
각 카드 결제사별 공통적으로 필요한 함수가 있을 것 입니다. (결제, 환불, 결제 내역 출력 등 ..)
추상화란, 특정 코드를 작성하기 전에 미리 추상적으로 어떤 코드(동작)가 필요할지 설계할 수 있음을 의미합니다.
interface CardService {
val name: String
fun pay()
fun refund()
fun print_payment_record()
}
위 코드 예제를 보면, 변수와 함수에 대한 정의는 있지만, 변수 및 함수에 대한 구현은 빠져있음을 확인할 수 있습니다.
이처럼 추상화된 필요한 기능을 정의만 할 수 있는 기능을 인터페이스 라고 부르며
코틀린에서는 'interface' 키워드를 통해 정의합니다.
인터페이스만 정의한다면 아무 동작도 할 수 없으며, 인터페이스를 통해 구현하는 방법은 다형성 파트에서 다루겠습니다.
추상화를 할 수 있는 다른 키워드가 있습니다.
abstract class CardService {
abstract val name: String
abstract fun pay()
abstract fun refund()
abstract fun print_payment_record()
fun printLog() {
print("log")
}
}
바로 'abstract class' 입니다.
직역하면 추상화 클래스입니다.
interface와의 차이점은, interface는 내부에 어떠한 구현도 할 수 없지만,
abstract class는 일부 공통 로직에 대한 구현이 가능합니다.
interface처럼 틀만 작성하고 싶은 함수라면, abstract 키워드를 앞에 붙여 추상화 함수임을 명시하고, 공통 로직을 구현하기 위한 함수라면 일반 함수와 동일하게 선언하면 됩니다.
형태가 다양하다는 뜻입니다.
우선 예시로, 다형성에 대해 이해하기 위해 위에서 다루었던 인터페이스인 CardService를 통해 class를 만들어보겠습니다.
class HyundaiCardService : CardService {
override val name: String = "현대카드"
override fun pay() {
// 현대카드 결제
}
override fun refund() {
// 환불 요청
}
override fun print_payment_record() {
// 결제 내역 반환
}
}
이처럼 위에서 정의한 CardService 인터페이스를 통해,
새로운 Class를 정의하고 카드사 각각의 개별 함수를 정의할 수 있습니다.
새롭게 만든 Class가 해당 인터페이스를 따른다는 것을 명시하기 위해, Class 이름 옆에 해당하는 인터페이스의 이름을 ':' 뒤에 작성해줍니다.
인터페이스에서 이미 정의한 함수와 변수를 재정의하는 것이기 때문에, override 키워드를 함께 작성해주어야 합니다.
또다른 추상화 방법이었던 abstract class의 경우, 새로운 Class를 정의하는 방법이 조금 다릅니다.
class HyundaiCardService : CardService() {
override val name: String = "현대카드"
override fun pay() {
// 현대카드 결제
}
override fun refund() {
// 환불 요청
}
override fun print_payment_record() {
// 결제 내역 반환
}
}
기본적으론 전부 유사하지만 무엇이 바뀌었냐면 ...
':' 뒤에 작성해주던 Abstract class 뒤에 '()' 이 붙은 것을 확인할 수 있습니다.
Kotlin에는 인스턴스라는 개념이 있습니다.
우리가 위에서 정의해온 인터페이스, Class .. 등은 모두 '틀'에 불과하고, 각각 동작하기 위해선 인스턴스를 생성해주어야 합니다.
우리가 다른 언어에서 사용하던 Int와 같은 Type 처럼, 선언만 하면 사용할 수 없지만 초기화를 통해 사용할 수 있던 것을 생각하면 편합니다.
인스턴스를 생성하기 위해 클래스명 뒤에 '()'를 사용합니다.
또한 Class인 틀은 하나여도, 인스턴스는 여러개여도 괜찮습니다.
class HyundaiCardService : CardService() {}
다시 abstract class를 재정의하는 코드를 보겠습니다.
인터페이스와 달리 공통 로직에 대한 구현이 들어있는 class이기 때문에 재정의한 class에 인스턴스를 생성할 때, 해당 absract class에 대한 인스턴스 생성 또한 필요하기에 '()'를 반드시 붙여줘야 합니다.
또한 이를 협업. 즉, 팀 프로젝트에 적용해보겠습니다.
인터페이스 하나만 잘 설계해둔다면, 다른 팀원에게 각각의 구현체를 구현하도록 할 수 있습니다. 서로의 작업 영역이 분리됨으로써 빠른 개발, 오류 방지 등의 효과를 얻을 수 있습니다.
단어 그대로 다른 Class에게
원하는 로직을 '상속' 시킬 수 있습니다.
위에서 살펴본 abstract class에 대한 구현체 또한 하나의 상속 사례로 볼 수 있습니다.
상속을 할 Class를 '부모 클래스' 라고 부르고,
상속을 받는 Class를 '자식 클래스' 라고 부릅니다.
상속에 대한 예제를 만들기 위해
새로운 시나리오를 만들겠습니다.
그간 만들어온 현대카드 서비스에 현대카드 V2가 출시되어 결제할 때, 캐시백을 추가로 지급하도록 예제를 만들어보겠습니다.
open class HyundaiCardService : CardService() {}
위 코드는 이전에 선언했던 HyndaiCardService의 선언부 입니다.
조금 달라진 부분이 있습니다.
바로 open 이라는 키워드가 가장 앞에 추가된 것입니다.
코틀린에서 class를 생성하게 되면, 기본적으로는 상속이 불가능합니다. (자바의 final이 기본적으로 붙어있음)
따라서 상속이 가능한 class로 선언하기 위해 open 이라는 키워드를 추가해줘야 합니다.
class HyundaiCardV2Service : HyundaiCardService() {
override fun pay() {
super.pay()
// 캐시백 지급
}
}
이제 상속을 받을 차례입니다.
이전 코드들과 동일하게 ':' 옆에 부모 class를 명시해준 후
수정을 원하는 함수를 override 해줍니다.
이렇게 하면 자식 class에서 원하는대로 부모 class의 함수를 수정하는 것이 가능합니다.
또한 예제처럼 부모 class의 함수를 동일하게 사용하고 싶다면,
super 키워드를 통해 부모 class의 인스턴스에 접근할 수 있습니다.
이를 통해 상속의 장점을 살리는 프로그래밍을 할 수 있습니다.
우리는 프로그램을 제작하며 많은 사람과 함께 작업을 하고, 과거의 나 자신과 싸우기도 합니다.
결제 서비스를 만들며 한땀한땀 열심히 만든 내 결제 로직을 누군가 접근하여 코드를 수정하면 어떨까요?
-> 높은 확률로 오류가 발생하고 시말서 작성이 필요할겁니다 ..
이를 막기 위해 객체지향에선 캡슐화 개념을 적용하였습니다.
단어 그대로 캡슐처럼 코드 영역을 감싸서 외부의 접근을 제어하는 것 입니다.
private, public, protected 등의 키워드를 통해 제어하며
다음처럼 제어가 가능합니다.
private : 같은 클래스 내에만 접근 가능
public : 모든 클래스에서 접근 가능 (아무것도 사용하지 않으면 기본적으로 public)
protected : 상속 관계에서만 접근 가능
이를 상속을 위한 부모 class 등의 상위 class에 적용 가능하며
적절히 사용하여 외부 접근을 막거나 허용할 수 있습니다.
open class CardService {
private val secretKey: String = ""
protected var name: String = "CardService"
public var temp: String = "public"
}
class CardServiceImpl: CardService() {
fun test() {
// private로 선언한 변수이기 때문에 접근 불가
// print(secretKey) // 오류 발생
// protected로 선언한 변수이기 자식 class에서는 접근 가능
print(name) // CardService
}
}
class MainTest {
fun test() {
val service = CardService()
// public으로 선언한 변수이기 때문에, 인스턴스만 존재한다면 어디서든 접근 가능
print(service.temp) // public
}
}
예시로 코드를 작성하며 반드시 필요한 상수값을 private으로 선언하여 외부에서 변경을 막을 수 있고,
같은 결제 서비스 내 공통적으로 필요한 변수가 있다면, 이는 protected로 선언하여
결제 class끼리만 공유할 수 있습니다.
이번 포스팅에서는 객체지향의 4가지 특징별로 정리를 하며
코틀린의 문법과 함께 포스팅했습니다.
활용도가 높아질수록 명확한 구조로 설계가 가능하기 때문에, 계속 공부해야하는 부분이라고 생각합니다.