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은 당연히 구체적이면 좋겠다. 네이밍이던, 메시지던...)
참고 자료