null 리턴은 왜 나쁠까?

토스페이먼츠·2023년 11월 8일
129

엔지니어링 노트

목록 보기
2/9
post-thumbnail

💡 엔지니어링 노트 2: 코드 복잡성 관리하기

엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다. 두 번째는 코드 복잡성을 관리하는 방법을 시리즈로 소개합니다. 이 포스트는 토스 테크 블로그에서도 읽을 수 있습니다.

개발자의 고객은 누구라고 생각하시나요? 우리 제품을 사용하는 사용자(End-user)죠. 그런데 또 다른 고객이 있어요. 컴파일 타임의 고객, 바로 동료 개발자입니다. 복잡하고 나쁜 코드는 사용자 고객에게는 버그와 장애를, 개발자 고객에게는 낮은 생산성을 줍니다. 이번 시리즈에서는 사용자 고객뿐 아니라 개발자 고객을 위한 코드 복잡성 관리에 대해 이야기해 볼게요.

먼저 내 코드가 얼마나 복잡한지 체크리스트로 확인해 볼게요.

✅ 코드를 읽고 있을 때 누군가 말을 걸면 어디까지 읽었는지 놓쳐서 처음부터 다시 읽어야 한다.
✅ 코드 한 줄을 바꾸기 위해 바꿔야 할 다른 코드가 많다.
✅ 새로운 사람이 팀에 합류하면 그 사람이 몇 주 내내 프로젝트 코드를 읽을 시간을 확보해야 한다.
✅ 메서드 인자에 값을 전달하기 위해 지나가는 모든 메서드 인자 값을 추가한 적이 있다. 혹은 이 문제를 해결하기 위해 전역 변수를 사용하고 싶은 유혹을 받은 적이 있다.
✅ 프로젝트 코드가 너무 복잡해서 처음부터 다시 만들면 적어도 지금보단 나았을 거라는 생각을 해본 적이 있다.

사실 모두 제 경험담인데요. 혹시 체크리스트를 읽으며 ‘개발자의 당연한 삶’이라는 생각이 들었다면, 만성적으로 높은 코드 복잡성을 경험하고 있는 거라고 해도 될 거예요. 이제부터 예시를 보면서 함께 복잡성 관리를 시작해 봐요.

null 리턴은 왜 나쁠까?

null이 왜 나쁜지에 대해서는 이미 많은 의견을 접해봤을 거예요. ‘백만 달러짜리 실수다’, ‘Optional 타입을 도입하면 해결되는 문제다’, ‘Null Object Pattern 사용으로 해결할 수 있다’ 등이죠. 지금 이 예제에서는 이런 생각들을 뒤로하고, 오로지 코드를 읽는 사람 입장에서 null이 왜 복잡성을 만드는 ‘나쁜 코드’인지 알아볼게요.

문제: 의미를 축약한 코드 표현

val user: User? = userRepository.findByName("김토스")
println(user) // nullable

위 예제 코드에서 user 변수가 null이라면 그 이유는 무엇일까요? 아래와 같이 여러 생각을 할 수 있어요.

  • 데이터베이스에 “김토스”라는 이름을 가진 사람이 없는 것 아닐까?
  • 데이터베이스와의 네트워크 연결이 불안정했던 것 아닐까?
  • “김토스”는 탈퇴한 회원인 것 아닐까?
  • “김토스”는 운영 환경에서만 존재하는 사용자인 것 아닐까?

사실은 이런 이유가 있었어요. (글의 이해를 돕기 위한 예시입니다.)

  • 매주 월요일마다 새로운 직원이 입사한다.
  • 매주 월요일마다 인사 관리 시스템에 새로운 직원의 입사 정보가 추가된다.
  • 인사 관리 시스템에 정보가 업데이트되는 정확한 시점은 알 수 없다.
  • 이 코드가 동작하는 서버의 데이터베이스는 매주 월요일에서 화요일로 넘어가는 00시에 인사 관리 시스템과 동기화된다.
  • “김토스”는 예외적으로 월요일이 아닌 수요일에 입사했다.
  • 따라서 “김토스” 유저는 아직 이 코드가 동작하는 서버의 데이터베이스에 존재하지 않는다.

