이전 포스팅에서 객체 지향 프로그래밍(OOP)가 무엇인지에 대한 개념을 살펴보았다. 하지만, 왜 자바스크립트를 비롯한 많은 프로그래밍 언어들이 객체 지향적인 프로그래밍을 도입했는지 궁금하지 않은가? 우리는 왜 클래스를 알고 객체를 알아야 할까?
객체 지향 프로그래밍 기법은 대표적인 4가지 특성을 제공하며 이 특성을 코드에 적용시키기 위해 프로그래밍 언어에 도입되었다고 할 수 있다. 이 특성들은 코드를 더 정돈되고 간결하게, 그리고 보다 안전하게 만들어준다.
따라서, 이번 포스팅에서는 객체 지향적인 코드를 작성하는데 필수적으로 알아야 할 객체 지향 프로그래밍의 4대 특성에 대해 살펴보도록 하자.
객체 지향 프로그래밍의 첫 번째 특성은 캡슐화다. 캡슐화에 대해 알아보기 전에 먼저 우리에게 친숙한 캡슐형 종합 비타민을 생각해보자.
안이 투명해보여서 마치 물이 들었을 것만 같은 비주얼이지만, 사실 내부에는 비타민 C, 비타민 D, 아미노산, 칼슘 등의 다양한 영양 성분들이 종합적으로 들어있다. 여기서 중요한 것은 다양한 영양 성분들을 하나의 캡슐로 만들었다는 점이다. 우리가 비타민 C 따로, 아미노산 따로, 칼슘 따로 영양제를 섭취해야 한다면 하루에 먹어야 할 영양제 양만 해도 무지막지할 것이다. 하지만 이러한 영양소들을 하나의 캡슐에 합쳐 제공함으로써, 구매자는 종합 비타민 한 알만 먹으면 간편하게 필요한 모든 영양소를 얻을 수 있다.
바로 이 캡슐형 종합 비타민이 객체 지향 프로그래밍에서의 캡슐화라고 할 수 있다. 캡슐화란 연관된 변수들과 함수들을 하나의 '클래스'라는 캡슐로 묶어 제공함으로써, 개발자는 미리 정의된 클래스로부터 하나의 객체를 생성하기만 하면 클래스가 제공하는 모든 변수 및 메소드들을 사용할 수 있게 된다.
캡슐화의 장점은 명확하다. 위에서 종합 비타민의 예시를 들었을 때, 각각의 영양분들을 따로 섭취해야 한다면 매우 불편할 것이라고 한 것이 기억나는가? 만약 클래스가 없다면, 코드 상에서 자동차를 표현할 때 각 자동차의 속성 및 동작들을 표현하기 위해 아래와 같이 작성해야 할 것이다.
표현해야 할 자동차의 종류가 20가지라면 총 20가지 자동차들에 대한 속성과 동작들을 각각의 변수와 메서드로 정의해줘야 한다는 뜻이다. 생각만해도 끔찍한 일이 아닐 수 없다.
만약 '자동차'라는 클래스를 하나 만든 후 자동차로부터 '티코' 객체와 '벤츠' 객체를 생성한다면, 이 문제를 쉽게 해결할 수 있다.
객체 지향 프로그래밍의 '캡슐화' 특성을 도입함으로써 반복되는 코드를 줄이고 코드를 더 깔끔하게 만들 수 있을 것이다.
객체 지향 프로그래밍의 두 번째 특성인 정보 은닉은 1번 특성인 '캡슐화'와 매우 밀접한 관련이 있다. 캡슐화를 사용하면 관련된 모든 변수 및 메서드들을 하나의 클래스에 정의하는데, 이로 인해 발생한 문제를 해결하기 위해 고안된 특성이라고 할 수 있다. 자바스크립트를 포함한 보통의 프로그래밍 언어에서는 별도의 처리가 없을 때 외부에서 객체의 모든 변수들과 메서드들에 자유롭게 접근할 수 있는데, 이 자유로운 접근이 문제가 될 수 있는 경우들이 존재한다.
예를 들어, 은행의 계좌 정보를 의미하는 'BankAccount'라는 이름의 클래스를 정의한 후, 고객들의 계좌를 각각의 BankAccount의 객체로 생성해 관리한다고 가정하자. 이 때 객체에는 계좌번호 정보나 잔고 정보 등의 개인적인 정보들이 포함되어 있는데, 외부에서 객체의 모든 변수들에 자유롭게 접근할 수 있다면 다른 사람이 나의 계좌 정보를 자유롭게 열어볼 수 있다는 뜻이 된다. 객체에 계좌 비밀번호 정보도 포함되어 있다면 금상첨화다. 도둑은 단지 당신의 BankAccount 객체 내부를 들여다 봄으로써 당신의 계좌를 손 쉽게 털어갈 수 있을 것이다.
당연히 이런 일이 있어서는 안되므로, 객체 지향 프로그래밍에서는 접근 제어자라는 개념을 도입하여 외부로 공개될 정보들과 공개되지 말아야 하는 정보를 나눌 수 있게 했다. 접근 제어자는 클래스 변수 및 메소드에 대해 적용될 수 있으며, 프로그래밍 언어에 따라 지원되는 접근 제어자의 종류가 다르나 가장 대표적인 두 가지는 다음과 같다.
- public: 외부에서 해당 변수 및 메소드에 접근할 수 있다. 대부분의 경우 접근 제어자가 명시되어 있지 않다면 기본값으로 public이다.
- private: 외부에서 해당 변수 및 메소드에 접근할 수 없으며 클래스 내부에서만 사용할 수 있다.
그럼 이제 BankAccount 클래스의 객체 데이터들을 안전하게 보호할 수 있는 방법을 찾은 셈이다. BankAccount 클래스에서 외부로부터 보호해야 할 모든 정보들에 private 접근 제어자를 사용하면 되지 않을까?
하지만 이 때에도 문제가 하나 있는데, 모든 정보들을 private로 선언하면 계좌의 원 주인은 대체 어떻게 계좌 정보에 접근할 수 있냐는 것이다. 이 문제를 해결하기 위해 도입된 개념이 바로 getter와 setter이다. getter와 setter는 외부에서 접근할 수 있는 클래스의 public 메소드로, getter은 private으로 선언된 클래스 변수들의 값을 얻어오고 setter는 클래스 변수들의 값을 변경하기 위해 사용된다.
어? 그런데 뭔가 이상하다. 우리가 클래스 변수들을 private으로 선언한 이유가 이 변수들을 보호하기 위해서였는데, private 변수들에 접근할 수 있는 public 메소드 getter와 setter를 사용한다면 아무런 의미가 없는 것 아닐까?
getter와 setter를 사용해도 클래스 변수들을 외부로부터 보호할 수 있는 이유는 우리가 getter 및 setter 메서드를 구현할 때 데이터 보호를 위한 코드를 직접 작성할 수 있기 때문이다.
BankAccount 클래스 예시를 다시 살펴보면, 우리가 계좌의 원 주인만 계좌 잔고를 확인할 수 있도록 하려면 다음과 같은 getter 메서드 getAccountBalance를 BankAccount 클래스에 추가할 수 있다.
클래스 BankAccount {
getAccountBalance(아이디, 비밀번호) {
if(아이디 === 원 주인 아이디 && 비밀번호 === 원 주인 비밀번호) {
return 잔고;
}
return null;
}
}
즉, 계좌 원 주인의 아이디와 비밀번호를 올바르게 입력하지 않으면 잔고 정보를 얻어올 수 없도록 getter 메서드를 구현하여 데이터를 보호할 수 있다.
setter 메서드도 getter에서와 동일하게 원 주인만 계좌 정보를 수정할 수 있도록 다음과 같이 구현할 수 있다.
클래스 BankAccount {
setAccountBalance(아이디, 비밀번호, 금액) {
if(아이디 === 원 주인 아이디 && 비밀번호 === 원 주인 비밀번호) {
잔고 += 금액;
return 잔고;
}
return null;
}
}
이처럼 접근 제어자와 getter, setter를 클래스에 도입함으로써 코드의 안정성을 높일 수 있는 객체 지향 프로그래밍의 특성이 바로 정보 은닉이다.
여담
정보 은닉의 개념이 코드의 세부 동작방식을 외부에 노출시키지 않음으로써 객체 사용의 유연성을 높이는 특성이라고 설명되어 있는 사이트도 있는데, 이 또한 맞는 개념이라고 생각한다.
클래스 내부를 최대한 외부에 노출시키지 않으면 보안성을 높일 수 있음과 동시에, 클래스 사용자들이 최대한 외부로 공개된 부분만을 사용하도록 강제하여 객체의 클래스 내부 구현에 대한 의존도를 최대한 낮출 수 있을 것이다.
객체 지향 프로그래밍의 세 번째 특성인 상속은 객체 지향 프로그래밍의 꽃이라고 불릴 만큼 중요하게 다뤄지는 개념이다. 상속을 설명하기 위해서는 첫 번째 포스팅에서 객체 지향 프로그래밍의 개념을 보다 쉽게 이해하기 위해 도입했던 개념 중심 프로그래밍을 다시 언급해야 한다.
첫 번째 포스팅에서는 자동차가 개념이고 홍길동이 소유한 메르세데스 벤츠 E 클래스 자동차가 물체라고 설명했었다. 여기서 가정했던 것은 홍길동이 소유한 메르세데스 벤츠 E 클래스 자동차라는 물체가 유일하게 정의된다는 것이었다.
하지만 메르세데스 벤츠 E 클래스는 어떤가? 세상에는 아주 많은 메르세데스 벤츠 E 클래스 차량이 존재하기 때문에, 이 문장 자체가 특정 물체를 가리킬 수는 없다. 따라서, 메르세데스 벤츠 E 클래스도 자동차와 같은 새로운 개념이 된다고 할 수 있다.
여기서 자동차도 개념이고 메르세데스 벤츠 E 클래스도 개념이지만, 자동차가 보다 상위의 개념임은 자명해보인다. '자동차'라는 개념이 가장 보편적인 속성들과 동작들을 정의하고 있을 것이고, '메르세데스 벤츠 E 클래스'라는 개념은 자동차가 가진 모든 속성 및 동작들과 더불어 벤츠만이 가지고 있는 속성 및 동작 (ex. 메르세데스 미 애플리케이션 지원)을 추가로 정의하고 있을 것이기 때문이다.
이처럼 개념들 중에도 서로 계층 관계가 생길 수 있으며, 보다 상위의 개념을 가리키는 클래스를 부모 클래스, 부모 클래스로부터 파생된 개념을 가리키는 클래스를 자식 클래스라고 부른다. 위 그림에서 자동차 클래스와 메르세데스 벤츠 E 클래스라는 클래스는 서로 '부모 - 자식 관계'에 있다고 할 수 있다.
위 예시에서도 볼 수 있듯이 자식 클래스는 부모 클래스의 모든 속성들과 동작들을 그대로 가진 채 자식 클래스만의 고유한 속성 및 동작들을 추가로 가지며, 이렇게 자식 클래스가 부모 클래스의 모든 것들을 이어받는 것을 상속이라고 한다. 부모의 모든 재산을 자식이 그대로 상속받는다고 생각하면 편하다.
그러면 객체 지향 프로그래밍에서 상속이 뭐가 그렇게 중요한 것일까? 상속이 중요한 이유는 객체 지향 프로그래밍의 특성 상 클래스를 많이 선언해야 하는데, 이 클래스들 간의 관계를 명확하게 표현해줄 수 있으며 중복되는 코드를 크게 줄여주기 때문이다.
상속을 사용하면 어떤 클래스가 어떤 클래스로부터 파생되었는지를 코드 상으로 명확하게 표현할 수 있다. 자동차를 의미하는 Car 클래스와 Benz라는 클래스가 있을 때, 이름을 보면 상하관계를 대충 알 수 있으나 컴퓨터는 이를 알지 못하며 정확히 Benz 클래스가 Car 클래스의 하위 개념이라는 것을 증명하기 위해서는 클래스 내부 구현을 봐야만 한다.
하지만, 상속을 사용하면 아래와 같이 Benz 클래스의 선언만을 보면 Benz 클래스가 Car 클래스의 자식 클래스라는 것을 한 눈에 파악할 수 있고, 컴퓨터도 두 클래스 사이의 관계를 파악할 수 있게 된다.
클래스 Car {
// Car 클래스 속성들
// Car 클래스 메소드들
}
클래스 Benz 상속 Car {
// 상속받은 Car 클래스의 속성 및 메소드들
// Benz 클래스만의 속성들
// Benz 클래스만의 메소드들
}
또한 Benz가 Car 클래스를 상속받게 되면 Car의 모든 속성들과 메소드들을 자동으로 가지게 되므로, 동일한 코드를 반복하지 않아도 되기 때문에 코드를 더 간결하게 유지할 수 있다.
이처럼 클래스들 간의 관계를 명확하게 표현할 수 있고, 연관된 클래스들에서 발생하는 코드의 중복을 해결해주는 객체 지향 프로그래밍의 특성이 바로 상속성이다.
객체 지향 프로그래밍의 마지막 네 번째 특성인 다형성은 그 이름에서 알 수 있듯이 다양한 형태를 가지는 특성이다. 두 번째 특성인 '정보 은닉'이 '캡슐화'와 관련이 있다면 다형성은 상속과 관련이 있다고 할 수 있다.
위 예시에서 Benz 클래스가 Car 클래스를 상속받으면, Car 클래스의 모든 속성 및 메소드들을 Benz 클래스도 가지게 된다고 설명했다. Car 클래스를 상속받는 또 다른 클래스인 Tico 클래스가 있다고 가정하고, 클래스들의 내부 구현이 아래와 같다고 해보자.
클래스 Car {
속성: {
name // 모델명
speedPerHour // 최고 속도
}
메소드: {
function Drive() // 운전하다
}
}
클래스 Benz 상속 Car {
속성: {
id // 벤츠 애플리케이션 아이디
}
메소드: {
function Drive();
}
}
클래스 Tico 상속 Car {
}
Tico 클래스는 Car 클래스를 상속받은 후 아무 속성이나 메소드를 추가로 정의하지 않았기 때문에, Car 클래스와 동일한 속성과 메소드만을 가지게 될 것이다. 하지만 Benz 클래스는 id라는 속성을 추가로 정의했기 때문에, Car 클래스의 name과 speedPerHour 속성에 추가로 id라는 속성을 가지게 될 것이다.
이 때, Benz와 Tico 클래스 모두 Car 클래스의 Drive 메소드를 상속받는데, Benz 클래스는 이름이 똑같은 Drive 메소드를 다시 정의하고 있다. 이렇게 되면 Benz 클래스에 2개의 Drive 클래스가 생겨버리니, 이름 충돌로 인한 에러가 발생하지 않을까?
답은 No다. 부모 클래스의 메소드와 동일한 이름의 메소드를 자식 클래스에서 정의하면, 자식 클래스에서는 부모 클래스로부터 상속받은 메소드를 자식 클래스의 메소드로 덮어씌운다. 따라서 Benz 클래스의 객체를 생성한 후 Drive 메소드를 호출하면 Car 클래스의 Drive 메소드가 아닌 Benz 클래스의 메소드가 호출되는데, 이를 메소드 오버라이딩 (Method Overriding)이라는 용어로 부른다.
이 메소드 오버라이딩은 특정 클래스 타입을 가지는 모든 객체들에 대해 동일한 메소드를 호출하고자 할 때 유용하게 사용될 수 있는 개념이다. 예를 들어, Car 클래스 타입을 가지는 모든 객체들에 대해 Drive 메소드를 호출하는 아래와 같은 코드를 작성했다고 가정하자.
const carArray = [모든 Car 클래스 타입 객체들이 담긴 배열];
for(let idx = 0; idx < carArray.length; idx++) {
const curObject = carArray[idx];
curObject.Drive();
}
Car 클래스를 상속받은 클래스인 Benz와 Tico 클래스 타입의 객체들도 결국 Car 클래스에 포함되므로, carArray에 Benz 및 Tico 타입 객체들도 포함될 것이다. 이 때, 만약 메서드 오버라이딩이 없었다면 curObject.Drive(); 라는 코드는 무조건 Car 클래스의 Drive 메서드를 호출하게 될 것이다. 즉, Benz 및 Tico에 대한 특정한 동작을 포함하는 메서드를 호출할 수 없다는 뜻이다.
하지만 메서드 오버라이딩이 존재하기 때문에, curObject가 Benz 클래스의 객체일 때 curObject.Drive(); 코드는 Benz 클래스의 Drive 메서드를 호출하고 된다. 따라서 Benz 클래스 객체에 대해서는 '주행거리를 메르세데스 미 애플리케이션에 저장한다'와 같이 Benz 객체에 한정된 동작을 수행할 수 있는 것이다.
객체 지향 프로그래밍의 4대 특성을 정리해보면 다음과 같다.
- 캡슐화: 관련된 모든 속성 및 메서드들을 하나의 클래스에 모은다.
- 정보 은닉: 외부로 공개해서는 안되는 정보들을 접근 제어자를 통해 숨긴다.
- 상속: 자식 클래스가 부모 클래스의 모든 것을 받는다.
- 다형성: 자식 클래스에서 부모 클래스의 메서드 재정의한다.
이들 모두를 이해하지는 못해도 괜찮다. 하지만 상속은 자바스크립트 뿐만 아니라 다른 프로그래밍 언어에서도 매우 중요하게 쓰이는 개념이므로 상속 하나만큼은 제대로 이해하는 것을 추천한다.
다음 포스팅에서는 '객체 지향 프로그래밍'이라는 포괄적인 개념에서 벗어나, 자바스크립트에서 객체가 어떻게 동작하는지를 알아보도록 하자.