본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

[Kotlin] 확장 함수와 람다를 사용해서 중복 코드를 제거해보자!

by 개발하는 곰돌이 2023. 5. 8.

목차

    확장 함수

    확장 함수는 특정 타입에 실제로 존재하는 메소드는 아니지만, 해당 타입의 메소드 처럼 사용할 수 있는 함수이다. 예를 들어, 문자열의 \(i\)번째 문자와 \(j\)번째 문자를 맞바꾼 새로운 문자열을 반환하는 확장 함수는 아래와 같이 작성할 수 있다.

    fun String.swapChar(i: Int, j: Int): String {
        val sb = StringBuilder(this)
        sb[i] = this[j]
        sb[j] = this[i]
        return sb.toString()
    }

    여기서 String.은 String 타입에 대한 확장 함수라는 것을 나타내며 확장 함수 내부의 this는 이 확장 함수를 사용하는 객체를 의미한다.

    이렇게 작성한 확장 함수는 다음과 같이 사용할 수 있다.

    fun main() {
        val str = "ABC"
        println(str.swapChar(0, 2))
    }
    
    /*
    CBA
    */

    swapChar()는 실제 String 클래스에 존재하는 메소드가 아니지만 String 클래스의 메소드 처럼 사용할 수 있다. 이런 확장 함수의 장점은 어떤 타입의 객체를 대상으로 하는 동작을 해당 타입의 메소드처럼 사용할 수 있다는 점, 이로 인해 해당 메소드가 어떤 타입의 객체를 대상으로 동작하는지 알기 쉽다는 점을 꼽을 수 있다.

     

    다만, 확장 함수도 엄연히 해당 외부의 함수이기 때문에 해당 타입의 private이나 protected인 프로퍼티와 메소드에는 접근할 수 없다. 그 외의 접근 가능한 프로퍼티와 메소드는 해당 클래스에서 사용하는 것과 같이 바로 사용할 수 있다.

    람다

    코틀린에서는 자바스크립트의 화살표 함수와 비슷한 형태로 간단하게 람다를 정의할 수 있다. 위의 swapChar() 함수에서 \(i\)번째 문자와 \(j\)번째 문자가 특정 문자인 경우에만 위치를 변경하고 싶을 때 아래와 같이 람다를 활용하여 작성할 수 있다.

    fun String.swapChar(i: Int, j: Int, filter: (Char) -> Boolean): String {
        val sb = StringBuilder(this)
        if (filter(sb[i]) && filter(sb[j])) {
            sb[i] = this[j]
            sb[j] = this[i]
        }
        return sb.toString()
    }

    여기서 괄호 속의 Char는 람다의 파라미터 타입을 의미하는데, 콤마로 구분하여 타입을 나열하는 것으로 여러 개의 파라미터를 설정할 수 있다. 화살표 뒤의 Boolean은 람다의 반환 타입을 의미한다.

    이렇게 람다를 파라미터로 받는 메소드는 아래와 같이 사용할 수 있다. 다음은 문자가 A~B인 경우에만 두 문자를 교환하도록 작성한 경우이다.

    fun main() {
        val str = "ABC"
        println(str.swapChar(0, 2, { it in 'A'..'B' }))
    }

    코틀린의 람다는 반드시 중괄호 내부에 함수 블럭과 같은 형태로 작성되어야 한다. 이 때, 람다가 가장 마지막 파라미터라면 중괄호를 괄호 밖으로 빼낼 수 있다.

    println(str.swapChar(0, 2) { it in 'A'..'B' })

    여기서 it은 람다의 파라미터가 1개 뿐일 때 인자를 지칭하는 default name이다. 람다의 인자에 별도의 이름을 지정하고 싶다면 자바나 자바스크립트와 같은 방식으로 지정해줄 수 있다.

    println(str.swapChar(0, 2) { c -> c in 'A'..'B' })

    이 3개의 코드는 모두 완벽히 동일한 코드이다.

    중복 코드를 줄여보자

    서론이 길었는데 이제 확장 함수와 람다를 사용해서 중복 코드를 줄여보자. 이 글에서는 Spring JPA에서 발생할 수 있는 중복 코드에 대해 다룰 것이다.

    확장 함수로 중복 코드 줄이기

    레포지토리에서 기본키를 기준으로 엔티티를 탐색할 땐 findById()메소드를 사용하게 되는데 이 메소드의 반환값은 Optional 객체이다. 그런데 코틀린은 null safety를 사용할 수 있기 때문에 Optional을 사용할 필요가 없어서 findByIdOrNull()이라는 메소드를 제공한다. 이 메소드의 구현을 보면 CrudRepository의 확장 함수로 구현되어 있는 것을 볼 수 있다.

    그런데 아래와 같이 기본키를 기준으로 엔티티를 탐색했을 때 엔티티가 존재하지 않아 결과가 null이 떨어지면 예외를 던져주는 동작이 굉장히 많이 중복될 수 있다.

    fun getMember(id: UUID): Member = memberRepository.findByIdOrNull(id) ?:
        throw NoSuchElementException("존재하지 않는 회원입니다.")

    이 경우엔 위의 findByIdOrNull()과 마찬가지로 CrudRepository의 확장 함수를 작성할 수 있다.

    inline fun <reified T, ID> CrudRepository<T, ID>.findByIdOrThrow(id: ID, message: String): T = findByIdOrNull(id) ?:
        throw NoSuchElementException(message)

    CrudRepository는 JpaRepository가 상속받는 ListCrudRepository의 상위 인터페이스이기 때문에 모든 JpaRepository에 대해 findByIdOrNull을 사용하여 결과가 null이면 NoSuchElementException을 던지던 모든 동작을 모두 findByIdOrThrow()로 수정할 수 있다.

    fun getMember(id: UUID): Member = memberRepository.findByIdOrThrow(id, "존재하지 않는 회원입니다.")

    람다를 사용해서 중복 코드 줄이기

    [JPA] Service 계층에서 DataIntegrityViolationException을 처리하기의 마지막에서 save()뿐만 아니라 delete()와 변경 감지를 통한 UPDATE 쿼리를 실행할 때도 Service 계층의 try-catch에서 DataIntegrityViolationException을 처리하지 못하는 문제가 있다는 이야기를 한 적이 있다. 이를 방지하기 위해 해당 링크의 글대로 코드를 작성하면 아래와 같은 모습이 될 것이다.

    @Transactional
    fun updateMember(id: UUID, request: MemberRequest): MemberResponse {
        val member = memberRepository.findByIdOrThrow(id, "존재하지 않는 회원입니다.")
        try {
            member.update(Member.from(request))
            memberRepository.flush()
        } catch (e: DataIntegrityViolationException) {
            throw IllegalArgumentException("이미 등록된 전화번호입니다.")
        }
        return MemberResponse.from(member)
    }

    여기서 Member 엔티티의 update()는 엔티티에 작성해둔, 객체의 프로퍼티 값들을 변경하는 메소드이다.

     

    이런 경우라면 DataIntegrityViolationException을 처리할 모든 메소드에 try-catch 블록이 들어갈 것이고, 많은 메소드에 중복되는 코드가 발생하게 된다.

     

    하지만 아래와 같이 확장 함수와 람다를 사용하면 이러한 중복 코드도 말끔하게 덜어낼 수 있다.

    inline fun <reified T, ID, R> JpaRepository<T, ID>.flushOrThrow(exception: Throwable, block: JpaRepository<T, ID>.() -> R): R {
        try {
            val result = block()
            flush()
            return result
        } catch (e: DataIntegrityViolationException) {
            throw exception
        }
    }

    이 확장 함수는 JpaRepository의 확장 함수로 동작한다. Throwable과 JpaRepository의 확장 함수 형태의 람다를 파라미터로 받아서 해당 람다의 결과를 반환하는데, 그 과정에서 DataIntegrityViolationException이 발생하게 되면 파라미터로 받은 Throwable을 던져주게 된다.

     

    이제 이 확장 함수를 사용해서 flush()를 사용하던 모든 메소드를 아래와 같이 수정할 수 있다.

    @Transactional
    fun updateMember(id: UUID, request: MemberRequest): MemberResponse {
        val member = memberRepository.findByIdOrThrow(id, "존재하지 않는 회원입니다.")
        memberRepository.flushOrThrow(IllegalArgumentException("이미 등록된 전화번호입니다.")) { member.update(Member.from(request)) }
        return MemberResponse.from(member)
    }
    @Transactional
    fun insertMember(request: MemberRequest): MemberResponse = 
        MemberResponse.from(memberRepository.flushOrThrow<Member, UUID, Member>(badRequest("이미 등록된 전화번호입니다.")) { save(Member.from(request)) })

    마치며

    개인적으로 확장 함수는 코틀린의 가장 매력적인 기능 중 하나라고 생각한다. 자바에서 별도의 특정 타입에 대한 유틸 클래스를 만들어서 그 내부에 정적 메소드로 작성하는 것에 비해서 해당 메소드가 어떤 타입에 대해 어떤 동작을 수행할 것인지 훨씬 직관적으로 볼 수 있기 때문이다.

     

    람다의 경우에는 반드시 인터페이스를 통해서 사용해야 하는 자바와 달리 별도의 인터페이스 없이 자유롭게 선언할 수 있고, 본문에서 메소드의 파라미터로 사용하는 경우만 이야기했지만 코틀린의 람다는 변수로 사용할 수도 있기 때문에 활용도가 뛰어나다고 생각한다.

     

    중복 코드가 많아지면 코드의 가독성이 떨어질 수 있고, 한 쪽을 수정했을 때 다른 쪽들도 모두 수정해야 하기 때문에 유지보수에도 영향을 줄 수 있다. 그렇기 때문에 확장 함수와 람다를 포함한 다양한 방법으로 중복 코드를 제거하여 코드의 가독성을 향상시키고, 깔끔한 코드를 작성하는 것이 좋을 것이다.

    댓글