모든 배경을 이해한 개발자가 null을 리턴하기로 결정했어요. 이 결정은 코드를 읽는 사람에게 위의 모든 문맥 정보를 null 값 하나로 추론하게 만드는 거예요.

또, 읽는 사람이 같은 프로젝트 안에서 null을 리턴하는 비슷한 코드를 만날 수도 있겠죠.

val pullRequest: PullRequest? = githubClient.getPullRequestById(19)
println(pullRequest) // nullable

pullRequest 변수는 왜 null일까요? usernull인 이유와 같을까요? 비슷한 이유일 수도 있고, 자신만의 고유한 원인과 히스토리가 있을지도 모르죠. 자세히 알아보기 위해 userRepository.findByName("김토스") 의 세부 구현을 들여다보기 시작하는 순간 개발자의 생산성은 이미 떨어집니다.

비슷한 문제들

null이 아니더라도 여러 원인을 하나의 표현으로 가려버리는 방식이라면 비슷한 문제를 만들게 될 거예요. 빈 문자열과 Int 타입, 리스트로 표현한 예시도 살펴볼게요.

  • 빈 문자열 “”을 사용한 코드를 읽었을 때
    • 사용자가 입력 시도를 하지 않았나?
    • 사용자가 무언가를 입력했지만 잘못된 입력이었나?
    • 사용자가 실제로 빈 문자열을 입력했나?
  • person.getAge() 함수가 Int 타입의 -1을 반환하는 코드를 읽었을 때
    • 함수 실행이 잘 됐고, personage 데이터는 실수로 누락되어서 알 수 없는 걸까?
    • 함수 실행이 잘 됐고, personage 데이터는 입력 선택 사항이라 알 수 없는 걸까?
    • 함수 실행에 실패했고, 원인은 알 수 없는 걸까?
  • person.getPhoneNumbers() 함수가 리스트를 반환하는 코드를 읽었을 때
    • 빈 리스트 []가 돌아왔다면 함수 실행이 잘 되었고, 이 사람은 핸드폰 번호가 없는 걸까?
    • null이 돌아왔다면 함수 실행이 잘 되었고, 이 사람은 핸드폰 번호가 없는 걸까?

해결 1단계: 로그에 맥락 남기기

이런 문제를 만들지 않으려면 코드에 담긴 다양한 의미를 축약하거나 없애지 않고 자세히 풀어 코드에 녹여내면 됩니다. 주석으로는 충분하지 않아요. 실제 코드가 아니기 때문입니다. 실제 동작하는 코드, 즉 로그에 직접 찍히는 내용만이 믿을 수 있는 정보라고 할 수 있어요.

usernull인 이유를 IllegalStateException 예외 인스턴스에 상세하게 적었어요.

class UserRepository {
  fun findByName(name: String): User {
    val result: User? = db.getUserBy(name)
	  if (result == null) {
		throw IllegalStateException("""|
	      |인사관리 시스템과 동기화 되지 않은 유저의 이름을 입력한 경우 이 메시지를 볼 수 있습니다.   
	      |매주 월->화 넘어가는 자정에 인사 관리 시스템과의 데이터 동기화가 수행되므로, 새로운 사람이 월요일이 아닌 다른 날짜에 입사하지 않았는지 확인하십시오.
	      |다음 주 월요일까지 기다리거나, 수동 동기화를 실행하면 문제가 해결될 수 있습니다.
	      |
	      |인사 관리 시스템과의 데이터 동기화 로직은 UserRepositorySync 클래스를 참고하십시오. 
	      |문제가 된 name=[$name]
	      """.trimMargin())
	  }
      return result
  }
}

이제 코드를 자세히 들여다보지 않아도, usernull인 이유는 물론이고 그 배경까지 별다른 노력 없이 알게 되었어요. 코드 형태만 단순한 게 아니라, 원인 파악도 복잡하지 않고 간단하게 할 수 있어요.

