컴퓨터 전공생, 아니 코딩에 대한 조금의 학습만 진행해본 사람이라면 객체지향 또는 객체라는 말에 대해 들어본 적이 있을 것이다. 필자도 4년 간의 전공 수업 또 외부 교육 프로그램을 수강하였지만 이에 대한 명확한 정의를 딱 내리라고 하면 솔직히 말해서 어려울 것 같다는 생각이 들었다.
그래서 객체와 객체지향이라는 개념에 대한 학습과 명확한 정의를 내려보고자한다.
객체란?
우선 객체란 무엇인지 먼저 알아보고 시작하는 것이 추후의 내용에 대한 이해를 위해 좋을 것 같다.
우선 객체의 사전적 의미는 실제 존재하는 것, 실체를 의미한다. 즉, 우리가 인식하고 확인할 수 있는 것이라고 말할 수 있겠다.
하지만 컴퓨터 공학에서 말하는 객체는 객체의 사전적 의미와 조금은 다르다.
An object is a class instance or an array.
The java Language Specification에 따르면 객체에 대한 정의를 위와 같이 내린다. 객체란 클래스의 인스턴스 또는 클래스의 배열이라는 것이다.
그렇다면 클래스와 인스턴스란 또 무엇일까?
클래스, 인스턴스 그리고 객체
1. 클래스 (Class)
먼저 클래스는 객체를 생성하기 위한 설계도라고 쉽게 생각할 수 있다.
클래스는 속성과 메서드를 정의한다. 또한 공통된 특성을 추상화하기에 해당 클래스로부터 생성된 객체들은 동일한 속성과 메서드를 가진다.
실생활에서 비유를 하자면, 붕어빵을 굽는 틀이라고 생각하면 되겠다.
2. 인스턴스 (Instance)
인스턴스는 클래스로부터 생성된 특정 객체를 가리킨다. 클래스로부터 객체를 생성할 때, 그 객체를 해당 클래스의 인스턴스라고 한다. 한 클래스에서 여러 개의 인스턴스를 생성할 수 있으며, 각 인스턴스는 독립적인 상태와 동작을 가지게 된다. 인스턴스는 클래스의 특정한 객체를 의미하며, 해당 클래스의 속성과 메서드에 접근할 수 있다.
3. 객체 (Object)
마지막으로, 객체는 클래스의 인스턴스 또는 배열이다. 클래스(설계도)를 기반으로 생성된 실체(물건)로, 실제로 메모리에 할당된 데이터 구조이다.
예를 들어 자전거를 생각해보자, 자전거는 페달, 체인, 바퀴, 프레임으로 이루어져 있으며 자전거는 앞으로, 좌로, 우로 움직이거나 정지하는 동작을 가진다.
객체도 마찬가지로 객체를 이루는 속성(구조)와 메서드(동작)을 가진다. 구체적으로 설명하자면, 객체의 속성은 객체의 멤버 변수로 객체의 메서드는 객체의 멤버 함수로 구성되는 방식이다.
정리하자면, 클래스는 객체를 생성하기 위한 설계도이고, 객체는 클래스로부터 생성된 실체이다. 인스턴스는 클래스로부터 생성된 특정 객체를 말한다.
객체지향이란?
객체지향 프로그래밍이란 컴퓨터 프로그램을 데이터를 입력 받아 순서대로 명령을 처리하고, 이를 출력하는 등의 형태로 바라보는 절차적/명령형 프로그래밍 시각에서 벗어나 여러 독립적인 서브시스템(객체)들의 조합과 이들간의 유기적인 결합으로 파악하는 프로그래밍의 일부를 뜻한다. 유지 보수의 용이성과 반복 감소 등의 큰 장점이 존재하기에 현재 가장 대중적으로 사용되는 프로그래밍의 패러다임 중 하나이다.
실생활과 연결지어 설명해보겠다. 컴퓨터를 만든다고 가정해보자. 그럼 우리는 컴퓨터를 크게 CPU, RAM, Disk, I/O Device 등의 부품들로 나누어 볼 수 있다. 컴퓨터를 구성하는 부품들을 미리 만들어두고, 합치는 과정을 거쳐 우리는 새로운 컴퓨터 하나를 만들 수 있다. 객체지향 프로그래밍도 마찬가지다. 먼저 부품에 해당하는 객체를 하나씩 만들고, 이를 한 번에 조립하는 과정을 거쳐 프로그램을 완성하는 기법을 우리는 객체지향 프로그래밍이라고 하는 것이다.
객체지향 4원칙
객체지향의 4원칙은 추상화, 상속, 다형성, 캡슐화이다. 이에 대해 하나씩 자세히 알아봐보자.
추상화 (사물 등의 공통성, 본질, 핵심을 파악해 추출해 내는 것)
추상화는 복잡한 시스템이나 개념을 단순화하여 핵심 개념 또는 특징에 집중하고, 불필요한 세부 사항을 숨기는 것이다.
실생활에서 예시를 들어보겠다. 자동차를 코드로 구현을 한다고 해보자. 이 때 자동차라는 객체를 추상화한다고하면 색상, 세부 엔진 내부 동작 등의 세부 사항을 무시하고 가속, 제동과 같은 핵심 특징에만 집중할 수 있다.
class Car {
constructor(make, model) {
this.make = make; // 브랜드
this.model = model; // 모델
this.speed = 0; // 초기 속도
}
// 가속 메서드
accelerate() {
this.speed += 10;
console.log(`Accelerating. Current speed: ${this.speed} km/h`);
}
// 제동 메서드
brake() {
this.speed -= 5;
console.log(`Braking. Current speed: ${this.speed} km/h`);
}
}
상속 (기존의 클래스를 재활용하여 새로운 클래스를 작성하는 것)
객체지향에서 가장 사람들에게 익숙한 내용으로 기존의 클래스(Parent 클래스)를 상속받은 새로운 클래스(Child 클래스)를 만드는 것이다. 상속을 통해 유지 보수가 용이해지고, 반복된 내용을 부모 클래스로 처리할 수 있기에 개발자에게 상당한 편안함을 안겨줄 수 있다. 아래는 상속의 예시이다.
// 부모 클래스
class Character {
constructor(name, hp, attackPower, position, board) {
this.name = name;
this.hp = hp;
this.attackPower = attackPower;
this.position = position;
this.board = board;
...
attack(character) {
character.hp -= this.attackPower;
...
}
}
// 자식 클래스
class HawkEye extends Character {
constructor(position, board) {
super("HE", 500, 20, position, board);
Object.defineProperty(this, "type", {
value: "Avengers",
writable: false, // 값을 변경할 수 없도록 설정
enumerable: true,
configurable: false,
});
}
...
}
위의 코드를 보면 Character라는 보드게임에 등장하는 모든 캐릭터들이 공통적으로 지니는 이름, HP 등의 여러 속성들을 한 번에 선언해둘 뿐만 아니라, attack()이라는 메서드도 미리 만들어둠으로써 향후에 HawkEye나 Thor같은 자식 클래스에서 굳이 이러한 것들을 선언하거나 구현할 필요 없이 단지 extends를 통해 이들을 상속하여 사용할 수 있도록 한다.
다형성 (어떤 특정 객체의 속성이나 기능이 상황에 따라 여러 형태를 띄는 것)
객체지향의 4원칙 중 가장 핵심적인 내용으로, 흔히 객체지향의 꽃이라고 말한다. 하나의 객체가 상황에 따라 여러가지의 타입을 가질 수 있음을 뜻하는 것이다.
함수의 다형성은 오버라이딩과 오버로딩을 뽑을 수 있다.
1. 오버라이딩
상위 클래스에 선언되어 있는 메소드를 하위 클래스에서 동일하게 선언하여 사용하는 것이다.
메소드의 이름, 시그니처가 동일하지만 하위 클래스에서 구현내용을 재정의 하여 사용할 수 있다.
public class Parent {
public static void main(String[] agrs) {
Parent p1 = new Parent();
Parent p2 = new Child();
Parent p3 = new ChildOther();
p1.printX();
p2.printX();
p3.printX();
}
public void printX() {
System.out.println("printX - Parent");
}
}
class Child extends Parent {
@Overriding
public void printX() {
System.out.println("printX - child");
}
}
Child에 Parent와 똑같은 이름과 똑같은 시그니쳐를 가지는 메서드가 존재하지만 오버라이딩을 통해 문제 없이 정상적으로 실행될 수 있다.
2. 오버로딩
한 클래스 내에 이미 사용하려는 이름과 같은 이름을 가진 메소드가 있더라도 매개변수의 개수 또는 타입이 다르면, 같은 이름을 사용해서 메소드를 정의할 수 있다.
class OverloadingMethods {
public void print() { System.out.println("오버로딩1"); }
String print(Integer a) {
System.out.println("오버로딩2");
return a.toString();
}
void print(String a) {
System.out.println("오버로딩3");
System.out.println(a);
}
String print(Integer a, Integer b) {
System.out.println("오버로딩4");
return a.toString() + b.toString();
}
}
모두 다 같은 print라는 이름을 가지지만, 다른 매개변수의 개수 또는 타입을 가지고 있기에 모두 정상적으로 실행된다. 이를 오버로딩이라 한다.
캡슐화 (서로 관련 있는 것들을 하나로 묶어 캡슐로 만들어 외부로부터 보호하는 것)
캡슐화는 관련된 데이터와 메서드를 하나의 단위(클래스)로 묶어 내부 세부 사항을 숨기는 것이다. 외부에는 직접적인 접근을 불가능하게 하고, 단지 객체의 상태에 간접적으로 접근하여 조작할 수 있는 인터페이스만을 제공한다.
class BankAccount {
constructor(accountHolder, balance = 0) {
this.accountHolder = accountHolder;
this._balance = balance; // _balance를 private 변수로 취급
}
// 잔고 조회 메서드
getBalance() {
console.log(`${this.accountHolder}'s balance: $${this._balance}`);
}
// 입금 메서드
deposit(amount) {
if (amount > 0) {
this._balance += amount;
console.log(`Deposit: $${amount}`);
this.getBalance(); // 잔고 조회
} else {
console.log("Invalid deposit amount.");
}
}
// 출금 메서드
withdraw(amount) {
if (amount > 0 && amount <= this._balance) {
this._balance -= amount;
console.log(`Withdrawal: $${amount}`);
this.getBalance(); // 잔고 조회
} else {
console.log("Invalid withdrawal amount or insufficient funds.");
}
}
}
// BankAccount 클래스의 인스턴스 생성
const account1 = new BankAccount("John Doe", 1000);
// 외부에서는 public 메서드를 통해 상호 작용
account1.getBalance(); // 출력: John Doe's balance: $1000
account1.deposit(500); // 출력: Deposit: $500, John Doe's balance: $1500
account1.withdraw(200); // 출력: Withdrawal: $200, John Doe's balance: $1300
// 외부에서는 private 변수에 직접 접근할 수 없음
console.log(account1._balance); // 출력: undefined 또는 에러 발생
위의 예시를 보면 외부에서는 getBalance, deposit, withdraw와 같은 public 메서드를 통해서만 은행 계좌라는 객체와 상호 작용할 수 있다. 이를 통해 잔고라는 객체의 내부 상태를 숨기고 안전하게 간접적으로만 상호 작용할 수 있다.
SOLID 원칙
위에서 말한 객체지향 4원칙을 보완한 것이 SOLID 원칙이다. 기존 객체지향 4원칙에서 존재하지 않던 개방/폐쇄 원칙과 인터페이스 분리 원칙을 추가하여 더 구체적인 OOP에서의 가이드라인을 제공하고, 보완하였다.
SOLID 원칙에 대한 간단한 설명은 아래와 같다.
1. SRP - 단일 책임 원칙 (Single Responsibility Principle)
하나의 클래스는 하나의 책임만 가져야 한다. 즉, 클래스는 변경되어야 할 이유가 하나여야 한다.
=> Guitar 클래스의 모델명이 변경되어서 기타의 색상, 길이, 연주법 등이 변경된다면 이는 SRP를 지키지 않은 것이다.
2. OCP - 개방/폐쇄 원칙 (Open/Closed Principle)
소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다. 즉, 새로운 기능을 추가할 때는 코드를 변경하지 않고 확장할 수 있어야 한다.
=> 추상화를 사용하여 OCP를 지킬 수 있음
3. LSP - 리스코프 치환 원칙 (Liskov Substitution Principle)
상위 타입의 객체를 하위 타입의 객체로 교체할 수 있어야 하며, 이때 프로그램의 의미는 변하지 않아야 한다.
=> 상위 타입을 기대하는 클라이언트 코드에서는 어떤 하위 타입이 전달되더라도 정상적으로 동작해야 한다.
4. ISP - 인터페이스 분리 원칙 (Interface Segregation Principle)
클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안된다. 즉, 인터페이스는 클라이언트에 필요한 메서드만 제공해야 한다. => 하나의 일반적인 인터페이스보단 여러 개의 구체적인 인터페이스가 낫다.
5. DIP - 의존 역전 원칙 (Dependency Inversion Principle)
고수준 모듈은 저수준 모듈에 의존해서는 안되며, 둘 다 추상화에 의존해야 한다.
추상화된 것은 구체적인 것에 의존해서는 안된다. 구체적인 것이 추상화에 의존해야 한다.
=> Spring에서의 DI
this vs super
this 키워드는 현재 클래스의 참조를 가리키는 반면, super 키워드는 부모 클래스의 참조를 가리킨다.
this는 현재 클래스의 변수 및 메서드에 액세스하는 데 사용할 수 있고, super는 하위 클래스에서 상위 클래스의 변수와 메서드에 액세스하는 데 사용할 수 있다.
// this
function MyClass(value) {
this.value = value;
}
MyClass.prototype.getValue = function() {
return this.value;
};
const obj = new MyClass(42);
console.log(obj.getValue()); // 출력: 42
// super
class ParentClass {
constructor(value) {
this.value = value;
}
getValue() {
return this.value;
}
}
class ChildClass extends ParentClass {
constructor(value, additionalValue) {
super(value); // 부모 클래스의 생성자 호출
this.additionalValue = additionalValue;
}
getCombinedValue() {
return super.getValue() + this.additionalValue;
}
}
const obj = new ChildClass(42, 10);
console.log(obj.getCombinedValue()); // 출력: 52
// 다만, 화살표 함수의 경우 현재 클래스가 아닌 선언 당시의 this를 유지한다.
this와 super 키워드는 객체의 인스턴스와 관련이 있으므로 정적 블록이나 정적 메서드 내에서 사용할 수 없다. 정적 컨텍스트에서 참조할 수 없는 비정적 요소들이기에 정적 커텍스트에서 참조 시 컴파일 오류가 발생한다.
class Example {
static int staticVariable = 10;
int instanceVariable = 20;
static {
// 정적 블록에서는 this를 사용할 수 없음 (컴파일 오류)
// System.out.println(this.staticVariable); // 오류
// System.out.println(this.instanceVariable); // 오류
// 정적 블록에서는 super를 사용할 수 없음 (컴파일 오류)
// System.out.println(super.toString()); // 오류
}
static void staticMethod() {
// 정적 메서드에서는 this를 사용할 수 없음 (컴파일 오류)
// System.out.println(this.staticVariable); // 오류
// System.out.println(this.instanceVariable); // 오류
// 정적 메서드에서는 super를 사용할 수 없음 (컴파일 오류)
// System.out.println(super.toString()); // 오류
}
}
JS에서의 객체
객체 인스턴스의 비교 방법
JS에는 일치 비교와 동등 비교가 존재한다.
1. 일치 비교(===)
두 개체가 자료형과 값 모두가 동일한지를 확인한다.
참조도 일치하는지 확인한다고 한다.
2. 동등 비교(==)
두 개체가 값만으로 동등한지를 확인한다. 이 때, 자료형이 다를 경우 자동으로 형 변환을 시도하기에 예기치 않은 결과를 가져올 수 있으므로, 객체 비교에는 권장되지 않는다.
// 예시 1: 동등 비교
console.log(5 == "5"); // true, 값만으로 비교하므로 자동으로 형 변환 발생
// 예시 2: 동등 비교에서의 불일치
console.log(1 == true); // true, 값만으로 비교하므로 자동으로 형 변환 발생
// 예시 3: 일치 비교에서의 불일치
console.log(1 === true); // false, 값과 자료형 모두 일치해야 함
// 예시 4: 동등 비교에서의 불일치
console.log(null == undefined); // true, 값만으로 비교하므로 자동으로 형 변환 발생
// 예시 5: 일치 비교에서의 불일치
console.log(null === undefined); // false, 값과 자료형 모두 일치해야 함
Class vs Prototype
클래스를 사용하여 객체를 생성하고 관련 메서드와 속성을 정의할 수 있다. 즉 객체의 틀이자 객체의 설계도라고 생각할 수 있겠다.
Prototype은 생성자 함수(Constructor Function)와 함께 사용되고, 생성된 객체는 해당 생성자 함수의 프로토타입을 상속한다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`Hello, My name is ${this.name}.`);
};
const person = new Person("꼬마개발자허니");
person.sayHello(); // 출력: Hello, My name is 꼬마개발자허니.
클래스와 프로토타입 둘 다, 상속을 할 수 있게 하며, 속성과 메서드를 가지고, 객체를 생성한다.
차이로는 상속 구현을 클래스는 extends 키워드를 사용하지만, 프로토타입은 프로토타입 체인을 이용한다고 한다.
ChatGPT에 따르면 아래와 같다.
- 클래스는 객체 지향 프로그래밍의 개념과 구조를 더 잘 반영하고자 할 때 사용됩니다. 또한, 클래스를 사용하면 상속과 다형성을 명확하게 지원하므로 대규모 애플리케이션에서 구조적으로 유지보수하기 좋습니다.
- 프로토타입은 자유로운 동적 프로그래밍이 필요한 경우나 작은 규모의 프로젝트에서 더 적합합니다. 프로토타입은 동적으로 속성과 메서드를 추가 및 변경할 수 있어 유연성이 높으며, 프로토타입 체인을 이용한 상속은 프로토타입의 공유로 인한 메모리 절약 효과를 가져올 수 있습니다.
- 프로토타입 체인을 통해 객체는 속성과 메서드를 상위 프로토타입(부모 객체)에서 찾을 수 없을 때 하위 프로토타입(상속받은 객체)으로 계속해서 탐색하며, 최종적으로 Object.prototype에 이를 때까지 탐색합니다. 이를 통해 객체 간에 속성과 메서드를 공유하고 재사용할 수 있습니다.
JavaScript는 원래 Prototype을 이용한 상속만을 지원하였으나, 아래와 같이 ES6(ECMAScript 2015)부터 클래스 문법이 도입되어 extends를 사용하여 더 쉽게 상속을 구현할 수 있다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
console.log(`Hello, My name is ${this.name}.`);
};
function ExtendedPerson(name, age) {
Person.call(this, name);
this.age = age;
}
// ExtendedPerson이 Person을 상속하도록 설정
ExtendedPerson.prototype = Object.create(Person.prototype);
ExtendedPerson.prototype.constructor = ExtendedPerson;
ExtendedPerson.prototype.sayAge = function () {
console.log(`I am ${this.age} years old.`);
};
const extendedPerson = new ExtendedPerson("꼬마개발자허니", 25);
extendedPerson.sayHello(); // 출력: Hello, My name is 꼬마개발자허니.
extendedPerson.sayAge(); // 출력: I am 25 years old.
다만 JavaScript에서 클래스를 사용한 상속은 사실상 프로토타입을 사용한 상속에 문법적인 편의성(syntactic sugar)을 제공한 것일 뿐임을 알아두자.
'개발 > CS' 카테고리의 다른 글
(CS) 계수 정렬 & 기수 정렬 (1) | 2023.12.25 |
---|---|
(CS) 트리 & 트라이 (0) | 2023.12.25 |
(CS) git (0) | 2023.07.31 |
(CS) HTTP Request/Response/Method, CORS, 서브넷 (0) | 2023.05.27 |
(CS) HTTP, HTTPS, TCP (0) | 2023.05.19 |