"자바는 객체지향적 언어이다" 라는 말을 들어보신 적이 있을겁니다.
사실 객체라는 용어는 실생활에서 접하기보단 이렇게 코딩을 하면서 많이 접하게 됐었는데요, 그 때마다 아 객체는 이런거지! 오브젝트는 이런거지! 하고 스스로 명확한 정의를 내린 적은 없었습니다.
그러다가 강의를 보면서 자바 공부를 하고 있었는데, 강사님께서 말씀을 하셨습니다.
자바 개발자라면, 객체지향을 자기 만의 언어로 표현할 줄 알아야한다.
저는 자바 개발자라고 생각해왔었는데... 아니었나봅니다.
오늘은 객체지향 프로그래밍이란 무엇인가 함께 알아보고 진정한 자바 개발자로 함께 성장해나갔으면 좋겠습니다.
객체지향 프로그래밍과 클래스 단위 프로그래밍
객체지향에 관한 정의를 내릴 때 저는 스스로 혼란스러웠던(?) 경험이 많습니다.
누군가는 객체지향에 관해 이렇게 이야기합니다.
객체지향 프로그래밍은 클래스 단위로 프로그래밍하는 것을 말해!
또 누군가는 완전히 반대되는 이야기를 합니다.
객체지향 프로그래밍은 클래스가 아닌 객체 중심적으로 프로그래밍 해야해!
객체랑 클래스가 뭐길래 클래스 단위로 프로그래밍을 하면서도 클래스 중심이 아닌 객체 중심으로 프로그래밍 할 수 있을까요?
객체 vs 클래스
먼저 클래스(Class)
란 객체를 생성하기 위한 틀이라고 생각하시면 됩니다.
코드로 예시를 살펴볼까요?
public class Person {
private String name;
private int age;
private double height;
private double weight;
public void introduce() {
System.out.println("제 이름은 " + this.name + " 입니다.");
}
}
저는 이름, 나이, 키, 몸무게와 같은 필드를 가지고 있는 Person
이라는 클래스를 생성했습니다.
Person 클래스 자체가 나이 키 무게를 가지고 있지는 않죠.
이 Person 클래스안의 introduce
메서드를 실행할 수 있는 방법도 이 클래스만 가지고서는 불가능합니다.
하지만 이렇게 하면 어떨까요?
public class Person {
private String name;
private int age;
private double height;
private double weight;
public void introduce() {
System.out.println("제 이름은" + this.name + " 입니다.");
}
public Person(String name, int age, double height, double weight) {
this.name = name;
this.age = age;
this.height = height;
this.weight = weight;
}
}
public static void main(String[] args) {
Person stoneHee = new Person("stoneHee", 25, 180.0, 60.0);
stoneHee.introduce();
}
// 출력: "제 이름은 stoneHee 입니다."
이런 식으로 Person이라는 틀로 stoneHee
라는 인스턴스(객체)를 생성한다면, 클래스만 있었을 때는 없었던 필드에 실제 값(속성)을 가지게 되고, 메서드도 실행 가능한 코드로 변경됩니다.
그래서 클래스를 붕어빵 틀, 객체를 붕어빵에 비유하시는 분도 많이 있습니다.
결론적으로 위에서 인용한 두 가지 주장은 모두 맞는 말입니다.
자바는 객체중심적으로 프로그래밍 해야한다 라는 말은 인간 중심 관점에서,
자바는 클래스 단위로 프로그래밍 해야한다 라는 말은 프로그램 중심 관점에서 하는 말인거죠.
클래스(Class)란
- 객체를 생성하기 위한 틀, 규칙
- 변수와 메서드의 집합
객체(Object)란
- 소프트웨어 안에서 구현될 대상
- 클래스의 틀, 규칙대로 생성된 실체 (클래스에 의해 생성)
객체 지향의 특징
객체 지향 프로그래밍은 실세계의 객체들을 모델링하여 소프트웨어를 더욱 효율적으로 설계하고 구현할 수 있도록 도와주는 프로그래밍 패러다임입니다.
이 객체 지향에 대해 더 깊이 이해하기 위해 객체 지향의 특징들을 함께 살펴보겠습니다.
1. 추상화(Abstraction)
추상화는 복잡한 실세계의 개념을 단순화해서 애플리케이션 안에서 쉽게 관리할 수 있도록 도와주는 개념입니다.
public abstract class Car {
public abstract void drive();
public abstract void brake();
}
public class Sedan extends Car {
@Override
public void drive() {
System.out.println("세단을 운전합니다");
}
@Override
public void brake() {
System.out.println("세단이 정지합니다");
}
}
public class SportsCar extends Car {
@Override
public void drive() {
System.out.println("스포츠카를 운전합니다");
}
@Override
public void brake() {
System.out.println("스포츠카가 정지합니다");
}
}
위 코드와 같이 Car
클래스는 기본적인 동작을 추상 메서드로 정의하였습니다.
그리고 Sedan
클래스와 SportsCar
클래스가 해당 추상 메서드를 더 자세히 구현할 수 있습니다.
이로써 관련있는 데이터와 메서드를 클래스로 표현하고 불필요한 세부사항은 숨길 수 있는 것입니다.
필요에 따라서 오버라이딩을 통해 메서드를 더 자세히 구현할 수도 있구요!
2. 캡슐화(Encapsulation)
캡슐화는 객체의 상태와 행위를 하나로 묶고, 구현 세부 사항을 외부로부터 숨기는 것을 의미합니다.
public class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("잔액은 0보다 적을 수 없습니다");
}
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다");
}
balance += amount;
}
public void withdraw(double amount) {
if (amouint <= 0 || amount > balance) {
throw new IllegalArgumentException("출금액은 0보다 커야 하며 잔액보다 작아야 합니다");
}
balance -= amount;
}
public double getBalance() {
return balance;
}
}
위 코드를 살펴보면 BankAccount
클래스는 은행 계좌를 나타냅니다. balance
필드는 private
접근 제한자로 선언되어 있어 외부에서 직접 접근할 수 없습니다.
계좌에 돈을 입금하는 deposit
메서드와 돈을 출금하는 withdraw
메서드는 public
으로 선언되어 있어 외부에서 호출할 수 있습니다.
클래스 필드의 값은 꼭 이 메서드를 거쳐서만 변경될 수 있기 때문에, 계좌의 잔액을 더 안전하게 관리할 수 있고 잘못된 값이 입력되는 불상사를 방지할 수 있습니다.
이렇게 각 클래스의 내부 상태를 보호하고 임의로 변경할 수 없고, 오직 공개된 메서드를 통해서만 객체와 상호작용하기 때문에 코드의 안정성을 높이고 유지보수를 용이하게 할 수 있습니다.
3. 상속(Inheritance)
상속은 기존 클래스의 속성과 행위를 새로운 클래스가 물려받아 재사용할 수 있도록 하는 것입니다.
class Animal {
private String name;
public void setName(String name) {
this.name = name;
}
public void sound() {
System.out.println("동물이 소리를 냅니다");
}
}
class Dog extends Animal {
public void wagTail() {
System.out.println(name + "이(가) 꼬리를 흔듭니다");
}
@Override
public void sound() {
System.out.println(name + "이(가) 멍멍 짖습니다");
}
}
class Cat extends Animal {
public void purr() {
System.out.println(name + "이(가) 웁니다");
}
@Override
public void sound() {
System.out.println(name + "이(가) 야옹 웁니다");
}
}
public class TestInheritance {
public static void main(String[] args) {
Dog dog = new Dog();
dog.setName("바둑이");
dog.sound(); // "바둑이가 멍멍 소리를 냅니다" 출력
dog.wagTail(); // "바둑이가 꼬리를 흔듭니다" 출력
Cat cat = new Cat();
cat.setName("나비");
cat.sound(); // "나비가 야옹 소리를 냅니다" 출력
cat.purr(); // "나비가 야옹 웁니다" 출력
}
}
위 코드에서 Animal
클래스는 모든 동물이 공통으로 가지는 name
속성과 sound
메서드를 가지고 있습니다.
Dog
클래스와 Cat
클래스는 모두 Animal 클래스를 상속 받아 속성과 메서드를 물려받습니다.
Dog 클래스와 Cat 클래스는 각각 고유한 메서드(wagTail, purr)를 가지며, Animal 클래스의 sound 메서드를 오버라이드하여 각 동물에 특성에 맞는 소리를 내도록 수정합니다.
이후 TestInheritance
클래스에서 테스트 코드를 실행해보면 나오는 결과를 알 수 있듯이, 상속을 사용함으로써 코드의 중복을 줄이고, 다시 말해 재사용성을 높이고 각 클래스의 고유한 메서드 또한 구현할 수 있음을 확인할 수 있습니다.
4. 다형성(Polymorphism)
다형성은 같은 이름의 메서드나 프로퍼티가 다른 동작을 할 수 있게 해주는 객체 지향의 특징입니다.
자바에서 다형성은 주로 상속과 인터페이스를 통해 구현되는데요,
interface Shape {
void draw();
}
class Circle implements Shape {
@Override
public void draw() {
System.out.println("원을 그립니다");
}
}
class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("사각형을 그립니다");
}
}
class Triangle implements Shape {
@Override
public void draw() {
System.out.println("삼각형을 그립니다");
}
}
public class TestPolymorphism {
public static void main(String[] args) {
Shape circle = new Circle();
Shape rectangle = new Rectangle();
Shape triangle = new Triangle();
drawShape(circle); // "원을 그립니다" 출력
drawShape(rectangle); // "사각형을 그립니다" 출력
drawShape(triangle); // "삼각형을 그립니다" 출력
}
private static void drawShape(Shape shape) {
shape.draw();
}
}
위 코드에서 Shape
인터페이스는 draw
메서드를 선언합니다. Circle
, Rectangle
, Triangle
클래스는 모두 Shape 인터페이스의 구현체이며, draw 메서드를 오버라이드하여 각 도형을 그리는 방법을 정의합니다.
TestPolymorphism
클래스의 코드를 통해 테스트를 하면, Shape 타입의 참조 변수를 사용하여 도형의 인스턴스를 참조하고, drawShape
메서드를 호출하여 다형성을 보여줍니다.
drawShape 메서드는 Shape 타입의 매개변수를 받기 때문에 그 어떤 Shape의 구현 클래스의 인스턴스도 받을 수 있습니다.
다형성 역시 코드의 유연성과 확장성을 높일 수 있으며 유지보수에 도움을 주는 객체지향의 특성입니다.
결론
즉, 객체 지향 프로그래밍이란 컴퓨터의 프로그램을 명령어의 목록으로 보는 것이 아닌, 객체들의 모임으로 파악하고자 하는 프로그래밍 이론입니다.
위 성질들에서 알 수 있듯, 프로그램이 유연하고 변경에 용이하게 되기 때문에 대규포 소프트웨어 개발에 적합합니다.
좋은 객체 지향 설계의 5가지 원칙(SOLID)
그렇다면 좋은 객체 지향 프로그래밍을 하기 위해선 어떻게 해야할까요?
좋은 객체 지향 설계를 위한 5가지 원칙을 알아보도록 하겠습니다.
1. SRP: 단일 책임 원칙(Single Responsibility Principle)
SRP
는 한 클래스는 하나의 책임만을 가져야 한다는 원칙입니다.
하나의 책임이라는 것이 모호하게 느껴질 수도 있는데요, 좋은 예시와 나쁜 예시를 비교하면서 알아보도록 하겠습니다.
public class OrderProcessor {
public void processOrder(Order order) {
/**
주문 처리 로직
**/
saveOrderToDatabase(order);
sendConfirmationEmail(order);
}
private void saveOrderToDatabase(Order order) {
/**
DB에 주문 정보 저장 로직
**/
}
private void sendConfirmationEmail(Order order) {
/**
고객에게 주문 확인 이메일을 보내는 로직
**/
}
}
위 코드의 OrderProcessor
클래스는 주문을 처리하는 동시에 데이터베이스 저장과 이메일 전송의 책임도 가지고 있습니다.
이는 SRP를 위반하는 것입니다.
public class OrderProcessor {
private final OrderSaver orderSaver;
private final EmailSender emailSender;
public OrderProcessor(OrderSaver orderSaver, EmailSender emailSender) {
this.orderSaver = orderSaver;
this.emailSender = emailSender;
}
public void processOrder(Order order) {
/**
주문 처리 로직
**/
orderSaver.saveOrder(order);
emailSender.sendConfirmationEmail(order);
}
}
public class OrderSaver {
public void SaveOrder(Order order) {
/**
DB에 주문 정보 저장 로직
**/
}
}
public class EmailSender {
public void sendConfirmationEmail(Order order) {
/**
고객에게 주문 확인 이메일을 전송하는 로직
**/
}
}
위의 예시에서 OrderProcessor 클래스는 주문 처리의 책임만을 가지고 있습니다.
데이터베이스 저장과 이메일 전송의 책임은 각각 OrderSaver
와 EmailSender
클래스로 분리되어 책임을 나누고 있습니다.
이로써 각 클래스는 하나의 책임만을 가지는 SRP 원칙을 잘 지키게 됩니다.
다만 하나의 책임이라는 기준이 참 모호한데요. 관점 마다 클 수 있고 작을 수 있습니다.
중요한 기준은 변경입니다. 변경이 생길 때 파급효과가 적을 수록 책임의 공유가 적은 것이니 SRP를 잘 따른 설계라고 할 수 있겠죠.
2. OCP: 개방-폐쇄 원칙(Open/Closed Principle)
OCP
는 소프트웨어의 구성 요소 즉 클래스, 모듈, 메서드 등이 확장에는 열려있어야 하고, 수정에는 닫혀있어야 한다는 원칙입니다.
즉 기존의 코드를 변경하지 않으면서 시스템의 기능을 확장할 수 있어야 합니다.
// Step 1. 인터페이스 정의
interface Shape {
double area();
}
// Step 2. 구체적인 클래스 구현
class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
class Circle implements Shape {
double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
// Step 3. AreaCalculator 클래스는 Shape 타입의 리스트를 받아 그 총 넓이를 계산
class AreaCalculator {
public double calculateTotalArea(List<Shape> shapes) {
double total = 0;
for (Shape shape: shapes) {
total += shape.area();
}
return total;
}
}
위 예시에서 Shape
인터페이스는 area
메서드를 가지고있고, 이를 구현한 Rectangle
클래스와 Circle
클래스가 있습니다.
AreaCalculator
클래스는 Shape 인터페이스를 구현한 객체들의 리스트를 받아 총 넓이를 계산합니다.
이 설계는 OCP를 잘 따르고 있습니다. 만약 또 다른 도형을 추가하고 싶다면 Shape 인터페이스를 구현하는 클래스를 만들기만 하면 됩니다. 기존의 코드는 변경할 필요 없이 말이죠.
이는 확장에는 열려있고 수정에는 닫혀있는 좋은 예시입니다.
// Step 4. 새 도형 추가
class Triangle implements Shape {
double base;
double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
}
Triangle
클래스를 추가했지만 다른 코드는 수정할 필요가 없습니다. 이것이 OCP의 장점입니다.
사실 이 OCP 원칙에 대해 잘 알고있어도 잘 실천할 수 없는 것이 현실인데요.
이를 도와주는 Spring Framework
같은 프레임워크의 도움을 받아서 OCP 원칙을 잘 지키는 코드를 편하게 짤 수 있답니다.
3. LSP: 리스코프 치환 원칙(Liskov Substitution Principle)
LSP
는 하위 타입은 그것의 기반 타입과 대체 가능해야 한다는 원칙입니다. 즉 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 수 있어야 한다는 것입니다.
class Bird {
void fly() {
System.out.println("새가 날다");
}
}
class Sparrow extends Bird {
@Override
void fly() {
System.out.println("참새가 날다");
}
}
class Penguin extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다");
}
}
위의 예시에서 Bird
클래스는 fly
메서드를 가지고 있으며 Sparrow
와 Penguin
은 Bird를 상속받습니다.
Sparrow 클래스는 fly 메서드를 오버라이드하여 "참새가 날다" 라는 메시지를 출력합니다. 그러나 Penguin 클래스는 fly 메서드를 오버라이드 하여 예외를 던집니다. 펭귄은 날 수 없기 때문이죠.
이 경우, Bird 타입의 객체를 사용하는 클라이언트 코드에서 Bird 대신 Penguin을 사용한다면 런타임에 예외가 발생합니다. 이는 LSP를 위반하는 것입니다.
interface FlyingBird {
void fly();
}
class Sparrow implements FlyingBird {
@Override
public void fly() {
System.out.println("참새가 날다");
}
}
class Penguin {
void swim() {
System.out.println("펭귄이 수영하다");
}
}
만약 이렇게 FlyingBird
인터페이스를 선언한다면 이 인터페이스를 사용하는 모든 클라이언트 코드는 구현체가 날 수 있다는 것을 안전하게 가정할 수 있습니다.
4. ISP: 인터페이스 분리 원칙(Interface Segregation Principle)
ISP
는 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다. 즉, 한 클래스가 자신이 사용하지 않는 메서드를 구현하도록 강요되어서는 안된다는 뜻입니다.
interface Worker {
void work();
void eat();
}
class Developer implements Worker {
@Override
public void work() {
System.out.println("개발자가 일을 합니다");
}
@Override
public void eat() {
System.out.println("개발자가 밥을 먹습니다");
}
}
class Robot implements Worker {
@Override
public void work() {
System.out.println("로봇이 일을 합니다");
}
@Override
public void eat() {
// 로봇은 먹을 필요가 없음
throw new UnsupportedOperationException();
}
}
위 코드에서 Worker
인터페이스는 work
와 eat
두 메서드를 가지고 있습니다. Developer
클래스는 이 두 메서드를 모두 구현하지만 Robot
클래스는 eat을 구현할 필요가 없습니다. 그럼에도 불구하고 worker 인터페이스를 구현하기 위해 eat을 구현해야 합니다. 이는 ISP 원칙을 위반하는 것입니다.
interface Wokable {
void work();
}
interface Eatable {
void eat();
}
class Developer implements Workable, Eatable {
@Override
public void work() {
System.out.println("개발자가 일을 합니다");
}
@Override
public void eat() {
System.out.println("개발자가 밥을 먹습니다");
}
}
class Robot implements Workable {
@Override
public void work() {
System.out.println("로봇이 일을 합니다");
}
}
하지만 Worker 인터페이스를 Workable
과 Eatable
로 분리한다면 Developer 클래스는 두 인터페이스를 모두 구현하지만, Robot 클래스는 Workable 만 구현하면 됩니다.
이로써 인터페이스가 명확해지고, 대체 가능성이 높아집니다.
즉 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다는 것입니다.
5. DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)
DIP
는 고수준의 모듈이 저수준의 모듈에 의존해서는 안되고, 둘 다 추상화에 의존해야 한다는 원칙입니다.
여기서 고수준 모듈
은 비즈니스 로직을 수행하는 Service
와 같은 클래스를, 저수준 모듈
은 데이터베이스 접근 네트워크 통신 등 세부작업을 수행하는 클래스를 의미합니다.
class LightBulb {
public void turnOn() {
System.out.println("불이 켜졌습니다");
}
public void turnOff() {
System.out.println("불이 꺼졌습니다");
}
}
class Switch {
private LightBulb bulb;
public Switch(LightBulb bulb) {
this.bulb = bulb;
}
public void operate() {
// ... 로직 ...
buib.trunOn();
// ... 로직 ...
}
}
위 코드에서 Switch
클래스는 LightBulb
클래스에 직접 의존하고 있습니다. 만약 다른 종류의 전등이나 전자기기에 연결하려면 Switch 를 수정해야합니다. 이는 DIP를 위반하는 것입니다.
interface SwitchableDevice {
void turnOn();
void turnOff();
}
class LightBulb implements SwitchableDevice {
@Override
public void turnOn() {
System.out.println("불이 켜졌습니다");
}
@Override
public void turnOff() {
System.out.println("불이 꺼졌습니다");
}
}
class Switch {
private SwitchableDevice device;
public Switch(SwitchableDevice device) {
this.device = device;
}
public void operate() {
// ... 로직 ...
device.turnOn();
// ... 로직 ...
}
}
위 코드에서는 SwitchableDevice
인터페이스를 통해 Switch 클래스와 LightBulb 사이의 의존성을 역전시켰습니다. 이제 Switch 클래스는 SwitchableDevice 인터페이스에 의존하며, LightBulb 클래스는 이 인터페이스를 구현합니다. 만약 다른 종류의 전자 기기와 연결하려면 SwitchalbeDevice를 구현하는 새로운 클래스를 만들면 됩니다.
즉 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻입니다.
결론
결론적으로 SOLID 원칙을 준수하는 프로젝트를 만들도록 노력해야겠지만, 쉽지만은 않은 것이 사실입니다.
하지만 이를 돕기 위한 여러 도구와 기술들이 존재합니다.
더욱 유지보수하기 쉽고 유연한 프로그램을 설계하기 위해 노력해야겠습니다. (저는 우선 스프링부터 뿌숴보겠습니다!)
'Computer Science > 프로그래밍 이론 💬' 카테고리의 다른 글
일급 컬렉션이란? (0) | 2024.02.07 |
---|---|
비동기 프로그래밍 (0) | 2024.02.06 |
Dependency Injection이란? (0) | 2024.02.06 |
제어의 역전(IOC) 이란? (0) | 2024.02.06 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