여기서 던진 예외는 @ExceptionHandler 어노테이션을 통해 처리할 수도 있고, 예외로 던지지 않고 로깅만 남길 수도 있겠네요. 핵심은, 맥락을 코드에 명시적으로 드러내기만 하면 어떤 방법이든 더 낫다는 거예요.

해결 2단계: 맥락 처리를 위한 기능 만들기

이번에는 개발자가 findByName() 함수 바깥에서 null인 경우를 알고 싶다면 어떨까요? 예를 들면 재시도 로직을 넣고 싶은 상황인 거죠. ‘인사관리 시스템과 동기화되지 않은 유저의 이름을 입력했을 때’를 User? 반환 타입으로 풍부하게 표현할 수 없으니 null을 리턴할 수 밖에 없을까요? 이 질문 역시 품질을 높이기 위한 고민이 기술적인 영역으로 넘어온 것이니 이미 절반의 성공을 했다고 볼 수 있는데요. 해결 방법을 하나씩 살펴봅시다.

val user: User? = userRepository.findByName("김토스")
if (user == null) {
  // user가 null이면 유저가 인사관리 시스템과 동기화 되지 않은 경우임. 
  // 동기화를 한 번 트리거 시켜주도록 하자
  userRepositorySync.trigger()
}

val user2: User? = userRepository.findByName("김토스") 
print(user2!!) // 위에서 동기화를 한 번 시켜주었기 때문에 null일 수 없다

이렇게 코드를 작성하는 건 어떨까요? 여전히 null을 리턴했지만 주석으로 그 이유를 표현해주고 있죠. 그러나 여전히 중요한 문맥 정보를 주석과 null 으로 표현했고 리턴 타입이 User?이기 때문에 재시도를 수행한 뒤에도 null이 리턴되는 케이스를 설명하기 어려워요. null을 리턴하는 또 다른 상황을 표현할 수 없는 문제도 있고요.

이 모든 이유와 복잡성을 findByName() 함수를 읽는 개발자에게 알아서 해석하라고 할 수는 없죠. 특히 마지막 주석인 위에서 동기화를 한 번 시켜주었기 때문에 null일 수 없다같은 내용이 반복되면, 코드를 읽는 사람은 '이 정보를 무시해야 한다'라는 생각이 머릿속을 가득 채워버려서 코드 읽기가 갈수록 버거워져요.

findByName()를 사용하는 10명 중 9명은 '이름을 통해 User 타입을 얻어가고 싶은' 개발자 고객일거에요. 그렇다면 이렇게 작성해 봅시다.

val user: User = userRepository.findByName(
    name = "김토스", 
    retryHandlerWhenMissing={ userRepositorySync.trigger() }
)

print(user) // non-null type

user도 더 이상 nullable 하지 않고, 분기문도 사라졌기 때문에 9명의 개발자 고객들은 편안해할 거예요. 그래도 아직 불편한 부분이 있네요. retryHandlerWhenMissing 를 항상 넣어줘야 한다는 점이죠.

해결 3단계: 필요할 때만 제공하기

retryHandlerWhenMissing이라는 함수를 추가했지만 이것도 역시 개발자에게 부담이 되네요. 적절한 기본값을 주거나 필요할 때만 기능을 사용할 수 있도록 제공하는 방식으로 바꿔보면 어떨까요?.

val user: User = userRepository
  .withRetryPolicy(ResyncWhenUserMissing()) // 이 라인을 삭제해도 findByName() 호출에는 문제 없음
  .findByName("김토스")

print(user) // non-null type

// 이렇게 사용해도 문제 없음
val user2: User = userRepository
  .findByName("김토스")

print(user2) // non-null type

Kotlin 언어를 사용한다면 default arguments 기능을 사용할 수 있어요. Kotlin이 아니라면 위와 같이 코드를 작성할 수 있고, retryHandlerWhenMissing 에 무엇을 넣어야 하는지 모르는 문제를 해결할 수 있죠. 비슷한 이유로 인자가 많은 함수는 나쁜데, 이 내용은 다음 글에서 다룰게요.

이제는 retryHandlerWhenMissing을 매번 사용하지 않고, ResyncWhenUserMissing라고 미리 정의해 둔 동작을 사용할 수 있어요. 로직을 그대로 노출하지 않으면서도 코드의 의도를 더 분명히 드러냈어요.

