💡 엔지니어링 노트 2: 코드 복잡성 관리하기
엔지니어링 노트 시리즈는 토스페이먼츠 개발자들이 제품을 개발하면서 겪은 기술적 문제와 해결 방법을 직접 다룹니다. 두 번째는 코드 복잡성을 관리하는 방법을 시리즈로 소개합니다. 이 포스트는 토스 테크 블로그에서도 읽을 수 있습니다.
개발자의 고객은 누구라고 생각하시나요? 우리 제품을 사용하는 사용자(End-user)죠. 그런데 또 다른 고객이 있어요. 컴파일 타임의 고객, 바로 동료 개발자입니다. 복잡하고 나쁜 코드는 사용자 고객에게는 버그와 장애를, 개발자 고객에게는 낮은 생산성을 줍니다. 이번 시리즈에서는 사용자 고객뿐 아니라 개발자 고객을 위한 코드 복잡성 관리에 대해 이야기해 볼게요.
먼저 내 코드가 얼마나 복잡한지 체크리스트로 확인해 볼게요.
✅ 코드를 읽고 있을 때 누군가 말을 걸면 어디까지 읽었는지 놓쳐서 처음부터 다시 읽어야 한다.
✅ 코드 한 줄을 바꾸기 위해 바꿔야 할 다른 코드가 많다.
✅ 새로운 사람이 팀에 합류하면 그 사람이 몇 주 내내 프로젝트 코드를 읽을 시간을 확보해야 한다.
✅ 메서드 인자에 값을 전달하기 위해 지나가는 모든 메서드 인자 값을 추가한 적이 있다. 혹은 이 문제를 해결하기 위해 전역 변수를 사용하고 싶은 유혹을 받은 적이 있다.
✅ 프로젝트 코드가 너무 복잡해서 처음부터 다시 만들면 적어도 지금보단 나았을 거라는 생각을 해본 적이 있다.
사실 모두 제 경험담인데요. 혹시 체크리스트를 읽으며 ‘개발자의 당연한 삶’이라는 생각이 들었다면, 만성적으로 높은 코드 복잡성을 경험하고 있는 거라고 해도 될 거예요. 이제부터 예시를 보면서 함께 복잡성 관리를 시작해 봐요.
null이 왜 나쁜지에 대해서는 이미 많은 의견을 접해봤을 거예요. ‘백만 달러짜리 실수다’, ‘Optional 타입을 도입하면 해결되는 문제다’, ‘Null Object Pattern 사용으로 해결할 수 있다’ 등이죠. 지금 이 예제에서는 이런 생각들을 뒤로하고, 오로지 코드를 읽는 사람 입장에서 null이 왜 복잡성을 만드는 ‘나쁜 코드’인지 알아볼게요.
val user: User? = userRepository.findByName("김토스")
println(user) // nullable
위 예제 코드에서 user
변수가 null
이라면 그 이유는 무엇일까요? 아래와 같이 여러 생각을 할 수 있어요.
사실은 이런 이유가 있었어요. (글의 이해를 돕기 위한 예시입니다.)
모든 배경을 이해한 개발자가 null
을 리턴하기로 결정했어요. 이 결정은 코드를 읽는 사람에게 위의 모든 문맥 정보를 null
값 하나로 추론하게 만드는 거예요.
또, 읽는 사람이 같은 프로젝트 안에서 null
을 리턴하는 비슷한 코드를 만날 수도 있겠죠.
val pullRequest: PullRequest? = githubClient.getPullRequestById(19)
println(pullRequest) // nullable
pullRequest
변수는 왜 null
일까요? user
가 null
인 이유와 같을까요? 비슷한 이유일 수도 있고, 자신만의 고유한 원인과 히스토리가 있을지도 모르죠. 자세히 알아보기 위해 userRepository.findByName("김토스")
의 세부 구현을 들여다보기 시작하는 순간 개발자의 생산성은 이미 떨어집니다.
null
이 아니더라도 여러 원인을 하나의 표현으로 가려버리는 방식이라면 비슷한 문제를 만들게 될 거예요. 빈 문자열과 Int
타입, 리스트로 표현한 예시도 살펴볼게요.
“”
을 사용한 코드를 읽었을 때person.getAge()
함수가 Int
타입의 -1을 반환하는 코드를 읽었을 때person
의 age
데이터는 실수로 누락되어서 알 수 없는 걸까?person
의 age
데이터는 입력 선택 사항이라 알 수 없는 걸까?person.getPhoneNumbers()
함수가 리스트를 반환하는 코드를 읽었을 때[]
가 돌아왔다면 함수 실행이 잘 되었고, 이 사람은 핸드폰 번호가 없는 걸까?null
이 돌아왔다면 함수 실행이 잘 되었고, 이 사람은 핸드폰 번호가 없는 걸까?이런 문제를 만들지 않으려면 코드에 담긴 다양한 의미를 축약하거나 없애지 않고 자세히 풀어 코드에 녹여내면 됩니다. 주석으로는 충분하지 않아요. 실제 코드가 아니기 때문입니다. 실제 동작하는 코드, 즉 로그에 직접 찍히는 내용만이 믿을 수 있는 정보라고 할 수 있어요.
user
가 null
인 이유를 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
}
}
이제 코드를 자세히 들여다보지 않아도, user
가 null
인 이유는 물론이고 그 배경까지 별다른 노력 없이 알게 되었어요. 코드 형태만 단순한 게 아니라, 원인 파악도 복잡하지 않고 간단하게 할 수 있어요.
여기서 던진 예외는 @ExceptionHandler
어노테이션을 통해 처리할 수도 있고, 예외로 던지지 않고 로깅만 남길 수도 있겠네요. 핵심은, 맥락을 코드에 명시적으로 드러내기만 하면 어떤 방법이든 더 낫다는 거예요.
이번에는 개발자가 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
를 항상 넣어줘야 한다는 점이죠.
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를 팔로우하시면 더욱 빠르게 블로그 업데이트 소식을 만나보실 수 있어요.
와 재밌다 하면서 읽었는데 토스에서 쓴거네 ㄷㄷ