| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 함수형프로그래밍
- 클린아키텍처
- Kotlin
- Effective Java
- 오블완
- 알고리즘정렬
- 예제로 배우는 스프링 입문
- 이차전지관련주
- 스프링부트
- 카카오
- kubernetes
- Spring
- 엘라스틱서치
- 자바스크립트
- 자바
- Sort
- 이펙티브자바
- ElasticSearch
- 이펙티브 자바
- effectivejava
- 스프링핵심원리
- 스프링
- k8s
- 김영한
- 알고리즘
- JavaScript
- 티스토리챌린지
- Effective Java 3
- java
- 스프링 핵심원리
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기 본문
Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기
김백개발자 2025. 12. 28. 13:49이 글을 읽으면: Java의 복잡한 예외 처리를 넘어 Kotlin의 우아한 에러 핸들링 방법을 배울 수 있습니다. 실무에서 바로 쓸 수 있는 안전하고 읽기 쉬운 에러 처리 패턴을 실전 예제로 마스터하세요.
📌 목차
- 들어가며 - 예외 처리, 왜 중요할까?
- Java vs Kotlin 예외 처리
- try-catch-finally 기본
- runCatching - 함수형 예외 처리
- Result 타입 완벽 활용
- Custom Exception 설계
- 실전 에러 핸들링 패턴
- 마무리 - 다음 편 예고
들어가며 - 예외 처리, 왜 중요할까?