그런데 아직도 문제가 있어요. 이런 식으로 코드를 작성하려면 UserRepository를 구현하기 까다로워요.

import org.springframework.data.jpa.repository.JpaRepository

interface UserRepository : JpaRepository<User, Long> {
    // 이건 Spring Framework가 알아서 해주는데,
    fun findByName(name: String): User

    // 이건 어떻게 하지?
    fun withRetryPolicy(retryPolicy: RetryPolicy): UserRepository
}

품질을 위해서 고민하다 보니 한 번 더 기술적인 문제에 부딪히게 됐네요. 이번엔 Spring 프레임워크에 대한 이해가 필요하겠군요. Spring은 저보다 여러분들이 더 잘 아실 테니 이건 연습 문제로 남겨둘게요. 반드시 이런 코드 형태가 아니어도 괜찮아요. 코드를 읽는 개발자가 혼란스럽지 않기만 하면 되거든요.

여러 단계를 거치며 문제를 해결해 보니 어떠셨나요? '이렇게까지 해야 하나?' 혹은 '제품이 당장 내일 망할 수도 있는데… 이럴 시간 없는데…' 같은 생각을 하신 분들도 있을 거예요. 회사 코드는 오랜 시간 많은 개발자들의 손끝을 거치며 어렵고 복잡해지기 쉬워요. 문제가 수면 위로 드러났을 때 학습해서 적용하기엔 회사 코드의 난이도는 높습니다.

그래서 우리는 미리 대비해야 합니다. 품질 높은 코드는 작성하는데 오래 걸리시나요? 어떻게 줄일 수 있을지 미리 고민해 보세요. 머릿속에 한 번에 넣어야 할, 기억해야 할 코드가 너무 많으신가요? 어떻게 하면 기억할 코드를 줄일 수 있을지 미리 고민해 보세요.

다음 글에서는 더욱 구체적인 사례를 소개할게요. 감사합니다.

Writer 나재은 Edit 한주연

토스페이먼츠 Twitter를 팔로우하시면 더욱 빠르게 블로그 업데이트 소식을 만나보실 수 있어요.


profile
개발자들이 만든, 개발자들을 위한 PG사 토스페이먼츠입니다.

14개의 댓글

comment-user-thumbnail
2023년 11월 9일

와 재밌다 하면서 읽었는데 토스에서 쓴거네 ㄷㄷ

1개의 답글
comment-user-thumbnail
2023년 11월 14일

좋은 글 잘 읽었습니다!

1개의 답글
comment-user-thumbnail
2023년 11월 15일

덕분에 많이 알아갑니다. 감사합니다.

1개의 답글
comment-user-thumbnail
2023년 11월 15일

벨로그 들어올 때 마다 계속 눈에 띄고 관심가서 보게 됐는데 역시 토스페이스먼츠군요...!!

1개의 답글
comment-user-thumbnail
2023년 11월 16일

함수나 변수명이 자기 자신을 설명할 수 있어야 하듯이 리턴값도 자기 자신을 설명할 수 있어야하는군요!

1개의 답글
comment-user-thumbnail
2023년 11월 17일

감사합니다

답글 달기
comment-user-thumbnail
2023년 12월 1일

안녕하세요! 글 잘 읽었습니다! 다만 "null 리턴은 왜 나쁠까?" 문단에서 ‘백만 달러짜리 실수다’라고 불리는 문제는 null 자체가 아니라 null pointer로 알고있어요! null이라고만 써놓으면 독자분들께 오해의 소지가 있을 것 같아 댓글 남겨봅니다. 감사합니다 :)

답글 달기
comment-user-thumbnail
2024년 4월 1일

초보자라서 아직 여러 상황에 맞닥뜨릴 일이 많진 않지만, 좋은 내용인 것 같습니다

답글 달기
comment-user-thumbnail
2024년 6월 5일

동기화까지 한 후에도 입력한 name으로 find를 실패했을 경우가 고려가 안된 것 같은데 nullable이 제거된 것이 맞나요??

답글 달기