A-4: 에러 처리·경계 — 없음과 실패를 안전하게, 외부와는 깨끗하게
목차 25
안녕하세요, 여러분의 코드 안목을 길러 드릴 홍순구 튜터입니다.
지난 시간엔 객체를 다뤘죠. 묻지 말고 시키고, 속을 감추고, 함부로 바뀌지 않게 지켰습니다. 그 마지막에 예약 명단을 불변으로 만들면서 제가 슬쩍 질문 하나를 남겼어요. "예약을 찾았는데 손님 명단이 비어 있거나, 아예 예약을 못 찾으면 뭘 돌려줘야 하지? 빈 명단? null? 예외?" 오늘이 바로 그 답을 푸는 시간입니다.
좋은 코드는 정상 흐름만 깔끔한 게 아닙니다. "없음"과 "실패"를 어떻게 다루느냐가 코드의 진짜 품질을 가릅니다. 그리고 우리 코드는 혼자 살지 않아요. 외부 라이브러리, 외부 API에 기대어 돌아갑니다. 그 외부와 만나는 경계를 어떻게 그으냐도 오늘의 주제입니다.
신입이 에러 처리와 경계에서 코드 리뷰 때 자주 듣는 두 마디가 있습니다.
- "여기
null체크가 지옥이에요."- "외부 API 바뀌니까 우리 코드가 다 터졌어요."
두 마디 모두 없음·실패·외부를 다루는 솜씨에 대한 지적입니다. 오늘은 에러 코드 대신 예외로 호출부를 깨끗하게 만들고, null 대신 빈 값과 Optional로 NPE 지옥을 끊고, 예외를 꼭 필요할 때만 쓰고, 외부 코드를 우리 경계로 감싸는 법을 봅니다. 그리고 이 과목이 늘 강조하는 한 가지, 각 원칙을 언제 깨야 하는지까지 함께요.
A-3 좋은 객체 "객체에게 시키고, 속을 감추고, 안 바뀌게"
│
▼
A-4 에러 처리 · 경계 오늘 — "없음과 실패를 안전하게, 외부와 깨끗하게"
│
├─ 에러 코드 대신 예외로 호출부를 깨끗하게
├─ null 대신 빈 값 · Optional NPE 지옥 끊기
├─ 예외는 진짜 예외 상황에만 흐름 제어에 쓰지 않기
└─ 외부는 감싸고 테스트로 고정 "API 바뀌어도 안 터지게"
│
▼
B-1 응집 · 결합 + SOLID 다음 — "클래스 사이의 구조"
💡 오늘 수업의 핵심 — "없음과 실패를 숨기지 말고, 타입과 예외로 또렷하게 말하라"
에러 코드와 null은 "문제가 생겼다"는 사실을 숫자나 빈 참조 뒤에 숨깁니다. 호출부가 그걸 일일이 외워서 검사해야 하죠. 오늘은 그 숨은 신호를 이름 있는 예외와 없음을 드러내는 타입(Optional)으로 끌어올려, 코드가 스스로 "여기서 이런 일이 생길 수 있다"고 말하게 만듭니다. 그리고 외부 코드는 우리 경계 안쪽에 가둬, 바깥이 흔들려도 우리 코드는 흔들리지 않게 합니다.
🎯 학습 목표
- 에러 코드 반환의 if 지옥을 예외로 풀고,
null대신 빈 컬렉션·특수 케이스 객체·Optional로 "없음"을 안전하게 표현한다. - 예외를 흐름 제어가 아닌 진짜 예외 상황에만 쓰고, checked와 unchecked의 차이를 안다.
- 외부 라이브러리를 경계로 감싸고, 학습 테스트로 외부 동작을 우리 테스트에 고정한다.
Step 1: "에러 코드의 if 지옥 — 예외로 호출부를 깨끗하게"
코드 리뷰에서 이런 코드를 만나면 리뷰어가 멈칫합니다. "이 -1이 무슨 뜻이에요? -2는요?" 결과를 숫자로 돌려주는 코드의 흔한 풍경입니다.
포인트를 차감하는 서비스를 봅시다. 성공이면 0, 회원이 없으면 -1, 잔액이 부족하면 -2를 돌려줍니다.
public class ErrorCodePointService {
public static final int SUCCESS = 0;
public static final int MEMBER_NOT_FOUND = -1;
public static final int INSUFFICIENT_POINT = -2;
private final MemberRepository repository;
public ErrorCodePointService(MemberRepository repository) {
this.repository = repository;
}
public int withdraw(Long memberId, int amount) {
Member member = repository.findById(memberId);
if (member == null) {
return MEMBER_NOT_FOUND;
}
if (member.getPoint() < amount) {
return INSUFFICIENT_POINT;
}
member.deductPoint(amount);
return SUCCESS;
}
}
이 메서드를 쓰는 호출부는 어떻게 될까요? 돌려받은 숫자가 무슨 뜻인지 외워서, 하나하나 if로 분기해야 합니다.
public String pay(Long memberId, int amount) {
int result = service.withdraw(memberId, amount);
if (result == ErrorCodePointService.MEMBER_NOT_FOUND) {
return "회원을 찾을 수 없습니다.";
} else if (result == ErrorCodePointService.INSUFFICIENT_POINT) {
return "포인트가 부족합니다.";
} else {
return "결제 완료";
}
}
문제가 보이시나요? 정상 흐름(차감 성공)이 에러 분기 더미에 파묻혀 잘 안 보입니다. 진짜 하려던 일은 "포인트 차감 후 결제 완료"인데, 그 한 줄이 if-else 사이에 끼어 있어요. 게다가 호출부가 -1 검사를 깜빡하면, 컴파일러는 아무 말도 안 합니다. 그냥 잘못된 안내가 조용히 나갈 뿐이죠.
에러 코드 방식의 흐름
withdraw() ──▶ 0 / -1 / -2 (숫자만 돌아온다, 의미는 호출부가 외운다)
│
▼
호출부: if (-1) ... else if (-2) ... else 정상
└── 정상 흐름이 에러 검사에 파묻혀 안 보인다
└── 검사를 깜빡해도 컴파일러는 모른다
이제 같은 일을 예외로 풀어 봅시다. 정상 처리는 그냥 끝내고(void), 잘못된 상황은 이름이 분명한 예외로 던집니다.
public class PointService {
private final MemberRepository repository;
public PointService(MemberRepository repository) {
this.repository = repository;
}
public void withdraw(Long memberId, int amount) {
Member member = repository.findById(memberId);
if (member == null) {
throw new MemberNotFoundException(memberId);
}
if (member.getPoint() < amount) {
throw new InsufficientPointException(member.getPoint(), amount);
}
member.deductPoint(amount);
}
}
-1 대신 MemberNotFoundException이라는 이름이 무엇이 잘못됐는지 스스로 말합니다. 예외 클래스는 맥락도 함께 담을 수 있어요.
public class InsufficientPointException extends RuntimeException {
public InsufficientPointException(int current, int requested) {
super("포인트가 부족합니다. 보유=" + current + ", 요청=" + requested);
}
}
그러면 호출부는 어떻게 바뀔까요? 정상 흐름만 적으면 됩니다.
public String pay(Long memberId, int amount) {
service.withdraw(memberId, amount);
return "결제 완료";
}
withdraw가 정상이면 다음 줄로 가고, 문제가 생기면 예외가 위로 전달됩니다. 정상 시나리오가 한눈에 읽히고, 에러 처리는 그걸 책임질 위쪽에서 한 번에 모아 다룰 수 있습니다. 에러 코드를 외워 분기하던 if 더미가 통째로 사라졌어요.
⚠️ 언제 깨나 — 예외도 공짜가 아닙니다. 예외 객체는 만들어질 때 호출 스택을 기록하는데, 이게 생각보다 비용이 듭니다. 그래서 아주 흔하게 일어나는 정상적인 분기까지 예외로 만들면, 비용도 비용이고 흐름도 더 안 보여요. "회원이 없다"처럼 진짜 비정상인 상황에만 예외를 쓰고, 평범한 분기는 그냥 if로 두세요. 이 경계는 Step 4에서 더 깊이 봅니다. 또 예외 클래스를 지나치게 잘게 쪼개 수십 개로 불리는 것도 과합니다.
💡 한 줄 정리
에러 코드는 "문제가 생겼다"를 숫자에 숨겨 호출부에 검사를 떠넘기지만, 예외는 이름으로 무엇이 잘못됐는지 말하고 정상 흐름을 또렷하게 남긴다.
🙋 학생 질문 — "에러 코드는 무조건 나쁜 건가요? 본 적이 있어서요."
좋은 질문이에요. 무조건 나쁜 건 아닙니다. C 언어나 운영체제 커널처럼 예외라는 장치가 아예 없거나, 예외 비용을 한 톨도 못 쓰는 극한 성능 환경에서는 에러 코드가 표준입니다. 리눅스 시스템 콜이 -1을 돌려주고 errno를 세팅하는 게 대표적이죠.
핵심은 "환경에 맞는 도구를 쓰라"입니다. 자바·코틀린 같은 일반 애플리케이션에서는 예외라는 잘 만들어진 장치가 있으니 그걸 쓰는 게 표준이에요. 에러 코드를 쓰면 오늘 본 if 지옥과 "검사 누락" 문제를 고스란히 떠안게 됩니다. 도구가 있는데 안 쓸 이유가 없는 거죠.
Step 2: "null을 주고받지 마라 — 빈 컬렉션과 특수 케이스 객체"
"여기 null 체크가 지옥이에요." 코드 리뷰 단골 멘트입니다. 그 지옥의 절반은, 메서드가 결과가 없을 때 null을 돌려주는 데서 시작됩니다.
등급으로 회원을 찾는 조회기를 봅시다. 해당하는 회원이 한 명도 없으면 null을 돌려줍니다.
public List<Member> findByGrade(String grade) {
List<Member> found = new ArrayList<>();
for (Member member : members) {
if (member.grade().equals(grade)) {
found.add(member);
}
}
if (found.isEmpty()) {
return null; // 호출부가 검사를 잊으면 NPE 의 씨앗이 된다
}
return found;
}
이걸 쓰는 호출부는 순회하기 전에 매번 null인지 검사해야 합니다. 한 번이라도 깜빡하면, 바로 다음 줄에서 NullPointerException이 터져요.
null 반환이 부르는 NPE 체인
findByGrade("VIP") ──▶ null (결과 없음을 null 로 표현)
│
▼
for (Member m : result) ← 검사 깜빡 → 여기서 NPE 폭발
if (result != null) for (...) ← 검사하면 호출부마다 null 가드가 번진다
해법은 단순합니다. "없음"을 null이 아니라 빈 컬렉션으로 표현하는 거예요.
public List<Member> findByGrade(String grade) {
List<Member> found = new ArrayList<>();
for (Member member : members) {
if (member.grade().equals(grade)) {
found.add(member);
}
}
return found; // 비어 있어도 null 이 아니라 빈 리스트
}
빈 리스트도 똑같이 for로 돌릴 수 있습니다. 0번 도는 것뿐이죠. 그래서 호출부는 null 검사 없이 그냥 순회하면 됩니다. "0건"은 컬렉션의 정상적인 상태이지, 오류 상황이 아니니까요.
값 하나를 돌려주는 경우엔 어떻게 할까요? 이때는 특수 케이스 객체가 잘 맞습니다. "아무 일도 하지 않는" 객체를 하나 만들어 null을 대신하는 방법이에요. 할인 정책을 예로 보죠. 먼저 정책 인터페이스와 기본 정책입니다.
public interface DiscountPolicy {
int applyTo(int price);
}
public class BasicDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.1;
@Override
public int applyTo(int price) {
return (int) (price * (1 - DISCOUNT_RATE));
}
}
이제 "할인 없음"도 null이 아니라 하나의 정책으로 봅니다. 아무것도 깎지 않는 정책을요.
public class NoDiscountPolicy implements DiscountPolicy {
public static final NoDiscountPolicy NONE = new NoDiscountPolicy();
@Override
public int applyTo(int price) {
return price; // 원가 그대로 — 깎지 않는다
}
}
정책을 찾아 주는 쪽은, 매칭되는 등급이 없을 때 null 대신 이 NONE을 돌려줍니다.
public DiscountPolicy findByGrade(String grade) {
return policies.getOrDefault(grade, NoDiscountPolicy.NONE);
}
이제 호출부는 정책이 무엇이든 policy.applyTo(price)를 그냥 부르면 됩니다. "VIP면 깎고, 아니면 그대로"라는 if 분기가 필요 없어요. NoDiscountPolicy가 "그대로"를 알아서 처리하니까요. null 검사가 통째로 사라집니다.
그리고 한 가지 더. null을 돌려주지 않는 것만큼, null을 인자로 넘기지도 마세요. 메서드가 null을 받을 수 있다고 가정하는 순간, 그 메서드는 맨 앞에 방어 코드를 깔게 됩니다. 호출하는 쪽도 받는 쪽도 서로 null을 안 주고 안 받기로 약속하면, 그 방어 코드가 애초에 필요 없어집니다.
⚠️ 언제 깨나 — 빈 값이나 특수 케이스 객체로 "진짜 없음"을 뭉개 버리면 안 됩니다. "조회 결과가 0건"과 "조회 자체가 불가능한 잘못된 요청"은 의미가 다른데, 둘 다 빈 리스트로 돌려주면 호출부가 구분할 수 없어요. 후자라면 빈 컬렉션이 아니라 예외가 맞습니다. 특수 케이스 객체도 "아무것도 안 하는 게 올바른 기본 동작일 때"만 쓰세요. 억지로 만들면 버그를 조용히 삼킵니다.
💡 한 줄 정리
"없음"은 null이 아니라 빈 컬렉션이나 특수 케이스 객체로 표현하면, 호출부의 null 검사가 통째로 사라진다. 단, 진짜 없음을 빈 값으로 뭉개지는 말 것.
🙋 학생 질문 — "특수 케이스 객체(Null Object)는 언제 과한가요?"
"아무것도 안 하는 게 명백히 올바른 기본 동작일 때"만 쓰세요. 할인 정책의 NoDiscountPolicy는 좋은 예입니다. 할인이 없으면 원가 그대로가 자연스러운 결과니까요.
반대로 위험한 경우도 있어요. 예를 들어 "결제 수단"을 못 찾았는데 NoPaymentMethod 같은 걸 만들어 아무 일도 안 하게 하면, 결제가 안 됐는데도 시스템은 정상인 줄 알고 넘어갑니다. 이건 "진짜 없음"을 조용히 삼켜 버리는 거예요. 이럴 땐 특수 케이스 객체가 아니라 예외가 맞습니다. 판단 기준은 늘 하나예요. "그 빈 동작이 정말 올바른 결과인가, 아니면 사실은 오류를 숨기는 건가."
Step 3: "없음을 타입으로 — Optional"
지난 시간 마지막에 던진 질문 기억하시죠? "예약을 못 찾으면 뭘 돌려줄까?" 컬렉션이면 빈 리스트로 풀었습니다. 그런데 값 하나를 찾는데 없을 수도 있다면요? 여기에 자바 8부터 들어온 도구가 있습니다. Optional이에요.
먼저 문제 상황부터요. 이메일로 회원 한 명을 찾는 메서드가 없을 때 null을 돌려준다고 합시다. 시그니처만 봐서는 이게 null을 줄 수 있는지 아무도 모릅니다.
Member findByEmail(String email) ← 이 시그니처는 "없을 수 있음" 을 말하지 않는다
호출부는 null 가능성을 모른 채 그냥 점을 찍는다 → NPE
Optional을 반환하면, 시그니처 자체가 "이 결과는 없을 수도 있다"고 강제로 알립니다.
public class MemberDirectory {
private final List<Member> members;
public MemberDirectory(List<Member> members) {
this.members = members;
}
public Optional<Member> findByEmail(String email) {
for (Member member : members) {
if (member.email().equals(email)) {
return Optional.of(member);
}
}
return Optional.empty();
}
}
Optional<Member>를 받은 호출부는 "그냥 점 찍기"를 할 수가 없습니다. 컴파일러가 "안에 값이 있는지 먼저 확인하라"고 요구하거든요. 덕분에 null 검사를 깜빡할 길이 막힙니다. 그리고 그 "없을 때 어떻게 할지"를 상황에 맞게 고를 수 있어요.
public class MemberLookup {
private final MemberDirectory directory;
public MemberLookup(MemberDirectory directory) {
this.directory = directory;
}
/** 반드시 있어야 하는 경우 — 없으면 예외로 끝낸다. */
public Member getOrThrow(String email) {
return directory.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("회원이 없습니다. " + email));
}
/** 없어도 괜찮은 경우 — 기본 이름으로 대체한다. */
public String nameOrGuest(String email) {
return directory.findByEmail(email)
.map(Member::name)
.orElse("게스트");
}
/** 값이 있을 때만 변환한다 — 없으면 빈 Optional 그대로 흘려보낸다. */
public Optional<String> emailUpperCase(String email) {
return directory.findByEmail(email)
.map(member -> member.email().toUpperCase());
}
}
같은 "없을 수 있음"이라도 처리가 다릅니다. 반드시 있어야 하면 orElseThrow로 없을 때 예외를 던지고, 없어도 괜찮으면 orElse로 기본값을 주고, 있을 때만 변환하면 map으로 처리한 뒤 없으면 빈 Optional을 그대로 흘려보냅니다. null이었다면 이 셋을 전부 if로 직접 짰어야 했죠.
⚠️ 언제 깨나 —
Optional은 "반환값"에만 쓰세요. 필드 타입이나 메서드 파라미터, 컬렉션의 원소 타입으로Optional을 쓰는 건 남용입니다. 객체마다Optional껍데기가 하나씩 더 붙어 메모리와 복잡도만 늘고, 직렬화에서도 문제가 생겨요. 컬렉션이 비었으면Optional<List>가 아니라 그냥 빈 리스트면 됩니다. 그리고Optional을 받자마자.get()으로 바로 까는 습관도 안티예요. 확인 없이.get()을 부르면 비었을 때 예외가 나는데, 결국null만큼 위험한 거니까요.
💡 한 줄 정리
Optional<T>은 "없을 수 있음"을 반환 타입으로 드러내 호출부가 그 가능성을 못 본 척할 수 없게 한다. 단 반환값 전용이고, 필드·파라미터·.get() 남발은 금물.
🙋 학생 질문 — "그럼 null은 이제 영영 안 쓰나요?"
그렇진 않아요. null이 완전히 사라지는 건 아닙니다. 메서드 안쪽의 짧은 지역 변수나, 성능이 아주 민감한 자료구조 내부, 또는 프레임워크가 null을 강제하는 곳에서는 여전히 등장합니다.
Optional의 진짜 역할은 "API 경계에서 없음을 알리는 것"이에요. 내가 만든 메서드가 다른 사람에게 결과를 돌려줄 때, "이건 없을 수도 있어요"를 타입으로 분명히 말하는 도구죠. 안쪽 구현 디테일까지 전부 Optional로 도배하라는 뜻은 아닙니다. 경계는 Optional로 또렷하게, 내부는 상황에 맞게 — 이 균형이 핵심입니다.
Step 4: "예외는 예외 상황에만 — 흐름 제어에 쓰지 마라"
Step 1에서 예외로 호출부를 깨끗하게 만들었죠. 그런데 예외를 너무 좋아하게 되면 정반대 함정에 빠집니다. 정상적인 흐름 제어에까지 예외를 쓰는 거예요. 코드 리뷰에서 이런 코드를 보면 리뷰어가 한 번 더 멈칫합니다. "이거 왜 try-catch로 루프를 돌아요?"
배열의 합을 구하는 코드를 봅시다. 배열 끝을 ArrayIndexOutOfBoundsException으로 감지해서 루프를 빠져나갑니다.
public int sum(int[] numbers) {
int total = 0;
int i = 0;
try {
while (true) {
total += numbers[i];
i++;
}
} catch (ArrayIndexOutOfBoundsException e) {
// 배열 끝에 닿으면 예외로 루프 종료 — 정상 흐름을 예외로 다룬다
}
return total;
}
동작은 합니다. 하지만 "배열의 끝에 도달했다"는 건 지극히 정상적인 상황인데, 그걸 예외로 처리하고 있어요. 문제가 셋입니다. 첫째, 예외 객체를 만드느라 비용이 들고요. 둘째, 코드를 읽는 사람이 "이게 진짜 오류 처리인가, 아니면 정상 종료인가"를 구분할 수 없어요. 셋째, 의도가 코드에 안 드러납니다.
같은 일을 정상적인 경계 검사로 풀면 이렇게 됩니다.
public int sum(int[] numbers) {
int total = 0;
for (int i = 0; i < numbers.length; i++) {
total += numbers[i];
}
return total;
}
i < numbers.length라는 조건이 "어디까지 도는지"를 그대로 말합니다. 예외는 진짜 예외 상황에만 남겨 두고요. 더 빠르고, 더 잘 읽힙니다.
그러면 진짜 예외는 어떻게 다룰까요? 여기서 checked 예외와 unchecked 예외의 구분이 나옵니다.
checked 예외 컴파일러가 try-catch(또는 throws)를 강제한다
예: IOException — "복구를 고려해야 하는 상황" 을 의도
unchecked 예외 강제하지 않는다 (RuntimeException 계열)
예: IllegalArgumentException — 프로그래밍 오류·복구 곤란
저수준에서 올라오는 예외를 그대로 위로 흘리면, 호출부가 외부 사정을 다 알아야 합니다. 그래서 경계에서 우리 도메인의 예외로 바꿔 감싸는 게 좋아요. 설정값을 정수로 읽는 작은 예를 봅시다.
public int loadTimeout(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException cause) {
throw new ConfigParseException("타임아웃 설정이 숫자가 아닙니다: " + raw, cause);
}
}
Integer.parseInt가 던지는 저수준 기술 예외(NumberFormatException)를, 우리 도메인 언어인 ConfigParseException으로 바꿔 던집니다. 호출부는 "설정 파싱이 실패했다"는 우리 언어만 알면 되고, 저수준 예외의 종류는 몰라도 됩니다. 이때 원래 예외를 cause로 함께 넘기는 걸 잊지 마세요. 나중에 원인을 추적할 수 있도록요. 그리고 우리 ConfigParseException은 RuntimeException을 상속한 unchecked 예외라, 호출부에 try-catch를 강제하지 않습니다.
⚠️ 언제 깨나 — "예외를 0개로 만드는 것"이 목표가 아닙니다. 파일이 없거나, 네트워크가 끊기거나, 잔액이 부족한 것처럼 진짜 비정상인 상황은 예외가 정답이에요. 오늘 말한 건 "정상적인 흐름 제어에 예외를 쓰지 말라"는 거지, "예외 자체를 피하라"가 아닙니다. 예외를 옳게 쓰는 것과, 예외를 흐름 제어 도구로 남용하는 것을 가르세요.
💡 한 줄 정리
예외는 진짜 비정상에만 쓴다. 정상적인 종료·분기를 예외로 처리하면 비싸고 안 읽힌다. 저수준 예외는 경계에서 우리 도메인 예외로 감싸 호출부를 보호한다.
🙋 학생 질문 — "checked랑 unchecked, 둘 중 뭘 써야 하나요?"
판단 기준은 "호출부가 그 예외를 받아 의미 있게 복구할 수 있는가"입니다. 호출부가 잡아서 재시도하거나 대체 경로로 갈 수 있는 상황이면 checked 예외를 고려할 수 있어요. 컴파일러가 "이건 처리해야 해"라고 강제해 주니까요.
하지만 현실의 비즈니스 오류(잔액 부족, 중복 가입 등)나 프로그래밍 오류는 대부분 호출부가 그 즉시 복구할 수 없습니다. 그래서 현대 자바에서는 unchecked(RuntimeException) 예외를 기본으로 쓰는 추세가 강해요. checked 예외를 남발하면 throws가 메서드마다 줄줄이 번지고, 의미 없이 잡아서 무시하는 catch 블록만 늘거든요. 결론은, 팀 컨벤션을 따르되 특별한 이유가 없으면 unchecked로 가는 게 요즘 흐름입니다.
Step 5: "외부 코드와 우리 코드 사이 — 경계를 감싸라"
이제 두 번째 단골 지적입니다. "외부 API 바뀌니까 우리 코드가 다 터졌어요." 왜 이런 일이 생길까요?
외부 라이브러리는 우리가 못 고칩니다. 그 라이브러리의 API가 어색하든 말든, 다음 버전에서 갑자기 바뀌든 말든, 우리는 그쪽 사정에 끌려다닐 수밖에 없어요. 문제는 그 어색한 API가 우리 코드 곳곳에 스며들었을 때입니다. 외부가 한 번 바뀌면, 그걸 직접 쓰던 우리 코드가 사방에서 깨집니다.
예를 들어 봅시다. 외부 날씨 SDK가 하나 있는데, API가 어색합니다. 결과를 타입 없는 Map으로 돌려주고, 키 이름도 "temp", "hum" 같은 줄임말이에요.
public class AcmeWeatherSdk {
public Map<String, Object> fetch(String cityCode) {
Map<String, Object> result = new HashMap<>();
if ("SEOUL".equals(cityCode)) {
result.put("temp", 21);
result.put("hum", 55);
} else {
result.put("temp", 18);
result.put("hum", 40);
}
return result;
}
}
이 SDK를 서비스 곳곳에서 직접 쓰면 어떻게 될까요? raw Map과 "temp", "hum" 같은 문자열 키가 우리 코드 한복판까지 그대로 들어옵니다.
public class DirectWeatherService {
private final AcmeWeatherSdk sdk = new AcmeWeatherSdk();
public String describe(String cityCode) {
Map<String, Object> raw = sdk.fetch(cityCode);
int temp = (int) raw.get("temp");
int hum = (int) raw.get("hum");
return temp + "도, 습도 " + hum + "%";
}
}
SDK가 다음 버전에서 키 이름을 "temperature"로 바꾸거나 반환 타입을 손보면? 이 Map을 파싱하던 모든 곳이 한꺼번에 깨집니다. 우리가 그 SDK를 직접 부르는 곳이 많을수록 피해가 커지죠.
해법은 외부와 우리 사이에 경계를 긋는 겁니다. 우리가 원하는 모습으로 인터페이스를 정의하고, 외부 SDK는 그 뒤에 숨기는 거예요. 먼저 우리 도메인의 깨끗한 타입과 인터페이스를 만듭니다.
public record Weather(int temperature, int humidity) {
}
public interface WeatherClient {
Weather getWeather(String city);
}
그리고 외부 SDK를 이 인터페이스 뒤에 가두는 어댑터를 둡니다. SDK의 어색한 Map을 다루는 코드가 오직 이 한 클래스 안에만 있게요.
public class AcmeWeatherAdapter implements WeatherClient {
private final AcmeWeatherSdk sdk;
public AcmeWeatherAdapter(AcmeWeatherSdk sdk) {
this.sdk = sdk;
}
@Override
public Weather getWeather(String city) {
Map<String, Object> raw = sdk.fetch(city);
int temperature = (int) raw.get("temp");
int humidity = (int) raw.get("hum");
return new Weather(temperature, humidity);
}
}
이제 우리 서비스는 SDK의 존재를 전혀 모릅니다. 깨끗한 WeatherClient 인터페이스에만 기대거든요.
public class WeatherService {
private final WeatherClient client;
public WeatherService(WeatherClient client) {
this.client = client;
}
public String describe(String city) {
Weather weather = client.getWeather(city);
return weather.temperature() + "도, 습도 " + weather.humidity() + "%";
}
}
경계를 긋기 전 (Before) 경계를 긋고 난 뒤 (After)
WeatherService WeatherService
│ 직접 호출 │ 우리 인터페이스에만 의존
▼ ▼
AcmeWeatherSdk (raw Map) WeatherClient (우리 타입 Weather)
▲
SDK 바뀌면 서비스가 깨진다 │ 구현
AcmeWeatherAdapter ── 여기서만 SDK 를 안다
│
AcmeWeatherSdk ── 바뀌어도 어댑터 한 곳만 고친다
SDK가 바뀌어도 고칠 곳은 AcmeWeatherAdapter 하나뿐입니다. 우리 서비스 코드는 한 줄도 안 건드려요. 게다가 테스트할 때는 진짜 SDK 대신 가짜 WeatherClient를 끼워 넣어, 네트워크 없이도 서비스 동작을 확인할 수 있습니다. 외부에 묶여 있던 코드가 풀려난 거죠.
이렇게 "안 맞는 외부 인터페이스를 우리가 원하는 인터페이스에 끼워 맞추는" 방식에는 사실 이름이 있습니다. 어댑터예요. 이건 검증된 설계 패턴 중 하나인데, 패턴 자체는 디자인 패턴을 다루는 C-2에서 본격적으로 만납니다. 오늘은 "경계를 감싼다"는 원리만 익혀 두면 충분해요.
⚠️ 언제 깨나 — 모든 외부 코드를 다 감쌀 필요는 없습니다. 자바 표준 라이브러리의
List,String,Map처럼 안정적이고 바뀔 일이 거의 없는 표준 API까지 일일이 감싸면, 쓸데없는 껍데기만 늘고 코드가 오히려 복잡해져요. 경계로 감싸는 건 "어색하거나, 바뀔 위험이 있거나, 우리가 통제할 수 없는" 외부에 집중하세요. 모든 걸 감싸는 건 또 다른 과잉입니다.
💡 한 줄 정리
외부 라이브러리는 우리 인터페이스 뒤에 감싸 한 곳(어댑터)에 가둔다. 그러면 외부가 바뀌어도 그 한 곳만 고치면 되고, 테스트에서 가짜로 갈아 끼우기도 쉽다.
🙋 학생 질문 — "내가 만든 라이브러리나 모듈도 다 감싸야 하나요?"
아니요, 그건 과해요. 경계를 감싸는 핵심 이유는 "통제할 수 없는 변화로부터 우리를 보호하는 것"입니다. 그러니 내가 통제하고, 안정적이고, 자주 안 바뀌는 것은 굳이 감쌀 필요가 없어요.
감싸야 할 대상은 분명합니다. 외부 회사의 SDK, 자주 버전이 바뀌는 서드파티 라이브러리, 결제·날씨·지도처럼 외부 서비스에 붙는 API 클라이언트 같은 것들이죠. 이런 건 우리가 손댈 수 없고 언제 바뀔지 모르니까 경계로 막아 둡니다. 반대로 우리 팀이 만든 안정적인 내부 유틸리티까지 감싸면, 보호 효과는 없고 껍데기 클래스만 늘어나요. 늘 "이게 우리 통제 밖에 있고, 바뀔 위험이 있나"를 기준으로 판단하세요.
Step 6: "외부가 어떻게 동작하는지 우리 테스트로 고정 — 학습 테스트"
경계로 외부를 감쌌습니다. 그런데 그 외부가 정확히 어떻게 동작하는지는 어떻게 알까요? 보통은 문서를 읽고 "아 이렇게 동작하겠지" 하고 추측합니다. 문제는, 문서가 모든 함정을 다 알려주지 않는다는 거예요. 그리고 라이브러리가 업그레이드되면 동작이 슬쩍 바뀌기도 합니다.
여기에 좋은 장치가 있습니다. 학습 테스트(learning test)예요. 외부 라이브러리의 동작을 추측하는 대신, 우리 테스트로 직접 확인해서 고정해 두는 겁니다. 이 과목은 테스트가 코드를 지키는 안전망 자체를 학습 주제로 다루니, 오늘은 테스트 코드를 직접 들여다봅니다.
자바 표준 라이브러리의 BigDecimal을 예로 봅시다. 돈 계산에 자주 쓰는데, 동작에 놀라운 함정이 몇 개 있어요. 문서로 읽으면 흘려보내기 쉽지만, 테스트로 확인해 두면 절대 까먹지 않습니다.
class BigDecimalLearningTest {
@DisplayName("equals 는 스케일까지 본다 — 1.0 과 1.00 은 같지 않다")
@Test
void equalsConsidersScale() {
assertFalse(new BigDecimal("1.0").equals(new BigDecimal("1.00")));
}
@DisplayName("compareTo 는 값만 본다 — 1.0 과 1.00 은 0(같음)이다")
@Test
void compareToIgnoresScale() {
assertEquals(0, new BigDecimal("1.0").compareTo(new BigDecimal("1.00")));
}
}
놀랍지 않나요? new BigDecimal("1.0")과 new BigDecimal("1.00")은 값으로는 똑같은데, equals로 비교하면 다르다고 나옵니다. equals가 소수점 자릿수(스케일)까지 따지거든요. 반면 compareTo는 값만 보니까 0(같음)이 나와요. 이걸 모르고 BigDecimal을 equals로 비교하면, "분명히 같은 값인데 왜 다르다고 하지?" 하고 한참 헤매게 됩니다. 테스트로 확인해 두면 이 함정을 코드가 영원히 기억해 줍니다.
나눗셈에도 함정이 있어요.
@DisplayName("무한소수 나눗셈은 RoundingMode 없이 하면 ArithmeticException 이 난다")
@Test
void divideWithoutRoundingThrows() {
BigDecimal one = new BigDecimal("1");
BigDecimal three = new BigDecimal("3");
assertThrows(ArithmeticException.class, () -> one.divide(three));
}
@DisplayName("RoundingMode 를 주면 무한소수 나눗셈도 안전하게 동작한다")
@Test
void divideWithRoundingWorks() {
BigDecimal one = new BigDecimal("1");
BigDecimal three = new BigDecimal("3");
assertEquals(new BigDecimal("0.33"),
one.divide(three, 2, RoundingMode.HALF_UP));
}
}
1 ÷ 3처럼 딱 떨어지지 않는 나눗셈을 그냥 하면 ArithmeticException이 터집니다. 반올림 방식(RoundingMode)을 알려 줘야 안전하게 동작해요. 이런 건 문서를 안 읽으면 운영 중에 처음 만나게 되는 함정인데, 학습 테스트로 미리 확인해 두는 거죠.
List.of로 만든 리스트의 동작도 고정해 둘 수 있습니다.
class ImmutableListLearningTest {
@DisplayName("List.of 로 만든 리스트에 add 하면 UnsupportedOperationException 이 난다")
@Test
void addToImmutableListThrows() {
List<String> list = List.of("a", "b");
assertThrows(UnsupportedOperationException.class, () -> list.add("c"));
}
}
List.of가 만든 리스트는 수정이 막혀 있어서, add를 하면 예외가 납니다. 지난 시간 불변 객체에서 방어적 복사를 할 때 살짝 봤던 동작이죠. 추측하지 말고 테스트로 확인해 두면 확실합니다.
학습 테스트의 진짜 가치는 시간이 지나서 나옵니다. 나중에 이 라이브러리를 새 버전으로 올렸는데 동작이 바뀌었다면, 이 테스트가 가장 먼저 빨개져서 "어? 외부 동작이 달라졌어!"라고 알려 줍니다. 우리 코드가 운영 중에 조용히 깨지기 전에 말이죠. 경계로 외부를 감싸고(Step 5), 그 외부의 동작을 학습 테스트로 고정하면(Step 6), 외부에 기대면서도 외부에 휘둘리지 않는 코드가 됩니다.
⚠️ 언제 깨나 — 학습 테스트도 한번 쓰면 계속 유지해야 하는 비용입니다. 외부 라이브러리의 모든 메서드를 다 테스트하려 들면, 결국 외부 코드를 우리가 떠안는 꼴이 돼요. 우리가 실제로 의존하는 동작, 그리고
BigDecimal.equals처럼 헷갈려서 사고 나기 쉬운 동작에만 집중하세요. 학습 테스트는 "전부 검증"이 아니라 "위험한 곳만 핀으로 고정"하는 도구입니다.
💡 한 줄 정리
학습 테스트는 외부 라이브러리의 동작을 추측 대신 우리 테스트로 확인해 고정한다. 라이브러리가 업그레이드돼 동작이 바뀌면 이 테스트가 먼저 알려 준다. 단, 위험한 곳만 골라서.
🙋 학생 질문 — "이건 우리 코드도 아닌데, 왜 우리가 테스트해요?"
핵심은 "우리가 그 동작에 의존하고 있다"는 점이에요. BigDecimal은 우리가 만들지 않았지만, 우리 결제 로직은 그게 정확히 어떻게 반올림하는지에 기대고 있잖아요. 그 동작이 바뀌면 우리 코드가 깨집니다.
학습 테스트는 그 의존을 눈에 보이게 만드는 장치예요. "우리는 이 외부 동작에 기대고 있다"를 테스트로 적어 두는 거죠. 그러면 두 가지를 공짜로 얻습니다. 하나는, 라이브러리를 업그레이드할 때 그 테스트가 회귀를 잡아 주는 알람이 됩니다. 다른 하나는, 새 팀원이 "우리가 이 라이브러리의 어떤 동작에 의존하는지"를 그 테스트만 봐도 알게 돼요. 외부 코드를 테스트하는 게 아니라, 외부에 대한 우리의 의존을 테스트하는 겁니다.
마무리
오늘은 코드의 "없음"과 "실패", 그리고 외부와의 "경계"를 다뤘습니다. A 카테고리의 마지막 모듈이었어요. 이름·함수·객체에 이어, 이제 에러 처리까지 코드 레벨의 기본기를 한 바퀴 다 돌았습니다.
오늘 배운 핵심 세 가지
- 💡 에러 코드와
null은 문제를 숨긴다. 에러 코드 대신 이름 있는 예외로,null대신 빈 컬렉션·특수 케이스 객체·Optional로 "없음"과 "실패"를 또렷하게 드러내자. 그래야 호출부가 깨끗해지고 NPE 지옥이 끊긴다. - 💡 예외는 꼭 필요할 때만. 예외로 호출부를 깨끗하게 만들되, 정상적인 흐름 제어에까지 예외를 쓰지는 말자. 저수준 예외는 경계에서 우리 도메인 예외로 감싼다.
- 💡 외부는 감싸고 고정한다. 외부 라이브러리는 우리 인터페이스 뒤에 감춰 변화를 한 곳에 가두고(경계), 그 동작을 학습 테스트로 고정해 업그레이드 회귀를 잡는다. 단, 다 감싸지도 다 테스트하지도 말 것.
다음 시간 예고
지금까지 A 카테고리에서는 한 줄, 함수 하나, 객체 하나, 에러 처리까지 "코드 레벨"을 다듬었습니다. 이제 시야를 한 단계 넓힐 차례예요. 다음 시간부터는 B 카테고리, 클래스 사이의 구조로 갑니다.
다음 시간(B-1)엔 좋은 설계의 두 축인 응집도와 결합도를 먼저 잡고, 거기서 SRP·OCP·LSP라는 설계 원칙으로 들어갑니다. "이 클래스, 바꿀 이유가 다섯 개나 되네요" 같은 지적을 그때 풀어 봐요. 오늘 배운 에러 처리·경계는 그 설계 원칙들의 든든한 바탕이 됩니다. 경계를 인터페이스로 감쌌던 오늘의 감각이, 다음 시간 "추상화에 의존하라"로 자연스럽게 이어질 거예요.
과제
오늘 배운 원리를 직접 손으로 고쳐 보며 익히는 과제입니다. 지저분한 Before를 깨끗한 After로 바꾸는 게 핵심이에요.
[기초] null 반환을 안전한 형태로 고치기
아래는 장바구니에서 특정 카테고리의 상품을 찾아 돌려주는 메서드입니다. 결과가 없으면 null을 돌려줘서, 호출부가 순회 전에 매번 null을 검사해야 합니다.
public List<Product> findByCategory(String category) {
List<Product> found = new ArrayList<>();
for (Product product : products) {
if (product.category().equals(category)) {
found.add(product);
}
}
if (found.isEmpty()) {
return null;
}
return found;
}
이 메서드를 호출부가 null 검사 없이 바로 순회할 수 있도록 고쳐 보세요. 그리고 "회원 한 명을 이메일로 찾되 없을 수 있는" 메서드도 하나 만들어, 반환 타입을 Optional로 표현해 보세요.
[응용] 에러 코드 반환을 예외로 리팩토링
아래는 주문을 검증하는 메서드입니다. 성공이면 0, 재고가 없으면 -1, 금액이 0 이하면 -2를 돌려줍니다. 호출부는 이 숫자를 if로 분기하고 있어요.
public int validateOrder(Long productId, int quantity, int price) {
if (!stock.has(productId, quantity)) {
return -1;
}
if (price <= 0) {
return -2;
}
return 0;
}
이 메서드를 예외 기반으로 리팩토링하세요. 상황에 맞는 이름의 예외 클래스(OutOfStockException, InvalidPriceException 등)를 만들고, 호출부가 정상 흐름만 적도록 바꿔 보세요. 예외에는 무엇이 잘못됐는지 알 수 있는 맥락(요청 수량, 잘못된 금액 등)을 담아 보세요.
[심화] 외부 SDK를 경계로 감싸기
아래는 외부 환율 SDK를 서비스에서 직접 쓰는 코드입니다. SDK가 raw Map을 돌려주고, 그 파싱이 서비스 안에 노출돼 있어요.
public class DirectExchangeService {
private final AcmeExchangeSdk sdk = new AcmeExchangeSdk();
public int toKrw(String currency, int amount) {
Map<String, Object> raw = sdk.fetch(currency);
double rate = (double) raw.get("rate");
return (int) (amount * rate);
}
}
이 코드를 경계로 감싸 보세요. 우리 도메인 인터페이스(ExchangeClient)와 깨끗한 결과 타입을 정의하고, SDK를 다루는 코드는 어댑터 한 곳에만 가두세요. 서비스가 SDK 대신 우리 인터페이스에만 의존하도록 바꾸고, 테스트에서 가짜 ExchangeClient를 끼울 수 있게 만들면 성공입니다.
생각해볼 주제
규칙을 외우는 게 아니라 "언제 어떻게 쓸지"를 판단하는 게 이 과목의 목표입니다. 정답이 하나가 아닌 주제들이에요. 혼자 고민해도 좋고, 동료와 논쟁해도 좋습니다.
1. "없음"을 표현하는 세 가지 방법 — 예외 vs 빈 값 vs Optional, 언제 무엇을?
오늘 "없음"과 "실패"를 표현하는 방법을 여럿 봤습니다. 빈 컬렉션, 특수 케이스 객체, Optional, 그리고 예외까지요. 그런데 같은 "회원을 못 찾았다"도 상황에 따라 답이 달라집니다. 어떤 때는 예외가 맞고, 어떤 때는 Optional이 맞고, 어떤 때는 빈 값이 맞아요. 각각을 언제 써야 할지, 그 판단 기준을 정리해 보세요.
2. Optional을 어디까지 써야 할까?
Optional은 "반환값에만 쓰라"고 배웠습니다. 필드나 파라미터, 컬렉션 원소에 쓰는 건 남용이라고요. 그런데 왜 그럴까요? 만약 모든 곳에 Optional을 쓰면 어떤 문제가 생기는지, 반대로 Optional을 너무 아껴 써서 null이 다시 새어 나오면 어떤 문제가 생기는지, 양쪽의 균형을 고민해 보세요.
3. 모든 외부 라이브러리를 다 감싸야 할까?
경계를 감싸면 외부 변화로부터 우리 코드를 보호할 수 있습니다. 그렇다면 안전을 위해 모든 외부 라이브러리를 다 감싸면 더 좋은 코드일까요? 감싸는 데도 비용이 듭니다. 껍데기 클래스가 늘고, 코드를 따라가기 위해 거쳐야 할 단계가 많아져요. 무엇은 감싸고 무엇은 그냥 써도 되는지, 그 경계를 어떻게 그을지 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 풀이의 클래스 이름과 나누는 단위는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 의도가 드러나고 동작이 보존되면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.
과제 예시답안
🎯 [과제 1 예시답안] null 반환을 안전한 형태로 고치기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 빈 컬렉션 반환 | 결과 없을 때 null 대신 빈 리스트를 돌려주는가 |
★★★ |
| 호출부 정리 | 호출부의 null 검사가 사라졌는가 |
★★☆ |
| Optional 표현 | "없을 수 있는 단건"을 Optional 반환으로 드러냈는가 |
★★★ |
| 동작 보존 | 찾는 로직 자체는 그대로 | ★★☆ |
풀이 예시
❌ Before
public List<Product> findByCategory(String category) {
List<Product> found = new ArrayList<>();
for (Product product : products) {
if (product.category().equals(category)) {
found.add(product);
}
}
if (found.isEmpty()) {
return null;
}
return found;
}
// 호출부 — 순회 전에 매번 null 검사
List<Product> result = catalog.findByCategory("BOOK");
if (result != null) {
for (Product p : result) {
System.out.println(p.name());
}
}
✅ After
public List<Product> findByCategory(String category) {
List<Product> found = new ArrayList<>();
for (Product product : products) {
if (product.category().equals(category)) {
found.add(product);
}
}
return found; // 비어 있어도 null 이 아니라 빈 리스트
}
// 호출부 — null 검사 없이 그냥 순회
for (Product p : catalog.findByCategory("BOOK")) {
System.out.println(p.name());
}
그리고 "없을 수 있는 단건"은 Optional로 표현합니다.
public Optional<Member> findByEmail(String email) {
for (Member member : members) {
if (member.email().equals(email)) {
return Optional.of(member);
}
}
return Optional.empty();
}
💡 튜터의 한마디: 컬렉션을 돌려줄 때 "0건"은 오류가 아니라 정상입니다. 그래서 빈 리스트면 충분하고, null을 줄 이유가 없어요. 빈 리스트도 그냥 for로 돌리면 0번 돌 뿐이니, 호출부의 null 검사가 통째로 사라집니다. 반대로 단건을 찾는데 없을 수 있다면, 그 "없음"을 Optional 반환 타입으로 드러내 호출부가 모른 척 못 하게 합니다. 컬렉션은 빈 값, 단건은 Optional — 이 구분만 잡아도 NPE가 크게 줄어요.
🎯 [과제 2 예시답안] 에러 코드 반환을 예외로 리팩토링
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 예외로 전환 | 에러 코드(-1/-2) 대신 예외를 던지는가 |
★★★ |
| 이름과 맥락 | 예외 이름이 의미를 말하고, 맥락(수량·금액)을 담는가 | ★★★ |
| 호출부 정리 | 호출부가 정상 흐름만 적게 됐는가 | ★★☆ |
| 반환 타입 | 의미 없는 int 반환이 사라졌는가 |
★★☆ |
풀이 예시
❌ Before
public int validateOrder(Long productId, int quantity, int price) {
if (!stock.has(productId, quantity)) {
return -1;
}
if (price <= 0) {
return -2;
}
return 0;
}
// 호출부 — 숫자를 외워 분기
int result = orderService.validateOrder(productId, quantity, price);
if (result == -1) {
return "재고가 부족합니다.";
} else if (result == -2) {
return "금액이 올바르지 않습니다.";
} else {
return "주문 가능";
}
✅ After
public class OutOfStockException extends RuntimeException {
public OutOfStockException(Long productId, int requested) {
super("재고가 부족합니다. 상품=" + productId + ", 요청 수량=" + requested);
}
}
public class InvalidPriceException extends RuntimeException {
public InvalidPriceException(int price) {
super("금액이 올바르지 않습니다. 입력=" + price);
}
}
public void validateOrder(Long productId, int quantity, int price) {
if (!stock.has(productId, quantity)) {
throw new OutOfStockException(productId, quantity);
}
if (price <= 0) {
throw new InvalidPriceException(price);
}
}
// 호출부 — 정상 흐름만
orderService.validateOrder(productId, quantity, price);
return "주문 가능";
💡 튜터의 한마디: -1, -2라는 숫자는 그 자체로 아무 의미가 없어, 호출부가 뜻을 외워야 했죠. OutOfStockException이라는 이름은 무엇이 잘못됐는지 스스로 말합니다. 그리고 예외에 요청 수량이나 잘못된 금액 같은 맥락을 함께 담으면, 로그만 봐도 원인이 보여요. 호출부는 정상 흐름인 "주문 가능"만 남고, 에러 처리는 이 예외를 책임질 위쪽에서 한 번에 모아 다룰 수 있습니다.
🎯 [과제 3 예시답안] 외부 SDK를 경계로 감싸기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 우리 인터페이스 | 외부 SDK가 아니라 우리 인터페이스를 정의했는가 | ★★★ |
| 어댑터 격리 | SDK의 Map·키 파싱이 어댑터 한 곳에만 갇혔는가 |
★★★ |
| 의존 방향 | 서비스가 우리 인터페이스에만 의존하는가 | ★★★ |
| 테스트 용이성 | 테스트에서 가짜 구현을 끼울 수 있는가 | ★★☆ |
풀이 예시
❌ Before
public class DirectExchangeService {
private final AcmeExchangeSdk sdk = new AcmeExchangeSdk();
public int toKrw(String currency, int amount) {
Map<String, Object> raw = sdk.fetch(currency);
double rate = (double) raw.get("rate");
return (int) (amount * rate);
}
}
✅ After
// 우리 도메인이 정의한 경계
public interface ExchangeClient {
double getRate(String currency);
}
// SDK 를 다루는 코드는 오직 이 어댑터 한 곳에만
public class AcmeExchangeAdapter implements ExchangeClient {
private final AcmeExchangeSdk sdk;
public AcmeExchangeAdapter(AcmeExchangeSdk sdk) {
this.sdk = sdk;
}
@Override
public double getRate(String currency) {
Map<String, Object> raw = sdk.fetch(currency);
return (double) raw.get("rate");
}
}
// 서비스는 우리 인터페이스에만 의존 — SDK 를 전혀 모른다
public class ExchangeService {
private final ExchangeClient client;
public ExchangeService(ExchangeClient client) {
this.client = client;
}
public int toKrw(String currency, int amount) {
return (int) (amount * client.getRate(currency));
}
}
💡 튜터의 한마디: 핵심은 의존 방향을 뒤집은 것입니다. Before는 서비스가 SDK에 직접 매달려, SDK의 Map과 "rate" 키 파싱이 서비스 안까지 들어와 있었어요. After는 우리가 원하는 모습으로 ExchangeClient를 정의하고, SDK는 어댑터 뒤에 숨겼습니다. 이제 SDK가 키 이름이나 반환 타입을 바꿔도 고칠 곳은 AcmeExchangeAdapter 하나뿐이고, 테스트에서는 고정 환율을 돌려주는 가짜 ExchangeClient를 끼워 네트워크 없이 환산 로직을 검증할 수 있습니다. 이 "안 맞는 외부를 우리 인터페이스에 끼워 맞추는" 방식이 바로 어댑터의 원리예요.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] "없음"을 표현하는 세 가지 방법 — 예외 vs 빈 값 vs Optional
문제 상황 요약
같은 "회원을 못 찾았다"도 상황에 따라 답이 달라집니다. 어떤 때는 예외가, 어떤 때는 Optional이, 어떤 때는 빈 값이 맞아요. 셋을 언제 써야 할지 기준을 정리해 봅시다.
튜터의 가이드 및 해설
판단의 출발점은 "그 없음이 정상인가, 비정상인가"입니다.
빈 컬렉션 — 여러 건을 찾는데 결과가 0건일 때. "조건에 맞는 게 없다"는 건 컬렉션 조회에서 지극히 정상적인 결과입니다. 검색 결과 0건이 오류는 아니잖아요. 이럴 땐 null도 예외도 아니고 빈 리스트가 맞습니다.
Optional — 단건을 찾는데 없을 수도 있고, 그 없음이 "정상 범위 안"일 때. 예를 들어 "이메일로 회원을 찾되, 가입 안 한 사람일 수도 있다"는 상황이죠. 호출부가 "있으면 이렇게, 없으면 저렇게"를 자연스럽게 정해야 할 때 Optional로 그 선택을 넘깁니다.
예외 — 그 없음이 "정상 흐름에서 일어나면 안 되는" 비정상일 때. 이미 로그인한 사용자의 ID로 회원을 조회했는데 없다면, 그건 데이터가 깨졌다는 신호예요. 이럴 때 Optional로 슬쩍 넘기면 오히려 문제를 숨깁니다. 분명히 예외로 멈춰 세워야 합니다.
같은 조회라도, "없는 게 당연할 수 있나 vs 없으면 큰일인가"를 먼저 물으면 셋 중 무엇인지 자연스럽게 갈립니다.
🎯 면접에선 이렇게 나온다
"조회 결과가 없을 때 무엇을 반환하나요?"라는 질문에는 한 가지로 답하지 않습니다. "여러 건이면 빈 컬렉션, 없을 수 있는 단건이면
Optional, 정상 흐름에서 일어나면 안 되는 없음이면 예외로 구분합니다. 판단 기준은 '그 없음이 정상인가 비정상인가'입니다.null을 반환하지 않는다는 원칙은 셋 모두에 공통으로 깔려 있습니다."
💡 실무에선
실무에서는 findX는 Optional을 돌려주고, 반드시 있어야 하는 getX는 없으면 예외를 던지는 식으로 메서드 이름에 규칙을 담는 경우가 많습니다. 이름만 봐도 "없을 수 있는지"를 알 수 있게요.
🤔 [생각해볼 주제 2] Optional을 어디까지 써야 할까?
문제 상황 요약
Optional은 "반환값에만 쓰라"고 배웠습니다. 필드나 파라미터, 컬렉션 원소에 쓰는 건 남용이라고요. 왜 그럴까요? 양쪽 극단의 문제를 함께 봅시다.
튜터의 가이드 및 해설
Optional은 "메서드가 결과를 돌려줄 때, 그게 없을 수 있음을 호출부에 알리는" 도구로 설계됐습니다. 이 본래 목적에서 벗어나면 비용만 늘어요.
남용하면 — 필드 타입으로 Optional을 쓰면 객체마다 껍데기가 하나씩 더 붙어 메모리를 더 쓰고, 직렬화에서도 문제가 생깁니다. 파라미터로 받으면 호출부가 매번 Optional.of로 감싸야 해서 더 번거로워져요. 컬렉션은 비었으면 빈 리스트면 되지, Optional<List>로 "리스트가 없음"과 "리스트가 비어 있음"을 둘 다 표현하면 오히려 헷갈립니다. 그리고 받자마자 .get()으로 까는 습관은, 확인 없이 까면 비었을 때 예외가 나니 null만큼 위험합니다.
너무 아끼면 — 반대로 "단건인데 없을 수 있는" 반환에까지 null을 쓰면, 호출부가 검사를 깜빡할 길이 다시 열립니다. Optional이 막아 주던 NPE가 새어 나오죠.
그래서 답은 "API 경계의 반환값"이라는 본래 쓰임을 지키는 것입니다. 거기선 적극적으로 쓰고, 그 바깥(필드·파라미터·내부 구현)으로는 번지지 않게 합니다.
🎯 면접에선 이렇게 나온다
"
Optional은 API 경계에서 '없을 수 있는 단건 반환'을 알리는 용도로 씁니다. 필드·파라미터·컬렉션 원소로 쓰는 건 메모리·직렬화 비용과 복잡도만 늘리는 남용이고, 받자마자.get()으로 까는 것도null만큼 위험합니다. 반대로 반환값에Optional을 안 쓰고null로 돌아가면 NPE가 다시 새어 나오니, '반환값에는 쓰고 그 바깥으로는 번지지 않게'가 균형점입니다."
💡 실무에선
자바 표준 라이브러리 설계자들도 "Optional은 반환 타입으로 쓰라고 만들었다"고 공식적으로 밝혔습니다. 팀 코드 컨벤션에도 "필드·파라미터에 Optional 금지"를 명시해 두는 곳이 많아요.
🤔 [생각해볼 주제 3] 모든 외부 라이브러리를 다 감싸야 할까?
문제 상황 요약
경계를 감싸면 외부 변화로부터 우리 코드를 보호할 수 있습니다. 그렇다면 안전을 위해 모든 외부 라이브러리를 다 감싸면 더 좋은 코드일까요? 감싸는 데도 비용이 있다는 점을 함께 봅시다.
튜터의 가이드 및 해설
경계를 감싸는 이유는 단 하나, "우리가 통제할 수 없는 변화로부터 우리를 지키는 것"입니다. 그러니 이 기준으로 대상을 가려야 해요.
감싸는 게 이득인 것 — 외부 회사의 SDK, 자주 버전이 바뀌는 서드파티 라이브러리, 결제·날씨·지도처럼 외부 서비스에 붙는 클라이언트. 이런 건 우리가 손댈 수 없고 언제 어떻게 바뀔지 모르니, 경계로 막아 변화를 한 곳에 가두는 게 분명히 이득입니다.
감싸면 오히려 손해인 것 — 자바 표준 라이브러리의 List·String·Map처럼 수십 년째 안정적인 표준 API. 이런 걸 감싸면 보호 효과는 거의 없고, 의미 없는 껍데기 클래스만 늘어요. 코드를 읽을 때 거쳐야 할 단계가 하나 더 생겨 오히려 따라가기 어려워집니다. 우리 팀이 만든 안정적인 내부 유틸리티도 마찬가지고요.
감싸는 것도 비용입니다. 클래스가 늘고, 한 단계를 더 거쳐야 하니까요. 그래서 "바뀔 위험"과 "감싸는 비용"을 저울에 올려, 위험이 비용보다 클 때만 감쌉니다. 모든 걸 감싸는 건 또 다른 과잉이에요.
🎯 면접에선 이렇게 나온다
"경계로 감싸는 기준은 '우리가 통제할 수 없고, 바뀔 위험이 있는가'입니다. 외부 SDK나 자주 바뀌는 서드파티는 감싸서 변화를 어댑터 한 곳에 가두는 게 이득이지만, 자바 표준 라이브러리처럼 안정적인 API까지 감싸면 껍데기만 늘고 가독성이 떨어집니다. 감싸는 것도 비용이라, '바뀔 위험 대 감싸는 비용'을 저울질해서 결정합니다."
💡 실무에선
처음부터 모든 걸 감싸기보다, "이 라이브러리는 자주 바뀌겠다" 싶은 의심이 들 때 그 부분만 어댑터로 감싸는 식으로 점진적으로 도입하는 경우가 많습니다. 미리 다 감싸면 안 바뀔 것까지 감싸느라 헛수고가 되니까요.