스프링입문을위한 자바 객체지향의 원리와 이해(1)
1. 들어가며
Java, Spring를 사용하여 몇번의 프로젝트를 해왔으나 진행할때마다 새로운 기분이 들었고, 이 Spring 코드를 왜 써야 하나 이런 궁금증이 생기기도 했다. 그러다가 설계패턴과목을 들으면서 OOP, SOLID원칙에 따라 코드를 역할과 책임에 따라 나누고 세련되게 작업하는 법을 배우고 실천하면서 문득 내가 주로 사용하는 Spring에 대해 얼마나 깊게 이해하고있나 의문이 들었고 그래서 Spring이 뭐고 왜 써야하나 라는 궁극적인 질문에 답하기 위해 이러한 OOP, SWE를 다루는 책을 읽게 되었다. 그 중에서 스프링 입문을 위한 자바 객체지향의 원리와 이해 라는 책이 좋다는 말을 구글링을 통해 알게 되었고, 마침 동네 도서관에 있어서 빌려보게 되었다.
이 책을 읽으며 깨달은 바가 적지 않아 북리뷰를 남기기로 했다. 그러나 이 리뷰글은 책의 목차나 내용순서와는 다를 수 있다.
2. C, C++, Java
2-1 C, C++의 동작
기계어, 어셈블리어를 뛰어넘는 C의 장점은 강력한 이식성이었다.
1
2
3
4
5
6
7
8
#기계어 실행 순서
기계어->HW //HW 종류마다 서로 다른 기계어 코드
#어셈블리어 실행 순서
어셈블리어-(어셈블러)->기계어->HW // HW 종류마다 서로 다른 어셈블리어, 어셈블러, 기계어 코드
#C, C++ 실행 순서
C 코드 -(컴파일러)-> 목적파일 -> HW //항상 같은 C코드, HW 종류마다 다른 컴파일러, 목적파일
기계어는 하드웨어에 직접 전달되어 실행되는 간단한 구조를 가지지만, 하드웨어마다 명령어 체계(ISA)가 다르기 때문에(MIPS, x86-64, ARM 등을 생각해보자), 같은 작업을 수행하는 기계어 코드라도 EDSAC에서는 동작하지만 UNIVAC에서는 동작하지 않고, 그 반대도 마찬가지였다.
어셈블리어의 등장으로, 사람들은 0과 1로만 이루어진 기계적 시선에서 벗어나, 보다 이해하기 쉬운 명령어 약어(mnemonic)를 사용해 프로그래밍할 수 있게 되었다. 프로그래머가 작성한 어셈블리 코드는 어셈블러를 통해 기계어로 변환되어 실행되었다. 그러나 여전히, UNIVAC의 어셈블리 코드는 UNIVAC 어셈블러를 통해 UNIVAC 기계어로만 변환될 수 있었고, 이는 UNIVAC에서만 실행 가능했으며 EDSAC과 같은 다른 하드웨어에서는 호환되지 않았다.
그러나 C언어는 HW를 가리지 않는 동일한 소스파일을 각 HW에 맞는 컴파일러만 있으면 실행 가능했다.
2-2 Java의 동작
Java는 한단계 더 나아갔다. “Write Once, Run Anywhere”, 한 번 작성한 코드를 어디서든 실행할 수 있도록 만들자는 것이 Java의 철학이다.
Java는 컴파일 이후 바로 하드웨어를 건드리지 않는다. 대신, 소스코드를 Bytecode라는 중간 형태로 컴파일하고, 이 Bytecode를 JVM(Java Virtual Machine) 이라는 소프트웨어 위에서 실행한다.
JVM은 일종의 가상 하드웨어 역할을 한다. 하드웨어나 OS에 종속되지 않고, Java 프로그램이 항상 같은 방식으로 실행될 수 있도록 만들어준다. 덕분에, Java는 컴파일은 한 번만 하고, 실행은 어디서든 가능하게 되었다.
비유 | 설명 |
---|---|
JVM = 컴퓨터(CPU) | Bytecode를 실행하는 엔진 |
JRE = 운영체제(OS) | JVM이 동작할 수 있는 실행 환경 |
JDK = 개발 패키지 | JRE + 개발 도구(코딩, 컴파일, 디버깅) |
2-3 C언어의 철학과 절차지향
✏️ C 언어의 절차지향적 특징
C는 절차지향 프로그래밍(Procedural Programming) 언어다.
즉, 프로그램을 “절차(Procedure)”, “순서(Sequence)” 중심으로 구성한다.
- 프로그램은 “어떤 작업을 먼저 하고, 그 다음에 무엇을 하고…” 라는 순서로 작성된다.
- 데이터와 함수가 별도로 존재하며, 데이터는 함수의 인자로 전달된다.
- 전역 변수 사용이 흔하고, 데이터 보호보다는 효율적인 접근에 초점을 맞춘다.
C스러운 코드 스타일은 이런 식이다:
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int calculate_sum(int a, int b) {
return a + b;
}
int main() {
int result = calculate_sum(3, 5);
printf("Result: %d\n", result);
return 0;
}
calculate_sum() 함수는 독립적으로 존재하고,
main() 함수는 이 함수를 호출하여 필요한 작업을 수행한다.
데이터(a, b, result)는 별도로 관리되며, 객체로 묶여 있지 않다.
2-4. 절차지향의 한계와 객체지향의 필요성
C 언어를 비롯한 절차지향 프로그래밍은 “순서”와 “흐름”을 중심으로 프로그램을 작성한다.
하지만 프로그램의 규모가 커지고 복잡도가 증가하면서, 절차지향 패러다임은 몇 가지 한계를 드러내기 시작했다.
✏️ 절차지향의 주요 한계
- 데이터와 함수를 분리해서 다루다 보니, 데이터 보호가 어렵다.
- 모든 함수가 데이터를 직접 읽고 쓸 수 있다.
- 데이터 무결성(Data Integrity)을 보장하기 힘들다.
- 프로그램이 커질수록 관리가 어려워진다.
- 함수 간 의존성이 복잡하게 얽힌다.
- 하나의 작은 수정이 전체 프로그램에 영향을 줄 수 있다.
- 재사용성과 확장성이 떨어진다.
- 코드가 “복붙” 위주로 늘어나기 쉽다.
- 유사한 기능을 가진 함수와 데이터 구조를 계속 새로 만들어야 한다.
- 현실 세계의 개념을 표현하기 어렵다.
- 현실은 “사물(객체)” 중심인데, 절차지향은 “행동(함수)” 중심이다.
- 프로그램 설계가 점점 비현실적이 된다.
📚 한계를 보여주는 예시
C로 절차지향적으로 “자동차”를 다루려고 하면 이런 식이 된다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
int speed = 0;
void accelerate() {
speed += 10;
}
void brake() {
if (speed >= 10)
speed -= 10;
}
int main() {
accelerate();
accelerate();
brake();
printf("Current speed: %d\n", speed);
return 0;
}
🤔이 코드가 뭐가 문제인데??
speed라는 데이터가 프로그램 전역에 노출돼 있다.
accelerate(), brake() 같은 함수들이 speed를 직접 조작한다. (speed 관련 로직에 추가 규칙이 생기면 함수들을 직접 다 고쳐야함)
📌 그래서 등장한 객체지향 프로그래밍 (OOP)
객체지향(Object-Oriented Programming, OOP)은 이러한 절차지향의 한계를 극복하기 위해 등장했다.
절차지향(Procedural) | 객체지향(Object-Oriented) |
---|---|
데이터와 함수가 분리됨 | 데이터와 메서드를 하나의 객체로 묶음 |
프로그램 흐름 중심 설계 | 객체 중심 설계 |
데이터 보호가 약함 | 캡슐화를 통해 데이터 은닉 |
유지보수 어려움 | 높은 확장성과 재사용성 |
OOP는 데이터를 중심으로 프로그램을 설계한다.
즉, 자동차라는 객체를 만들고, 그 객체 안에 가속과 브레이크라는 동작을 넣는 식이다.
2-5. 객체지향(OOP)의 네 가지 기본 특성, First Look
객체지향 프로그래밍(Object-Oriented Programming, OOP)은
절차지향 프로그래밍이 가진 한계를 극복하기 위해 등장했다.
OOP는 프로그램을 데이터 중심(객체 중심)으로 설계하며,
다음 네 가지 핵심 특성을 기반으로 한다.
✏️ 객체지향 4대 특성
- 캡슐화(Encapsulation)
- 데이터(속성)와 메서드(동작)를 하나의 단위인 객체로 묶는다.
- 외부에서는 객체의 내부 구현을 몰라도, 제공하는 인터페이스만 통해 접근할 수 있다.
- 예: 자동차의 가속페달을 밟으면 엔진 내부 작동 방식을 몰라도 차가 움직인다.
- 상속(Inheritance)
- 기존 객체(클래스)를 기반으로 새로운 객체를 만들 수 있다.
- 코드의 중복을 줄이고, 기능을 재사용할 수 있다.
- 예: ‘동물’ 클래스를 상속받아 ‘개’, ‘고양이’ 클래스를 만들 수 있다.
- 추상화(Abstraction)
- 현실 세계의 복잡한 요소 중에서 필요한 부분만 모델링한다.
- 불필요한 세부사항은 숨기고, 본질적인 것만 드러낸다.
- 예: 병원 시스템에서는 ‘사람’이 아니라 ‘환자’라는 관점으로 필요한 정보만 다룬다.
- 다형성(Polymorphism)
- 같은 인터페이스를 통해 서로 다른 동작을 수행할 수 있다.
- 프로그램의 유연성과 확장성을 높인다.
- 예:
move()
라는 명령을 호출하면, 사람은 걷고, 자동차는 달리고, 비행기는 난다.
📚 요약
특성 | 설명 |
---|---|
캡슐화 | 데이터와 메서드를 객체로 묶고, 외부로부터 보호한다. |
상속 | 기존 클래스를 확장해 새로운 클래스를 만든다. |
추상화 | 필요한 특성만 남기고 나머지는 숨긴다. |
다형성 | 동일한 메시지에 대해 다양한 반응을 보인다. |
✅ 요약:
객체지향 프로그래밍은 이 네 가지 특성을 통해 복잡한 프로그램을 더 쉽게 관리하고 확장할 수 있도록 설계되었다.
다음으로,
이러한 객체지향 특성들을 C 언어로 억지로 흉내내려 했던 시도를 살펴보자.
2-6. C : 객체지향, 그거 나도 하면 안되냐??
앞서 살펴본 것처럼, C 언어는 철저한 절차지향 언어다.
하지만 객체지향(OOP)의 개념을 흉내 내려는 시도는 가능하다.
어떻게?
struct
로 데이터를 묶고,- 함수 포인터를 사용해서 동작(메서드) 을 흉내낼 수 있다.
✏️ 객체지향 흉내내기: 자동차 예시
C로 “Car” 객체를 흉내내보자.
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
#include <stdio.h>
/* Car 객체를 표현할 구조체 */
typedef struct Car {
int speed;
/* 메서드 역할을 하는 함수 포인터 */
void (*accelerate)(struct Car*);
void (*brake)(struct Car*);
} Car;
/* 메서드 함수 정의 */
void car_accelerate(Car* car) {
car->speed += 10;
}
void car_brake(Car* car) {
if (car->speed >= 10)
car->speed -= 10;
}
/* 객체 생성 함수 */
Car create_car() {
Car car;
car.speed = 0;
car.accelerate = car_accelerate;
car.brake = car_brake;
return car;
}
/* 사용 예시 */
int main() {
Car myCar = create_car();
myCar.accelerate(&myCar);
myCar.accelerate(&myCar);
myCar.brake(&myCar);
printf("Current speed: %d\n", myCar.speed);
return 0;
}
📚 코드 설명
1
2
3
4
5
struct Car는 데이터(speed)와 동작(accelerate, brake)을 묶었다.
accelerate와 brake는 함수 포인터를 통해 메서드처럼 동작한다.
create_car() 함수는 일종의 생성자 역할을 한다.
이렇게 하면 마치 객체를 생성하고, 메서드를 호출하는 것처럼 보인다. 그러나 문제는…
🤔이 코드가 뭐가 문제인데???
문제점 | 설명 |
---|---|
복잡성 증가 | 단순한 객체 하나를 만들기 위해 함수 포인터를 일일이 세팅해야 한다. |
타입 안정성 부족 | 함수 포인터를 잘못 연결해도 컴파일 타임에 잡아내기 힘들다. |
캡슐화 부재 | 여전히 speed 같은 속성은 직접 접근 가능하다. 완전한 은닉 불가. |
상속 불가능 | 객체 간 계층 구조(상속)를 표현할 방법이 없다. |
2-7. C++의 등장과 객체지향적 코드
C 언어는 절차지향 프로그래밍을 기반으로 했기 때문에,
객체지향적 사고를 자연스럽게 지원하지 못했다.
이에 따라 C 언어를 확장하여,
객체지향 개념을 자연스럽게 언어 차원에서 지원하도록 설계한 언어가 등장했는데,
그것이 바로 C++ 이다.
C++는 1980년대 초반, 비야네 스트롭스트룹(Bjarne Stroustrup)이 개발했다.
✏️ C++의 핵심 변화
클래스(Class) 도입
데이터(속성)와 함수(메서드)를 하나의 단위로 묶을 수 있게 했다.캡슐화, 상속, 다형성 지원
객체지향(OOP)의 기본 개념을 언어 차원에서 자연스럽게 구현할 수 있게 했다.생성자(Constructor)와 소멸자(Destructor) 도입
객체의 생성과 소멸 과정을 자동화하고 제어할 수 있게 했다.연산자 오버로딩 등 고급 기능 추가
기존 연산자(+, -, == 등)를 클래스에 맞게 재정의할 수 있게 했다.
📚 예시: C++로 작성한 객체지향 코드
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
#include <iostream>
using namespace std;
/* Car 클래스를 정의 */
class Car {
private:
int speed;
public:
Car() : speed(0) {} // 생성자
void accelerate() {
speed += 10;
}
void brake() {
if (speed >= 10)
speed -= 10;
}
void showSpeed() const {
cout << "Current speed: " << speed << " km/h" << endl;
}
};
/* 사용 예시 */
int main() {
Car myCar;
myCar.accelerate();
myCar.accelerate();
myCar.brake();
myCar.showSpeed();
return 0;
}
📌 코드 특징
항목 | 설명 |
---|---|
클래스 사용 | Car라는 객체를 명확하게 정의 |
캡슐화 | speed는 private으로 외부로부터 보호 |
메서드 | accelerate(), brake(), showSpeed()로 동작 정의 |
생성자 | 객체 생성 시 초기 상태 설정 (speed = 0) |
C로 억지로 흉내내던 객체지향적 접근에 비해, 훨씬 자연스럽고 명확한 객체지향 프로그래밍이 가능해졌다.
2-8. C++의 한계와 문제점
C++는 객체지향 프로그래밍을 언어 차원에서 지원하면서,
절차지향 언어였던 C에 비해 엄청난 진보를 이뤘다.
하지만, C++ 역시 완벽한 객체지향 언어는 아니었다.
오히려 절차지향과 객체지향을 모두 지원하는 복합적인 성격 때문에 몇 가지 문제점들이 드러났다.
✏️ 주요 한계와 문제점
- 다중상속(Multiple Inheritance)의 복잡성
- C++는 하나의 클래스가 여러 부모 클래스를 동시에 상속받을 수 있다.
- 하지만 다중상속은 ‘다이아몬드 상속 문제’ 같은 모호성과 충돌을 초래할 수 있다.
- 포인터와 수동 메모리 관리
- C++는 여전히 C 스타일의 포인터 연산을 지원한다.
- 개발자가 직접
new
,delete
를 사용해 메모리를 관리해야 한다. - 메모리 누수(Leak)나 댕글링 포인터(Dangling Pointer) 같은 심각한 버그가 발생할 수 있다.
- 언어 자체의 복잡성
- 절차지향과 객체지향을 모두 수용하려다 보니 문법과 규칙이 복잡해졌다.
- 새로운 개발자가 C++를 배우는 데 진입장벽이 높았다.
- 안전성 문제
- 포인터 오류, 메모리 관리 실수, 다중상속 문제 등으로 인해
런타임 에러가 자주 발생할 위험이 있었다.
- 포인터 오류, 메모리 관리 실수, 다중상속 문제 등으로 인해
📚 예시: 다중상속으로 발생할 수 있는 문제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;
class A {
public:
void sayHello() { cout << "Hello from A" << endl; }
};
class B : public A { };
class C : public A { };
class D : public B, public C { }; // D는 A를 두 번 상속받는다
int main() {
D d;
// d.sayHello(); // 컴파일 에러! 어떤 A의 sayHello를 호출할지 모호함
return 0;
}
🤔이 코드가 뭐가 문제인데??
D 클래스는 A를 두 번 상속받는다.
이 경우 d.sayHello() 호출 시, 어느 A의 메서드를 호출해야 할지 컴파일러가 결정할 수 없어 에러가 발생한다.
(※ C++에서는 virtual inheritance라는 방법으로 해결할 수 있지만, 복잡도가 높아진다.)
이러한 문제점들을 해결하고, 보다 순수하고 안전한 객체지향 언어를 만들기 위한 시도가 이루어졌고, 그 결과 탄생한 것이 바로 Java였다.
다음으로, Java는 어떻게 C++의 문제를 해결했는지 살펴보자.
2-9. Java의 등장: 객체지향 철학의 완성
C++는 절차지향과 객체지향을 모두 지원하면서도,
여전히 포인터, 수동 메모리 관리, 다중상속 등 여러 복잡성과 위험성을 안고 있었다.
이러한 문제를 해결하고,
더 안전하고, 더 순수한 객체지향 프로그래밍을 실현하기 위해 등장한 언어가 바로 Java이다.
Java는 1995년 썬 마이크로시스템즈(Sun Microsystems)에서 개발되었으며,
초기의 프로젝트 이름은 Oak이었다.
✏️ Java가 지향한 목표
- Write Once, Run Anywhere
- JVM을 통해 플랫폼 독립성을 확보했다.
- 한 번 작성한 코드를 어디서든 실행할 수 있다.
- 순수한 객체지향 프로그래밍
- 모든 코드는 반드시 클래스 내부에 존재한다.
- 전역 변수, 전역 함수 같은 개념이 없다.
- 메모리 안전성 확보
- 포인터를 제거했다.
- Garbage Collector(GC)를 통해 메모리를 자동 관리한다.
- 단일 상속 + 다중 인터페이스
- 다중상속 문제를 해결하기 위해 클래스는 단일 상속만 허용하고,
인터페이스를 통한 다중 구현을 허용했다.
- 다중상속 문제를 해결하기 위해 클래스는 단일 상속만 허용하고,
- 단순성과 안전성
- 문법을 간결하게 만들고, 오류 가능성을 줄였다.
- 런타임에 발생할 수 있는 문제들을 컴파일 타임에 최대한 방지한다.
📚 예시: Java로 작성한 객체지향 코드
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
public class Car {
private int speed;
public Car() {
this.speed = 0;
}
public void accelerate() {
speed += 10;
}
public void brake() {
if (speed >= 10)
speed -= 10;
}
public void showSpeed() {
System.out.println("Current speed: " + speed + " km/h");
}
}
class Main {
public static void main(String[] args) {
Car myCar = new Car();
myCar.accelerate();
myCar.accelerate();
myCar.brake();
myCar.showSpeed();
}
}
📌 코드 특징
항목 | 설명 |
---|---|
순수 클래스 | 모든 데이터(speed)와 메서드(accelerate, brake, showSpeed)가 객체 내부에 캡슐화, 접근 제어자로 캡슐화의 정도를 제어 가능능 |
포인터 없음 | 객체를 직접 조작하는 포인터 연산이 없다 |
메모리 관리 | 객체를 new로 생성하지만, 소멸은 GC가 알아서 처리 |
안전한 상속 모델 | 필요하면 인터페이스로 다중 구현 가능 |
이렇듯 Java는 객체지향 개념을 지원하기위해, Born To Be OOP인인 언어지만 결국 도구란 사용자 쓰기 나름이므로 Java로도 본인이 원하든, 원하지 않든 절차지향스러운 코드를 작성하게 되는 경우가 있기도 하다.
다음 포스팅에서는 객체지향에 대해 조금 더 자세히 들어가 보도록 하겠다.