springboot와 kotlin으로 개발 중 한 트랜잭션 내에서 save와 file에 write를 해야하는 경우가 생겼습니다.
아래 코드를 보며 자세히 설명드리겠습니다. (코드는 이해의 편의를 위해 조금 극단적으로(?) 작성되었음을 알려드립니다)
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
@Transactional
fun save(loginId: String, name: String): Member{
val savedMember = memberRepository.save(
Member(
loginId = loginId,
name = name
)
)
val file = File("test.txt")
val fileWriter = FileWriter(file)
val bufferedWriter = BufferedWriter(fileWriter)
bufferedWriter.write(getContents(loginId, name))
bufferedWriter.close()
fileWriter.close()
return savedMember
}
private fun getContents(loginId: String, name: String) =
"로그인 id: $loginId \n 이름: $name"
}
위 코드처럼 한 트랜잭션 내에서 save와 file write를 했습니다.
그리고 테스트를 해봤는데, 500 error가 나왔고 로그를 보니 FileIOException이 발생한 것을 알 수 있었습니다.
저는 Exception이 발생했으니 당연히 트랜잭션이 rollback 될 것이라 생각했는데, 로그와 DB를 보니 commit된 것을 확인할 수 있었습니다.
(위 코드를 예를들면, file write시 FileIOException이 발생했는데 member가 save가 됨)
실제 구현한 코드에서는 @Transactional 의 설정 문제(propagation이나 isolation level 등의..)인가 해서 설정에 대해 공부했지만 그 문제는 아니었습니다.
그래서 파트원분에게 도움을 청했고, IOException이 CheckedException이라서 롤백이 되지않음을 알 수 있었습니다.
예전에 ChechedException과 UncheckedException을 공부했지만 실제로 CheckedException이 발생할만한 경우가 없어서 잊고 있었던 것 같습니다. (예를들어, IO를 처리할 상황이 없었다던지..)
그래서 이번 기회에 Exception에 대해 자세히 알아보고, CheckedException은 어떻게 다루면 좋을지 기록해두려고 합니다.

Throwable클래스는 Java의 모든 에러와 예외의 슈퍼 클래스입니다. 즉, Throwable을 상속받은 클래스만 예외를 던질 수 있습니다.
자세한 내용은 이 Docs 를 확인하시면 됩니다.
Error는 애플리케이션을 구동하는 가상머신에서 발생하는 예외입니다. OutOfMemoryError, StackOverFlowError 등이 있는데, 이것들은 로직을 수정하거나, 가상머신의 설정을 변경하는 등으로 문제를 해결해야 한다. 즉, Exception처럼 try-catch로 해결이 불가능합니다.
Exception은 우리가 개발한 코드 내에서 발생하는 것으로, 사전에 처리가 가능합니다.
Exception에는 CheckedException과 UncheckedException이 있습니다.
Java를 기준으로 보면, CheckedException과 UncheckedException의 성질은 다음과 같습니다.
| CheckedException | UncheckedException | |
|---|---|---|
| 처리 여부 | 예외 처리가 필수적 | 선택적(해도되고 안해도되고) |
| Spring에서 트랜잭션 롤백 여부 | 롤백 하지 않음 | 롤백 함 |
| 종류 | RuntimeException을 제외한 Exception의 하위 클래스 IOException, TimeoutException, SQLException 등 자세한건 여기 | RuntimeException을 상속하는Exception IndexOutOfBoundException IllegalArgumentException 등 자세한 건 여기 |
자바에서는 CheckedException에 대해 예외 처리가 필수지만,
B.U.T
코틀린에서는 CheckedException에 대해 예외 처리가 필수가 아닙니다.
공식 문서를 보면, '코틀린은 CheckedException과 UncheckedException을 구분하지 않습니다'라고 나와있습니다.
이는 코틀린이 CheckedException을 try-catch로 꼭 처리하지 않아도 됨을 의미하는 것이지, 롤백 여부에 대한 성질은 java와 같습니다.
여기까지 공부한 결과, 원인 분석을 해보면 다음과 같습니다.
- 발생한 FileIoException은 CheckedException 이다.
- CheckedException은 트랜잭션 롤백을 하지 않으므로, 추가적인 처리를 해줘야 한다.
- 코틀린에서 CheckedException은 UncheckedException과 마찬가지로 예외 처리는 필수가 아니다.
원인 분석을 하고, 문제를 해결할 방법을 생각해봤더니
"그럼 IOException을 catch해서 UncheckedException을 던지도록 하면 되겠네"
라는 해결 방법이 생각났습니다.
그리고 위 코드를 변경해본 코드는 다음과 같습니다.
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
@Transactional
fun save(loginId: String, name: String): Member{
val savedMember = memberRepository.save(
Member(
loginId = loginId,
name = name
)
)
runCatching {
val file = File("test.txt")
val fileWriter = FileWriter(file)
val bufferedWriter = BufferedWriter(fileWriter)
bufferedWriter.write(getContents(loginId, name))
bufferedWriter.close()
fileWriter.close()
}
.onFailure {
throw FileProcessFailedException("Fail to process File: loginId=$loginId")
}
return savedMember
}
private fun getContents(loginId: String, name: String) =
"로그인 id: $loginId\n이름: $name"
}
class FileProcessFailedException(
message: String,
cause: Throwable? = null
): RuntimeException(message, cause)
위와 같이 runCatching을 통해 CheckedException을 catch해서 UncheckedException인 FileProcessFailedException을 던지도록 했습니다.
그리고 테스트해보니 예상한대로 예외 발생 시 롤백이 되는 것을 확인할 수 있었습니다.
만약 롤백을 하지 않아도 되고 로그만 남기고 싶다면,
onFailure내부에서 RuntimeException을 상속한 custom Exception을 throw하는 대신 로그를 남기면 될 것 같습니다.
이번에 공부를 하며 간단히 제가 내린 결론입니다.
1. Exception에는 CheckedException과 UncheckedException이 있으며, UncheckedException은 RuntimeException의 하위 클래스이다.
2. 자바에서는 CheckedException에 대한 예외처리가 필수지만,
코틀린에서는 필수가 아니다. (따라서 CheckedException이 발생할만한 곳을 예상할 수 있으면 좋을것 같다는 생각이 든다)
3. CheckedException은 트랜잭션 롤백을 진행하지 않고, UncheckedException은 롤백을 한다.
4. 따라서 트랜잭션 내에서 CheckedException이 발생할 수 있고 예외 복구 전략이 필요하다면,
CheckedException을 catch해서 Unchecked Exception을 던지도록 하는 것이 좋겠다.
(UncheckedExceptiond은 당연히 구체적이면 좋겠다. 네이밍이던, 메시지던...)
참고 자료