목표
객체지향 프로그래밍(OOP) 공부
공부
객체지향 프로그래밍이란?
학교 수업때 객체지향프로그래밍 이라는 강의를 들었다. 이때는 분명 공부를 하면서 이해도 잘 했었고, 추상화, 상속, 다형성, 캡슐화 라는 속성이 있다는 것까지도 잘 기억이 난다.
그렇지만 정확한 정의를 내려보라고 한다면 잘 모르겠다.
객체지향 프로그래밍이란, 컴퓨터 프로그램을 명령어의 목록으로 보는 것이 아닌, "객체"들의 모임으로 파악하고자 하는 것이다.
이게 대체 무슨 말이냐?
평소에 프로그래밍을 하듯이 필요한 정보들을 길게 나열하면서 프로그래밍을 하는 것이 아닌, 필요한 부분들을 객체로 나누어 그 객체들 간의 상호작용을 통해 프로그램을 완성하는 것이다.
방금 설명한 문장에서 "필요한 정보들을 길게 나열하면서 프로그래밍을 하는 것", 이것은 절차 지향적 프로그래밍이라고 한다. 초기에는 이 방식이 지배적이었다.
그러면 "필요한 부분들을 객체로 나누어 그 객체들 간의 상호작용을 통해 프로그래밍을 완성하는 것", 이것이 객체 지향적 프로그래밍이다.
왜 쓸까?
그럼 어떠한 장점이 있길래 객체 지향적 프로그래밍을 쓰는 것일까?
절차지향
public class Main {
// Dog의 소리를 내는 함수
public static void dogSound() {
System.out.println("Bark!");
}
// Cat의 소리를 내는 함수
public static void catSound() {
System.out.println("Meow!");
}
// 동물의 수면을 표현하는 함수
public static void sleep() {
System.out.println("Zzz...");
}
public static void main(String[] args) {
// Dog의 행동
dogSound(); // 출력: Bark!
sleep(); // 출력: Zzz...
// Cat의 행동
catSound(); // 출력: Meow!
sleep(); // 출력: Zzz...
}
}
이런 코드가 있다고 하자, 정말 우리에게 익숙한 코드일 것이다.
이 코드가 절차지향 프로그래밍 방법으로 짠 코드이다.
이렇게 짜게 된다면, 간단한 형식의 이런 코드에선 굉장히 가독성이 좋다.
그럼 문제가 뭘까?
1. 코드 재사용성
dogSound(), catSound(), sleep() 이런 함수들이 별도로 존재하여 중복 코드가 발생할 가능성이 크고, 다른 동물을 추가하고 싶다면 새로운 함수를 만들어야 한다.
2. 확장성
새로운 동물을 추가할 때마다 새로운 함수를 정의해야 하고, 호출하는 부분도 추가해야해서 코드가 점점 복잡해진다.
3. 유지보수
공통된 동작을 수정하려고 한다면, 중복된 모든 함수를 바꿔야 한다. 이 말이 참 애매해보이고, 이 코드를 봤을땐 이해가 안될 수도 있다. 쉽게 설명해서 만약 모든 동물이 호출 전에 "Makes a sound:"라는 출력문이 들어가야한다고 한다면, dogSound(), catSound()에 모두 추가해주어야 한다. 이게 문제인 것이다.
4. 다형성
함수를 호출할 때 각 동물의 함수를 명시적으로 호출해야 한다. 즉, 유연성이 떨어진다.
그럼 이제 이 코드를 수정하여 객체지향적으로 짠 코드를 보자.
객체지향
// 추상 클래스 Animal
abstract class Animal {
// 추상 메서드
abstract void makeSound();
// 일반 메서드
public void sleep() {
System.out.println("Zzz...");
}
}
// 서브 클래스 Dog
class Dog extends Animal {
// 추상 메서드 구현
@Override
void makeSound() {
System.out.println("Bark!");
}
}
// 서브 클래스 Cat
class Cat extends Animal {
// 추상 메서드 구현
@Override
void makeSound() {
System.out.println("Meow!");
}
}
// 메인 클래스
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
// 각 객체의 메서드 호출
myDog.makeSound(); // 출력: Bark!
myDog.sleep(); // 출력: Zzz...
myCat.makeSound(); // 출력: Meow!
myCat.sleep(); // 출력: Zzz...
}
}
난 이런 코드를 보면 "아름답다"라고 생각한다.
아까 말했던 절차지향 코드의 단점을 어떻게 보완했는지 알아보자.
1. 코드 재사용성
Animal이라는 추상 클래스를 선언하고, 이를 상속받는 Dog와 Cat 클래스를 통해 sleep()이라는 공통 기능을 상위 클래스에 두고, makeSound()라는 함수는 하위 클래스에 두어 재사용성을 높였다.
새로운 동물을 추가할때는 그저 makeSound()를 오버라이딩하여 사용하면 된다.
2. 확장성
새로운 동물을 추가하면, 새로운 클래스를 정의하고 Animal을 상속받아 추상 메서드만 구현하면 된다.
3. 유지보수
공통 동작을 Animal에 정의하여 Animal을 상속받은 모든 클래스에 적용이 된다.
절차지향에서는 하나하나 추가해줘야 했지만, 객체지향은 다르다.
4. 다형성
Animal 타입의 객체로 Dog와 Cat을 활용할 수 있다. 이렇게 하면, Animal 타입의 배열을 이용하여 여러 동물을 한 번에 처리할 수도 있게 된다.
이 정도 설명만으로도 왜 쓰는지 알 수 있다.
이제 대충 알아봤으니, 처음에 얘기했던 객체지향의 특성을 보자.
객체지향의 특성
1. 캡슐화
캡슐화? 그냥 쉽게 "외부의 접근을 막는 것"이다. 이러면 객체의 데이터도 보호할 수 있다.
하지만 언제나 막을 수는 없으니, 필요한 경우에 메서드를 통해서 접근하도록 하는 것이다.
class Person {
private String name;
private int age;
// Getter 메서드
public String getName() {
return name;
}
// Setter 메서드
public void setName(String name) {
this.name = name;
}
// Getter 메서드
public int getAge() {
return age;
}
// Setter 메서드
public void setAge(int age) {
if (age > 0) {
this.age = age;
}
}
}
public class Main {
public static void main(String[] args) {
Person person = new Person();
person.setName("Alice");
person.setAge(30);
System.out.println("Name: " + person.getName()); // 출력: Name: Alice
System.out.println("Age: " + person.getAge()); // 출력: Age: 30
}
}
이 코드를 보면 필드를 private로 선언해놓았기 때문에 외부에서 직접 접근하는 것은 허용되지 않는다.
그럼 왜 출력이 될까?
public으로 정의된 메서드 덕분이다.
이러한 메서드 덕분에 우리는 캡슐화된 객체에 접근할 수 있다.
2. 상속
상속은 말 그대로 상속이다... 뭐라 할 말이 없다.
class Animal {
public void eat() {
System.out.println("This animal eats food.");
}
}
class Dog extends Animal {
public void bark() {
System.out.println("The dog barks.");
}
}
public class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.eat(); // 출력: This animal eats food.
dog.bark(); // 출력: The dog barks.
}
}
Animal이라는 부모 클래스를 Dog라는 자식 클래스가 상속받아 사용하는 것인데, 이 경우의 자식 클래스에서는 부모 클래스의 메서드를 사용할 수 있다.(같이 전달됨)
3. 다형성
절차지향의 단점과 객체지향의 장점을 얘기하며 나왔던 단어이다.
정확한 내용은, 동일한 인터페이스나 부모 클래스를 상속받은 여러 객체가 동일한 메서드를 다르게 구현할 수 있는 특성이다.
-> 즉, 분명 같은 메서드를 상속받았는데, 실행해보니 다른 결과가 나오는 것이다.
abstract class Animal {
abstract void makeSound();
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Bark");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
myDog.makeSound(); // 출력: Bark
myCat.makeSound(); // 출력: Meow
}
}
이 코드를 보면 알 수 있다.
분명 같은 makeSound를 상속받았는데, 각자 다른 값을 출력하고 있다.
이것이 다형성이다.
4. 추상화
불필요한 세부 사항을 숨기고, 중요한 부분만을 노출하여 복잡성을 줄이는 것인데,
코드로 보면 이해가 정말 쉽다.
abstract class Shape {
abstract void draw();
}
class Circle extends Shape {
@Override
void draw() {
System.out.println("Draw a circle");
}
}
class Rectangle extends Shape {
@Override
void draw() {
System.out.println("Draw a rectangle");
}
}
public class Main {
public static void main(String[] args) {
Shape circle = new Circle();
Shape rectangle = new Rectangle();
circle.draw(); // 출력: Draw a circle
rectangle.draw(); // 출력: Draw a rectangle
}
}
각 클래스는 Shape라는 추상 클래스를 상속받아 draw()라는 추상 메서드를 구현한다.
Main에 draw()를 보면, 정말 단순하게 생각할 수 있게 된다.
처음 이 코드를 보는 사용자 입장에서는 이 draw()라는 함수가 정확히 어떻게 동작하고, 어떤 알고리즘으로 짜여져 있고, 등등.. 이러한 정보들을 알 이유가 있을까?
-> 전혀 없다.
코드 이해를 위해서는 이러한 정보만 가지고도 충분하다.
처음 이 코드를 보는 사람은 Main문만 봤을때 "circle을 그리는구나", "rectangle을 그리는구나" 정도 알 수 있다. 이것으로 충분하다.
이게 바로 추상화이다.