A-3: 좋은 객체 — 묻지 말고 시켜라, 속을 감춰라
목차 23
안녕하세요, 여러분의 코드 안목을 길러 드릴 홍순구 튜터입니다.
지난 시간엔 함수를 다뤘죠. 작게 쪼개고, 한 가지 일만 하게 하고, 인자를 줄이고, 숨은 동작을 걷어냈습니다. 그 Step 2에서 주문 합계를 구하는 total() 함수를 만들었던 것 기억하시나요? 그때 제가 슬쩍 이런 그림을 던졌습니다. 만약 Order가 가진 항목 목록을 바깥으로 다 꺼내 주고, 합계 계산은 엉뚱한 다른 곳에서 했다면 어땠을까요? 오늘은 바로 그 이야기입니다. 함수가 사는 집, 객체를 다듬는 시간이에요.
신입이 코드 리뷰에서 객체를 두고 자주 듣는 세 마디가 있습니다.
- "이 객체, getter로 속을 다 까발리네요."
- "여기 점(.)이 세 번이나 찍혀 있어요."
- "이 객체, 값이 아무 데서나 바뀌는데요?"
세 마디 모두 객체를 잘 만들었는가에 대한 지적입니다. 좋은 이름과 작은 함수를 갖췄으니, 이제 그것들을 품는 객체 자체를 다듬을 차례예요. 객체에게 일을 시키고, 속을 감추고, 함부로 바뀌지 않게 지키는 법. 그리고 이 과목이 계속 강조하는 한 가지, 각 규칙을 언제 깨야 하는지까지 함께 봅니다.
A-2 함수 설계
"함수 한 개를 작게, 한 가지만"
│
▼
A-3 좋은 객체·캡슐화 ◀ 오늘 여기
"객체에게 시키고, 속을 감추기"
│
▼
A-4 에러 처리·경계
"실패와 없음을 안전하게"
오늘 다룰 다섯 걸음을 두 갈래로 묶어 봤습니다.
[객체란 무엇인가]
① 객체 vs 자료구조 → ② 묻지 말고 시켜라 → ③ 기차 충돌(디미터)
[속을 지키기]
④ 캡슐화 → ⑤ 불변성
💡 오늘 수업의 핵심 — "객체에게 묻지 말고 시켜라 — 속은 감추고, 행동으로 연다"
🎯 학습 목표
- 객체와 자료구조의 차이를 알고, 언제 캡슐화하고 언제 그냥 데이터로 둘지 판단한다.
- 묻지 말고 시켜라(Tell, Don't Ask)와 디미터 법칙으로 객체가 속을 까발리지 않게 한다.
- 캡슐화와 불변성으로 객체의 상태를 안전하게 지키고, 각 규칙을 언제 깰지도 함께 익힌다.
Step 1: "두 종류의 코드 — 객체와 자료구조"
객체지향을 배우면 이런 주문을 외웁니다. "모든 걸 객체로 만들어라. 필드는 다 private로 숨기고, getter와 setter를 달아라." 그런데 사실 코드에는 성격이 다른 두 종류가 있습니다. 이 둘을 구분하지 못하면, 객체도 자료구조도 아닌 어정쩡한 잡종(hybrid)을 만들게 돼요.
- 자료구조(data structure): 데이터를 그대로 드러내고, 행동은 거의 없습니다. 좌표, 주소, 화면에 뿌릴 응답 같은 것이죠. 자바의
record가 대표적입니다. - 객체(object): 데이터를 숨기고, 행동(메서드)을 드러냅니다. 계좌, 주문처럼 "스스로 일을 하는" 것이죠.
도형 넓이를 구하는 코드로 그 차이를 봅시다. 먼저 자료구조로 푸는 방식입니다. 도형은 데이터만 들고, 계산은 바깥의 AreaCalculator가 타입을 따져 가며 합니다.
❌ Before (자료구조 + 절차적 계산)
public record RectangleData(double width, double height) {
}
public record CircleData(double radius) {
}
public class AreaCalculator {
public double calculate(Object shape) {
if (shape instanceof RectangleData r) {
return r.width() * r.height();
}
if (shape instanceof CircleData c) {
return Math.PI * c.radius() * c.radius();
}
throw new IllegalArgumentException("알 수 없는 도형: " + shape.getClass());
}
}
이번엔 객체로 푸는 방식입니다. 각 도형이 자기 넓이를 스스로 계산하게 하고, 데이터(width, radius)는 숨깁니다.
✅ After (객체)
public interface Shape {
double area();
}
public class Rectangle implements Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
Circle도 똑같이 Shape를 구현하고, area()에서 Math.PI * radius * radius를 돌려줍니다.
여기서 재미있는 비대칭이 보입니다. 두 방식은 "무엇을 추가하기 쉬운가"가 정반대예요.
무엇을 자주 추가하나?
타입(새 도형, 삼각형)을 자주 추가 → 객체 방식이 유리 (클래스 하나만 더 만들면 끝)
연산(새 계산, 둘레)을 자주 추가 → 자료구조+절차 방식이 유리 (메서드 하나만 추가)
객체 방식에서 삼각형을 추가하려면 Shape를 구현하는 클래스 하나만 만들면 됩니다. 기존 코드는 손대지 않아요. 반대로 "둘레 계산"이라는 새 연산을 넣으려면 Shape 인터페이스를 바꾸고 모든 구현체를 고쳐야 합니다. 자료구조 방식은 정확히 그 반대고요.
그래서 정답은 "무조건 객체"가 아닙니다. 자주 바뀌는 축이 타입이냐 연산이냐를 보고 고르는 거예요.
⚠️ 언제 깨나 — 자료구조는 그냥 데이터로 두는 게 맞을 때가 많습니다. 화면에 뿌릴 응답이나 좌표처럼 데이터를 나르는 게 목적이라면
record가 정직한 표현이에요. 가장 나쁜 건 둘을 섞은 잡종입니다. 데이터를getter로 다 드러내면서 어설픈 행동까지 끼워 넣으면, 자료구조의 안정성도 객체의 캡슐화도 둘 다 잃습니다.
💡 한 줄 정리
코드에는 데이터를 드러내는 자료구조와 데이터를 숨기는 객체, 두 종류가 있고, 자주 바뀌는 축을 보고 고른다. 둘을 섞은 잡종이 가장 위험하다.
🙋 학생 질문 — "그럼 record는 캡슐화를 안 하니까 나쁜 코드인가요?"
전혀요. record는 "나는 데이터를 나르는 자료구조다"라고 정직하게 선언하는 도구입니다. 주문 항목, 좌표, API 응답처럼 데이터를 전달하는 게 목적이라면 record가 가장 깔끔한 선택이에요.
문제가 되는 건, 데이터를 숨기고 규칙으로 지켜야 할 대상(계좌 잔액 같은)을 record나 getter/setter 범벅으로 풀어 둘 때입니다. 데이터를 나르는 일이면 자료구조, 규칙으로 지킬 상태가 있으면 객체. 이 구분만 잡으면 됩니다.
Step 2: "묻지 말고 시켜라 (Tell, Don't Ask)"
지난 시간 약속한 그 이야기입니다. 함수 편 Step 2에서 주문 합계를 total()로 만들었죠. 오늘은 그 합계가 어디서 계산되어야 하는가를 따져 봅니다.
흔한 실수는 이렇습니다. 주문 객체에게 "네 항목 목록 좀 줘 봐"라고 묻고(Ask), 받아 온 목록을 바깥에서 돌며 직접 계산하는 거예요.
❌ Before (묻고 꺼내서 바깥에서 계산)
public class OrderBefore {
private final List<LineItem> items;
public OrderBefore(List<LineItem> items) {
this.items = items;
}
public List<LineItem> getItems() {
return items;
}
}
public class CheckoutService {
public int calculateTotal(OrderBefore order) {
int total = 0;
for (LineItem item : order.getItems()) {
total += item.unitPrice() * item.quantity();
}
return total;
}
}
LineItem은 이름·단가·수량을 담는 자료구조(record)입니다. 문제는 합계 계산이 Order 바깥, 그러니까 CheckoutService에 흩어져 있다는 점이에요. 만약 할인이 붙거나 항목 구조가 바뀌면, 이 계산을 베껴 쓴 모든 곳을 다 같이 고쳐야 합니다.
이번엔 시켜 봅시다(Tell). 합계는 Order가 가장 잘 아는 일이니, 그 일을 Order에게 맡깁니다.
✅ After (객체에게 시킨다)
public class OrderAfter {
private final List<LineItem> items;
public OrderAfter(List<LineItem> items) {
this.items = items;
}
public int total() {
int total = 0;
for (LineItem item : items) {
total += item.unitPrice() * item.quantity();
}
return total;
}
}
호출부는 이제 order.total() 한 줄이면 끝입니다. 항목 목록을 꺼낼 일도, 계산식을 바깥에 둘 일도 없어요. 내부 구조가 바뀌어도 total()만 고치면 되고, 호출부는 영향을 받지 않습니다. 이게 "묻지 말고 시켜라"입니다. 객체에게 데이터를 달라고 묻지 말고, 그 데이터로 할 일을 시키세요.
⚠️ 언제 깨나 — 모든 걸 시키려 들면 객체가 온갖 책임을 떠안아 비대해집니다. 하나의 객체가 수십 가지 일을 다 하는 신(God) 객체가 되면 그것대로 문제예요. 그리고 조회 자체가 목적인 자료구조에서는
getter가 정당합니다. 화면에 이름을 뿌리려고member.name()을 부르는 건 데이터를 묻는 게 아니라 그 객체의 떳떳한 쓰임이에요.
💡 한 줄 정리
객체에게 "데이터를 달라"고 묻지 말고 "이 일을 해 달라"고 시킨다. 결정과 계산은 데이터를 가진 객체 안에 둔다.
🙋 학생 질문 — "그럼 getter를 다 지우라는 말씀인가요?"
아니에요. 핵심은 getter의 개수가 아니라 의도입니다.
- 데이터를 화면에 뿌리거나 응답으로 내보내려고 값을 읽는
getter는 정당합니다. - 반면 "객체의 속을 꺼내서, 그 객체가 했어야 할 결정을 바깥에서 대신하려는"
getter가 문제예요.
방금 본 getItems() 다음에 바깥에서 합계를 계산한 게 후자입니다. 같은 getter라도, 뒤에 따라붙는 게 "단순 표시"냐 "남의 결정 대행"이냐를 보세요.
Step 3: "기차 충돌 — 디미터 법칙"
이번엔 점(.)을 세어 봅시다. 배송 라벨에 도시 이름을 찍으려고, 주문에서 고객을 꺼내고, 고객에서 주소를 꺼내고, 주소에서 도시를 꺼내는 코드입니다.
❌ Before (기차 충돌, train wreck)
// 점(.)이 세 번 — 주문 → 고객 → 주소 → 도시
String city = order.getCustomer().getAddress().city();
order.getCustomer().getAddress().city(). 점이 줄줄이 이어진 모습이 마치 기차가 충돌해 객차가 늘어선 것 같다고 해서 기차 충돌이라고 부릅니다. 이게 왜 나쁠까요? 이 한 줄을 쓰려면 호출부가 너무 많이 알아야 합니다. 주문이 고객을 들고 있고, 고객이 주소를 들고 있고, 주소에 도시가 있다는 내부 구조를 전부 꿰고 있어야 해요. 중간에 어느 하나라도 구조가 바뀌면 이 줄이 깨집니다.
이걸 막는 규칙이 디미터 법칙(Law of Demeter)입니다. "한 메서드는 자기 자신, 자기가 가진 필드, 인자로 받은 것, 자기가 만든 것에게만 말을 걸어라." 쉽게 말해 친구하고만 이야기하고, 친구의 친구에게는 말 걸지 마라예요. 그래서 객체를 타고 들어가지 말고, 각 객체가 자기 이웃에게만 묻도록 위임합니다.
✅ After (위임 — 점 하나씩)
public class OrderAfter {
private final Customer customer;
public OrderAfter(Customer customer) {
this.customer = customer;
}
public String shipToCity() {
return customer.cityForShipping();
}
}
Customer는 자기 주소에게만 묻고, 호출부에는 도시만 돌려줍니다.
public class Customer {
private final String name;
private final Address address;
// 생성자 · name() 생략
public String cityForShipping() {
return address.city();
}
}
이제 호출부는 order.shipToCity() 한 줄입니다. 점이 하나죠. 주문은 고객에게만, 고객은 주소에게만 말을 겁니다. 중간 구조가 바뀌어도 호출부는 모르고, 알 필요도 없어요.
⚠️ 언제 깨나 — 자료구조(
record) 체인은 디미터 예외입니다.Address는 데이터를 나르는record라,address.city()로 값을 꺼내는 것 자체는 위반이 아니에요. 진짜 문제는 객체인Customer가 자기 속의 주소를 통째로 내줘서, 호출부가 그 속을 타고 들어가게 만드는 부분입니다. 또 위임 메서드를 무조건 만들면shipToCity,customerName,customerEmail처럼 껍데기 메서드만 수십 개로 불어날 수 있어요. 진짜 필요한 행동만 위임하세요.
💡 한 줄 정리
점을 줄줄이 잇는 기차 충돌은 호출부가 남의 속을 너무 많이 알게 만든다. 친구의 친구에게 말 걸지 말고, 각 객체가 자기 이웃에게만 묻도록 위임한다.
🙋 학생 질문 — "빌더나 스트림은 점이 더 많은데, 그것도 기차 충돌인가요?"
좋은 의심입니다. 결론부터 말하면 아니에요.
new Builder().name("재훈").age(20).build() 같은 빌더나, list.stream().filter(...).map(...).toList() 같은 스트림은 점이 많아도 디미터 위반이 아닙니다. 이들은 계속 자기 자신(또는 같은 종류)을 돌려주는 흐름(fluent API)이거든요. 같은 객체에게 연달아 말을 거는 것이니 "친구의 친구"가 아닙니다.
기차 충돌은 order → customer → address처럼 서로 다른 객체를 한 칸씩 타고 들어갈 때입니다. 점의 개수가 아니라, 매번 다른 객체로 갈아타느냐를 보세요.
Step 4: "속을 감추고 의미로 연다 — 캡슐화"
이번 후크는 "객체가 getter로 속을 다 까발려요"입니다. 계좌를 봅시다. 잔액을 getBalance로 읽고 setBalance로 쓰게 열어 두면 어떤 일이 벌어질까요?
❌ Before (getter/setter로 잔액을 드러낸 계좌)
public class AccountBefore {
private int balance;
public AccountBefore(int initialBalance) {
this.balance = initialBalance;
}
public int getBalance() {
return balance;
}
public void setBalance(int balance) {
this.balance = balance;
}
}
출금은 이렇게 합니다.
// 바깥에서 잔액을 꺼내 직접 빼고 다시 넣는다
account.setBalance(account.getBalance() - amount);
balance는 분명 private인데, setBalance가 열려 있으니 사실상 누구나 잔액을 마음대로 덮어쓸 수 있습니다. "잔액보다 많이 출금할 수 없다" 같은 규칙을 어디서도 강제할 수 없어요. 출금할 때마다 그 규칙을 매번 기억해야 하고, 한 군데라도 빠뜨리면 잔액이 음수가 됩니다.
캡슐화(encapsulation)는 데이터를 숨기는 데서 끝나지 않습니다. 그 데이터를 바꾸는 규칙을 객체 안에 모으는 것이 핵심이에요. setter를 없애고, 입금과 출금이라는 의미 있는 행동으로만 잔액을 바꾸게 합니다.
✅ After (의미 메서드로만 바꾸는 계좌)
public class AccountAfter {
private int balance;
public AccountAfter(int initialBalance) {
this.balance = initialBalance;
}
public void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
}
this.balance += amount;
}
public void withdraw(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출금액은 0보다 커야 합니다.");
}
if (amount > balance) {
throw new IllegalStateException("잔액이 부족합니다.");
}
this.balance -= amount;
}
public int balance() {
return balance;
}
}
이제 출금은 account.withdraw(amount) 한 줄이고, 잔액 부족 검사는 withdraw 안에 단 한 번만 있습니다. 바깥 어디서 출금하든 같은 규칙이 강제돼요. 잔액을 읽기만 하는 balance()는 남겨 둡니다. 화면에 잔액을 보여 줘야 하니까요. 이렇게 읽기는 열어 두되, 바꾸기는 의미 있는 행동으로만 여는 게 캡슐화입니다.
⚠️ 언제 깨나 —
getter/setter를 기계적으로 전부 다는 건 캡슐화가 아니라 그 반대입니다. 도구로@Getter @Setter를 한 번에 붙이는 습관은, 애써 숨긴 필드를 도로 다 열어 버려요. 캡슐화의 핵심은 "필드를private로 했는가"가 아니라 "그 필드를 바꾸는 규칙을 안에 모았는가"입니다. 반대로 데이터 운반이 목적인 자료구조라면 접근자는 정당하니, 거기에 억지로 행동을 욱여넣지 마세요.
💡 한 줄 정리
캡슐화는 데이터를 숨기는 것을 넘어, 그 데이터를 바꾸는 규칙을 객체 안에 모으는 것이다. setter를 의미 있는 행동 메서드로 바꾼다.
🙋 학생 질문 — "실무에선 롬복으로 @Getter @Setter 다 붙이던데요?"
맞아요, 현장에서 흔히 봅니다. 하지만 @Setter를 습관처럼 클래스 전체에 붙이면, 방금 본 setBalance 같은 구멍이 자동으로 뚫립니다. 누구나 상태를 마음대로 덮어쓸 수 있게 되는 거죠.
그래서 보통은 이렇게 갑니다. 데이터를 나르는 자료구조(record나 응답 DTO)에는 읽기 접근자를 두지만, 규칙으로 지켜야 할 상태가 있는 객체에는 @Setter를 붙이지 않고 deposit/withdraw 같은 의미 메서드를 직접 만듭니다. 도구가 편하다고 모든 클래스에 똑같이 적용하지 않는 게 안목이에요.
Step 5: "바뀌지 않아 안전한 객체 — 불변성"
마지막 후크는 "이 객체, 값이 아무 데서나 바뀌어요"입니다. 예약 손님 명단을 봅시다. 생성자에서 받은 리스트를 그대로 들고 있고, getGuests로 그 리스트를 그대로 내주면 어떤 사고가 날까요?
❌ Before (가변 + 내부 리스트 노출)
public class ReservationBefore {
private String restaurantName;
private List<String> guests;
public ReservationBefore(String restaurantName, List<String> guests) {
this.restaurantName = restaurantName;
this.guests = guests; // 참조를 그대로 저장 — 위험
}
// getRestaurantName · setRestaurantName 생략
public List<String> getGuests() {
return guests;
}
public void setGuests(List<String> guests) {
this.guests = guests;
}
public int guestCount() {
return guests.size();
}
}
여기엔 함정이 숨어 있습니다. 생성자가 받은 리스트를 복사 없이 그대로 들고 있어요. 그래서 바깥 리스트를 건드리면 예약 명단도 같이 바뀝니다.
List<String> guests = new ArrayList<>(List.of("김재훈", "박지수"));
ReservationBefore r = new ReservationBefore("스시집", guests);
guests.add("불청객"); // 바깥 리스트에 한 명 추가했을 뿐인데
// r.guestCount() 가 3 으로 늘어난다 — 예약 내부 명단이 함께 바뀌었다
내가 만든 예약이 내가 모르는 사이에 바뀌는 거예요. 이런 공유 참조 사고는 추적하기가 정말 어렵습니다. 누가 언제 그 리스트를 건드렸는지 알 수가 없거든요.
불변성(immutability)은 이 문제를 뿌리째 없앱니다. 생성된 뒤로는 상태가 절대 바뀌지 않게 만드는 거예요. 필드를 final로 잠그고, setter를 없애고, 생성자에서 받은 리스트는 방어적 복사로 떠서 보관합니다.
✅ After (불변 + 방어적 복사)
public class ReservationAfter {
private final String restaurantName;
private final List<String> guests;
public ReservationAfter(String restaurantName, List<String> guests) {
this.restaurantName = restaurantName;
this.guests = List.copyOf(guests); // 방어적 복사 — 원본 변경 차단
}
public String restaurantName() {
return restaurantName;
}
public List<String> guests() {
return guests;
}
public int guestCount() {
return guests.size();
}
}
List.copyOf로 새 리스트를 떠 두니, 바깥에서 원본을 아무리 건드려도 예약 명단은 흔들리지 않습니다. 게다가 List.copyOf가 만든 리스트는 수정 불가라, guests().add(...)를 시도하면 예외가 납니다. 생성된 예약은 그 순간의 상태에서 더 바뀌지 않아요. 여러 곳에서 같은 객체를 공유해도 누가 몰래 바꿀까 걱정할 필요가 없습니다.
⚠️ 언제 깨나 — 모든 것을 불변으로 만드는 건 과합니다. 아주 큰 컬렉션을 바꿀 때마다 통째로 복사하면 비용이 커지고, 본래 상태가 자주 변하는 대상(게임 캐릭터의 위치, 담는 중인 장바구니)은 가변이 더 자연스러워요. 돈·좌표·기간처럼 작고 의미가 고정된 값부터 불변으로 만드는 게 좋은 출발점입니다.
그런데 여기서 한 가지 질문이 남습니다. 만약 예약을 찾았는데 손님 명단이 비어 있거나, 아예 예약을 못 찾으면 어떻게 돌려줘야 할까요? 빈 명단? null? 아니면 예외? 이 "없음"과 "실패"를 안전하게 다루는 이야기는 다음 시간 에러 처리에서 본격적으로 풀어 봅니다.
💡 한 줄 정리
불변 객체는 생성 후 상태가 바뀌지 않아 공유 참조 사고를 막는다. final 필드와 방어적 복사로 만들되, 작고 의미가 고정된 값부터 적용한다.
🙋 학생 질문 — "record를 쓰면 자동으로 불변 아닌가요?"
절반만 맞습니다. record는 필드를 다시 다른 값으로 바꾸는 것(재할당)은 막아 줍니다. 하지만 그 안에 든 게 List 같은 가변 객체라면, 그 리스트 내부는 여전히 바뀔 수 있어요.
예를 들어 record Reservation(String name, List<String> guests)를 만들어도, 누군가 생성자에 넘긴 원본 리스트를 나중에 건드리면 방금 본 사고가 똑같이 납니다. 그래서 record를 쓰더라도, 가변 컬렉션을 받을 때는 List.copyOf 같은 방어적 복사가 여전히 필요합니다. "껍데기가 record냐"가 아니라 "안에 든 것까지 못 바꾸느냐"가 진짜 불변이에요.
마무리
객체 하나를 잘 만드는 일, 생각보다 결이 깊죠? 오늘 우리는 "모든 걸 객체로, getter/setter 다 달아라"는 주문에서 벗어나, 객체가 스스로 일하고 자기 속을 지키게 만드는 법을 봤습니다.
오늘 배운 핵심 세 가지
💡 하나 — 두 종류의 코드. 데이터를 드러내는 자료구조와 데이터를 숨기는 객체는 서로 다른 도구다. 자주 바뀌는 축(타입이냐 연산이냐)을 보고 고르되, 둘을 섞은 잡종은 피한다.
💡 둘 — 묻지 말고 시켜라, 그리고 디미터. 객체에게 속을 묻고 꺼내 바깥에서 일하지 말고, 그 일을 객체에게 시킨다. 점을 줄줄이 잇는 기차 충돌 대신, 각 객체가 자기 이웃에게만 묻게 한다.
💡 셋 — 캡슐화와 불변성. 상태를 바꾸는 규칙을 객체 안에 모으고(캡슐화), 작고 의미가 고정된 값은 바뀌지 않게 지킨다(불변성). 단, 자료구조에는 억지로 적용하지 않는다.
다음 시간 예고
다음 시간엔 A-4 에러 처리·경계로 갑니다. 오늘 Step 5 끝에서 "명단을 못 찾으면 null을 줄까, 예외를 던질까?"를 잠깐 흘렸죠. 그 실패와 없음을 어떻게 다룰지를 정면으로 봅니다. 예외와 에러 코드 중 무엇을 쓸지, null을 반환하거나 넘기지 않는 법, 없음을 타입으로 표현하는 Optional, 그리고 외부 라이브러리가 바뀌어도 우리 코드가 안 터지게 감싸는 경계까지. "외부 API 바뀌니 우리 코드가 다 터졌어요"라는 단골 지적을, 그때 풀어 봅시다.
과제
오늘은 직접 Before를 After로 고치는 손 실습입니다. 코드베이스에 있는 예제와 다른 소재로 냈으니, 베끼지 말고 원리를 적용해 보세요.
[기초] 장바구니에게 합계를 시켜라 (Tell, Don't Ask)
아래 Cart는 항목 목록을 getItems()로 통째로 내주고, 합계 계산은 바깥 PriceService가 합니다. 이걸 "묻지 말고 시켜라"로 고치세요. Cart가 스스로 totalPrice()를 계산해 돌려주고, 호출부는 cart.totalPrice() 한 줄이 되게 만들면 됩니다.
public class Cart {
private final List<CartItem> items;
public Cart(List<CartItem> items) {
this.items = items;
}
public List<CartItem> getItems() {
return items;
}
}
// 바깥에서 계산 중 — 이 흐름을 뒤집으세요
int total = 0;
for (CartItem item : cart.getItems()) {
total += item.price() * item.count();
}
[응용] 재고를 캡슐화하라
아래 Inventory는 getStock/setStock으로 재고를 열어 둬서, 바깥에서 setStock(getStock() - n)으로 직접 깎습니다. 그래서 재고가 음수가 되는 사고를 아무도 못 막아요. setStock을 없애고, removeStock(int n)/addStock(int n) 같은 의미 메서드로만 바꾸게 하세요. 그리고 "재고는 0 밑으로 내려갈 수 없다"는 규칙을 removeStock 안에 모으세요.
public class Inventory {
private int stock;
public Inventory(int stock) {
this.stock = stock;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
this.stock = stock;
}
}
[심화] 팀 명단을 불변으로 (방어적 복사)
아래 Team은 받은 명단 리스트를 그대로 들고, getMembers()로 그대로 내줍니다. 그래서 바깥에서 원본 리스트나 반환된 리스트를 건드리면 팀 명단이 몰래 바뀝니다. 이 Team을 불변으로 바꾸세요. 필드를 final로 잠그고, setter를 없애고, 생성자에서 방어적 복사를 하고, 명단은 수정 불가능한 형태로 돌려주면 됩니다. 마지막으로, "생성자에 넘긴 원본 리스트를 나중에 바꿔도 팀 명단은 안 바뀐다"를 어떻게 확인할 수 있을지도 한 줄로 적어 보세요.
public class Team {
private String teamName;
private List<String> members;
public Team(String teamName, List<String> members) {
this.teamName = teamName;
this.members = members; // 그대로 저장 — 어디를 고쳐야 할까?
}
public List<String> getMembers() {
return members;
}
public void setMembers(List<String> members) {
this.members = members;
}
}
생각해볼 주제
정답이 하나가 아닌 질문들입니다. 오늘 배운 원칙을 언제 적용하고 언제 깰지를 스스로 따져 보세요.
1. record(자료구조)와 캡슐화한 객체, 무엇을 언제 쓸까?
한쪽에서는 "객체지향이니 모든 걸 객체로 감싸고 데이터는 숨겨라"라고 하고, 다른 쪽에서는 "데이터를 나르는 건 그냥 record로 두라"고 합니다. 둘 다 맞는 말입니다. 그렇다면 새 클래스를 만들 때, 이게 자료구조여야 하는지 캡슐화한 객체여야 하는지를 무엇으로 가를 수 있을까요? 잡종을 피하려면 어떤 신호를 봐야 할지 각자의 기준을 세워 토론해 보세요.
2. getter는 정말 죄가 없을까?
"묻지 말고 시켜라"를 극단으로 밀면 이상한 결론에 도달합니다. 객체가 아무 값도 안 내주면, 화면에 이름 하나 뿌리는 것도 불가능해지죠. 그렇다고 모든 getter를 정당화하면 캡슐화가 무너집니다. 어디까지가 "정당한 조회"이고, 어디부터가 "속을 까발리는 노출"일까요? getItems() 다음에 무엇이 오느냐로 가른다는 오늘의 힌트를, 여러분의 코드에서 어떻게 적용할지 이야기해 보세요.
3. 불변이 항상 정답일까?
함수형 언어들은 불변을 기본값으로 둡니다. 바뀌지 않으니 안전하고, 동시성 문제도 줄죠. 그런데 자바에서 모든 객체를 불변으로 만들면, 큰 컬렉션을 바꿀 때마다 통째로 복사하는 비용이 듭니다. 본래 자주 변하는 대상은 가변이 더 자연스럽기도 하고요. 불변을 기본으로 삼되, 어떤 신호가 보일 때 가변을 허용하는 게 합리적일까요? 성능과 안전 사이의 균형점을 고민해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 풀이의 메서드 이름과 나누는 단위는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 의도가 드러나고 동작이 보존되면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.
과제 예시답안
🎯 [과제 1 예시답안] 장바구니에게 합계를 시켜라 (Tell, Don't Ask)
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 계산의 이동 | 합계 계산이 Cart 안으로 들어갔는가 |
★★★ |
| 노출 줄이기 | getItems()로 목록을 통째로 내주지 않는가 |
★★☆ |
| 호출부 정리 | 호출부가 cart.totalPrice() 한 줄이 됐는가 |
★★★ |
| 동작 보존 | 합계 계산식 자체는 그대로 | ★★☆ |
풀이 예시
❌ Before
public class Cart {
private final List<CartItem> items;
public Cart(List<CartItem> items) {
this.items = items;
}
public List<CartItem> getItems() {
return items;
}
}
// 바깥에서 계산 중
int total = 0;
for (CartItem item : cart.getItems()) {
total += item.price() * item.count();
}
✅ After
public class Cart {
private final List<CartItem> items;
public Cart(List<CartItem> items) {
this.items = items;
}
public int totalPrice() {
int total = 0;
for (CartItem item : items) {
total += item.price() * item.count();
}
return total;
}
}
// 호출부
int total = cart.totalPrice();
💡 튜터의 한마디: 합계를 구하는 일은 항목을 가진 Cart가 가장 잘 압니다. 그래서 목록을 꺼내 주지 말고, 합계를 구하라고 시켰어요. 호출부는 cart.totalPrice() 한 줄이 되고, 나중에 할인이 붙거나 항목 구조가 바뀌어도 Cart 안만 고치면 됩니다. 핵심은 "데이터를 달라"고 묻는 대신 "이 일을 해 달라"고 시킨 것입니다.
🎯 [과제 2 예시답안] 재고를 캡슐화하라
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| setter 제거 | setStock을 없앴는가 |
★★★ |
| 의미 메서드 | addStock/removeStock으로 바꿨는가 |
★★★ |
| 규칙 모으기 | "재고는 음수가 될 수 없다"를 한 곳에 모았는가 | ★★★ |
| 조회 유지 | 읽기용 접근자는 남겼는가 | ★★☆ |
풀이 예시
❌ Before
public class Inventory {
private int stock;
public Inventory(int stock) {
this.stock = stock;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
this.stock = stock;
}
}
// 바깥에서 직접 깎는다 — 음수 재고를 아무도 못 막는다
inventory.setStock(inventory.getStock() - 10);
✅ After
public class Inventory {
private int stock;
public Inventory(int stock) {
this.stock = stock;
}
public void addStock(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입고 수량은 0보다 커야 합니다.");
}
this.stock += amount;
}
public void removeStock(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출고 수량은 0보다 커야 합니다.");
}
if (amount > stock) {
throw new IllegalStateException("재고가 부족합니다.");
}
this.stock -= amount;
}
public int stock() {
return stock;
}
}
// 호출부
inventory.removeStock(10);
💡 튜터의 한마디: setStock을 없애는 게 시작입니다. 그게 열려 있으면 누구나 재고를 음수로 덮어쓸 수 있으니까요. 재고를 바꾸는 길을 addStock과 removeStock으로만 열고, "0 밑으로 내려갈 수 없다"는 규칙을 removeStock 안에 단 한 번 두면, 바깥 어디서 출고하든 같은 규칙이 강제됩니다. 읽기용 stock()은 남겨 둡니다. 화면에 재고를 보여 줘야 하니까요.
🎯 [과제 3 예시답안] 팀 명단을 불변으로 (방어적 복사)
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| final + setter 제거 | 필드를 final로 잠그고 setMembers를 없앴는가 |
★★★ |
| 방어적 복사(입구) | 생성자에서 원본을 복사해 보관하는가 | ★★★ |
| 불변 반환(출구) | 반환한 명단을 바깥에서 못 바꾸는가 | ★★★ |
| 확인 방법 서술 | 원본 변경이 내부에 영향 없음을 확인할 방법을 적었는가 | ★★☆ |
풀이 예시
❌ Before
public class Team {
private String teamName;
private List<String> members;
public Team(String teamName, List<String> members) {
this.teamName = teamName;
this.members = members; // 그대로 저장 — 공유 참조 사고
}
public List<String> getMembers() {
return members;
}
public void setMembers(List<String> members) {
this.members = members;
}
}
✅ After
public class Team {
private final String teamName;
private final List<String> members;
public Team(String teamName, List<String> members) {
this.teamName = teamName;
this.members = List.copyOf(members); // 방어적 복사 — 입구를 막는다
}
public String teamName() {
return teamName;
}
public List<String> members() {
return members; // copyOf 결과라 수정 불가 — 출구도 막힘
}
}
확인 방법: 생성자에 넘긴 원본 리스트에 한 명을 추가한 뒤 team.members().size()가 그대로인지 보면 됩니다. 입구를 복사로 막았으니 원본을 건드려도 팀 명단은 흔들리지 않아야 하죠. 또 team.members().add(...)를 시도하면 예외가 나는 것으로 출구가 막힌 것도 확인할 수 있습니다.
💡 튜터의 한마디: 불변에는 입구와 출구, 두 군데를 다 막아야 합니다. 입구는 생성자에서 받은 리스트를 List.copyOf로 복사해 보관하는 것이고, 출구는 그 복사본이 수정 불가라 반환해도 안전한 것이에요. 둘 중 하나만 막으면 다른 쪽으로 사고가 샙니다. final은 필드를 다른 리스트로 다시 가리키지 못하게 잠그는 한 겹이고요. 이렇게 바뀌지 않는 객체는 여러 곳에서 공유해도 마음이 놓입니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] record와 캡슐화한 객체, 무엇을 언제 쓸까?
문제 상황 요약
"모든 걸 객체로 감싸고 데이터는 숨겨라"와 "데이터를 나르는 건 그냥 record로 두라"가 부딪힐 때, 새 클래스를 어느 쪽으로 만들지 가르는 주제입니다.
튜터의 가이드 및 해설
가르는 질문은 하나입니다. "이 타입에 지켜야 할 규칙이 있는가." 잔액은 음수가 될 수 없고, 재고는 0 밑으로 못 내려갑니다. 이렇게 지켜야 할 규칙(불변식)이 있으면 캡슐화한 객체로 만들어, 그 규칙을 안에 모으고 함부로 못 바꾸게 합니다.
반대로 좌표나 주문 항목, 화면에 뿌릴 응답처럼 지킬 규칙 없이 데이터를 나르기만 하면 record가 정직한 선택이에요. 여기에 억지로 캡슐화를 씌우면 코드만 늘고 얻는 게 없습니다.
잡종을 알아채는 신호도 있습니다. getter와 setter가 다 열려 있는데, 그 사이에 검증 메서드나 계산 메서드가 어정쩡하게 섞여 있다면 의심하세요. 데이터를 다 드러내면서 행동도 하려는 그 어정쩡함이, 자료구조의 단순함도 객체의 안전함도 둘 다 놓치게 만듭니다. 규칙이 있으면 객체로 끝까지 감추고, 없으면 자료구조로 깔끔히 두세요.
🎯 면접에선 이렇게 나온다
"타입에 지켜야 할 불변식이 있으면 캡슐화한 객체로, 데이터를 나르기만 하면 record로 둡니다. 잔액·재고처럼 규칙이 있는 건 객체로 규칙을 안에 모으고, 좌표·DTO처럼 규칙 없이 값을 옮기는 건 자료구조로 둡니다. 가장 피하는 건 getter/setter를 다 열어 둔 채 행동까지 끼운 잡종입니다."
💡 실무에선
계층 경계를 넘나드는 요청·응답 데이터는 record로, 도메인 규칙을 품은 엔티티·값 객체는 캡슐화한 객체로 나눕니다. 이 구분이 흐려지면 도메인 규칙이 여기저기 새어 나가기 시작합니다.
🤔 [생각해볼 주제 2] getter는 정말 죄가 없을까?
문제 상황 요약
"묻지 말고 시켜라"를 극단으로 밀면 화면에 값 하나 못 뿌리고, 그렇다고 모든 getter를 정당화하면 캡슐화가 무너집니다. 그 경계를 어디에 둘지 보는 주제입니다.
튜터의 가이드 및 해설
getter의 죄는 존재가 아니라 용도입니다. 값을 화면에 보여 주거나 JSON으로 내보내려고 읽는 건 그 객체의 떳떳한 쓰임이에요. 데이터는 결국 어딘가에서 표시돼야 하고, 그걸 막으면 프로그램이 아무 일도 못 합니다.
문제가 되는 건 "꺼내서 그 객체가 했어야 할 결정을 바깥에서 대신할 때"입니다. order.getItems()로 목록을 꺼낸 다음 바깥에서 합계를 계산하거나, account.getBalance()로 잔액을 꺼낸 다음 바깥에서 출금 가능 여부를 판단하는 거죠. 그 판단은 데이터를 가진 객체가 했어야 할 일입니다.
그래서 간단한 점검법은 이거예요. getter로 값을 꺼낸 바로 다음에 무엇이 오는지 보세요. 단순히 화면에 찍거나 응답에 담으면 정당한 조회입니다. 반면 if로 분기하거나 계산을 시작하면, 그 분기와 계산을 객체 안으로 들여보낼 수 있는지 의심해야 합니다. 묻고 나서 판단하면 Ask, 판단까지 시키면 Tell입니다.
🎯 면접에선 이렇게 나온다
"getter는 존재가 아니라 용도가 문제입니다. 표시·직렬화하려고 값을 읽는 건 정당하고, 꺼내서 그 객체가 했어야 할 결정을 바깥에서 대신하면 캡슐화가 깨집니다. 그래서 getter 다음에 if나 계산이 붙으면, 그 로직을 객체 안으로 옮길 수 있는지부터 봅니다."
💡 실무에선
화면용 DTO로 값을 매핑할 때는 getter를 자유롭게 씁니다. 다만 도메인 코드에서 getter 뒤에 분기가 붙기 시작하면, 그 분기를 객체의 메서드로 끌어들일 수 있는지 점검합니다.
🤔 [생각해볼 주제 3] 불변이 항상 정답일까?
문제 상황 요약
함수형 언어는 불변을 기본으로 두지만, 자바에서 모든 것을 불변으로 만들면 복사 비용과 부자연스러움이 따라옵니다. 불변을 어디까지 밀지 판단하는 주제입니다.
튜터의 가이드 및 해설
좋은 기본값은 "불변을 먼저, 가변은 근거가 있을 때만"입니다. 바뀌지 않는 객체는 공유해도 안전하고, 여러 스레드가 동시에 건드려도 사고가 없어요. 그래서 돈·좌표·기간처럼 작고 의미가 고정된 값 객체는 불변으로 두는 게 거의 항상 이득입니다.
가변을 택하는 게 합리적인 신호도 분명합니다. 첫째, 아주 큰 컬렉션을 자주 바꿔야 하는데 매번 통째로 복사하면 그 비용이 무겁습니다. 둘째, 본질이 계속 변하는 대상이 있어요. 진행 중인 장바구니, 상태가 바뀌는 주문, 사용자 세션 같은 것은 변화 자체가 그 객체의 일이라 억지로 불변으로 만들면 오히려 코드가 꼬입니다.
그러니 "불변이 무조건 옳다"가 아니라 "불변을 기본으로 두되, 복사 비용이 무겁거나 본질이 변하는 대상이면 가변을 의도적으로 택한다"가 균형점입니다. 그리고 성능이 걱정될 때는 추측하지 말고 측정하세요. 막연한 불안으로 가변을 고르기 전에, 정말 그 복사가 병목인지부터 재 보는 게 순서입니다.
🎯 면접에선 이렇게 나온다
"불변을 기본값으로 두고 가변은 근거가 있을 때만 택합니다. 값 객체는 불변으로 두어 공유와 동시성에서 안전을 얻고, 큰 컬렉션을 자주 바꾸거나 본질이 변하는 엔티티는 복사 비용 때문에 가변을 허용합니다. 성능이 걱정되면 추측하지 않고 측정한 뒤 판단합니다."
💡 실무에선
값 타입부터 불변으로 잡고, 자주 변하는 엔티티는 가변으로 둡니다. 복사 비용이 정말 문제인지는 측정으로 확인하고요. 성능과 가독성이 부딪히는 더 깊은 판단은 이 과목 마지막 모듈에서 다룹니다.