
함수는 작고 명확해야 한다. 하지만 “작다”는 것만으로는 충분하지 않다.
함수가 어떤 구조로 작성되었는가, 인자와 반환, 예외와 제어 흐름을 어떻게 다루는가가 읽히는 코드와 유지보수 가능한 코드를 가르는 핵심이 된다.
함수의 인자는 복잡도를 결정짓는다.
3개 이하가 이상적이며, 그 이상이 필요하다면 설계를 의심해야 한다.
void createUser(String name, int age, String address, boolean isAdmin);
→ 인자가 4개 이상이라면 “이 함수가 너무 많은 일을 하는 건 아닐까?”를 고민해야 한다.
여러 인자가 논리적으로 하나의 의미를 가진다면, 객체로 묶어라.
class DateRange {
private LocalDate from;
private LocalDate to;
}
int getInvoicesWithin(DateRange range);
→ 인자 개수를 줄이면서 코드의 의미도 명확해진다.
생성자에 인자가 많다면 Builder 패턴을 사용하라.
User user = new User.Builder("Kim")
.age(25)
.email("kim@example.com")
.build();
필수 인자는 생성자에서 받고, 선택 인자는 setter 메서드로 체이닝한다.
불린 인자는 두 가지 일을 한 함수에 섞는 신호다.
함수는 한 가지 일만 해야 하므로, 불린 값으로 분기하는 대신 함수를 분리해야 한다.
// 나쁜 예시
void printReport(boolean detailed);
// 좋은 예시
void printSummaryReport();
void printDetailedReport();
이렇게 하면 함수 이름만 보고도 어떤 역할인지 명확히 알 수 있다.
CQS 원칙은 함수를 크게 두 종류로 구분한다.
| 종류 | 역할 | 상태 변경 | 반환값 |
|---|---|---|---|
| Command | 시스템 상태를 변경 | O | X |
| Query | 상태 조회 | X | O |
하나의 함수가 둘을 동시에 수행하면 의도가 흐려진다.
// 나쁜 예시
int withdraw(int amount) {
balance -= amount;
return balance;
}
// 좋은 예시
void withdraw(int amount) { balance -= amount; }
int getBalance() { return balance; }
이 원칙을 지키면 함수의 부작용을 명확히 구분할 수 있다.
객체의 상태를 꺼내서 조건문으로 처리하지 말고,
객체에게 해야 할 일을 직접 시켜라.
// 나쁜 예시
if (order.isPaid()) {
order.sendEmail();
}
// 좋은 예시
order.notifyIfPaid();
함수는 “상태를 묻는 존재”가 아니라,
“행동을 요청받는 주체”가 되어야 한다.
“친한 객체에게만 말하라.”
함수는 다른 객체의 내부에까지 관여하지 않아야 한다.
즉, 메서드 체이닝(obj.getA().getB().getC())은 피해야 한다.
// 나쁜 예시
order.getCustomer().getAddress().getZipCode();
// 좋은 예시
order.getZipCode();
Order가 내부적으로 Customer를 알고 있다면,
그 세부 사항은 Order가 직접 처리하도록 해야 한다.
이렇게 하면 결합도가 낮아지고, 변경 영향이 줄어든다.
복잡한 중첩은 함수의 흐름을 흐리게 만든다.
조기 리턴을 활용해 단계를 단순하게 만들어라.
// 나쁜 예시
void validateUser(User user) {
if (user != null) {
if (user.isActive()) {
if (user.hasPermission()) {
process();
}
}
}
}
// 좋은 예시
void validateUser(User user) {
if (user == null) return;
if (!user.isActive()) return;
if (!user.hasPermission()) return;
process();
}
단, 루프 중간에서의 return은 피해야 한다.
흐름을 따라가기 어려워지고 디버깅이 복잡해진다.
예외 처리도 함수의 구조적 일관성을 깨뜨리기 쉬운 부분이다.
try 블록 내부에 여러 줄의 코드를 두면
정상 흐름과 예외 흐름이 섞이게 된다.
try {
processPayment(); // 한 문장만
} catch (PaymentFailedException e) {
logError(e);
}
조건문으로 처리 가능한 상황을 예외로 던지지 마라.
예외는 정말 복구 불가능한 상황에서만 사용한다.
불필요하게 많은 예외를 생성하지 말고,
특별한 경우는 특별한 클래스로 분리하라.
class Stack {
static class ZeroSizeStack extends RuntimeException { }
}
Null 여부를 계속 검사하기보다는, 단위 테스트로 보장하거나
Null Object 패턴으로 대체하는 편이 낫다.
class NullCustomer extends Customer {
@Override
public void sendPromotion() { /* 아무 일도 하지 않음 */ }
}
Switch문은 객체지향의 적이다.
새로운 조건이 추가될 때마다 기존 코드를 수정해야 하기 때문이다.
// 나쁜 예시
switch (shape.getType()) {
case CIRCLE -> drawCircle();
case RECTANGLE -> drawRectangle();
}
→ 다형성으로 바꿔라.
interface Shape { void draw(); }
class Circle implements Shape {
public void draw() { ... }
}
class Rectangle implements Shape {
public void draw() { ... }
}
이제 새로운 도형이 추가되어도 기존 코드를 건드릴 필요가 없다.
함수는 위에서 아래로 읽히는 흐름을 가져야 한다.
이때 public 메서드는 위에, private 메서드는 아래에 배치한다.
public class PaymentService {
// 의도를 드러내는 공개 메서드
public void pay() {
if (isAvailable()) processPayment();
}
// 세부 동작을 수행하는 내부 메서드
private boolean isAvailable() { ... }
private void processPayment() { ... }
}
이런 구조를 Stepdown Rule이라 부른다 —
코드가 한 문서처럼 위에서 아래로 자연스럽게 읽히게 하는 규칙이다.
| 항목 | 핵심 원칙 |
|---|---|
| 인자 | 3개 이하, 빌더·객체로 단순화 |
| Boolean 인자 | 두 함수로 분리 |
| CQS | Command와 Query를 분리 |
| Tell, Don’t Ask | 객체에 시켜라 |
| Law of Demeter | 메서드 체이닝 지양 |
| Early Return | 중첩 최소화, 단 루프 내 리턴은 피함 |
| 예외 | try는 한 문장, 예외는 구체적, Null Object |
| Switch | 다형성으로 대체 |
| Stepdown Rule | public 위, private 아래, 위→아래로 읽히는 흐름 |