자바에서 클래스와 참조형을 배우고 나면 다음으로 중요한 개념이 객체 지향 프로그래밍입니다. 이름은 조금 거창하지만, 출발점은 단순합니다.
객체 지향은 관련 있는 데이터와 그 데이터를 다루는 기능을 하나의 객체 안에 묶어서 생각하는 방식입니다.
앞에서 클래스와 참조형이 아직 헷갈린다면 자바 클래스와 데이터 정리 - 객체로 묶어 생각하기, 자바 기본형과 참조형 정리 - 값과 주소로 이해하기를 먼저 보고 오는 편이 좋습니다.
절차 지향과 객체 지향의 차이
프로그램을 작성하는 방식은 크게 두 관점으로 볼 수 있습니다.
| 구분 | 중심에 두는 것 | 생각 방식 |
|---|---|---|
| 절차 지향 | 실행 순서 | 어떤 순서로 처리할까 |
| 객체 지향 | 객체 | 어떤 대상이 어떤 상태와 기능을 가질까 |
절차 지향은 코드가 실행되는 순서를 중심으로 생각합니다. 먼저 값을 만들고, 그다음 계산하고, 그다음 출력하는 식입니다.
객체 지향은 대상 자체를 중심으로 생각합니다. 예를 들어 온도 조절기를 만든다면, 온도 조절기가 어떤 값을 가지고 어떤 기능을 해야 하는지 먼저 생각합니다.
사육장 온도 조절기
상태
- 전원이 켜져 있는가
- 현재 설정 온도는 몇 도인가
기능
- 전원 켜기
- 전원 끄기
- 온도 올리기
- 온도 내리기
- 현재 상태 보기
객체 지향은 이런 상태와 기능을 하나의 단위로 묶어서 코드로 표현합니다.
절차 중심으로 먼저 작성해보기
먼저 객체 지향을 쓰지 않고, main 안에서 순서대로 처리해보겠습니다.
public class HeaterProceduralMain {
public static void main(String[] args) {
boolean powerOn = false;
int targetTemperature = 28;
powerOn = true;
System.out.println("히터 전원을 켭니다.");
targetTemperature++;
System.out.println("설정 온도: " + targetTemperature);
targetTemperature++;
System.out.println("설정 온도: " + targetTemperature);
targetTemperature--;
System.out.println("설정 온도: " + targetTemperature);
if (powerOn) {
System.out.println("히터 ON, 설정 온도: " + targetTemperature);
} else {
System.out.println("히터 OFF");
}
powerOn = false;
System.out.println("히터 전원을 끕니다.");
}
}
이 코드는 동작합니다.
하지만 모든 작업이 main 안에 길게 섞여 있습니다.
온도를 올리는 코드가 여러 번 반복되고, 상태를 출력하는 규칙도 main 안에 직접 들어 있습니다.
기능이 늘어날수록 main은 더 길어지고 읽기 어려워집니다.
데이터를 클래스로 묶기
먼저 온도 조절기와 관련된 데이터를 클래스로 묶어보겠습니다.
public class HeaterData {
boolean powerOn;
int targetTemperature;
}
이제 main에서는 흩어진 변수를 따로 관리하지 않고, HeaterData 객체 하나로 관리할 수 있습니다.
public class HeaterDataMain {
public static void main(String[] args) {
HeaterData heater = new HeaterData();
heater.targetTemperature = 28;
heater.powerOn = true;
heater.targetTemperature++;
heater.targetTemperature++;
heater.targetTemperature--;
System.out.println("전원: " + heater.powerOn);
System.out.println("설정 온도: " + heater.targetTemperature);
}
}
이전보다 데이터는 더 잘 묶였습니다.
powerOn과 targetTemperature가 온도 조절기 객체 안에 함께 들어 있기 때문입니다.
하지만 아직 부족한 점이 있습니다.
데이터는 HeaterData에 있는데, 그 데이터를 다루는 기능은 여전히 main에 흩어져 있습니다.
메서드로 기능을 분리하기
이번에는 반복되는 기능을 메서드로 분리해보겠습니다.
public class HeaterMethodMain {
public static void main(String[] args) {
HeaterData heater = new HeaterData();
heater.targetTemperature = 28;
turnOn(heater);
temperatureUp(heater);
temperatureUp(heater);
temperatureDown(heater);
showStatus(heater);
turnOff(heater);
}
static void turnOn(HeaterData heater) {
heater.powerOn = true;
System.out.println("히터 전원을 켭니다.");
}
static void turnOff(HeaterData heater) {
heater.powerOn = false;
System.out.println("히터 전원을 끕니다.");
}
static void temperatureUp(HeaterData heater) {
heater.targetTemperature++;
System.out.println("설정 온도: " + heater.targetTemperature);
}
static void temperatureDown(HeaterData heater) {
heater.targetTemperature--;
System.out.println("설정 온도: " + heater.targetTemperature);
}
static void showStatus(HeaterData heater) {
if (heater.powerOn) {
System.out.println("히터 ON, 설정 온도: " + heater.targetTemperature);
} else {
System.out.println("히터 OFF");
}
}
}
이제 main은 훨씬 읽기 쉬워졌습니다.
각 작업에 이름이 붙었기 때문입니다.
하지만 여전히 아쉬운 부분이 있습니다.
온도 조절기의 데이터는 HeaterData에 있고, 온도 조절기의 기능은 HeaterMethodMain에 있습니다.
즉, 서로 강하게 관련 있는 코드가 두 곳으로 나뉘어 있습니다.
HeaterData
- powerOn
- targetTemperature
HeaterMethodMain
- turnOn()
- turnOff()
- temperatureUp()
- temperatureDown()
- showStatus()
객체 지향은 이 둘을 한곳에 모으는 방향으로 나아갑니다.
데이터와 기능을 한 클래스에 넣기
이제 온도 조절기의 상태와 기능을 하나의 클래스에 넣어보겠습니다.
public class Heater {
boolean powerOn;
int targetTemperature = 28;
void turnOn() {
powerOn = true;
System.out.println("히터 전원을 켭니다.");
}
void turnOff() {
powerOn = false;
System.out.println("히터 전원을 끕니다.");
}
void temperatureUp() {
targetTemperature++;
System.out.println("설정 온도: " + targetTemperature);
}
void temperatureDown() {
targetTemperature--;
System.out.println("설정 온도: " + targetTemperature);
}
void showStatus() {
if (powerOn) {
System.out.println("히터 ON, 설정 온도: " + targetTemperature);
} else {
System.out.println("히터 OFF");
}
}
}
이제 Heater는 단순히 데이터를 담는 구조가 아닙니다.
자신의 상태를 가지고, 자신의 상태를 바꾸는 기능도 함께 가집니다.
사용하는 쪽 코드는 훨씬 단순해집니다.
public class HeaterOopMain {
public static void main(String[] args) {
Heater heater = new Heater();
heater.turnOn();
heater.temperatureUp();
heater.temperatureUp();
heater.temperatureDown();
heater.showStatus();
heater.turnOff();
}
}
main은 이제 세부 계산을 직접 알 필요가 없습니다.
그저 heater 객체에게 필요한 일을 요청합니다.
heater.turnOn()
heater.temperatureUp()
heater.showStatus()
이것이 객체 지향의 중요한 느낌입니다. 객체가 자기 상태를 가지고 있고, 그 상태를 다루는 기능도 객체 안에 있습니다.
객체의 메서드는 자기 필드에 접근할 수 있습니다
Heater 클래스 안의 메서드를 다시 보겠습니다.
void temperatureUp() {
targetTemperature++;
System.out.println("설정 온도: " + targetTemperature);
}
메서드 안에서 targetTemperature를 바로 사용하고 있습니다.
이 변수는 지역 변수가 아니라 객체의 필드입니다.
객체의 메서드는 같은 객체 안에 있는 필드에 접근할 수 있습니다.
그래서 매번 heater.targetTemperature처럼 쓰지 않아도 됩니다.
Heater 객체
필드
- powerOn
- targetTemperature
메서드
- turnOn()
- temperatureUp()
- showStatus()
객체 안의 메서드는 객체 자신의 필드를 사용해 동작합니다. 이 구조 덕분에 데이터와 기능이 자연스럽게 묶입니다.
static 메서드와 인스턴스 메서드의 차이
지금까지 입문 단계에서는 static 메서드를 자주 사용했습니다.
예를 들어 main에서 바로 호출하기 위해 아래처럼 작성했습니다.
static void temperatureUp(HeaterData heater) {
heater.targetTemperature++;
}
이 메서드는 특정 객체에 속한 메서드라기보다, 클래스에 붙어 있는 기능처럼 사용됩니다. 그래서 어떤 데이터를 바꿀지 매개변수로 직접 받아야 합니다.
반면 Heater 안의 메서드는 객체에 속한 메서드입니다.
void temperatureUp() {
targetTemperature++;
}
이 메서드는 heater.temperatureUp()처럼 객체를 통해 호출합니다.
호출된 메서드는 자기 객체의 필드를 사용합니다.
| 구분 | 호출 방식 | 특징 |
|---|---|---|
static 메서드 | temperatureUp(heater) | 필요한 객체를 매개변수로 받음 |
| 인스턴스 메서드 | heater.temperatureUp() | 객체 자신의 필드를 사용함 |
입문 단계에서는 이 정도만 구분해도 충분합니다. 객체가 가진 상태를 다루는 기능이라면 인스턴스 메서드로 옮기는 것이 자연스럽습니다.
객체 지향으로 바꾸면 좋은 점
객체 지향으로 바꾸면 코드의 중심이 달라집니다. 단순히 실행 순서를 나열하는 것에서, 역할을 가진 객체를 만드는 쪽으로 생각이 옮겨갑니다.
좋은 점은 다음과 같습니다.
- 관련 데이터와 기능을 한곳에서 관리할 수 있습니다.
- 사용하는 쪽 코드는 객체에게 필요한 일을 요청하면 됩니다.
- 기능 이름만 보아도 객체가 할 수 있는 일이 드러납니다.
- 수정할 때 관련 코드가 한 클래스 안에 모여 있어 찾기 쉽습니다.
예를 들어 온도 조절 규칙을 바꾸고 싶다면 Heater 클래스 안의 메서드를 보면 됩니다.
Heater를 사용하는 여러 코드가 있어도, 온도 조절기의 규칙은 Heater 안에서 관리됩니다.
객체 지향은 절차를 버리는 것이 아닙니다
객체 지향이라고 해서 실행 순서가 사라지는 것은 아닙니다. 프로그램은 여전히 위에서 아래로 실행됩니다.
heater.turnOn();
heater.temperatureUp();
heater.showStatus();
heater.turnOff();
다만 차이는 역할 분담입니다.
실행 순서는 main이 조율하지만, 실제 동작은 객체가 맡습니다.
main은 “무엇을 시킬지”를 보여주고, Heater 객체는 “어떻게 처리할지”를 가지고 있습니다.
이렇게 나누면 코드가 더 읽기 쉬워지고, 객체가 가진 책임도 분명해집니다.
간단한 예시: 사육장 물그릇
조금 더 작은 예시도 보겠습니다. 사육장 물그릇을 객체로 표현한다고 생각해보겠습니다.
public class WaterBowl {
int waterAmount;
void fill(int amount) {
waterAmount += amount;
}
void drink(int amount) {
if (waterAmount >= amount) {
waterAmount -= amount;
} else {
waterAmount = 0;
}
}
void showAmount() {
System.out.println("남은 물: " + waterAmount);
}
}
사용하는 코드는 이렇게 쓸 수 있습니다.
public class WaterBowlMain {
public static void main(String[] args) {
WaterBowl bowl = new WaterBowl();
bowl.fill(500);
bowl.drink(120);
bowl.showAmount();
}
}
물그릇 객체는 waterAmount라는 상태를 가지고 있습니다.
그리고 fill(), drink(), showAmount()라는 기능으로 자신의 상태를 다룹니다.
이처럼 객체 지향은 현실의 대상을 그대로 복사하는 것이 아니라, 프로그램에 필요한 상태와 기능을 묶어 표현하는 방식입니다.
실수하기 쉬운 부분
객체 지향을 처음 배울 때는 아래를 헷갈리기 쉽습니다.
- 필드만 있는 클래스는 아직 데이터 묶음에 가깝습니다.
- 객체 지향은 데이터와 기능을 함께 묶을 때 더 자연스럽습니다.
static메서드는 객체 없이 호출할 수 있습니다.- 인스턴스 메서드는 객체를 통해 호출합니다.
- 객체의 메서드는 자기 객체의 필드에 접근할 수 있습니다.
처음부터 완벽한 객체 지향 설계를 하려고 하면 오히려 어렵습니다. 먼저 “관련 있는 데이터와 기능을 같은 클래스 안에 둔다”는 감각부터 잡는 것이 좋습니다.
정리
절차 지향은 실행 순서를 중심으로 코드를 바라봅니다. 객체 지향은 역할을 가진 객체를 중심으로 코드를 바라봅니다.
클래스는 필드만 가질 수도 있지만, 메서드도 함께 가질 수 있습니다. 필드는 객체의 상태를 나타내고, 메서드는 객체가 할 수 있는 동작을 나타냅니다.
객체 지향 프로그래밍의 출발점은 복잡한 이론이 아닙니다. 관련 있는 데이터와 기능을 한곳에 모아, 객체가 자기 일을 스스로 처리하게 만드는 것입니다.
이 감각이 잡히면 이후 생성자, 접근 제어자, 캡슐화, 상속 같은 개념도 훨씬 자연스럽게 이어집니다.