Java 17 기준으로 클래스와 객체를 왜 분리해서 생각해야 하는지, 생성자와 this의 역할, 실무에서 통하는 클래스/필드/메서드 네이밍 컨벤션까지 한 번에 정리합니다.
코드가 커질수록 “그냥 변수 몇 개와 함수 몇 개면 되는데, 왜 굳이 클래스로 나눠야 하지?”라는 생각이 들 때가 있어요. 특히 DTO, 도메인, 서비스가 뒤섞이면 객체지향이 오히려 복잡해 보이기도 합니다. 클래스와 객체를 제대로 구분해서 설계하면, 복잡함이 줄고 변경에 강해집니다.
핵심 개념 — Java에서 클래스와 객체를 분리해 생각해야 하는 이유

클래스는 **설계도(타입)**이고, 객체는 그 설계도로부터 만들어진 **실체(인스턴스)**입니다. 이 구분이 중요한 이유는 단순히 “문법”이 아니라, **변경을 어디에 가둬둘지(캡슐화)**를 결정하기 때문이에요.
예를 들어 “주문(Order)”을 다룰 때, 주문 금액 계산 규칙이 바뀌는 건 흔합니다. 이 규칙이 여기저기 흩어져 있으면(컨트롤러, 서비스, 유틸에 분산) 변경 비용이 커져요. 반대로 주문이라는 객체가 스스로 상태와 규칙을 함께 갖고 있으면, 변경은 주문 클래스 안에서 끝날 가능성이 높습니다. 클래스는 “무엇을 책임질지”를 담는 경계선이고, 객체는 그 경계선 안에서 실제 데이터를 들고 움직입니다.
생성자 — “객체를 유효한 상태로 시작”시키는 장치
생성자는 객체 생성 시점에 필수 값 검증과 불변 조건(인바리언트) 보장을 맡기 좋습니다. “생성만 하면 이미 쓸 수 있는 상태”를 만들면, 이후 로직에서 null 체크나 방어 코드가 크게 줄어요.
- 필수 값은 생성자에서 받기
- 유효하지 않은 값은 즉시 예외로 거절하기
- 가능하면 필드는
final로 두고, 생성자에서 한 번만 세팅하기
Java 17에서는 record가 간단한 불변 데이터에 특히 유용하지만(예: 요청/응답 DTO), 도메인 로직이 있는 경우엔 일반 클래스로 책임을 담는 편이 더 자연스러운 경우가 많습니다.
this 키워드 — “이 객체 자신”을 명확히 가리키는 표식
this는 단순히 “필드와 파라미터 이름이 같을 때 쓰는 것” 이상입니다.
- 현재 인스턴스의 필드를 명확히 지칭 (
this.name) - 생성자에서 다른 생성자 호출 (
this(...))로 초기화 규칙을 한 곳에 모으기 - 메서드 체이닝(빌더 스타일)에서 자기 자신 반환
즉, this는 객체 경계를 코드로 드러내는 도구라고 보면 이해가 쉬워요. “지금 다루는 값이 지역 변수인지, 객체 상태인지”를 명확히 하면 유지보수성이 올라갑니다.
실무 네이밍 컨벤션 — 이름이 설계를 대신 설명하게 만들기
클래스/객체 설계에서 네이밍은 “취향”이 아니라 커뮤니케이션 비용입니다. 특히 협업에서는 “이 클래스가 뭘 하는지”를 파일을 열기 전에 예측할 수 있어야 해요.
아래 표는 실무에서 자주 쓰는 네이밍 선택 가이드입니다.
| 대상 | 추천 패턴 | 예시 | 피하면 좋은 예시 | 이유 |
|---|---|---|---|---|
| 도메인(핵심 개념) | 명사 단수 | Order, Customer, Money |
OrderData, CustomerInfo |
“개념” 자체를 표현해야 로직을 담기 쉬움 |
| 서비스(유스케이스) | 동사+명사 | PlaceOrderService, CancelOrderService |
OrderManager, OrderHandler |
Manager/Handler는 책임이 뭉개지기 쉬움 |
| 값 객체(Value Object) | 도메인 명사 | Email, PhoneNumber |
EmailString, PhoneVO |
타입으로 의미를 고정하면 검증/포맷이 모임 |
| 컬렉션 | 복수형 | orders, customers |
orderList(항상 나쁜 건 아님) |
구현체(List/Set)를 이름에 박지 않기 |
| boolean | is/has/can | isPaid, hasCoupon |
paid, couponYn |
읽는 순간 의미가 자연스럽게 이어짐 |
코드 예제 — 생성자, this, 네이밍을 한 번에 적용한 Java 17 예시
아래 코드는 “주문(Order)”을 클래스로 설계할 때 최소한으로 챙기면 좋은 것들을 담았습니다. 그대로 복붙해서 실행할 수 있고, 생성자에서 유효성 보장, this(...)로 초기화 위임, 의미 있는 네이밍을 확인할 수 있어요.
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
public class Main {
public static void main(String[] args) {
CustomerId customerId = new CustomerId("C-1001");
Money unitPrice = Money.wons(12_500);
int quantity = 2;
Order order = new Order(customerId, unitPrice, quantity);
System.out.println("orderId = " + order.getOrderId());
System.out.println("customerId = " + order.getCustomerId().value());
System.out.println("totalPrice = " + order.totalPrice().amount());
System.out.println("createdAt = " + order.getCreatedAt());
System.out.println("isPaid = " + order.isPaid());
order.pay();
System.out.println("isPaid(after pay) = " + order.isPaid());
}
}
/**
* 도메인 엔티티 예시: 상태(필드) + 규칙(메서드)을 함께 둡니다.
*/
class Order {
private final String orderId;
private final CustomerId customerId;
private final Money unitPrice;
private final int quantity;
private final LocalDateTime createdAt;
private boolean paid;
// 핵심 생성자: 객체를 "유효한 상태"로 시작
public Order(CustomerId customerId, Money unitPrice, int quantity) {
this(generateOrderId(), customerId, unitPrice, quantity, LocalDateTime.now());
}
// this(...)로 초기화 규칙을 한 곳으로 모읍니다.
public Order(String orderId, CustomerId customerId, Money unitPrice, int quantity, LocalDateTime createdAt) {
this.orderId = requireText(orderId, "orderId");
this.customerId = Objects.requireNonNull(customerId, "customerId");
this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice");
this.quantity = requirePositive(quantity, "quantity");
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
this.paid = false;
}
public Money totalPrice() {
return unitPrice.multiply(quantity);
}
public void pay() {
if (this.paid) {
throw new IllegalStateException("Order is already paid.");
}
this.paid = true;
}
public boolean isPaid() {
return paid;
}
public String getOrderId() {
return orderId;
}
public CustomerId getCustomerId() {
return customerId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
private static String generateOrderId() {
// 실무에서는 DB/UUID/시퀀스 등으로 대체됩니다.
return "O-" + System.currentTimeMillis();
}
private static int requirePositive(int value, String name) {
if (value <= 0) throw new IllegalArgumentException(name + " must be positive.");
return value;
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " must not be blank.");
return value;
}
}
/**
* 값 객체(Value Object): String을 그대로 쓰지 말고 의미 있는 타입으로 감싸면 검증이 모입니다.
*/
record CustomerId(String value) {
public CustomerId {
if (value == null || value.isBlank()) throw new IllegalArgumentException("CustomerId must not be blank.");
}
}
/**
* 금액도 값 객체로 두면 통화/반올림/연산 규칙이 한 곳에 모입니다.
*/
record Money(BigDecimal amount) {
public Money {
Objects.requireNonNull(amount, "amount");
if (amount.signum() < 0) throw new IllegalArgumentException("Money must not be negative.");
}
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money multiply(int multiplier) {
if (multiplier <= 0) throw new IllegalArgumentException("multiplier must be positive.");
return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)));
}
}
flowchart TD
A["Class (Order)"] --> B["Object (order1)"]
A --> C["Object (order2)"]
B --> D["State (fields)"]
B --> E["Behavior (methods)"]
클래스는 설계도(타입)이고, 객체는 그 설계도로 만든 실체이며 상태와 행동이 함께 움직입니다.
실무 팁
💡 실무에서는
생성자에서 검증을 빡세게 하면 “예외가 너무 많이 나요”라는 반응이 나올 때가 있습니다. 이때 기준은 간단해요. 도메인 객체가 깨진 상태로 존재하면 안 되는 규칙(필수 값, 음수 금액, 수량 0 등)은 생성자에서 막고, 입력 폼 수준의 검증(예: 글자 수 제한, UI 정책)은 바깥(컨트롤러/검증 레이어)에서 처리하는 식으로 경계를 나누면 충돌이 줄어듭니다.
💡 실무에서는
네이밍에서 Manager, Util, Common이 늘어나기 시작하면 “책임이 떠돌고 있다”는 신호일 가능성이 큽니다. 파일을 만들기 전에 클래스에 한 문장으로 역할을 붙여 보세요. “이 클래스는 주문을 취소한다”, “이 타입은 이메일을 표현한다”처럼 말이 되면 이름도 자연스럽게 정리되고, 메서드가 커지는 문제도 줄어듭니다.
핵심 요약: 클래스는 책임의 경계(설계도)이고 객체는 그 책임을 수행하는 실체입니다.
핵심 요약: 생성자는 객체를 유효한 상태로 시작하게 만들고, this는 초기화/경계를 명확히 합니다.
핵심 요약: 네이밍은 설계 품질을 드러내며, 의미 있는 타입(Value Object)이 유지보수를 크게 돕습니다.
다음 글: #06 메서드 잘 만드는 법
'JAVA' 카테고리의 다른 글
| Java 접근 제어자와 캡슐화 — public/private/protected/default 제대로 쓰기 (0) | 2026.02.15 |
|---|---|
| Java 메서드 잘 만드는 법: 파라미터 설계부터 반환 타입, 오버로딩, 길이 원칙까지 (0) | 2026.02.14 |
| Java 연산자와 제어문 핵심 정리 — if/switch, 반복문, 비교 연산 실수 방지 (0) | 2026.02.13 |
| Java 변수와 타입 — 이것만 알면 된다 (기본 타입 8가지, var, 타입 추론까지) (0) | 2026.02.13 |
| Java 개발 환경 한방에 세팅하기 (JDK + IntelliJ + Hello World) (0) | 2026.02.12 |