객체 지향 프로그래밍
객체 지향 프로그래밍이란?
객체 지향 프로그래밍(OOP)은 소프트웨어 개발의 핵심 패러다임 중 하나로, 프로그램을 ‘객체(Object)’라는 기본적인 단위로 구성하고 이러한 객체들 간의 상호작용을 통해 시스템을 구축하는 방식이다.
등장 배경
초기 프로그래밍 패러다임인 절차적/구조적 프로그래밍(예: C, 파스칼)은 컴퓨터의 처리 구조와 유사하여 실행 속도가 빠르다는 장점을 가졌다. 그러나 프로그램의 규모가 커지고 복잡성이 증가함에 따라, 이러한 방식은 코드의 유지보수를 어렵게 만들고, 디버깅을 복잡하게 하며, 코드의 순서 변경에 따라 예상치 못한 결과가 발생할 수 있는 ‘스파게티 코드’ 문제에 직면하게 되었다.
함수 중심의 구조적 프로그래밍 역시 데이터 자체를 체계적으로 구조화하지 못하여 전역 네임스페이스의 포화, 실행 콘텍스트 저장 문제, 데이터 오염 등의 부작용을 야기했다. 이러한 절차 지향 프로그래밍의 한계를 극복하기 위한 대안으로 객체 지향 프로그래밍이 등장했다.
개념
객체 지향은 “무엇을 어떤 절차로 할 것인가?”에 집중하는 절차 지향과 달리, “누가 어떤 일을 할 것인가?”에 초점을 맞춘다. 즉, 문제를 여러 절차의 함수로 해결하는 대신, 작은 문제들을 해결할 수 있는 ‘객체’를 먼저 만들고 이 객체들을 조합하여 더 큰 문제를 해결하는 상향식(Bottom-up) 접근법을 도입한 것이다.
단점
성능 오버헤드
객체지향 언어는 절차지향 언어에 비해 상대적으로 실행 속도가 느리고 메모리 사용량이 많다. 이는 객체 생성 및 관리, 메서드 호출 오버헤드, 다형성을 위한 동적 바인딩 때문이다.
자바의 가비지 컬렉션(GC)은 자동 메모리 관리의 편의성을 제공하지만, GC 실행 시 발생하는 ‘Stop-The-World’ 현상이 애플리케이션 성능에 영향을 줄 수 있다. 특히 과도한 객체 생성은 GC 빈도를 높여 전체 시스템의 응답성을 저하시킬 수 있다.
설계 복잡성
객체지향 프로그래밍은 설계 단계에서 상당한 시간과 노력이 필요하다. 과도한 추상화나 복잡한 상속 구조는 오히려 코드 이해를 어렵게 만들고 유지보수성을 저해할 수 있다.
높은 학습 곡선
객체지향 프로그래밍은 단순히 새로운 문법을 익히는 것을 넘어 ‘객체 지향적 사고 방식’으로의 전환을 요구한다. 많은 개발자들이 문법만 익히고 본질적인 설계 원칙을 놓치는 경우가 많다.
객체 지향의 핵심 원칙
객체지향 프로그래밍은 추상화, 캡슐화, 상속, 다형성이라는 네 가지 핵심 원칙을 기반으로 한다. 이 원칙들은 코드의 재사용성을 높이고, 모듈 간의 결합도를 낮추며, 시스템의 유연성과 확장성을 증대시킨다.
추상화 (Abstraction)
추상화는 객체들의 공통적인 속성과 기능을 추출하여 하나의 클래스나 인터페이스로 정의하는 과정이다. 복잡한 현실 세계를 단순화하여 소프트웨어 모델로 표현하며, 핵심 요소에 집중하고 불필요한 세부 사항을 숨긴다.
자바에서는 abstract
키워드를 사용한 추상 클래스나 인터페이스를 통해 구현한다. 예를 들어, Animal
추상 클래스에 sound()
추상 메서드를 정의하고, Dog
, Cat
클래스가 각자의 특성에 맞게 구현하도록 강제할 수 있다.
추상화는 ‘무엇을 할 것인가’에 대한 설계에 초점을 맞추며, 객체 간의 유연한 관계를 형성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* Animal 추상 클래스 - 동물의 공통 특성을 정의
*/
abstract class Animal {
protected String name; // 공통 속성
protected int age;
// 생성자 - 추상 클래스도 생성자를 가질 수 있음
public Animal(String name, int age) {
this.name = name;
this.age = age;
}
// 구체적인 메서드 - 모든 동물이 공통으로 사용
public void sleep() {
System.out.println(name + "이(가) 잠을 자고 있습니다.");
}
public void eat() {
System.out.println(name + "이(가) 먹이를 먹고 있습니다.");
}
// 추상 메서드 - 각 동물마다 다르게 구현되어야 함
public abstract void makeSound();
// 추상 메서드 - 이동 방식도 동물마다 다름
public abstract void move();
}
/**
* Dog 클래스 - Animal을 상속받아 구체적으로 구현
*/
class Dog extends Animal {
private String breed;
public Dog(String name, int age, String breed) {
super(name, age); // 부모 생성자 호출
this.breed = breed;
}
@Override
public void makeSound() {
System.out.println(name + ": 멍멍!");
}
@Override
public void move() {
System.out.println(name + "이(가) 네 발로 달려갑니다.");
}
// Dog만의 고유 메서드
public void wagTail() {
System.out.println(name + "이(가) 꼬리를 흔듭니다.");
}
}
/**
* Cat 클래스 - Animal을 상속받아 다르게 구현
*/
class Cat extends Animal {
private boolean isIndoor;
public Cat(String name, int age, boolean isIndoor) {
super(name, age);
this.isIndoor = isIndoor;
}
@Override
public void makeSound() {
System.out.println(name + ": 야옹~");
}
@Override
public void move() {
System.out.println(name + "이(가) 조용히 발끝으로 걸어갑니다.");
}
// Cat만의 고유 메서드
public void climb() {
System.out.println(name + "이(가) 나무를 타고 올라갑니다.");
}
}
캡슐화 (Encapsulation)
캡슐화는 데이터(속성)와 그 데이터를 다루는 메서드를 하나의 객체 내부에 묶어 외부에서 직접 접근하지 못하도록 보호하는 메커니즘이다. 정보 은닉을 통해 코드 재사용성을 높이고 변경에 따른 위험을 줄인다.
1
2
3
4
5
6
7
public class Person {
private int age; // 외부 접근 차단
public void setAge(int age) {
if (age >= 0) this.age = age; // 유효성 검사
}
}
상속 (Inheritance)
상속은 한 클래스(부모)의 속성과 메서드를 다른 클래스(자식)가 물려받아 재사용하고 확장할 수 있게 하는 메커니즘이다.
자바에서는 extends
키워드를 사용하며, 단일 상속만 지원한다.
1
2
3
4
5
6
7
8
public class Animal {
protected String name;
public void eat() { /* 구현 */ }
}
public class Dog extends Animal {
public void bark() { /* 고유 기능 추가 */ }
}
상속은 코드 재사용성을 높이고 계층 구조를 통해 유지보수를 용이하게 한다. 하지만 부모-자식 간 강한 결합도를 형성하므로, IS-A 관계가 명확할 때만 사용하는 것이 바람직하다.
다형성 (Polymorphism)
다형성은 하나의 객체나 메서드가 여러 타입을 참조하거나, 같은 이름의 메서드가 다른 클래스에서 다양한 방식으로 실행될 수 있음을 의미한다. 자바에서는 오버로딩과 오버라이딩을 통해 구현한다.
오버로딩(Overloading): 같은 이름의 메서드를 매개변수의 개수, 타입, 순서를 다르게 하여 여러 개 정의하는 것. 컴파일 시점에 결정된다(정적 바인딩).
오버라이딩(Overriding): 상속 관계에서 자식 클래스가 부모 클래스의 메서드를 재정의하는 것. 런타임 시점에 결정된다(동적 바인딩).
다형성은 동일한 인터페이스를 통해 다양한 구현을 가능하게 하여, 새로운 타입이 추가되어도 기존 코드를 변경하지 않고 확장할 수 있게 한다.
객체 지향 설계 원칙 (SOLID)
SOLID 원칙은 로버트 C. 마틴(Robert C. Martin)이 제안한 객체 지향 설계의 5가지 핵심 원칙을 의미한다.
이는 SRP(단일 책임 원칙), OCP(개방 폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존 역전 원칙)로 구성된다.
원칙들은 객체 지향 프로그래밍의 4가지 특징(추상화, 상속, 다형성, 캡슐화)과 더불어, 유지보수와 확장이 용이한 시스템을 구축하기 위한 지침으로 작용한다.
단일 책임 원칙 (SRP: Single Responsibility Principle)
단일 책임 원칙은 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙이다. 여기서 ‘책임’은 하나의 ‘기능 담당’을 의미하며, 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다는 것을 강조한다.
이 원칙의 목적은 하나의 클래스가 하나의 기능에 집중하도록 클래스를 여러 개로 분리하여 설계하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
class PasswordService {
public String hashPassword(String plainPassword) {
// 실제로는 BCrypt, Argon2 등을 사용
return "hash_" + plainPassword + "_salt";
}
public boolean verifyPassword(String plainPassword, String hashedPassword) {
return hashPassword(plainPassword).equals(hashedPassword);
}
}
개방 폐쇄 원칙 (OCP: Open/Closed Principle)
개방 폐쇄 원칙은 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 ‘확장에 열려있어야 하며, 수정에는 닫혀있어야 한다’는 원칙이다. 이 원칙의 목적은 기능 추가 요청이 오면 기존 클래스를 확장하여 손쉽게 구현하면서, 확장에 따른 기존 클래스의 수정은 최소화하도록 프로그램을 작성해야 한다는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 결제 방법을 나타내는 인터페이스 (추상화)
interface PaymentMethod {
void processPayment(double amount);
String getPaymentType();
}
// 신용카드 결제 구현체
class CreditCardPayment implements PaymentMethod {
private String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void processPayment(double amount) {
System.out.println("신용카드(" + maskCardNumber() + ")로 " + amount + "원 결제 처리");
// 신용카드 결제 로직
}
@Override
public String getPaymentType() {
return "CREDIT_CARD";
}
}
// 계좌이체 결제 구현체
class BankTransferPayment implements PaymentMethod {
private String accountNumber;
public BankTransferPayment(String accountNumber) {
this.accountNumber = accountNumber;
}
@Override
public void processPayment(double amount) {
System.out.println("계좌이체(" + maskAccountNumber() + ")로 " + amount + "원 결제 처리");
// 계좌이체 결제 로직
}
@Override
public String getPaymentType() {
return "BANK_TRANSFER";
}
}
리스코프 치환 원칙 (LSP: Liskov Substitution Principle)
리스코프 치환 원칙은 서브(자식) 타입은 언제나 자신의 상위(부모) 타입으로 대체될 수 있어야 한다는 원칙이다.
이는 다형성 원리를 이용하기 위한 핵심 개념으로, 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 함을 의미한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 공통 인터페이스로 추상화
interface Shape {
int getArea();
String getShapeType();
}
class GoodRectangle implements Shape {
private final int width, height;
public GoodRectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
@Override
public String getShapeType() {
return "Rectangle(" + width + "x" + height + ")";
}
}
class GoodSquare implements Shape {
private final int side;
public GoodSquare(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
@Override
public String getShapeType() {
return "Square(" + side + "x" + side + ")";
}
}
인터페이스 분리 원칙 (ISP: Interface Segregation Principle)
인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
이는 큰 덩어리의 인터페이스를 구체적으로 작은 단위로 분리시켜, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이 목표이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ❌모든 기능이 하나의 인터페이스에 집중된 나쁜 설계
interface BadMultiFunctionDevice {
void print(String document);
void scan(String document);
void fax(String document);
void copy(String document);
void wifi_connect();
void bluetooth_connect();
void cloud_upload(String document);
}
// ✅기능별로 인터페이스 분리
interface Printable {
void print(String document);
}
interface Scannable {
void scan(String document);
}
interface Faxable {
void fax(String document);
}
interface Copyable {
void copy(String document);
}
interface WifiConnectable {
void connectWifi();
}
interface BluetoothConnectable {
void connectBluetooth();
}
interface CloudUploadable {
void uploadToCloud(String document);
}
의존 역전 원칙 (DIP: Dependency Inversion Principle)
의존 역전 원칙은 어떤 클래스를 참조해서 사용해야 하는 상황이 생긴다면, 그 구체적인 클래스를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 또는 인터페이스)로 참조하라는 원칙이다.
핵심은 ‘구현 클래스에 의존하지 말고, 인터페이스에 의존하라’는 것이다.
DIP의 궁극적인 지향점은 각 클래스 간의 결합도(coupling)를 낮추는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ❌DIP 위반: 고수준 모듈이 저수준 모듈에 직접 의존
class BadUserService {
private MySQLDatabase database; // 구체적인 클래스에 의존!
public BadUserService() {
this.database = new MySQLDatabase(); // 강한 결합
}
public void saveUser(String userData) {
database.save(userData);
}
public String getUser(String userId) {
return database.load(userId);
}
// 만약 Oracle로 바꾸고 싶다면? 이 클래스를 수정해야 함!
}
// ✅DIP 준수: 고수준 모듈이 추상화에 의존
class GoodUserService {
private Database database; // 인터페이스에 의존!
// 의존성 주입을 통해 구체적인 구현 제공
public GoodUserService(Database database) {
this.database = database;
}
public void saveUser(String userData) {
database.save(userData);
}
}
결합도와 응집도
응집도 (Cohesion) - 높을수록 좋음
“모듈 내부가 얼마나 단합되어 있는가?”
- 높은 응집도: 한 모듈이 하나의 명확한 목적만 수행
- 낮은 응집도: 한 모듈이 여러 관련 없는 일들을 처리 (God Object)
1
2
3
4
5
6
7
8
9
// 높은 응집도 ✅
class UserAuthentication {
login(), logout(), validatePassword() // 인증 관련 기능만
}
// 낮은 응집도 ❌
class UserManager {
login(), sendEmail(), calculateTax(), printReport() // 뭔가 이상함
}
결합도 (Coupling) - 낮을수록 좋음
“모듈들이 서로 얼마나 의존하고 있는가?”
- 낮은 결합도: 모듈들이 독립적, 한 곳 변경이 다른 곳에 영향 적음
- 높은 결합도: 모듈들이 강하게 연결, 한 곳 변경이 여러 곳에 영향
1
2
3
4
5
6
7
8
9
// 낮은 결합도 ✅
class OrderService {
private PaymentInterface payment; // 인터페이스에 의존
}
// 높은 결합도 ❌
class OrderService {
private CreditCardPayment payment = new CreditCardPayment(); // 구체 클래스에 직접 의존
}
결합도 감소 및 응집도 향상을 위한 디자인 패턴
Adapter 패턴: 기존 코드 재사용 및 인터페이스 호환성 증대
개념 및 원리
Adapter 패턴은 호환되지 않는 인터페이스를 가진 클래스들이 함께 작동할 수 있도록 중간에서 인터페이스를 변환해주는 구조 디자인 패턴입니다.
이 패턴은 기존 코드를 수정하지 않고도 새로운 시스템에 통합하거나 재사용할 수 있게 하는 “브릿지” 역할을 수행합니다.
Java 적용 사례: 음악 플레이어 시스템 확장
기존 MP3만 지원하는 음악 플레이어 시스템을 VLC 및 MP4 형식까지 지원하도록 확장하는 시나리오를 통해 Adapter 패턴을 살펴봅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public interface MediaPlayer {
void play(String audioType, String fileName);
}
// Client (기존 AudioPlayer)
public class AudioPlayer implements MediaPlayer {
MediaPlayerAdapter mediaPlayerAdapter; // 어댑터 인스턴스
@Override
public void play(String audioType, String fileName) {
if ("mp3".equalsIgnoreCase(audioType)) {
System.out.println("Playing mp3 file. Name: " + fileName);
} else if ("vlc".equalsIgnoreCase(audioType) ||
"mp4".equalsIgnoreCase(audioType)) {
// 어댑터를 통해 AdvancedMediaPlayer 기능 사용
mediaPlayerAdapter = new MediaPlayerAdapter(audioType);
mediaPlayerAdapter.play(audioType, fileName);
} else {
System.out.println("Invalid media. " + audioType + " format not supported");
}
}
}
// Adaptee Interface
public interface AdvancedMediaPlayer {
void playVlc(String fileName);
void playMp4(String fileName);
}
// Adaptee 1
public class VlcPlayer implements AdvancedMediaPlayer {
@Override
public void playVlc(String fileName) {
System.out.println("Playing vlc file. Name: " + fileName);
}
@Override
public void playMp4(String fileName) { /* do nothing */ }
}
// Adaptee 2
public class Mp4Player implements AdvancedMediaPlayer {
@Override
public void playMp4(String fileName) {
System.out.println("Playing mp4 file. Name: " + fileName);
}
@Override
public void playVlc(String fileName) { /* do nothing */ }
}
// Adapter: Target 인터페이스를 구현하고 Adaptee 객체를 감싼다.
public class MediaPlayerAdapter implements MediaPlayer {
AdvancedMediaPlayer advancedMediaPlayer;
public MediaPlayerAdapter(String audioType) {
if ("vlc".equalsIgnoreCase(audioType)) {
advancedMediaPlayer = new VlcPlayer();
} else if ("mp4".equalsIgnoreCase(audioType)) {
advancedMediaPlayer = new Mp4Player();
}
}
@Override
public void play(String audioType, String fileName) {
if ("vlc".equalsIgnoreCase(audioType)) {
advancedMediaPlayer.playVlc(fileName);
} else if ("mp4".equalsIgnoreCase(audioType)) {
advancedMediaPlayer.playMp4(fileName);
}
}
}
// 사용 예시
public class AdapterPatternDemo {
public static void main(String[] args) {
AudioPlayer audioPlayer = new AudioPlayer();
audioPlayer.play("mp3", "beyond the horizon.mp3");
audioPlayer.play("mp4", "alone.mp4");
audioPlayer.play("vlc", "far far away.vlc");
audioPlayer.play("avi", "mind me.avi"); // 지원하지 않는 형식
}
}
결합도 및 응집도 개선 효과 분석
결합도 감소 (Coupling Reduction)
문제점 해결:
1
2
3
4
5
6
7
8
9
10
// Before: 직접 의존 (강한 결합)
class BadPlayer {
VlcPlayer vlc = new VlcPlayer(); // 직접 생성!
Mp4Player mp4 = new Mp4Player(); // 직접 생성!
}
// After: 인터페이스를 통한 느슨한 결합
class GoodPlayer {
MediaAdapter adapter; // 인터페이스에만 의존!
}
응집도 향상 (Cohesion Improvement)
책임 분리:
AudioPlayer
: 오직 음악 재생만MediaAdapter
: 오직 인터페이스 변환만VlcPlayer
: 오직 VLC 재생만
댓글남기기