클래스와 객체 — 자바가 객체지향인 이유
"클래스가 뭔가요?"라고 물으면 "객체를 만드는 틀입니다"라는 답이 나옵니다. 그런데 바로 다음 질문이 따라오죠 — "그 틀을 왜 써야 하나요? 그냥 변수랑 함수만 쓰면 안 되나요?" Java가 왜 모든 코드를 클래스 안에 넣으라고 하는지, 처음부터 정리해볼게요.
클래스가 필요한 이유
클래스 없이 짜면 어떻게 되나
학생 정보를 관리하는 코드를 클래스 없이 작성하면 이렇게 됩니다.
String name1 = "김철수";
int age1 = 20;
double gpa1 = 3.5;
String name2 = "이영희";
int age2 = 22;
double gpa2 = 4.0;
학생 3명이면 변수 9개, 100명이면 변수 300개가 됩니다. "학생 정보 출력" 같은 기능을 만들려면 매번 변수를 하나하나 넘겨야 해요. 관련 있는 데이터가 흩어져 있기 때문에 관리가 불가능해집니다.
클래스를 쓰면
public class Student {
String name;
int age;
double gpa;
}
Student s1 = new Student();
s1.name = "김철수";
s1.age = 20;
s1.gpa = 3.5;
관련 있는 데이터를 하나로 묶을 수 있다. 이게 클래스의 첫 번째 존재 이유입니다.
정리하면 이렇습니다.
- ** 클래스 **: 설계도. "학생은 이름, 나이, 학점을 가진다"는 정의
- ** 객체(인스턴스)**: 설계도로 만들어낸 실체.
new Student()로 생성 - **
new키워드 **: 힙 메모리에 공간을 할당하고 객체를 생성하는 명령
핵심은 "붕어빵 틀" 비유를 넘어서, ** 데이터와 그 데이터를 다루는 동작을 하나로 묶는다 **는 점입니다.
필드와 메서드 — 데이터와 동작
클래스는 ** 필드(데이터)**와 ** 메서드(동작)**로 구성됩니다. 데이터와 동작이 같은 클래스 안에 있기 때문에, 메서드에서 필드에 바로 접근할 수 있어요.
public class Student {
String name;
int age;
double gpa;
void introduce() {
System.out.println("안녕하세요, " + name + "입니다.");
System.out.println("나이: " + age + ", 학점: " + gpa);
}
boolean isScholarshipEligible() {
return gpa >= 3.8;
}
}
Student s = new Student();
s.name = "김철수";
s.age = 20;
s.gpa = 3.5;
s.introduce();
// 안녕하세요, 김철수입니다.
// 나이: 20, 학점: 3.5
메서드 안에서 name, age에 바로 접근할 수 있습니다. 이 데이터가 같은 객체에 묶여 있기 때문이에요. 절차적 방식에서는 매번 변수를 인자로 넘겨야 했죠.
생성자 — 객체 생성과 초기화를 한 번에
위 코드에서 new Student() 후에 필드를 일일이 세팅하는 건 번거롭습니다. ** 생성자(Constructor)**를 쓰면 객체 생성과 초기화를 한 번에 할 수 있어요.
public class Student {
String name;
int age;
double gpa;
public Student(String name, int age, double gpa) {
this.name = name; // this.name = 필드, name = 매개변수
this.age = age;
this.gpa = gpa;
}
}
Student s = new Student("김철수", 20, 3.5); // 생성과 동시에 초기화
기본 생성자의 함정
여기서 주의할 점이 있습니다.
- ** 생성자를 하나도 안 쓰면 **: 컴파일러가 기본 생성자(
Student())를 자동으로 만들어줍니다 - ** 매개변수 있는 생성자를 하나라도 쓰면 : 기본 생성자가 ** 자동 생성되지 않습니다
그래서 Student(String name, int age, double gpa)만 만들어놓고 new Student()를 호출하면 컴파일 에러가 나요. 기본 생성자도 필요하면 직접 작성해야 합니다.
생성자 오버로딩과 this()
생성자를 여러 개 만들 수 있습니다. this()로 같은 클래스의 다른 생성자를 호출하면 코드 중복을 줄일 수 있어요.
public Student(String name, int age, double gpa) {
this.name = name;
this.age = age;
this.gpa = gpa;
}
public Student(String name) {
this(name, 0, 0.0); // 위의 생성자를 호출
}
public Student() {
this("이름 없음"); // 위의 생성자를 호출
}
this()는 ** 생성자의 첫 줄에서만** 사용할 수 있습니다.
접근제어자 — 왜 필드를 숨겨야 하나
지금까지 코드에서 s.gpa = -999.0처럼 필드에 직접 접근할 수 있었습니다. 학점이 -999? 아무도 막지 않아요. 필드를 열어두면 ** 잘못된 값이 들어오는 걸 방지할 수 없습니다.**
4가지 접근제어자
| 접근제어자 | 같은 클래스 | 같은 패키지 | 하위 클래스 | 어디서든 |
|---|---|---|---|---|
private | O | X | X | X |
default (생략) | O | O | X | X |
protected | O | O | O | X |
public | O | O | O | O |
범위가 좁은 순서: private → default → protected → public
캡슐화 — private + getter/setter
필드를 private으로 숨기고, 외부에는 getter/setter 메서드를 통해서만 접근을 허용합니다. setter에 검증 로직을 넣으면 잘못된 값을 막을 수 있어요.
public class Student {
private String name;
private int age;
private double gpa;
public Student(String name, int age, double gpa) {
this.name = name;
setAge(age);
setGpa(gpa);
}
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("나이는 0~150 사이여야 합니다.");
}
this.age = age;
}
public void setGpa(double gpa) {
if (gpa < 0.0 || gpa > 4.5) {
throw new IllegalArgumentException("학점은 0.0~4.5 사이여야 합니다.");
}
this.gpa = gpa;
}
public String getName() { return name; }
public int getAge() { return age; }
public double getGpa() { return gpa; }
}
이제 잘못된 값을 넣으려고 하면 예외가 발생합니다.
Student s = new Student("김철수", 20, 3.5);
s.setGpa(-999.0); // IllegalArgumentException!
캡슐화의 핵심은 세 가지입니다: ** 필드는 private으로 숨기고 **, **getter/setter로만 접근하고 **, setter에서 유효성을 검증합니다.
static — 클래스 소속 vs 인스턴스 소속
static의 핵심은 ** 누구의 것이냐 **입니다. 인스턴스 변수는 각 객체마다 따로 존재하지만, static 변수는 클래스에 하나만 존재하고 모든 객체가 공유해요.
public class Student {
private String name; // 인스턴스 변수 — 학생마다 다름
private static int count = 0; // static 변수 — 전체 학생 수
public Student(String name) {
this.name = name;
count++; // 학생이 생성될 때마다 증가
}
public static int getCount() {
return count;
}
}
new Student("김철수");
new Student("이영희");
System.out.println(Student.getCount()); // 2 (클래스 이름으로 호출)
static 메서드에서 인스턴스 변수 접근이 안 되는 이유
public static int getCount() {
// System.out.println(name); // 컴파일 에러!
return count;
}
static 메서드는 객체 없이도 호출할 수 있습니다. name은 특정 객체에 속한 값인데, 객체 없이 호출되면 "어떤 객체의 name?"을 알 수 없어요. 그래서 접근이 불가능합니다. 반대로, 인스턴스 메서드에서 static 변수에 접근하는 것은 가능해요 — static 변수는 클래스에 딱 하나이므로 혼동이 없기 때문입니다.
static을 쓰는 기준
"이 메서드가 특정 객체의 상태에 의존하는가?"를 기준으로 판단하면 됩니다. 의존하지 않으면 static으로 만들어도 돼요. Math.max(), Integer.parseInt() 같은 유틸리티 메서드가 대표적입니다.
불변 객체 — 왜 바꾸지 못하게 만들까
** 불변 객체 **는 한번 만들면 내부 상태를 바꿀 수 없는 객체입니다. String이 대표적인 예예요.
String s = "Hello";
s = s + " World"; // 새로운 String 객체가 생성됨. "Hello"는 그대로
직접 불변 객체를 만들려면 네 가지 규칙을 따릅니다.
- ** 모든 필드를
private final로 선언** - setter를 제공하지 않습니다
- ** 값을 변경해야 할 때는 새 객체를 반환합니다**
- 생성자에서만 필드를 초기화합니다
public class Money {
private final int amount;
private final String currency;
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public int getAmount() { return amount; }
public String getCurrency() { return currency; }
// 값을 바꾸고 싶으면 새 객체를 반환
public Money add(int value) {
return new Money(this.amount + value, this.currency);
}
}
Money price = new Money(1000, "KRW");
Money newPrice = price.add(500);
System.out.println(price); // 1000 KRW (원본 변하지 않음)
System.out.println(newPrice); // 1500 KRW (새 객체)
불변이 좋은 이유는 결국 ** 안전성** 때문입니다.
- ** 멀티스레드 환경에서 안전 **: 상태가 바뀌지 않으니 동기화가 필요 없습니다
- **Side effect 없음 **: 어떤 메서드에 넘겨도 원래 값이 바뀌지 않아요
- ** 디버깅이 쉽습니다 **: "이 값이 어디서 바뀌었지?"를 추적할 필요가 없습니다
this 키워드
this는 현재 객체 자신을 가리키는 참조입니다. 세 가지 상황에서 주로 쓰여요.
1. 필드와 매개변수 이름이 같을 때 — this가 없으면 둘 다 매개변수를 가리킵니다.
public Student(String name) {
this.name = name; // this.name = 필드, name = 매개변수
}
2. 다른 생성자를 호출할 때 — 위 생성자 오버로딩에서 본 this().
3. 메서드 체이닝 — this를 반환하면 메서드를 연달아 호출할 수 있습니다.
public StudentBuilder setName(String name) {
this.name = name;
return this;
}
new StudentBuilder().setName("김철수").setAge(20);
주의할 점
1. 매개변수 있는 생성자만 쓰면 기본 생성자가 사라진다
생성자를 하나도 안 쓰면 기본 생성자가 자동 생성되지만, 매개변수 있는 생성자를 하나라도 쓰면 기본 생성자가 사라집니다. 프레임워크(Spring, JPA 등)가 기본 생성자를 요구하는 경우가 많으므로, 매개변수 있는 생성자를 만들 때는 ** 기본 생성자도 함께 작성하는 습관 **을 들이는 게 좋습니다.
2. getter/setter를 무조건 만들면 캡슐화 의미가 없다
모든 필드에 getter와 setter를 기계적으로 만들면, 필드를 public으로 열어둔 것과 다를 바 없습니다. setter가 필요 없는 필드(변경되면 안 되는 값)에는 getter만 제공 해야 해요. 불변 필드는 final로 선언하면 실수로 setter를 만드는 걸 방지할 수 있습니다.
3. static 남용 — 모든 걸 static으로 만들면 안 되는 이유
static이 편하다고 모든 메서드를 static으로 만들면, 상태를 가진 객체를 설계할 수 없게 됩니다. static 변수는 프로그램 전체에서 하나이므로, 멀티스레드 환경에서 동시 접근 문제 가 발생할 수 있어요. 테스트도 어려워집니다 — static 상태는 테스트 간에 공유되기 때문에 독립적인 테스트가 힘들어져요.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
| 항목 | 설명 |
|---|---|
| 클래스 / 객체 | 클래스는 데이터+동작의 설계도. 객체는 new로 만든 실체 |
| 생성자 | 객체 생성 시 초기화 담당. 안 쓰면 기본 생성자 자동 생성 |
| 접근제어자 | private → default → protected → public 순으로 범위 확장 |
| 캡슐화 | 필드는 private, 외부 접근은 getter/setter로만 |
| static | 클래스 소속. 객체 없이 사용 가능하지만 인스턴스 변수 접근 불가 |
| 불변 객체 | final 필드 + setter 없음. 멀티스레드 안전, side effect 방지 |
| this | 현재 객체 참조. 이름 충돌 해결, 생성자 체이닝, 메서드 체이닝 |
다음 글에서는 ** 상속과 다형성 **을 다룹니다. 클래스와 객체를 이해했다면, 이제 클래스를 확장하고 재사용하는 방법을 알아볼 차례예요.