A-2: 함수 설계 — 작게, 한 가지만, 그리고 언제 멈출지
목차 29
안녕하세요, 여러분의 코드 안목을 길러 드릴 홍순구 튜터입니다.
지난 시간엔 코드의 가장 작은 단위인 이름을 다뤘죠. 의도를 드러내는 이름, 매직 넘버를 없앤 상수, 거짓말하지 않는 주석까지. 그 마지막 즈음에 회원이 탈퇴할 수 있는지 판단하는 코드를 설명 변수(passedCooldown, notSuspended, hasNoPoint)로 풀었던 것 기억하시나요? 그때 제가 한 가지 약속을 남겼습니다. "이 세 조건을 아예 isEligibleToWithdraw() 같은 작은 함수로 뽑을 수도 있다"고요. 오늘이 그 약속을 지키는 시간입니다.
신입이 코드 리뷰에서 가장 자주 듣는 세 마디가 있습니다.
- "이 함수 200줄이에요."
- "이 함수 뭐 하는지 한 문장으로 설명이 안 돼요."
- "이 플래그
true가 뭘 켜는 거죠?"
세 마디 모두 함수 설계에 관한 지적입니다. 좋은 이름을 지을 줄 알게 됐으니, 이제 그 좋은 이름이 붙는 함수 자체를 다듬을 차례예요. 함수를 작게, 한 가지 일만 하게, 인자를 줄이고, 숨은 동작을 걷어내는 법. 그리고 이 과목이 계속 강조하는 한 가지 — 각 규칙을 언제 깨야 하는지까지 함께 봅니다.
지난 시간 오늘 다음 시간
A-1 이름·주석·포매팅 A-2 함수 설계 A-3 좋은 객체
한 줄 한 줄을 ─▶ 함수 한 개를 ─▶ 객체 한 개를
읽기 좋게 작게·한 가지만 Tell Don't Ask
오늘 다듬을 함수의 여덟 걸음을 세 갈래로 묶어 봤습니다.
[크기와 책임]
작게 추출 → 한 가지만, 한 눈높이에서
[들어가고 나오기]
이른 반환 → 인자 줄이기 → 플래그 없애기
[정직한 함수]
부수 효과 제거 → 명령·조회 분리 → 중복 제거
💡 오늘 수업의 핵심 — "함수는 작게, 한 가지만 — 한 문장으로 설명되면 합격"
🎯 학습 목표
- 긴 함수를 작은 함수로 추출하고, 한 함수가 한 가지 일만 하도록(추상화 수준을 통일해) 다듬는다.
- 이른 반환·인자 줄이기·불리언 플래그 제거·부수 효과 제거·명령과 조회 분리·중복 제거로 함수를 정직하게 만든다.
- 각 규칙을 맹목적으로 따르지 않고, 언제 적용하고 언제 깰지 판단한다.
Step 1: "한 화면에 들어오게 — 긴 함수를 작은 함수로"
코드 리뷰에서 가장 흔한 한마디부터 시작할게요. "이 함수 200줄이에요." 함수가 길다는 건 보통 그 안에서 여러 일을 하고 있다는 신호입니다. 지난 시간에 만든 탈퇴 판단 코드를 다시 봅시다.
❌ Before
public WithdrawResult withdraw(Member member) {
if (member.daysSinceJoined() >= 7 && !member.isSuspended() && member.point() == 0) {
return new WithdrawResult(true,
member.name() + "님, 그동안 함께해 주셔서 감사합니다. 다시 만나요!");
}
return new WithdrawResult(false, member.name() + "님은 아직 탈퇴할 수 없습니다.");
}
지금은 짧지만, 한 함수 안에 두 가지 일이 섞여 있어요. 하나는 "탈퇴할 자격이 있는가" 를 판단하는 일, 다른 하나는 작별 메시지를 만드는 일입니다. 자격 조건이 늘거나 메시지가 길어지면 이 함수는 금세 부풀어 오릅니다. 두 가지 일을 각각 이름 붙은 작은 함수로 뽑아 봅시다.
✅ After
private static final int WITHDRAW_COOLDOWN_DAYS = 7;
public WithdrawResult withdraw(Member member) {
if (isEligibleToWithdraw(member)) {
return new WithdrawResult(true, farewellMessage(member));
}
return new WithdrawResult(false, member.name() + "님은 아직 탈퇴할 수 없습니다.");
}
private boolean isEligibleToWithdraw(Member member) {
boolean passedCooldown = member.daysSinceJoined() >= WITHDRAW_COOLDOWN_DAYS;
boolean notSuspended = !member.isSuspended();
boolean hasNoPoint = member.point() == 0;
return passedCooldown && notSuspended && hasNoPoint;
}
private String farewellMessage(Member member) {
return member.name() + "님, 그동안 함께해 주셔서 감사합니다. 다시 만나요!";
}
지난 시간에 만든 설명 변수 세 개가 그대로 isEligibleToWithdraw()라는 작은 함수 안으로 들어왔습니다. 이렇게 코드의 한 덩어리를 이름 붙은 함수로 빼내는 걸 함수 추출(extract method) 이라고 불러요. 이제 withdraw()를 읽으면 "자격이 되면 작별 인사, 아니면 거절"이라는 이야기가 한눈에 들어옵니다. 더 자세히 알고 싶으면 isEligibleToWithdraw() 한 단계 아래로 내려가 보면 되고요.
"한 화면에 들어오게"라는 말이 여기서 나옵니다. 함수가 스크롤 없이 한눈에 보이는 크기(대략 15~20줄 안쪽)면, 읽는 사람이 머릿속에 통째로 담을 수 있어요.
⚠️ 언제 깨나 — 작게가 좋다고 무조건 잘게 쪼개면 역효과입니다. 함수 사이를 이리저리 점프하느라 흐름이 끊겨, 오히려 읽기 어려워져요. 딱 한 번 쓰고 의미가 자명한 두세 줄까지 함수로 빼면 이름 짓는 수고만 늘죠. 기준은 줄 수가 아니라 "이 코드를 읽는 사람이 본문을 읽는 것보다 함수 이름으로 더 빨리 이해하나"입니다.
💡 한 줄 정리
함수는 한 화면에 들어오게 — 큰 함수는 작은 함수들의 이야기로 풀어라.
🙋 학생 질문 — "그럼 함수는 정확히 몇 줄이 정답인가요?"
정해진 숫자는 없습니다. "20줄 이하" 같은 권장은 있지만, 핵심은 줄 수가 아니라 한 가지 일을 하느냐예요. 네 줄짜리도 두 가지를 하면 길고, 스무 줄짜리도 한 가지면 짧습니다. 줄 수는 "혹시 너무 긴 거 아닌가?" 하고 멈춰서 살펴보게 하는 신호일 뿐, 그 자체가 목표는 아니에요. 그 "한 가지 일"을 어떻게 가려내는지는 바로 다음 Step에서 다룹니다.
Step 2: "한 가지만, 한 눈높이에서"
이번엔 두 번째 단골 지적입니다. "이 함수 뭐 하는지 한 문장으로 설명이 안 돼요." 사실 이 한마디가 "함수가 한 가지만 하는가" 를 가려내는 가장 좋은 시금석이에요. 함수를 한 문장으로 설명해 보세요. "이 함수는 소계를 구하고, 그리고 세금을 계산하고, 또 배송비를 정한다"처럼 "그리고", "또"가 끼면 두 가지 이상을 하고 있다는 뜻입니다. 주문 합계를 구하는 코드를 볼게요.
❌ Before
public int total(Order order) {
int subtotal = 0;
for (int price : order.itemPrices()) {
subtotal += price;
}
int tax = (int) (subtotal * 0.1);
int shipping = subtotal >= 50000 ? 0 : 3000;
return subtotal + tax + shipping;
}
이 함수는 세 가지 눈높이가 뒤섞여 있어요. 소계를 더하는 루프(아주 낮은 눈높이의 잔일), 세금을 0.1로 곱하는 계산(낮은 눈높이), 배송비를 정하는 판단(조금 높은 눈높이)이 한곳에 모여 있습니다. 큰 그림과 미세한 계산이 같은 함수에 섞이면, 읽는 눈이 위아래로 흔들려요.
✅ After
private static final double TAX_RATE = 0.1;
private static final int FREE_SHIPPING_THRESHOLD = 50000;
private static final int SHIPPING_FEE = 3000;
public int total(Order order) {
int subtotal = subtotal(order);
return subtotal + tax(subtotal) + shippingFee(subtotal);
}
private int subtotal(Order order) {
int subtotal = 0;
for (int price : order.itemPrices()) {
subtotal += price;
}
return subtotal;
}
private int tax(int subtotal) {
return (int) (subtotal * TAX_RATE);
}
private int shippingFee(int subtotal) {
return subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_FEE;
}
이제 total()을 읽으면 "소계 + 세금 + 배송비"라는 이야기가 한 줄로 읽힙니다. 루프를 어떻게 도는지, 세금률이 얼마인지 같은 디테일은 각 함수 안으로 내려갔어요. 위에서 아래로, 큰 이야기에서 세부로 자연스럽게 내려가며 읽히는 이 흐름을 추상화 수준 통일이라고 합니다. 한 함수 안의 문장들이 모두 같은 눈높이에 있을 때, 그 함수는 비로소 "한 가지"를 합니다.
여기서 다루는 건 어디까지나 함수 한 개가 한 가지를 하는 이야기입니다. "클래스가 한 가지 책임만 진다"는 더 큰 설계 이야기(단일 책임 원칙)는 뒤에 나오는 설계 원칙 모듈에서 본격적으로 다뤄요.
⚠️ 언제 깨나 — "한 가지"의 크기는 보는 눈높이에 따라 달라집니다. 너무 잘게 정의해서 두세 줄짜리 저수준 연산까지 전부 별도 함수로 올리면, 함수 개수가 폭발해 오히려 추적이 어려워져요. 한 문장 설명이 자연스럽게 되는 선까지만 쪼개면 충분합니다.
💡 한 줄 정리
함수는 한 가지만 — 설명에 "그리고"가 끼면 둘로 쪼갤 신호다.
🙋 학생 질문 — "추상화 수준이 섞였다는 게 잘 안 와닿아요."
요리 레시피로 비유해 볼게요. "재료를 볶는다" 옆에 "가스 밸브를 시계 반대 방향으로 30도 돌린다"가 나란히 적혀 있으면 눈이 어지럽죠? 앞은 큰 동작이고 뒤는 미세 조작이라, 둘은 보는 눈높이가 다릅니다. 함수도 똑같아요. "배송비를 정한다"(큰 동작)와 "0.1을 곱한다"(미세 계산)가 한 함수에 섞이면 읽는 사람이 매번 초점을 다시 맞춰야 합니다. 같은 눈높이의 문장끼리 모으면, 큰 그림은 위에서, 디테일은 한 단계 내려가서 따로 보게 돼요.
Step 3: "들여쓰기 피라미드를 무너뜨려라"
조건이 겹치면 if 안에 if, 그 안에 또 if가 들어가면서 코드가 오른쪽으로 계단처럼 밀려납니다. 이걸 화살표 안티패턴이라고 불러요. 결제를 검증하는 코드로 봅시다.
❌ Before
public String pay(Order order) {
if (order == null) {
return "주문 없음";
} else {
if (order.items() > 0) {
if (order.balance() >= order.amount()) {
return "결제 완료";
} else {
return "잔액 부족";
}
} else {
return "빈 주문";
}
}
}
정상으로 결제되는 "결제 완료"가 가장 안쪽, 세 겹의 if 깊숙한 곳에 숨어 있어요. 정작 중요한 정상 흐름을 보려면 비정상 케이스들을 다 헤치고 들어가야 합니다. 비정상 케이스를 먼저 걸러서 내보내면 어떻게 될까요?
✅ After
public String pay(Order order) {
if (order == null) {
return "주문 없음";
}
if (order.items() <= 0) {
return "빈 주문";
}
if (order.balance() < order.amount()) {
return "잔액 부족";
}
return "결제 완료";
}
비정상 케이스를 위에서 하나씩 걸러 return으로 내보내고 나니, 마지막에 정상 흐름인 "결제 완료"만 평평하게 남았습니다. 이렇게 함수 앞부분에서 예외 케이스를 먼저 쳐내는 if를 이른 반환(early return), 또는 guard clause(보호 구문) 라고 해요. 들여쓰기 피라미드가 사라지고, 읽는 사람은 "아, 이 조건들만 통과하면 결제되는구나"를 위에서 아래로 죽 읽으면 됩니다.
🌟 새 함수를 짤 때는 guard clause를 기본값으로 두세요. 비정상부터 걸러 내보내고 본문은 정상 흐름만 남기는 모양이 대체로 가장 잘 읽힙니다.
⚠️ 언제 깨나 — 이른 반환도 과하면 독입니다. 한 함수에
return이 예닐곱 개씩 흩어지면 "이 함수는 대체 어디서 끝나나"를 추적하기 어려워져요. 분기가 한둘이고 정상 흐름이 분명하면 단일return도 충분히 읽힙니다. "출구는 적을수록 좋다"는 옛 규칙과 부딪힐 때는, 더 잘 읽히는 쪽을 택하면 됩니다.
💡 한 줄 정리
비정상은 먼저 걸러 내보내고, 본문엔 정상 흐름만 — 들여쓰기가 평평해진다.
🙋 학생 질문 — "함수 중간에 return을 여러 번 쓰면 안 좋다고 배웠는데요?"
과거의 "단일 출구(함수는 return이 하나여야 한다)" 규칙은, C 언어처럼 함수 끝에서 메모리·자원을 손수 해제하던 시절의 안전장치였어요. 중간에 빠져나가면 해제 코드를 건너뛰니까요. 그런데 요즘 언어는 try-with-resources나 finally가 자원을 알아서 정리해 줘서, 그 이유가 많이 약해졌습니다. 이유가 사라지면 규칙도 다시 봐야죠. 오늘날엔 guard clause로 얻는 가독성이 보통 더 큽니다. 물론 정답은 맥락에 따라 갈리고요 — 규칙엔 이유가 있고, 그 이유가 살아 있는지 늘 확인하는 게 이 과목의 태도입니다.
Step 4: "인자는 적을수록 좋다"
함수 호출부에 인자가 줄줄이 늘어선 코드를 본 적 있을 거예요. create("재훈", "오늘의 일상", "url...", "서울", true, false) — 다섯 번째 true, 여섯 번째 false가 각각 뭘 뜻하는지 호출부만 봐선 알 수가 없죠. 함수 인자는 0개·1개·2개가 이상적이고, 3개부터는 한 번 멈춰 생각해야 합니다. 게시물을 만드는 코드를 봅시다.
❌ Before
public Post create(String author, String caption, String imageUrl, String location,
boolean commentsEnabled, boolean hideLikeCount) {
return new Post(author, caption, imageUrl, location, commentsEnabled, hideLikeCount);
}
인자가 여섯 개입니다. 호출할 때 순서를 헷갈리거나, imageUrl과 location을 거꾸로 넣어도 컴파일러는 잡아 주지 못해요(둘 다 String이니까). 이렇게 함께 다니는 값들은 하나의 객체로 묶는 게 정석입니다. 이걸 매개변수 객체라고 해요.
✅ After
public Post create(PostForm form) {
return new Post(form.author(), form.caption(), form.imageUrl(), form.location(),
form.commentsEnabled(), form.hideLikeCount());
}
PostForm이라는 record 하나로 묶으니 인자가 1개로 줄었습니다. PostForm 자체는 이렇게 생겼어요.
public record PostForm(String author, String caption, String imageUrl, String location,
boolean commentsEnabled, boolean hideLikeCount) {
}
값들을 모아 이름을 붙이니 "이건 게시물 입력 폼이구나"라는 의미까지 생겼습니다. 참고로 Python이라면 키워드 인자로 create(author="재훈", caption="...")처럼 호출부에 이름을 달아 어느 정도 읽히게 할 수 있어요. 다만 "인자가 너무 많다"는 신호 자체는 언어를 가리지 않아서, 관련된 값을 객체(Python이라면 dataclass)로 묶는 해법은 똑같이 통합니다.
⚠️ 언제 깨나 — 매개변수 객체가 항상 답은 아닙니다. 서로 아무 관련 없는 값을 인자 수만 줄이려고 한 객체에 욱여넣으면, 의미 없는 가방이 될 뿐이에요.
author와caption은 "게시물 입력"으로 자연스럽게 묶이지만, 환율과 사용자 나이를 한 객체에 담으면 곤란하죠. 묶을 값들이 함께 다닐 이유가 있을 때만 묶습니다.
💡 한 줄 정리
인자는 0~2개를 노리고, 늘어나면 "함께 다니는 값"을 객체로 묶어라.
🙋 학생 질문 — "인자가 3개면 무조건 객체로 바꿔야 하나요?"
아니에요. 3개는 "한 번 멈춰서 생각해 볼" 신호지, 넘으면 안 되는 금지선이 아닙니다. 세 값이 자연스럽게 한 덩어리(예: 시작일, 종료일, 기간단위)면 객체로 묶으면 좋고, 서로 독립적이면 그냥 둬도 됩니다. 숫자에 집착하기보다 "호출부가 읽히나? 인자 순서를 헷갈려 잘못 넣을 위험이 있나?"를 보세요. 같은 타입 인자가 여러 개 나란히 있으면(여기선 String이 네 개) 특히 순서 실수가 나기 쉬워서, 객체로 묶는 효과가 큽니다.
Step 5: "플래그 true가 뭘 켜는 거죠?"
방금 본 그 다섯 번째 true 이야기를 이어가 볼게요. 함수 인자로 들어가는 불리언(true/false) 값을 불리언 플래그 인자라고 합니다. 알림을 보내는 코드를 봅시다.
❌ Before
public String notify(String user, String msg, boolean byEmail) {
if (byEmail) {
return "EMAIL:" + user + ":" + msg;
}
return "SMS:" + user + ":" + msg;
}
이 함수를 notify("재훈", "안녕", true)로 호출하면, 그 true가 "이메일로 보내라"인지 "급한 알림이다"인지 호출부만 봐선 알 수 없어요. 게다가 함수 안을 보면 byEmail 값에 따라 흐름이 둘로 갈립니다. 이건 사실 이 함수가 한 가지가 아니라 두 가지 일(이메일 보내기 / 문자 보내기)을 한다는 자백이에요. 함수를 둘로 나눠 봅시다.
✅ After
public String sendEmail(String user, String msg) {
return "EMAIL:" + user + ":" + msg;
}
public String sendSms(String user, String msg) {
return "SMS:" + user + ":" + msg;
}
이제 호출부가 sendEmail("재훈", "안녕")이라고 적히니, 무엇을 하는지 이름이 곧바로 말해 줍니다. 플래그로 흐름을 가르던 if도 사라졌고요. Python에서도 notify(user, msg, by_email=True)처럼 키워드로 주면 호출부가 조금 읽히긴 하지만, "한 함수가 두 일을 한다"는 본질은 그대로라, 두 함수로 나누는 편이 더 선명한 건 마찬가지입니다.
⚠️ 언제 깨나 — 모든 불리언 인자가 악은 아닙니다. 진짜로 무언가를 켜고 끄는 단순 토글(예: 라이브러리 경계의
caseSensitive옵션)이고, 호출부가 키워드 인자로 의미를 드러낼 수 있다면, 굳이 함수를 둘로 안 나눠도 돼요. 판단 기준은 "이 플래그가 함수 안에서 흐름을 두 갈래로 가르나"입니다. 흐름을 가른다면 분리 신호예요.
💡 한 줄 정리
불리언 플래그는 "두 가지 일"의 자백 — 함수를 둘로 나눠 이름에 뜻을 담아라.
🙋 학생 질문 — "그럼 enum 인자도 나쁜가요? sendEmail/sendSms 대신 send(channel)은요?"
enum 인자는 불리언 플래그보다 낫습니다. 호출부에 send(Channel.EMAIL)이라고 뜻이 드러나니까요. 채널이 둘뿐이면 두 함수로 나누는 게 가장 선명하고, 채널이 다섯 개·열 개로 늘어날 수 있으면 enum 하나로 받아 분기하는 편이 함수 폭발을 막아 줍니다. 이렇게 "여러 갈래로 가는 분기를 어떻게 다룰지"는 디자인 패턴(전략 같은)에서 다시 만나는 주제예요. 지금은 "흐름을 가르는 불리언 플래그는 함수 분리 신호"까지만 알아 두면 충분합니다.
Step 6: "이름에 없는 일을 몰래 하지 마라"
함수 이름은 checkPassword(비밀번호 확인)인데, 실행해 보니 세션까지 초기화하고 있다면 어떨까요? 이렇게 함수가 이름에 드러나지 않은 일을 몰래 하는 것을 부수 효과(side effect)라고 합니다.
❌ Before
public boolean checkPassword(String stored, String input) {
if (stored.equals(input)) {
sessionInitialized = true;
return true;
}
return false;
}
이름은 "확인"인데 비밀번호가 맞으면 sessionInitialized = true로 세션을 초기화해 버립니다. 이 함수를 호출하는 사람은 "비밀번호만 확인하는 줄" 알고 불렀다가, 자기도 모르게 세션 상태가 바뀌는 일을 당하게 돼요. 검사와 초기화를 떼어 놓읍시다.
✅ After
public boolean isPasswordValid(String stored, String input) {
return stored.equals(input);
}
public void initializeSession() {
sessionInitialized = true;
}
isPasswordValid()는 이제 검사만 하고 아무것도 바꾸지 않는 정직한 함수가 됐고, 세션 초기화가 필요하면 호출하는 쪽이 initializeSession()을 명시적으로 부르면 됩니다. 이름과 행동이 일치하니 함수가 거짓말을 멈춥니다. 참고로 부수 효과의 사촌으로 "조용히 null을 돌려주는" 함수도 있는데, null과 예외를 안전하게 다루는 이야기는 다음 시간(에러 처리)에서 본격적으로 다뤄요.
⚠️ 언제 깨나 — 모든 부수 효과가 악은 아닙니다. 로깅, 캐시 갱신, 저장처럼 의도된 효과는 정상이에요. 다만 이름에 드러나면 됩니다(
saveAndNotify처럼). 순수 함수에 대한 강박으로 모든 효과를 떼어 내려다 코드가 더 복잡해지면 본말전도죠. 핵심은 "함수가 이름이 약속한 일만 하느냐"입니다.
💡 한 줄 정리
함수는 이름이 약속한 일만 — 숨은 동작은 거짓말이다.
🙋 학생 질문 — "부수 효과를 아예 없앤 함수(순수 함수)가 항상 좋은 거 아닌가요?"
순수 함수(같은 입력엔 늘 같은 출력, 바깥 상태를 안 건드림)는 테스트하기 쉽고 결과를 예측하기 쉬워서 분명 장점이 많습니다. 하지만 프로그램은 결국 무언가를 저장하고, 보내고, 화면에 그려야(전부 부수 효과) 일을 하죠. 그래서 현실적인 목표는 "효과를 0으로"가 아니라 "효과를 이름에 드러내고, 한곳에 모으는 것" 입니다. 효과 없는 계산과 효과 있는 동작을 한 함수에 섞지 않는 게 핵심이에요. 계산은 계산대로, 저장은 저장대로 나누면 둘 다 다루기 쉬워집니다.
Step 7: "묻기와 시키기를 한 손에 들지 마라"
함수는 둘 중 하나여야 합니다. 무언가를 바꾸는 명령(command) 이거나, 무언가를 알려 주는 조회(query) 거나. 한 함수가 둘을 동시에 하면 호출부가 헷갈려요. 이 원칙을 명령-조회 분리(CQS, Command-Query Separation)라고 합니다. 값을 저장하는 코드를 봅시다.
❌ Before
public boolean set(String key, String value) {
boolean isNew = !store.containsKey(key);
store.put(key, value);
return isNew;
}
이 set()은 값을 저장하는 명령도 하고, "방금 그게 새 키였나"를 알려 주는 조회도 합니다. 그런데 호출부에서 boolean result = set("k", "v")라고 받으면, 이 result가 "저장 성공 여부"인지 "새 키 여부"인지 이름만으론 알 수가 없어요. 둘을 나눕시다.
✅ After
public boolean exists(String key) {
return store.containsKey(key);
}
public void set(String key, String value) {
store.put(key, value);
}
exists()는 묻기만 하고(조회), set()은 저장만 합니다(명령, 반환값 없음). "새 키였나"를 알고 싶으면 호출부에서 set 전에 exists()로 먼저 물어보면 돼요. 각 함수가 무엇을 하는지 이름과 반환 타입이 또렷하게 말해 줍니다.
⚠️ 언제 깨나 — 이미 모두가 아는 관용적인 API는 깹니다.
stack.pop()은 값을 꺼내면서(명령) 그 값을 돌려주고(조회),map.put()은 이전 값을 반환하죠. 수십 년간 굳어진 관용이라 아무도 헷갈리지 않아요. 또 동시성에서 "확인하고 바꾸기"를 한 번에 원자적으로 처리해야 하면(compareAndSet같은) 한 함수가 맞습니다. CQS는 가독성의 기본값이지 종교가 아니에요.
💡 한 줄 정리
한 함수는 묻거나 시키거나 — 둘을 섞으면 호출부가 무엇이 바뀌는지 모른다.
🙋 학생 질문 — "pop()은 꺼내면서 값도 주잖아요. 그건 왜 괜찮나요?"
pop()은 수십 년간 모든 개발자가 "꺼내면서 값을 돌려준다"는 동작을 똑같이 알고 있는 관용 표현이라, 헷갈릴 위험이 거의 없습니다. CQS의 진짜 목적은 "예상 못 한 부작용으로 호출부를 놀라게 하지 마라"예요. 모두가 아는 관용 API는 그 놀라움이 없어서 예외가 됩니다. 다만 우리가 새로 만드는 함수엔 기본적으로 CQS를 지키는 게 안전해요. 굳은 관용이거나 원자성이 꼭 필요한 곳에서만 의식적으로 깨면 됩니다.
Step 8: "중복은 줄이되, 성급한 추상화는 더 나쁘다"
같은 계산이 여러 군데 흩어져 있으면, 규칙이 바뀔 때 그 모든 곳을 일일이 고쳐야 합니다. 한 곳만 깜빡해도 버그죠. 그래서 같은 것을 반복하지 말라(DRY, Don't Repeat Yourself) 고 합니다. 할인 계산 코드를 봅시다.
❌ Before
public int loyalPrice(int price) {
return price - (int) (price * 0.2);
}
public int repeatPrice(int price) {
return price - (int) (price * 0.1);
}
public int eventPrice(int price) {
return price - (int) (price * 0.3);
}
price - (int) (price * rate)라는 똑같은 할인 공식이 세 번 반복됩니다. 할인을 "원가에서 빼기"가 아니라 "곱하기"로 바꾸자는 정책이 내려오면, 세 곳을 다 고쳐야 해요. 공식을 한 함수로 모읍시다.
✅ After
private static final double LOYAL_RATE = 0.2;
private static final double REPEAT_RATE = 0.1;
private static final double EVENT_RATE = 0.3;
public int loyalPrice(int price) {
return applyDiscount(price, LOYAL_RATE);
}
public int repeatPrice(int price) {
return applyDiscount(price, REPEAT_RATE);
}
public int eventPrice(int price) {
return applyDiscount(price, EVENT_RATE);
}
private int applyDiscount(int price, double rate) {
return price - (int) (price * rate);
}
할인 공식이 applyDiscount() 한 곳에 모였습니다. 이제 공식을 바꿀 일이 생기면 여기 한 곳만 고치면 돼요. 덤으로 지난 시간에 배운 대로 0.2, 0.1, 0.3 같은 매직 넘버도 이름 있는 상수로 뺐고요.
그런데 여기서 이 과목이 가장 강조하는 경계가 나옵니다. 모든 중복을 합치는 게 정답은 아니에요.
⚠️ 언제 깨나 — "지금 우연히 같아 보이는" 코드를 성급히 합치면, 나중에 둘이 서로 다른 이유로 달라져야 할 때 한 함수에
if를 덕지덕지 붙이게 돼 중복보다 더 엉킵니다. 이걸 AHA(Avoid Hasty Abstractions, 성급한 추상화를 피하라) 원칙이라고 해요. 같은 지식의 반복(할인 공식처럼 항상 함께 바뀌는 것)만 합치고, 우연한 중복(지금만 모양이 닮았을 뿐 다른 이유로 바뀔 코드)은 두세 번 더 반복될 때까지 지켜봅니다. "두 번까진 두고, 세 번째에 합쳐라"가 실무 감각이에요.
💡 한 줄 정리
같은 지식의 반복만 합쳐라 — 우연히 닮은 코드를 성급히 묶으면 중복보다 나쁘다.
🙋 학생 질문 — "중복이 보이면 바로 합치라고 배웠는데, 지켜보라니 헷갈려요."
"같은 지식인가, 우연히 같은 모양인가"를 구분하면 길이 보입니다. 두 할인 공식이 항상 같은 규칙을 따른다면 진짜 중복이니 합칩니다. 반대로 두 화면의 코드가 "지금은 같아 보이지만 서로 다른 이유로 바뀔" 거라면 우연한 중복이니 둡니다. 판단이 안 서면 합치지 말고 한두 번 더 기다리는 쪽이 안전해요. 흩어진 코드를 합치는 건 쉽지만, 잘못 합친 걸 다시 떼어 내는 건 훨씬 어렵거든요.
마무리
오늘은 함수 하나를 어떻게 다듬는지, 여덟 걸음을 걸었습니다. 지난 시간에 좋은 이름을 짓는 법을 배웠다면, 오늘은 그 이름이 붙는 함수 자체를 작고 정직하게 만드는 법을 익혔어요.
오늘 배운 핵심 세 가지
💡 하나 — 작게, 한 가지만, 한 눈높이에서. 긴 함수는 작은 함수들의 이야기로 풀고, "한 문장으로 설명되나"를 시금석으로 삼는다.
💡 둘 — 들어가고 나오는 길을 다듬어라. guard clause로 중첩을 풀고, 인자는 0~2개로 줄이고(많으면 객체로), 흐름을 가르는 불리언 플래그는 함수를 둘로 나눈다.
💡 셋 — 정직한 함수. 이름에 없는 부수 효과를 숨기지 말고, 명령과 조회를 분리하고, 진짜 중복만 합친다. 그리고 매번 ⚠️ "언제 깨나"를 함께 묻는다.
다음 시간 예고
다음 시간엔 좋은 객체·에러 처리·경계로 갑니다. 오늘 함수를 다듬었으니, 이제 그 함수들이 사는 집인 객체를 볼 차례예요. 오늘 Step 2에서 total() 함수가 Order의 데이터(itemPrices)를 꺼내 와서 계산을 대신했죠? 만약 객체가 자기 속을 getter로 다 까발리고 바깥 함수가 계산을 도맡는다면, 그건 좋은 설계일까요? 그 흐름을 "묻지 말고 시켜라(Tell, Don't Ask)" 로 뒤집는 이야기를 합니다. 더불어 오늘 Step 6·7에서 살짝 스친 null 반환과 예외 처리도 본격적으로 다뤄요.
과제
오늘 배운 함수 설계 원칙을 직접 손으로 적용해 보는 과제입니다. 눈으로 읽는 것과 손으로 고치는 건 전혀 다르니, 꼭 직접 Before를 After로 바꿔 보세요.
[기초] 긴 함수를 작은 함수로 쪼개기
아래 함수는 회원 등급 요약 문자열을 만드는데, 한 함수 안에서 여러 일을 합니다.
public String summary(List<Integer> orderPrices) {
int total = 0;
for (int price : orderPrices) {
total += price;
}
String grade;
if (total >= 100000) {
grade = "VIP";
} else if (total >= 50000) {
grade = "GOLD";
} else {
grade = "BASIC";
}
return "누적 " + total + "원 · 등급 " + grade;
}
이 함수를 작은 함수로 추출하고, summary()가 같은 눈높이의 호출만 남도록 다듬으세요. 힌트: "합계를 구한다", "등급을 정한다", "문자열로 꾸민다" 세 가지가 보일 겁니다. 매직 넘버(100000, 50000)도 함께 정리해 보세요.
[응용] 중첩과 불리언 플래그 함께 풀기
아래 함수는 중첩 if와 불리언 플래그를 둘 다 안고 있습니다.
public String render(String userName, boolean editable) {
if (userName != null) {
if (editable) {
return "[편집 가능] " + userName;
} else {
return "[읽기 전용] " + userName;
}
}
return "사용자 없음";
}
두 가지를 적용하세요. (1) guard clause로 userName == null을 먼저 걸러 중첩을 평평하게 만들고, (2) 흐름을 가르는 불리언 플래그 editable 을 어떻게 다룰지 결정해 고치세요. 분리한다면 왜, 남긴다면 왜인지 한 줄로 근거를 적어 보세요.
[심화] 규칙을 적용할까, 깰까 — 도그마 판단
아래 세 함수에 대해, "오늘 배운 규칙을 적용해 고칠지, 아니면 그대로 둘지"를 판단하고 그 이유를 한 줄씩 적으세요. 정답이 하나가 아닐 수 있습니다.
// (가) 자료구조에서 값을 꺼내며 돌려준다
public String pollNext() {
return queue.poll(); // 큐에서 꺼내고(명령) + 그 값을 반환(조회)
}
// (나) 지금은 본문이 똑같은 두 함수
public int areaOfSquare(int side) {
return side * side;
}
public int areaOfRectangleAsSquare(int side) {
return side * side; // 우연히 지금만 같다 — 직사각형은 곧 가로·세로가 달라질 예정
}
// (다) 이름과 다른 일을 한다
public boolean hasEnoughPoint(Member member) {
boolean enough = member.point() >= 1000;
member.markChecked(); // 확인하면서 '검사됨' 표시까지 남긴다
return enough;
}
생각해볼 주제
1. "함수는 20줄 이하"는 법칙일까?
클린 코드를 다루는 책과 글에는 "함수는 짧을수록 좋다", "20줄을 넘기지 마라" 같은 권장이 자주 나옵니다. 그런데 이걸 절대 법칙으로 받아들이면, 멀쩡한 함수를 억지로 잘게 쪼개 함수 사이를 점프하느라 더 읽기 어려워지는 일이 생깁니다. 줄 수라는 숫자와 "한 가지 일만 한다"는 본질 사이에서, 우리는 무엇을 기준으로 삼아야 할까요?
2. DRY와 "성급한 추상화 회피"는 부딪힌다 — 어디서 멈출까?
"중복을 없애라(DRY)"와 "성급하게 추상화하지 마라(AHA)"는 언뜻 반대로 들립니다. 둘 다 옳은 조언인데, 실제 코드 앞에서는 "이 중복을 지금 합칠까, 더 기다릴까"를 매번 판단해야 하죠. 진짜 중복과 우연한 중복을 가르는 나만의 기준은 무엇일지, 동료와 의견이 갈린다면 어떻게 합의할지 생각해 보세요.
3. 작은 함수가 많아지면 오히려 읽기 어렵지 않을까?
함수를 잘게 쪼갤수록 함수 하나하나는 짧아지지만, 전체 흐름을 따라가려면 여러 함수를 오가며 읽어야 합니다. "작은 함수가 많은 코드"와 "큰 함수 몇 개인 코드" 중 어느 쪽이 더 읽기 좋은지는 상황에 따라 다를 수 있어요. 어떤 경우에 잘게 쪼개는 게 이득이고, 어떤 경우에 흐름이 끊겨 손해인지 따져 보세요. (성능과 가독성이 부딪히는 더 깊은 이야기는 이 과목 마지막에 다룹니다.)
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 풀이의 함수 이름과 쪼개는 단위는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 의도가 드러나고 동작이 보존되면 다른 모양도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.
과제 예시답안
🎯 [과제 1 예시답안] 긴 함수를 작은 함수로 쪼개기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 함수 추출 | 합계 구하기·등급 정하기를 각각 작은 함수로 분리 | ★★★ |
| 추상화 수준 통일 | summary()가 같은 눈높이의 호출만 남는가 |
★★★ |
| 매직 넘버 | 100000·50000 → 이름 있는 상수 |
★★☆ |
| 동작 보존 | 합계·등급 계산 로직 자체는 그대로 | ★★★ |
풀이 예시
❌ Before
public String summary(List<Integer> orderPrices) {
int total = 0;
for (int price : orderPrices) {
total += price;
}
String grade;
if (total >= 100000) {
grade = "VIP";
} else if (total >= 50000) {
grade = "GOLD";
} else {
grade = "BASIC";
}
return "누적 " + total + "원 · 등급 " + grade;
}
✅ After
private static final int VIP_THRESHOLD = 100000;
private static final int GOLD_THRESHOLD = 50000;
public String summary(List<Integer> orderPrices) {
int total = total(orderPrices);
return "누적 " + total + "원 · 등급 " + grade(total);
}
private int total(List<Integer> orderPrices) {
int total = 0;
for (int price : orderPrices) {
total += price;
}
return total;
}
private String grade(int total) {
if (total >= VIP_THRESHOLD) {
return "VIP";
}
if (total >= GOLD_THRESHOLD) {
return "GOLD";
}
return "BASIC";
}
💡 튜터의 한마디: summary()를 읽으면 "합계를 구하고, 등급을 붙여 문구로 만든다"는 이야기가 한눈에 들어옵니다. 루프를 어떻게 도는지, 등급 경계가 얼마인지 같은 디테일은 각 함수 안으로 내려갔어요. grade()에서는 등급 결정을 이른 반환으로 풀어, else if 사슬보다 더 평평하게 만들었습니다(이건 취향이라 else if로 둬도 좋아요). 100000·50000을 상수로 빼 두면, 등급 기준이 바뀔 때 상수 한 곳만 고치면 됩니다. 핵심은 "한 함수가 한 가지 일만, 같은 눈높이에서" 하도록 나눈 것입니다.
🎯 [과제 2 예시답안] 중첩과 불리언 플래그 함께 풀기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| guard clause | userName == null을 먼저 걸러 중첩 제거 |
★★★ |
| 불리언 플래그 판단 | 분리/유지 중 택하고 근거를 적었는가 | ★★★ |
| 동작 보존 | 세 경우(없음·편집·읽기)의 결과가 그대로 | ★★☆ |
풀이 예시
먼저 어느 쪽을 택하든 guard clause로 중첩을 평평하게 푸는 건 공통입니다.
✅ After — (1) 플래그를 남기되 중첩만 푼 버전
public String render(String userName, boolean editable) {
if (userName == null) {
return "사용자 없음";
}
if (editable) {
return "[편집 가능] " + userName;
}
return "[읽기 전용] " + userName;
}
✅ After — (2) 흐름을 가르는 플래그라 함수를 둘로 나눈 버전
public String renderEditable(String userName) {
if (userName == null) {
return "사용자 없음";
}
return "[편집 가능] " + userName;
}
public String renderReadOnly(String userName) {
if (userName == null) {
return "사용자 없음";
}
return "[읽기 전용] " + userName;
}
💡 튜터의 한마디: 두 버전 다 맞습니다. editable이 함수 안에서 흐름을 두 갈래로 가르니, 원칙대로면 (2)처럼 함수를 나눠 이름이 의도를 말하게 하는 게 더 선명해요. 다만 (2)는 null 검사가 두 함수에 똑같이 들어가 작은 중복이 생깁니다. 화면 모드가 앞으로 더 늘어날 가능성이 거의 없고 호출부가 충분히 읽힌다면, (1)처럼 플래그를 남기는 선택도 합리적이에요. 정답을 고르는 것보다 "왜 이 선택을 했는지" 근거를 댈 수 있는 것이 핵심입니다.
🎯 [과제 3 예시답안] 규칙을 적용할까, 깰까 — 도그마 판단
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| (가) 관용 API 예외 | poll을 CQS 예외로 인정하는가 |
★★★ |
| (나) 우연한 중복 | 지금만 닮은 코드라 합치지 않음을 판단 | ★★★ |
| (다) 부수 효과·CQS | 이름과 다른 동작을 식별해 분리 | ★★★ |
| 판단 근거 서술 | "적용/보류/예외"를 이유와 함께 설명 | ★★★ |
풀이 예시
(가) — 그대로 둔다 (CQS를 깨는 게 맞다). poll()은 "꺼내면서 값을 돌려준다"는 동작을 수십 년간 모든 개발자가 똑같이 아는 관용 API라 헷갈릴 위험이 없습니다. CQS의 목적은 "예상 못 한 부작용으로 호출부를 놀라게 하지 마라"인데, 여기엔 그 놀라움이 없어요. 이름 pollNext가 "꺼낸다"는 뜻을 담고 있어 더 안전합니다.
(나) — 합치지 않는다 (DRY를 보류한다). 지금은 두 함수 본문이 side * side로 똑같지만, 주석대로 직사각형은 곧 width * height로 바뀔 예정입니다. 이건 같은 지식의 반복이 아니라 우연히 지금만 닮은 모양이에요. 성급히 합치면, 나중에 직사각형 공식이 달라질 때 합쳐 둔 함수에 분기를 덧대다 더 엉킵니다(AHA). 둘이 정말 같은 규칙으로 굳을 때까지 둡니다.
(다) — 분리한다 (부수 효과와 CQS 위반을 고친다). 이름은 hasEnoughPoint라 "묻기만 할 것" 같은데, 실제로는 markChecked()로 상태를 바꾸는 부수 효과가 숨어 있습니다. 조회인 척하면서 명령을 하는 거짓말이에요.
// After — 조회와 명령을 분리
public boolean hasEnoughPoint(Member member) {
return member.point() >= MIN_POINT;
}
// 표시가 필요하면 호출부가 명시적으로 부른다
member.markChecked();
💡 튜터의 한마디: 이 과제의 진짜 목표는 규칙을 외워 기계처럼 적용하는 게 아니라, 언제 적용하고 언제 깰지 가리는 판단입니다. 같은 "CQS 위반"이라도 (가)는 관용이라 두고 (다)는 거짓말이라 고칩니다. 같은 "중복처럼 보이는 코드"라도 (나)는 우연이라 둡니다. 이 판단을 근거와 함께 내릴 수 있으면, 규칙을 외우는 단계를 넘어선 거예요.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] "함수는 20줄 이하"는 법칙일까?
문제 상황 요약
"함수는 짧을수록 좋다", "20줄을 넘기지 마라" 같은 권장을 절대 법칙으로 받아들일 때의 위험을 짚어 보는 주제입니다.
튜터의 가이드 및 해설
줄 수는 목표가 아니라 신호입니다. 본질은 "이 함수가 한 가지 일을 하는가"예요. 한 가지 일만 하는 25줄 함수가, 두 가지 일을 하는 10줄 함수보다 읽기 좋을 수 있습니다.
"20줄"을 절대 법칙으로 삼으면 방향이 뒤집힙니다. 멀쩡히 한 가지 일을 하는 함수를 줄 수만 맞추려고 억지로 잘게 쪼개면, 읽는 사람이 함수 사이를 점프하느라 흐름이 끊겨 오히려 이해가 느려져요. 줄 수 제한을 통과시키려고 만든 작은 함수들은 보통 이름도 어색하고, 한 번만 호출됩니다.
그러니 줄 수는 "혹시 이 함수가 여러 일을 하는 건 아닌가?" 하고 멈춰 살펴보게 하는 알람으로 쓰면 됩니다. 알람이 울리면 줄을 세지 말고 "한 문장으로 설명되나, 추상화 수준이 섞이지 않았나"를 보세요. 그 답이 진짜 기준입니다.
🎯 면접에선 이렇게 나온다
"함수 길이는 목표가 아니라 '한 가지 일을 하는가'를 의심하게 하는 신호로 봅니다. 길이 제한을 맞추려고 억지로 쪼개면 함수 점프로 가독성이 오히려 떨어지니까요. 줄 수가 길면 '여러 일을 하는 건 아닌지' 점검하고, 한 가지 일이라면 길어도 둡니다."
💡 실무에선
린터가 "함수가 너무 길다"고 경고하면 기계적으로 자르지 않습니다. 먼저 "왜 긴가 — 여러 일을 하나, 아니면 한 가지인데 본래 긴가"를 보고, 전자일 때만 추출합니다.
🤔 [생각해볼 주제 2] DRY와 "성급한 추상화 회피"는 부딪힌다 — 어디서 멈출까?
문제 상황 요약
"중복을 없애라(DRY)"와 "성급하게 추상화하지 마라(AHA)"가 충돌하는 지점에서, 무엇을 기준으로 멈출지 판단하는 주제입니다.
튜터의 가이드 및 해설
두 조언은 사실 충돌하지 않습니다. DRY가 없애려는 건 "코드 줄의 반복"이 아니라 "지식의 반복" 이기 때문이에요. 같은 규칙·같은 결정이 여러 곳에 흩어져, 하나가 바뀌면 나머지도 반드시 함께 바뀌어야 하는 것 — 그게 진짜 중복입니다.
반대로 "지금은 코드 모양이 같지만, 서로 다른 이유로 바뀔" 코드는 우연한 중복이에요. 이걸 성급히 한 함수로 합치면, 나중에 둘이 갈라져야 할 때 그 함수에 if와 플래그를 덧대다 중복이었을 때보다 더 엉킵니다. 잘못 만든 추상화를 다시 떼어 내는 비용이, 중복을 잠시 두는 비용보다 훨씬 커요.
그래서 실무에서는 "같은 코드가 세 번째 등장할 때 합쳐라(rule of three)"는 감각을 씁니다. 두 번까지는 우연일 수 있으니 지켜보고, 세 번째에 패턴이 분명해지면 그때 합치는 거죠. 판단이 안 서면 합치지 않는 쪽이 안전합니다.
🎯 면접에선 이렇게 나온다
"DRY는 코드 줄이 아니라 '지식'의 중복을 없애는 원칙이라고 봅니다. 같은 이유로 늘 함께 바뀌는 코드만 합치고, 지금만 닮았을 뿐 다른 이유로 변할 코드는 성급히 합치지 않습니다. 잘못된 추상화를 되돌리는 비용이 중복을 잠시 두는 비용보다 크기 때문에, 보통 'rule of three'로 세 번째에 합칩니다."
💡 실무에선
중복을 합칠지 망설여지면 일단 두는 쪽을 택합니다. 합치는 건 나중에 쉽지만, 잘못 합친 추상화를 떼는 건 어렵거든요. 두세 번째 반복에서 "정말 같은 규칙"임이 분명해질 때 추출합니다.
🤔 [생각해볼 주제 3] 작은 함수가 많아지면 오히려 읽기 어렵지 않을까?
문제 상황 요약
함수를 잘게 쪼갤수록 함수 하나는 짧아지지만 전체를 따라가려면 여러 함수를 오가야 합니다. 추출의 이득과 비용을 어디서 가늠할지 보는 주제입니다.
튜터의 가이드 및 해설
함수 추출에는 이득과 비용이 같이 따라옵니다. 이득은 각 함수가 한눈에 들어오고, 함수 이름이 곧 작은 문서가 된다는 것. 비용은 흐름을 따라가려 함수 사이를 오가야 한다는 것(함수 점프)이에요.
여기서 갈림길은 추출이 이름으로 의미를 더하느냐입니다. 잘 추출된 코드는 위에서 아래로 큰 이야기부터 세부로 자연스럽게 읽혀서, 굳이 모든 함수로 내려가지 않아도 윗단만 읽고 흐름을 잡을 수 있어요. 반대로 이름이 부실하거나, 한 번만 쓰는 두세 줄을 억지로 빼면 점프 비용만 남고 얻는 의미가 없습니다.
그러니 "작은 함수가 많은 게 무조건 좋다"가 아니라, "이 추출이 본문을 읽는 것보다 더 빠른 이해를 주는가"로 판단하면 됩니다. 그 답이 '예'면 이득이고, '아니오'면 그냥 인라인으로 두는 게 낫습니다.
🎯 면접에선 이렇게 나온다
"함수 추출의 가치는 함수 개수가 아니라 '이름이 의미를 더하는가'로 판단합니다. 잘 추출하면 윗단만 읽고도 흐름을 잡을 수 있어 점프가 줄지만, 이름이 부실하거나 한 번만 쓰는 코드를 억지로 빼면 점프 비용만 남습니다. 그래서 추출이 본문보다 빠른 이해를 주는지를 기준으로 삼습니다."
💡 실무에선
한 번만 쓰고 이름이 본문보다 길어지는 추출은 인라인으로 되돌립니다. 대부분의 IDE에 함수를 다시 펼치는 기능이 있어, 추출과 인라인을 오가며 더 읽기 좋은 쪽을 고르면 됩니다. (성능과 가독성이 부딪히는 더 깊은 이야기는 이 과목 마지막 모듈에서 다룹니다.)