해당 내용에 대한 예시코드는 [깃허브 예시코드] 에서 확인 하실 수 있습니다.
확장함수란
코틀린의 확장 함수(Extension Function)는 기존 클래스의 소스 코드를 수정하거나 상속받지 않고, 마치 그 클래스의 멤버 메소드인 것처럼 새로운 함수를 추가할 수 있는 기능입니다.
주요 특징
- 기존 클래스 확장: 외부 라이브러리나 수정 불가능한 클래스에도 새로운 기능 추가 가능
- 정적 바인딩: 컴파일 시점에 호출되는 함수가 결정됨
- 접근 제한: 클래스의 public 멤버에만 접근 가능
구현 방법
fun 클래스명.함수명(파라미터): 반환타입 {
// 함수 본문
}
활용 예시
- 외부 라이브러리 클래스에 추가 기능 제공
- 기본 클래스에 자주 쓰는 유틸리티 함수 정의
- 코드의 재사용성과 가독성 향상
리팩토링과 확장 함수 도입 배경
프로젝트를 진행하면서 리스트의 !contains() 가독성이 좋도록 특정 확장 함수를 정의했습니다. 시간이 갈수록 사람의 뇌가 단순해지는 것 같기는 하다만, 긍정 -> 부정 보다는 태초의 부정이 좀 더 코드의 가독성을 높이기 때문입니다. 코드는 아래와 같이 간단합니다.

그리고 특정 유스케이스를 한 번에 실행하는 Facade 로직을 추가했습니다. 이 Facade는 도메인 객체를 받아 특정 행위를 수행하는 역할을 합니다. 도메인 객체는 간단히 id만 가진다고 가정하였습니다.

기존 코드가 잘 작동하고 있었지만, 메서드 내부 마다 중복된 코드가 발견되었으며, 공통된 비즈니스 사항을 다른 곳에서도 쓸 수 있기에 리스트 관련 코드를 더욱 도메인 친화적으로 리팩토링했고, 아래와 같이 간단한 코드 수정 이후 배포했습니다.


문제 발생과 재현 과정
그러나 배포 후 예상치 못한 동작이 발생했습니다. 리스트에 특정 원소가 포함되지 않으면 모든 유스케이스를 실행하고, 포함되어 있으면 하나만 실행되어야 했는데, 포함되지 않았음에도 특정 유스케이스가 실행된 것입니다.
디버깅
빠르게 롤백한 뒤 원인을 분석하기 위해 로컬 환경에서 디버깅을 진행했습니다. 해당 if문에 watch point를 걸었더니, 분명히 포함되지 않았는데도 불구하고 true를 반환하는 것을 확인했습니다.

혹시 IDE 캐시 문제일까 싶어 인텔리제이의 캐시를 삭제하고 다시 테스트했지만, 결과는 변함이 없었습니다.
테스트와 원인 검증
테스트 코드
이해가 되지 않아 더 명확한 검증을 위해 테스트 코드를 작성했습니다. 실제로 if문 내부의 로직이 수행되는지 궁금했기에, 빠르게 mock 객체를 사용하여 테스트했습니다. 하지만 테스트 결과 역시 같은 문제가 발생했습니다.


혹시 확장 함수 자체의 문제인가 싶어 확장 함수를 별도로 테스트했더니, 마찬가지로 true를 반환했습니다.
더 나아가, contains 함수를 잘못 이해했나 하는 생각에 다시 테스트 코드를 작성했는데, 이 과정에서 갑작스러운 compile Error가 발생했습니다. 오류 메시지는 "타입이 불일치한다"는 내용이었습니다.

일단 타입을 맞추고, not() 케이스도 추가하여 테스트했습니다.

이제 제가 의도한 대로 코드가 동작하기 시작했습니다.
근본 원인
이때 문득 한 가지 생각이 들었습니다.
"잠깐... 설마 명시된 타입이 다른가?"
코틀린은 숫자 뒤에 'L' 이 붙으면 Long 타입으로, 붙지 않으면 Int 타입으로 타입을 추론합니다. 문제는 실제 전달된 값은 Long 타입이었고 리스트 내 값은 Int 타입이었다는 점이었습니다. 실제로, 선언되어있던 리스트의 타입을 Long 타입으로 명시해주고(각, 원소에 L을 붙여도 되지만..) 다시 기존의 mock 테스트를 돌려본 결과 추론이 맞음을 확인 할 수 있었습니다.


Arrays.contains()
타입 불일치로 인해 발생한 문제였음을 알게 되었고, 정확한 원인을 확인하기 위해 실제로 Array의 contains() 구현을 살펴봤습니다. 구현은 생각보다 단순했습니다. 기본 구현체의 Arrays는 배열 내의 시작부터 배열의 크기까지 해당 원소의 존재 유무를 판단합니다. 매번 탐색시 마다 O(N)의 시간 복잡도를 띄고 있습니다. 결국 equals()의 구현이 중요한 부분이기에, 각 클래스의 equals()를 다시 한 번 살펴봤습니다.

Long.equals()
Object의 equals()를 오버라이드한 Long 클래스의 equals()를 보면, 그리고 Int 클래스의 equals()를 보면 다 사진과 같은 구조로 해당 타입으로부터 타입 캐스팅이 가능한지에 대한 검증 이후 값 비교를 시작합니다.

실제로 Int To Long, Long To Int 는 컴파일 시점에 타입 캐스팅이 안된다고 컴파일 에러를 나타내나, Object로 형변환을 한 후에 타입캐스팅을 해보면 False를 결과로 내뱉는 것을 테스트 코드를 통해 확인 할 수 있습니다.

결론적으로, TypeCasting을 실패하여 결과 값으로는 return false가 발생하고, 이는 contains 함수의 결과 값도 false로 전파됩니다. 이에, 확장함수의 contains().not() 또한 return true 를 반환하게 된 것입니다.
회고
결국 이 문제는 휴먼 에러였지만, 작은 실수 하나가 큰 문제로 이어질 수 있음을 다시 한번 깨닫게 해줬습니다. 이전 유튜브에서 형변환을 잘못해주어서 5천만원... 5억..? 을 날린 개발자의 실수 라는 영상을 본 기억이 문득 떠올랐는데 저 또한 똑똑한 타입 캐스팅에 의존하다가 동일한 실수를 저지르고 있었음을 문득 깨달았습니다. 다행스럽게도 비용에 타격을 주는 코드가 아니여서 별 탈 없이 끝났음에 감사함을 느낍니다.
무엇보다, 기본에 충실해야 하며 대부분의 장애는 시스템의 장애가 아닌 휴먼 에러임을 다시 한 번 깨닫습니다.
'Backend' 카테고리의 다른 글
| Outbox Pattern 의 이해와 구현 (1) (0) | 2025.05.25 |
|---|---|
| Rate Limiter 시리즈 - 이론편 (0) | 2025.03.16 |
| EC2 재부팅 시 Docker, Nginx 자동 실행 (1) | 2023.12.11 |
| java Test 코드 작성 시 lombok을 사용할 수 없는 경우 해결법 (0) | 2023.04.13 |