실제 프로젝트에서 겪은 사고
2024년 8월, 월요일 오전 10시
상황: 쇼핑몰 서비스 전체 다운
원인: 결제 API에서 NullPointerException 발생
→ try-catch 없이 예외가 그대로 전파
→ 전체 서버 500 에러
영향: 30분간 서비스 중단, 매출 손실 약 500만원
교훈: "설마 null일까?"는 절대 금물
개발자의 착각 vs 현실
개발자 생각 현실
| "이 API는 항상 성공할 거야" | → 네트워크 끊김 |
| "데이터베이스는 항상 있어" | → DB 서버 다운 |
| "파일은 항상 있을 거야" | → 권한 없음 |
| "사용자는 올바른 값을 입력해" | → "나이: -100살" |
예외 처리를 안 하면 생기는 일
// ❌ 위험한 코드 (실제 있었던 사례)
fun getUserEmail(userId: String): String {
val user = database.findUser(userId) // null일 수 있음
return user.email // 💥 NPE 발생 → 서버 다운
}
// ✅ 안전한 코드
fun getUserEmail(userId: String): String {
return try {
val user = database.findUser(userId)
user?.email ?: "이메일 없음"
} catch (e: Exception) {
println("에러 발생: ${e.message}")
"이메일 없음"
}
}
이 글에서 배울 것
- try-catch를 언제, 어떻게 쓸까? (기본기)
- runCatching으로 코드 간결하게 만들기 (실용 팁)
- Result 타입으로 에러를 값처럼 다루기 (고급 패턴)
- 실무에서 바로 쓸 수 있는 패턴 (실전 예제)
Java vs Kotlin 예외 처리
Java의 체크 예외 - 강제하지만 귀찮다
Java의 철학: "모든 예외를 명시해라"
// Java - 체크 예외 강제
public String readFile(String path) throws IOException {
FileReader reader = new FileReader(path); // throws 필수
// ...
}
// 호출하는 쪽도 강제로 처리
public void process() {
try {
String content = readFile("data.txt");
} catch (IOException e) { // 강제로 처리해야 함
e.printStackTrace();
}
}
장점과 단점
✅ 장점: 예외 처리를 잊어버릴 수 없음
❌ 단점: 너무 귀찮아서 빈 catch 블록 남발
// 실제로 자주 보는 코드 (좋지 않음)
try {
riskyOperation();
} catch (Exception e) {
// TODO: 나중에 처리하기
}
Kotlin의 언체크 예외 - 자유롭지만 책임감 필요
Kotlin의 철학: "예외는 선택이다"
// Kotlin - throws 불필요
fun readFile(path: String): String {
return File(path).readText() // 예외 발생 가능하지만 명시 안 해도 됨
}
// 호출하는 쪽도 자유
fun process() {
val content = readFile("data.txt") // try-catch 선택사항
}
왜 이렇게 바뀌었을까?
Bruce Eckel (Thinking in Java 저자)의 말:
"체크 예외는 좋은 아이디어였지만,
실제로는 개발자들이 빈 catch 블록만 만들었다.
차라리 필요할 때만 처리하게 하자."
그럼 Kotlin에서는 어떻게?
// 필요한 곳에만 처리
fun importantOperation() {
try {
riskyOperation()
} catch (e: Exception) {
logger.error("중요한 작업 실패", e)
// 실제 복구 로직
}
}
// 안 중요한 곳은 그냥 전파
fun minorOperation() {
riskyOperation() // 예외 그냥 던짐
}
try-catch-finally 기본
가장 기본적인 형태
"뭔가 잘못될 수 있다면, try로 감싸라"
fun divide(a: Int, b: Int): Int {
return try {
a / b
} catch (e: ArithmeticException) {
println("0으로 나눌 수 없습니다!")
0 // 기본값 반환
}
}
fun main() {
println(divide(10, 2)) // 5
println(divide(10, 0)) // 0으로 나눌 수 없습니다! → 0
}
실무 예제: 파일 읽기
fun readUserData(filename: String): String {
return try {
File(filename).readText()
} catch (e: FileNotFoundException) {
println("파일을 찾을 수 없습니다: $filename")
"{}" // 빈 JSON 반환
} catch (e: IOException) {
println("파일 읽기 실패: ${e.message}")
"{}"
}
}
왜 여러 개의 catch를 쓸까?
비유: 병원 응급실
- FileNotFoundException → "환자가 안 왔어요" (파일 없음)
- IOException → "치료 중 문제 발생" (읽기 오류)
- Exception → "알 수 없는 증상" (기타 오류)
각각 다르게 대응해야 하니까 분리!
try는 표현식이다 - 값을 반환할 수 있다
Java와의 차이점
// Java - 변수 선언 후 할당
String result;
try {
result = readFile();
} catch (Exception e) {
result = "기본값";
}
// Kotlin - 바로 할당 가능
val result = try {
readFile()
} catch (e: Exception) {
"기본값"
}
실무 활용: API 호출
fun fetchUserName(userId: String): String {
return try {
api.getUser(userId).name
} catch (e: NetworkException) {
"네트워크 오류"
} catch (e: Exception) {
"알 수 없는 사용자"
}
}
// 사용
val name = fetchUserName("user123")
println("안녕하세요, $name님!")
finally - 무조건 실행되는 코드
"성공하든 실패하든, 이건 꼭 해야 해"
fun processFile(filename: String) {
val file = File(filename)
var reader: BufferedReader? = null
try {
reader = file.bufferedReader()
val content = reader.readText()
println(content)
} catch (e: IOException) {
println("파일 읽기 실패")
} finally {
reader?.close() // 무조건 실행 (자원 해제)
println("정리 완료")
}
}
finally가 필요한 상황
- 파일 닫기 - 열었으면 꼭 닫아야
- DB 연결 해제 - 연결했으면 꼭 끊어야
- Lock 해제 - 잠갔으면 꼭 풀어야
- 로그 남기기 - 성공/실패 상관없이 기록
더 나은 방법: use 함수
// Kotlin의 use - 자동으로 닫아줌
fun processFile(filename: String) {
File(filename).bufferedReader().use { reader ->
val content = reader.readText()
println(content)
} // use 블록 끝나면 자동으로 close()
}
runCatching - 함수형 예외 처리
왜 runCatching이 생겼을까?
문제 상황
// 여러 단계의 예외 처리 - 지저분함
fun processUser(userId: String): String {
try {
val user = try {
fetchUser(userId)
} catch (e: NetworkException) {
return "네트워크 오류"
}
val profile = try {
fetchProfile(user.id)
} catch (e: Exception) {
return "프로필 로드 실패"
}
return profile.displayName
} catch (e: Exception) {
return "알 수 없는 오류"
}
}
runCatching으로 해결
fun processUser(userId: String): String {
return runCatching {
val user = fetchUser(userId)
val profile = fetchProfile(user.id)
profile.displayName
}.getOrElse {
"오류 발생: ${it.message}"
}
}
runCatching 기본 사용법
성공/실패를 Result로 감싸준다
fun divide(a: Int, b: Int): Result<Int> {
return runCatching { a / b }
}
fun main() {
val success = divide(10, 2)
println(success.isSuccess) // true
println(success.getOrNull()) // 5
val failure = divide(10, 0)
println(failure.isFailure) // true
println(failure.getOrNull()) // null
}
getOrElse - 실패하면 기본값
fun getUserAge(userId: String): Int {
return runCatching {
api.getUser(userId).age
}.getOrElse { 0 } // 실패하면 0
}
getOrDefault - 더 명확하게
fun getUserEmail(userId: String): String {
return runCatching {
database.findUser(userId).email
}.getOrDefault("이메일 없음")
}
체이닝 - map, mapCatching
성공했을 때만 변환하기
fun getUserDisplayName(userId: String): String {
return runCatching {
fetchUser(userId)
}.map { user ->
"${user.name} (${user.age}세)"
}.getOrElse {
"알 수 없는 사용자"
}
}
실무 예제: API 호출 → 파싱 → 변환
fun fetchAndProcessData(url: String): List<String> {
return runCatching {
// 1단계: API 호출
http.get(url).body
}.mapCatching { json ->
// 2단계: JSON 파싱
JsonParser.parse(json)
}.map { data ->
// 3단계: 변환
data.map { it.toString() }
}.getOrElse {
emptyList() // 어느 단계든 실패하면 빈 리스트
}
}
onSuccess, onFailure - 부가 작업
성공/실패 시 로깅
fun processPayment(amount: Int) {
runCatching {
paymentGateway.charge(amount)
}.onSuccess { receipt ->
logger.info("결제 성공: ${receipt.id}")
sendEmail("결제 완료", receipt)
}.onFailure { error ->
logger.error("결제 실패: ${error.message}")
alertAdmin(error)
}
}
체이닝 활용
fun registerUser(email: String, password: String) {
runCatching {
validateEmail(email)
validatePassword(password)
database.createUser(email, password)
}.onSuccess { user ->
sendWelcomeEmail(user)
logger.info("회원가입 성공: ${user.id}")
}.onFailure { error ->
when (error) {
is ValidationException -> logger.warn("검증 실패: ${error.message}")
is DatabaseException -> logger.error("DB 오류: ${error.message}")
else -> logger.error("알 수 없는 오류", error)
}
}
}
Result 타입 완벽 활용
Result가 뭐길래?
비유: 택배 상자
일반 함수: "물건만 주세요"
→ 물건 또는 예외를 던짐
Result 함수: "택배 상자를 주세요"
→ 상자 안에 물건 또는 실패 메모가 들어있음
→ 상자를 열어봐야 알 수 있음
코드로 보면
// 일반 함수
fun divide(a: Int, b: Int): Int {
if (b == 0) throw Exception("0으로 나눌 수 없음")
return a / b
}
// Result 함수
fun safeDivide(a: Int, b: Int): Result<Int> {
return if (b == 0) {
Result.failure(Exception("0으로 나눌 수 없음"))
} else {
Result.success(a / b)
}
}
Result의 장점
1. 예외를 값처럼 다룰 수 있다
val result1 = safeDivide(10, 2)
val result2 = safeDivide(10, 0)
// 값을 모아서 처리
val results = listOf(result1, result2)
val successes = results.filter { it.isSuccess }
println("성공한 것만: ${successes.size}개")
2. 타입으로 실패 가능성을 명시
// 이 함수는 실패할 수 있다는 게 타입에 드러남
fun fetchUser(id: String): Result<User>
// 호출하는 쪽에서 알 수 있음
val userResult = fetchUser("123")
// "아, 이 함수는 실패할 수 있구나"
3. 함수형 스타일로 처리 가능
fetchUser("123")
.map { it.name } // 성공 시 이름만
.map { it.uppercase() } // 대문자로
.getOrElse { "UNKNOWN" } // 실패 시 기본값
실전 예제: 다단계 작업
문제: 사용자 정보 → 주문 내역 → 총액 계산
data class User(val id: String, val name: String)
data class Order(val userId: String, val amount: Int)
fun fetchUser(id: String): Result<User> = runCatching {
// DB에서 사용자 조회
User(id, "규철")
}
fun fetchOrders(userId: String): Result<List<Order>> = runCatching {
// DB에서 주문 조회
listOf(
Order(userId, 10000),
Order(userId, 20000)
)
}
fun getTotalAmount(userId: String): Result<Int> {
return fetchUser(userId)
.mapCatching { user ->
fetchOrders(user.id).getOrThrow()
}.map { orders ->
orders.sumOf { it.amount }
}
}
fun main() {
val total = getTotalAmount("user123")
total.onSuccess { amount ->
println("총 주문액: ${amount}원")
}.onFailure { error ->
println("조회 실패: ${error.message}")
}
}
Custom Exception 설계
왜 커스텀 예외를 만들까?
일반 예외의 문제점
// ❌ 나쁜 예 - 뭐가 문제인지 모름
throw Exception("오류 발생")
// ✅ 좋은 예 - 명확함
throw UserNotFoundException("사용자를 찾을 수 없습니다: $userId")
throw InsufficientBalanceException("잔액 부족: 필요 ${amount}원")
기본 커스텀 예외
// 기본 형태
class UserNotFoundException(message: String) : Exception(message)
class InvalidEmailException(
val email: String,
message: String = "올바르지 않은 이메일: $email"
) : Exception(message)
// 사용
fun findUser(email: String): User {
if (!email.contains("@")) {
throw InvalidEmailException(email)
}
return database.find(email)
?: throw UserNotFoundException("이메일: $email")
}
계층 구조 설계
실무 예제: 결제 시스템
// 최상위 예외
sealed class PaymentException(message: String) : Exception(message)
// 세부 예외
class InsufficientBalanceException(
val required: Int,
val current: Int
) : PaymentException("잔액 부족: ${required}원 필요, ${current}원 보유")
class PaymentGatewayException(
val gatewayName: String,
cause: Throwable
) : PaymentException("$gatewayName 오류: ${cause.message}")
class InvalidAmountException(
val amount: Int
) : PaymentException("잘못된 금액: $amount")
// 사용
fun processPayment(userId: String, amount: Int) {
when {
amount <= 0 -> throw InvalidAmountException(amount)
!hasEnoughBalance(userId, amount) -> {
val balance = getBalance(userId)
throw InsufficientBalanceException(amount, balance)
}
}
try {
paymentGateway.charge(amount)
} catch (e: Exception) {
throw PaymentGatewayException("토스페이", e)
}
}
// 호출
fun checkout(userId: String, amount: Int) {
try {
processPayment(userId, amount)
println("결제 성공!")
} catch (e: PaymentException) {
when (e) {
is InsufficientBalanceException -> {
println("잔액 ${e.current}원 부족합니다")
println("${e.required - e.current}원 충전하세요")
}
is InvalidAmountException -> {
println("금액을 확인하세요")
}
is PaymentGatewayException -> {
println("결제사 오류. 잠시 후 다시 시도하세요")
}
}
}
}
실전 패턴 모음
패턴 1: 재시도 로직
"3번까지는 다시 시도해보자"
fun <T> retry(
times: Int = 3,
delay: Long = 1000,
block: () -> T
): Result<T> {
repeat(times - 1) { attempt ->
runCatching { block() }
.onSuccess { return Result.success(it) }
.onFailure {
println("시도 ${attempt + 1} 실패, ${delay}ms 후 재시도")
Thread.sleep(delay)
}
}
// 마지막 시도
return runCatching { block() }
}
// 사용
fun fetchData(): String {
return retry(times = 3, delay = 1000) {
api.getData() // 네트워크 요청
}.getOrElse { "기본 데이터" }
}
패턴 2: Fallback 체인
"A 안 되면 B, B 안 되면 C"
fun getUserName(userId: String): String {
return runCatching {
// 1순위: 캐시
cache.get(userId)
}.recoverCatching {
// 2순위: DB
database.find(userId).name
}.recoverCatching {
// 3순위: 외부 API
api.fetchUser(userId).name
}.getOrElse {
// 최후: 기본값
"알 수 없는 사용자"
}
}
패턴 3: 검증 체인
"여러 검증을 한 번에"
class ValidationException(message: String) : Exception(message)
fun validateSignup(
email: String,
password: String,
age: Int
): Result<Unit> {
return runCatching {
require(email.contains("@")) {
"이메일 형식이 올바르지 않습니다"
}
require(password.length >= 8) {
"비밀번호는 8자 이상이어야 합니다"
}
require(age >= 14) {
"만 14세 이상만 가입 가능합니다"
}
}
}
// 사용
fun signup(email: String, password: String, age: Int) {
validateSignup(email, password, age)
.onSuccess {
createAccount(email, password)
println("가입 완료!")
}
.onFailure { error ->
println("가입 실패: ${error.message}")
}
}
마무리 - 다음 편 예고
오늘 배운 것 ✅
- try-catch-finally - 기본 예외 처리
- 체크 예외가 없는 Kotlin - 자유롭지만 책임감 필요
- runCatching - 함수형 스타일로 간결하게
- Result 타입 - 예외를 값처럼 다루기
- Custom Exception - 명확한 에러 메시지
- 실전 패턴 - 재시도, Fallback, 검증
다음 편에서 배울 것 📚
14편: 코루틴 기초 | launch, async, suspend로 비동기 정복하기
- 코루틴이 뭐길래?
- launch vs async 차이
- suspend 함수 만들기
- CoroutineScope와 Job
- 실전 비동기 패턴
핵심 정리
예외 처리 3원칙
- 예상 가능한 건 try-catch로 처리
- 함수형으로 하고 싶으면 runCatching
- 명확한 에러는 Custom Exception
💬 댓글로 알려주세요!
- 예외 처리로 고생한 경험이 있나요?
- runCatching vs try-catch 중 뭘 선호하시나요?
- 이 글이 도움이 되셨나요?
'개발 > java basic' 카테고리의 다른 글
| Kotlin Delegation 완벽 가이드 | by 키워드로 보일러플레이트 제거하기 (0) | 2025.12.28 |
|---|---|
| Kotlin 상속과 인터페이스 완벽 가이드 | open, abstract, sealed (1) | 2025.12.27 |
| Kotlin 제네릭스 완벽 가이드 | in, out, reified 마스터하기 (0) | 2025.12.27 |
| Kotlin 람다와 고차 함수 완벽 가이드 | 함수형 프로그래밍 심화 (0) | 2025.12.26 |
| Kotlin 컬렉션 완벽 가이드 | List, Set, Map 함수형 프로그래밍 (1) | 2025.12.24 |
