티스토리 뷰
클래스의 본질은 "현실의 개념을 상태와 행위로 모델링하고, 그 내부를 보호하는 캡슐"입니다.
이 글에서는 자바 클래스의 문법 요소 하나하나를 단순 암기가 아닌 설계 관점에서 설명하겠습니다.
"이 문법이 왜 존재하는가"를 이해하면, 코드를 작성할 때 더 나은 판단을 내릴 수 있습니다.
1. 클래스와 객체: 설계도와 실체의 관계
객체 지향 프로그래밍의 출발점은 클래스와 객체의 관계를 정확히 이해하는 것입니다. 이 관계를 명확히 잡아야 이후의 모든 문법이 자연스럽게 연결됩니다.
1-1. 클래스: 설계도
클래스는 "이런 종류의 것은 이런 상태를 갖고, 이런 행위를 할 수 있다"고 정의하는 틀입니다.
클래스 자체는 메모리에 실체를 만들지 않습니다. 건축의 도면이 집 그 자체가 아닌 것과 같습니다.
📄 클래스 정의 — 기본 구조
public class BankAccount {
// ── 상태(State): 이 객체가 "무엇을 알고 있는가" ──
private String owner;
private long balance;
// ── 행위(Behavior): 이 객체가 "무엇을 할 수 있는가" ──
public void deposit(long amount) {
this.balance += amount;
}
public long getBalance() {
return this.balance;
}
}
위 코드는 "은행 계좌란 소유자와 잔액이라는 상태를 갖고, 입금과 잔액 조회라는 행위를 할 수 있다"는 개념을 코드로 표현한 것입니다. 아직 어떤 실제 계좌도 존재하지 않습니다.
1-2. 객체(인스턴스): 설계도로 만든 실체
new 키워드를 사용하면 클래스라는 설계도로부터 메모리에 실제 공간을 할당받은 객체가 생성됩니다.
같은 클래스에서 여러 객체를 만들 수 있고, 각 객체는 독립된 상태를 가집니다.
📄 객체 생성과 독립성
BankAccount accountA = new BankAccount(); // 첫 번째 객체
BankAccount accountB = new BankAccount(); // 두 번째 객체
accountA.deposit(100_000);
// accountA.balance = 100,000
// accountB.balance = 0 ← accountA의 입금이 accountB에 영향 없음
accountA와 accountB는 같은 설계도로 만들어졌지만, 각자의 owner와 balance를 독립적으로 보유합니다. 이것이 객체의 핵심 특성입니다.
2. 필드(Field): 객체의 상태를 정의하는 변수
필드는 클래스 내부에 선언된 변수로, 해당 객체가 기억해야 할 정보(상태)를 저장합니다. 메서드 안에서 선언하는 지역 변수와는 생명주기와 역할이 근본적으로 다릅니다.
2-1. 필드 vs 지역 변수
혼동하기 쉬운 두 개념을 먼저 구분하겠습니다.
📄 필드와 지역 변수의 위치와 역할
public class Order {
// 필드: 객체가 살아있는 동안 유지되는 상태
private String productName;
private int quantity;
public int calculateTotal(int unitPrice) {
// 지역 변수: 이 메서드가 실행되는 동안만 존재
int total = this.quantity * unitPrice;
return total;
// 메서드 종료 → total은 메모리에서 사라짐
// 하지만 productName, quantity는 여전히 존재
}
}
- 필드는 객체의 상태입니다. 객체가 GC에 의해 제거될 때까지 유지됩니다.
- 지역 변수는 메서드의 임시 계산 공간입니다. 메서드가 끝나면 사라집니다.
이 차이를 명확히 인식하면, "이 값을 필드로 둘 것인가, 지역 변수로 둘 것인가"를 판단하는 기준이 생깁니다.
객체가 기억해야 할 정보만 필드로 선언하는 것이 올바른 설계입니다.
2-2. 인스턴스 필드 vs 클래스 필드(static)
필드는 다시 두 종류로 나뉩니다.
인스턴스 필드는 객체마다 독립적으로 존재하고, **클래스 필드(static)**는 모든 객체가 공유합니다.
📄 두 종류의 필드 비교
public class Employee {
// 클래스 필드: 모든 Employee 객체가 공유하는 단 하나의 값
private static int totalCount = 0;
// 인스턴스 필드: 각 Employee 객체마다 독립적으로 존재
private String name;
public Employee(String name) {
this.name = name;
totalCount++; // 객체가 생성될 때마다 공유 카운터 증가
}
}
static 필드는 클래스 자체에 귀속됩니다. 객체를 100개 만들어도 totalCount는 메모리에 단 하나만 존재합니다.
반면 name은 객체마다 별도의 메모리 공간을 차지합니다.
static을 남용하면 객체 간의 독립성이 무너지고, 테스트와 유지보수가 어려워집니다.
모든 인스턴스가 반드시 공유해야 하는 값에만 제한적으로 사용해야 합니다.
3. 메서드(Method): 객체의 행위를 정의하는 함수
메서드는 객체가 할 수 있는 일을 정의합니다. 단순히 "함수를 클래스 안에 넣은 것"이 아니라, 해당 객체의 상태에 접근하고 조작할 수 있는 행위라는 점이 핵심입니다.
3-1. 메서드의 구성 요소
메서드 시그니처를 구성하는 각 요소에는 명확한 역할이 있습니다.
public long calculateInterest(double rate, int years) {
//│ │ │
//│ │ └─ 매개변수(Parameter): 외부에서 받는 입력값
//│ └─ 메서드 이름: 이 행위가 무엇인지 설명
//└─ 접근 제어자: 누가 이 메서드를 호출할 수 있는지 제한
// long: 반환 타입 — 이 행위의 결과로 무엇을 돌려주는지
좋은 메서드는 하나의 명확한 행위만 수행합니다. 메서드 이름을 지을 때 "그리고(and)"가 들어간다면, 그것은 두 개의 메서드로 나눠야 한다는 신호입니다.
3-2. this 키워드의 의미
this는 현재 이 메서드를 실행하고 있는 객체 자신을 가리키는 참조입니다. 매개변수 이름과 필드 이름이 같을 때 구분하는 용도로만 사용한다고 오해하는 경우가 많지만, this의 본질은 "이 객체"라는 명시적 표현입니다.
📄 this가 필요한 상황
public class Product {
private String name;
private int price;
public Product(String name, int price) {
// this.name = "이 객체의 name 필드"
// name = "매개변수로 받은 name"
this.name = name;
this.price = price;
}
public Product withDiscountedPrice(int discountRate) {
int newPrice = this.price * (100 - discountRate) / 100;
return new Product(this.name, newPrice);
// this를 통해 현재 객체의 상태를 읽어 새 객체를 생성
}
}
this를 의식적으로 사용하면 "지금 이 코드가 어떤 객체의 맥락에서 실행되고 있는가"를 더 명확하게 인식할 수 있습니다.
4. 생성자(Constructor): 객체의 탄생을 책임지는 코드
생성자는 객체가 생성되는 바로 그 순간에 실행되어, 객체를 사용 가능한 상태로 초기화하는 역할을 합니다. 생성자를 올바르게 설계하면 "불완전한 객체"가 코드 사이를 돌아다니는 것을 막을 수 있습니다.
4-1. 기본 생성자와 매개변수 생성자
📄 Bad Practice — 생성 후 수동 초기화
// ❌ 생성 직후 객체가 불완전한 상태
BankAccount account = new BankAccount();
account.setOwner("김철수"); // 이 줄을 빠뜨리면?
account.setBalance(100_000); // owner가 null인 채로 사용될 위험
// setOwner를 호출하기 전에 다른 메서드가 먼저 실행되면
// NullPointerException 발생 가능
📄 Good Practice — 생성자에서 필수 값을 강제
// ✅ 생성 시점에 필수 상태를 보장
public class BankAccount {
private final String owner; // final: 생성 후 변경 불가
private long balance;
// 매개변수 생성자: 필수 값 없이는 객체를 만들 수 없음
public BankAccount(String owner, long initialBalance) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("소유자는 필수입니다");
}
this.owner = owner;
this.balance = initialBalance;
}
}
// 사용: 불완전한 객체가 존재할 수 없음
BankAccount account = new BankAccount("김철수", 100_000);
생성자에서 필수 값을 받으면, 불완전한 객체가 생성되는 것 자체를 컴파일 타임에 방지할 수 있습니다. 이것은 런타임 에러를 컴파일 에러로 끌어올리는 안전한 설계입니다.
4-2. 생성자 오버로딩
하나의 클래스에 매개변수 조합이 다른 여러 생성자를 정의할 수 있습니다. 이를 생성자 오버로딩이라 합니다.
📄 생성자 오버로딩과 this() 호출
public class BankAccount {
private final String owner;
private long balance;
// 주 생성자: 모든 필드를 초기화
public BankAccount(String owner, long initialBalance) {
this.owner = owner;
this.balance = initialBalance;
}
// 보조 생성자: 잔액 없이 계좌 개설
public BankAccount(String owner) {
this(owner, 0); // 주 생성자를 호출하여 중복 제거
}
}
this()는 같은 클래스의 다른 생성자를 호출합니다. 초기화 로직을 주 생성자 하나에 집중시키고, 보조 생성자들은 this()로 위임하는 패턴을 사용하면 중복 코드를 제거하고 유지보수성을 높일 수 있습니다.
5. 접근 제어자: 보호의 경계를 긋는 도구
접근 제어자는 "이 필드나 메서드를 누가 사용할 수 있는가"를 결정합니다.
단순한 문법 규칙이 아니라, 클래스의 내부 구현을 외부로부터 보호하는 설계 도구입니다.
5-1. 네 가지 접근 제어자의 범위
| 접근 제어자 | 같은 클래스 | 같은 패키지 | 하위 클래스 | 전체 |
| private | ✅ | ❌ | ❌ | ❌ |
| (default) | ✅ | ✅ | ❌ | ❌ |
| protected | ✅ | ✅ | ✅ | ❌ |
| public | ✅ | ✅ | ✅ | ✅ |
위 표는 좁은 범위(private)에서 넓은 범위(public) 순으로 정렬되어 있습니다. 기본 원칙은 가능한 한 좁은 범위를 선택하는 것입니다.
5-2. 왜 private이 기본이어야 하는가
필드를 public으로 공개하면, 클래스 외부의 어떤 코드든 그 값을 직접 변경할 수 있습니다. 이것은 객체가 자신의 상태를 통제할 수 없다는 뜻입니다.
📄 Bad Practice — 필드를 public으로 노출
// ❌ 누구나 balance를 직접 조작 가능
public class BankAccount {
public String owner;
public long balance;
}
// 외부 코드에서 마음대로 조작
BankAccount account = new BankAccount();
account.balance = -500_000; // 음수 잔액? 검증 없이 허용됨
account.owner = ""; // 빈 문자열? 그대로 통과
📄 Good Practice — private 필드 + 검증 로직이 있는 메서드
// ✅ 객체가 자신의 상태를 보호
public class BankAccount {
private String owner;
private long balance;
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 양수여야 합니다");
}
this.balance += amount; // 검증을 통과한 값만 반영
}
public void withdraw(long amount) {
if (amount > this.balance) {
throw new IllegalStateException("잔액이 부족합니다");
}
this.balance -= amount;
}
}
필드를 private으로 숨기고 메서드를 통해서만 접근을 허용하면, 모든 상태 변경에 검증 로직을 끼워 넣을 수 있습니다.
이것이 캡슐화의 핵심 이점입니다.
| 비교 항목 | public 필드 직접 노출 | private 필드 + 메서드 |
| 외부에서 값 변경 | 아무 제약 없이 가능 | 메서드의 검증 로직을 통과해야만 가능 |
| 유효하지 않은 상태 진입 | 방지 불가 | 메서드에서 차단 |
| 내부 구현 변경 시 영향 | 필드를 참조하는 모든 외부 코드 수정 필요 | 메서드 내부만 수정하면 됨 |
| 디버깅 | 어디서 값이 바뀌었는지 추적 어려움 | 메서드에 브레이크포인트 설정으로 추적 가능 |
6. 캡슐화(Encapsulation): 클래스 설계의 핵심 원칙
지금까지 설명한 필드, 메서드, 생성자, 접근 제어자는 모두 캡슐화라는 하나의 원칙을 실현하기 위한 도구입니다.
캡슐화는 "관련된 상태와 행위를 하나로 묶고, 내부 구현을 숨기는 것"입니다.
6-1. 캡슐화가 해결하는 문제
캡슐화 없이 코드를 작성하면, 데이터와 그 데이터를 처리하는 로직이 여기저기 흩어집니다. 잔액 검증 로직이 서비스 A, 컨트롤러 B, 유틸리티 C에 중복으로 존재하게 되고, 규칙이 변경되면 세 곳을 모두 찾아 수정해야 합니다.
캡슐화된 클래스에서는 해당 데이터에 대한 모든 규칙이 한 곳에 집중됩니다. 잔액에 관한 모든 검증과 변경은 BankAccount 클래스 내부에서만 일어납니다. 이것이 유지보수성의 근본적인 차이를 만듭니다.
6-2. Getter/Setter에 대한 올바른 이해
많은 교재에서 "필드를 private으로 하고 getter/setter를 만들라"고 가르칩니다. 하지만 모든 필드에 getter와 setter를 기계적으로 생성하는 것은 캡슐화가 아닙니다. public 필드를 우회 경로로 열어놓는 것과 다를 바 없습니다.
📄 Bad Practice — 무분별한 Getter/Setter
// ❌ 모든 필드에 getter/setter → public 필드와 다를 바 없음
public class BankAccount {
private long balance;
public long getBalance() { return balance; }
public void setBalance(long balance) {
this.balance = balance; // 검증 없이 어떤 값이든 설정 가능
}
}
// 결국 이렇게 사용됨
account.setBalance(account.getBalance() - 50_000);
// → 출금 로직이 클래스 "외부"에 존재
📄 Good Practice — 의미 있는 행위 메서드
// ✅ setter 대신 비즈니스 의미가 있는 메서드를 제공
public class BankAccount {
private long balance;
public long getBalance() { return balance; } // 읽기는 허용
// setBalance() 대신 → 출금이라는 "행위"로 제공
public void withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("출금액 오류");
if (amount > this.balance) throw new IllegalStateException("잔액 부족");
this.balance -= amount;
}
public void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("입금액 오류");
this.balance += amount;
}
}
핵심 차이는 명확합니다. setBalance()는 "값을 바꿔라"라는 구현 수준의 지시이고, withdraw()는 "출금하라"라는 비즈니스 수준의 요청입니다. 좋은 캡슐화는 객체에게 무엇을 해달라고 요청하는 것이지, 내부 값을 꺼내서 직접 조작하는 것이 아닙니다.
7. 완성된 클래스 설계: 모든 요소의 조합
지금까지 개별적으로 학습한 필드, 메서드, 생성자, 접근 제어자, 캡슐화를 하나의 클래스에 종합해 보겠습니다.
📄 종합 예제 — 설계 원칙이 적용된 클래스
public class BankAccount {
// ── 상태: private으로 보호 ──
private final String owner; // 불변: 계좌 소유자는 변경 불가
private final String accountNo; // 불변: 계좌번호는 변경 불가
private long balance; // 가변: 입출금에 따라 변동
// ── 생성자: 필수 값을 강제하여 불완전한 객체 방지 ──
public BankAccount(String owner, String accountNo, long initialBalance) {
if (owner == null || owner.isBlank())
throw new IllegalArgumentException("소유자는 필수입니다");
if (accountNo == null || accountNo.isBlank())
throw new IllegalArgumentException("계좌번호는 필수입니다");
if (initialBalance < 0)
throw new IllegalArgumentException("초기 잔액은 0 이상이어야 합니다");
this.owner = owner;
this.accountNo = accountNo;
this.balance = initialBalance;
}
// ── 행위: 비즈니스 의미를 가진 메서드 ──
public void deposit(long amount) {
validatePositiveAmount(amount);
this.balance += amount;
}
public void withdraw(long amount) {
validatePositiveAmount(amount);
if (amount > this.balance) {
throw new IllegalStateException("잔액이 부족합니다");
}
this.balance -= amount;
}
// ── 읽기 전용 접근: 필요한 정보만 공개 ──
public String getOwner() { return owner; }
public String getAccountNo() { return accountNo; }
public long getBalance() { return balance; }
// setBalance(), setOwner() 등은 의도적으로 제공하지 않음
// ── 내부 헬퍼: private으로 외부 노출 차단 ──
private void validatePositiveAmount(long amount) {
if (amount <= 0)
throw new IllegalArgumentException("금액은 양수여야 합니다");
}
}
이 클래스에 적용된 설계 원칙을 정리하면 다음과 같습니다.
- 필드는 모두 private: 외부에서 직접 상태를 변경할 수 없습니다.
- 변경 불가한 값은 final: owner와 accountNo는 생성 이후 절대 바뀌지 않습니다.
- 생성자에서 필수 값 강제: 소유자와 계좌번호 없이는 객체를 생성할 수 없습니다.
- setter 대신 행위 메서드: withdraw(), deposit()이 검증과 함께 상태를 변경합니다.
- 검증 로직 내부 집중: 금액 검증은 validatePositiveAmount()에 단 한 번만 작성됩니다.
8. 결론: 클래스 설계는 "보호"에서 시작됩니다
자바 클래스의 문법 요소들(필드, 메서드, 생성자, 접근 제어자)은 각각 독립적인 기능처럼 보이지만, 모두 캡슐화라는 하나의 목표를 향해 설계되어 있습니다. 필드를 private으로 선언하는 이유, 생성자에서 필수 값을 받는 이유, setter 대신 행위 메서드를 제공하는 이유는 모두 "객체가 항상 유효한 상태를 유지하도록 보호하기 위해서"입니다.
문법을 아는 것과 설계를 아는 것은 다릅니다.
private, public, this 같은 키워드의 동작 방식을 이해하는 것은 출발점이지만, 왜 그것이 필요한지를 이해하는 것이 실무에서 더 좋은 코드를 작성하는 진짜 역량입니다.
- 클래스는 코드 묶음이 아니라, 상태와 행위를 하나의 책임 단위로 캡슐화하는 설계 도구입니다.
- 객체는 클래스로부터 생성된 독립적인 실체이며, 각자 고유한 상태를 가집니다.
- 필드는 무조건 private이 기본입니다. 공개할 이유가 명확할 때만 범위를 넓히세요.
- 생성자에서 필수 값을 강제하면, 불완전한 객체가 존재할 가능성을 원천 차단할 수 있습니다.
- 모든 필드에 getter/setter를 기계적으로 만드는 것은 캡슐화가 아닙니다. 행위(withdraw) 중심의 메서드를 설계하세요.
'IT > Java' 카테고리의 다른 글
| [Java] 에러(Error)와 예외 클래스(Exception) (0) | 2026.03.09 |
|---|---|
| [Java] 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy) (0) | 2026.03.08 |
| [Java] 추상 클래스(Abstract Class) vs 인터페이스(Interface) (0) | 2026.03.06 |
| [Java] String vs StringBuilder vs StringBuffer 비교 (0) | 2026.03.05 |
| Java 자주 쓰는 문법·메소드 정리 | String, List, Map, Stream, Arrays까지 한 번에 (0) | 2022.08.22 |
