개발자가 코드를 짜고 있는 모습

인자가 많은 메서드는 왜 나쁠까?

#Server
나재은 · 토스페이먼츠 Server Developer
2023년 11월 29일

이전 글 ‘null은 왜 나쁠까?’의 핵심은 코드를 읽는 사람의 입장을 생각하자는 거였어요. 이번 글에서는 코드를 사용하는 사람의 입장에 관해 이야기해 볼게요.

재전송, 메일 수신자 필터링, SMS 전송(fallback) 등 다양한 기능을 제공하는 메일 발송 기능이 있다고 상상해 볼게요. 그 기능을 하는 메서드의 인자가 11개 정도 있다면 어떨까요?

class Mail(
	// ...
) {

  fun send(
    phoneFallback: Boolean?,
    phoneNumber: String?,
    isForceSend: Boolean?,
    recipient: String,
    id: Long,
    mailDomainFilterService: MailDomainFilterService?,
    mailRetryService: MailRetryService?,
    title: String,
    body: String,
    param: Map<Any, Any>,
    reservedAt: Instant?,
  )
}

이 메서드를 사용하기 위해 다음과 같이 호출 준비를 해봤어요.

mail.send(
  phoneFallback = null,
  phoneNumber = null,
  isForceSend = null,
  recipient = "jaeeun.na@tosspayments.com",
  id = -1,
  mailDomainFilterService = null,
  mailRetryService = null,
  title = "안녕하세요",
  body = "메일 본문입니다",
  param: emptyMap(),
  reservedAt: null,
)

코드만 봤을 때 몇 가지 인자는 이해돼서 값을 넣었지만, phoneFallback이나 param 같은 인자에는 무슨 값을 넣어줘야 하는지 모르겠네요. 메일 제목과 내용, 받는 사람만 채워서 메일 한 통을 보내고 싶을 뿐인데 이렇게 인자가 많고 의미를 파악하기 어려우면 메서드의 사용성이 떨어집니다.

생산성 악마의 미소

잠깐 같은 기능을 하는 Gmail UI를 한 번 볼까요? 받는 사람만 입력하면 본문 내용이 없어도 메일이 발송됩니다. 어떤 값이 필수인지도 바로 알 수 있고요.

그런데 Mail.send메서드에는 입력해야 하는 ‘필수’ 인자가 너무 많습니다. 잘 모르는 인자, 사용할 필요 없는 인자에 null을 채워 넣는 것도 불편해요. mailDomainFilterService 객체는 어떻게 만들 수 있는지, isForceSend의 의미는 뭔지 알기 어렵죠. 답답한 마음에 git blame을 입력할까 망설이다가 다른 선택지를 고민해 봅니다.

  • send 메서드는 다른 곳에서 어떻게 쓰고 있을까? 비슷하게 따라 해야겠다.
  • 아무 인자나 넣고 일단 실행해 볼까? 실행에 성공하면 좋고, 예외가 생긴다면 그때 확인해야지.
  • send 메서드 구현은 어떻게 되어있지? 각 인자의 의미를 파악해 보자.

메서드를 사용하는 개발자는 세 가지 중 하나를 선택할 수밖에 없게 됩니다. 어떤 선택지를 고르더라도 모두 생산성이 낮은 방법이죠. 선택지 하나를 고르는 순간, 생산성 악마는 이런 질문을 던집니다.

  1. ‘비슷하게 따라 한다’를 선택한 당신에게
    • 메서드 실행에 과연 성공할까? 따라 한 방법이 올바른지 검증할 수 있을까?
    • 따라 한 코드의 맥락과 사용하는 맥락이 같은가?
  2. ‘아무 인자나 넣고 일단 실행해 본다’를 고른 당신에게
    • 이 상태로 라이브 배포를 할 수 있을까?
    • 내가 만드는 코드를 정확히 이해하지 못해도 괜찮은가?
  3. ‘내부 구현을 파악해본다’를 고른 당신에게
    • 내부 구현을 정말로 이해할 수 있을까?
    • 그 시간에 다른 기능을 만드는 게 낫지 않을까?

질문에 답을 하다 보면 세 가지 선택지 모두 좋은 접근이 아니라는 생각을 하게 됩니다. 문제를 해결하기 위해서는 근본적이고 장기적인 해결책이 필요합니다. 한걸음에 천리를 갈 필요는 없습니다. 대신 ‘이런 메서드는 쓰기 어렵겠는데’라며 메서드의 소비자 입장에서 생각하는 것부터 시작하세요.

(1) 함께 사용하는 인자는 하나로 묶는다

이해하기 어려운 인자부터 하나씩 볼게요. 옆자리 동료에게 phoneFallback 인자가 뭔지 물어봤어요. 메일 발송이 실패했을 때 메일에 있는 내용을 문자로 대신 보내기 위한 인자라고 하네요. phoneNumber도 왜 있는지 알겠네요. phoneFallbacktrue 라면 phoneNumber에 적힌 번호로 메일 내용이 전송되는 거에요. 즉, phoneFallbackphoneNumber은 항상 같이 사용되는 인자에요. 알게 된 내용을 코드에 반영해 볼게요.

// 아래 두 변수가 둘 다 null이거나, 둘 다 null이 아니어야 한다는 사실을
// 더 이상 기억하고 있을 필요가 없다  
data class FallbackFeatureOption(
  val phoneFallback: Boolean,
  val phoneNumber: String,
)

class Mail(
  // ...
) {
  fun send(
    fallbackFeatureOption: FallbackFeatureOption?,    
    isForceSend: Boolean?,
    recipient: String,
    id: Long,
    mailDomainFilterService: MailDomainFilterService?,
    mailRetryService: MailRetryService?,
    title: String,
    body: String,
    param: Map<Any, Any>,
    reservedAt: Instant?,
) { 
	sendInternal(
      phoneFallback = fallbackFeatureOption?.phoneFallback,
      phoneNumber = fallbackFeatureOption?.phoneNumber,
      isForceSend = isForceSend,
      recipient = recipient,
      id = id,
      mailDomainFilterService = mailDomainFilterService,
      mailRetryService = mailRetryService,
      title = title,
      body = body,
      param = param,
      reservedAt = reservedAt,
    )
  }

  @Deprecated("이 메서드는 너무 인자가 많아서 코드를 이해하기 어렵습니다. 다른 send() 메서드를 사용하세요.")
  fun sendInternal(
  	phoneFallback: Boolean?,
      phoneNumber: String?,
      isForceSend: Boolean?,
      recipient: String,
      id: Long,
      mailDomainFilterService: MailDomainFilterService?,
      mailRetryService: MailRetryService?,
      title: String,
      body: String,
      param: Map<Any, Any>,
      reservedAt: Instant?,
    ) { ... }
}

아직 완벽한 코드는 아닙니다. 하지만 코드 사용자가 기억할 내용을 하나 줄였기 때문에 원래 코드보다는 나아졌습니다. 좀 더 보다 보니 fallbackFeatureOptionnull을 넣어야 한다는 게 마음에 들지 않네요. 문자 발송은 더 이상 신경 쓰고 싶지 않아요. 코드를 좀 더 수정해 볼게요.

// fallbackFeatureOption 인자를 삭제했다
fun send(
  // ... 
) {
  sendInternal(
    phoneFallback = null,
    phoneNumber = null,
    // ...
  )
}

// 메서드 이름으로 의도를 표현했다
fun sendWithFallback(
  fallbackFeatureOption: FallbackFeatureOption, // non-nullable
  // ...
) {
  sendInternal(
    phoneFallback = fallbackFeatureOption.phoneFallback,
    phoneNumber = fallbackFeatureOption.phoneNumber,
    // ...
  )
}

드디어 send 메서드가 노출하고 있던 phoneFallback 맥락이 사라졌어요. 하지만phoneFallbackfalse 인데 phoneNumber 값을 넣어야 해서 여전히 어색하네요. 문자를 보내지 않을 건데 핸드폰 번호를 넣어야 하니까요. 이 부분은 조금 뒤에서 다시 개선해 볼게요.

(2) 관련 없는 것은 분리한다

isForceSend에 대해서도 알아볼게요. 동료에게 물어보니, 메일 수신을 거부한 이메일 주소에도 발송 기록을 남겨야 하는 정책 때문에 사용하는 인자라고 하네요. 즉, isForceSendtrue로 설정하면 수신 거부한 주소에도 메일을 발송할 수 있어요.

이메일 수신 거부 목록은 데이터베이스에 들어 있고, MailDomainFilterService에서 데이터베이스에 접근해서 발송할지 결정합니다.

class MailDomainFilterService {
  fun isFiltered(domain: String): Boolean { 
    // database 에서 목록을 가져온 뒤, 발송 여부 결정
  }
}

그래서 isForceSendtrue면, mailDomainFilterService 인자가 사용되지 않아요. 메일 한 통을 보내고 싶을 뿐인데, 이런 세부 사항까지 알아야 하네요. 다음에 코드를 읽는 개발자가 같은 경험을 하지 않기를 바라며 이렇게 코드를 변경해 봤어요.

class Mail(
  private val mailDomainFilterService: MailDomainFilterService,
) {
  fun send(
    // ... 
  )

  fun sendWithFallback(
    // 기존과 동일
  )
 
  fun sendWithFallbackAndForced(
    fallbackFeatureOption: FallbackFeatureOption?,   
    recipient: String, 
    id: Long,
    mailRetryService: MailRetryService?,
    title: String,
    body: String,
    param: Map<Any, Any>,
    reservedAt: Instant?,
  ) { 
    sendInternal(
      phoneFallback = fallbackFeatureOption?.phoneFallback,
      phoneNumber = fallbackFeatureOption?.phoneNumber,
      isForceSend = true,
      recipient = recipient,
      id = id,
      mailDomainFilterService = null,
      mailRetryService = mailRetryService,
      title = title,
      body = body,
      param = param,
      reservedAt = reservedAt,
    )
  }
}

그런데 문제가 생겼습니다. fallbackFeatureOption 기능과 isForceSend 기능이 서로 겹치기 때문에, 다음과 같이 다섯 개의 메서드를 만들어야 하는 상황이 되어 버렸어요.

  • send()
  • sendInternal()
  • sendWithFallbackAndForced()
  • sendWithFallback()
  • sendWithForce()

아직 포함하지 않은 기능까지 고려하면 메서드가 수십 가지가 되어야 하는 상황이네요. 연관이 없는 메서드들은 독립적으로 사용하도록 만들어 볼게요.

class Mail(
  private val mailDomainFilterService: MailDomainFilterService
) {
  private var enableForceSendFeatureForCompliancePurpose: Boolean = false
  private var smsFallbackFeatureOption: SmsFallbackFeatureOption? = null

  fun enableSmsFallbackFeature(smsFallbackFeatureOption: SmsFallbackFeatureOption): Mail {
    this.smsFallbackFeatureOption = smsFallbackFeatureOption
    return this
  }

  // isForceSend 라는 이름에서, 조금 더 서술적인 이름으로 변경했다.
  fun enableForceSendFeatureForCompliancePurpose(): Mail { 
    this.enableForceSendFeatureForCompliancePurpose = true
    return this
  }

  fun send(
    id: Long,
    recipient: String,
    mailRetryService: MailRetryService?,
    title: String,
    body: String,
    param: Map<Any, Any>,
    reservedAt: Instant?,
  ) {
    // 이제 인자가 아닌 객체 필드에서 값을 얻어올 수 있다.
    sendInternal(
      phoneFallback = this.smsFallbackFeatureOption?.phoneFallback,
      phoneNumber = this.smsFallbackFeatureOption?.phoneNumber,
      isForceSend = this.enableForceSendFeatureForCompliancePurpose,
      recipient = recipient,
      id = id,
      mailDomainFilterService = if (this.enableForceSendFeatureForCompliancePurpose) {
        this.mailDomainFilterService
      } else {
        null
      },
      mailRetryService = mailRetryService,
      title = title,
      body = body,
      param = param,
      reservedAt = reservedAt,
    )
  }
}

의미가 뒤섞여서 사용하기 어색했던 SmsFallbackFeatureOption 클래스도 마저 개선해 봐요. 메서드 사용성을 개선하면서 의미가 충돌하게 됐거든요.

// sms fallback 기능을 사용하겠다고 했는데 true를 입력하는게 어색하다. 
mail
  .enableSmsFallbackFeature(SmsFallbackFeatureOption(true, "010-1234-5678"))
  .send(...)

// sms fallback 기능을 사용하겠다고 했는데 false를 입력하면 어떻게 되는거지...?
mail
  .enableSmsFallbackFeature(SmsFallbackFeatureOption(false, "???"))
  .send(...)

불필요한 인자를 삭제하고, 이름을 개선했어요. 그래서 이렇게 바꿀 수 있어요.

class Mail(
  private val mailDomainFilterService: MailDomainFilterService
) {
  private var enableForceSendFeatureForCompliancePurpose: Boolean = false
  private var smsFallbackFeaturePhoneNumber: String? = null

  fun enableSmsFallbackFeature(smsFallbackFeaturePhoneNumber: String): Mail {
    this.smsFallbackFeaturePhoneNumber = smsFallbackFeaturePhoneNumber
  }

  fun enableForceSendFeatureForCompliancePurpose(): Mail { 
    this.enableForceSendFeatureForCompliancePurpose = true
  }

  fun send(
    id: Long,
    recipient: String,
    mailRetryService: MailRetryService?,
    title: String,
    body: String,
    param: Map<Any, Any>,
    reservedAt: Instant?,
  ) {
    sendInternal(
      phoneFallback = this.smsFallbackFeaturePhoneNumber != null,
      phoneNumber = this.smsFallbackFeaturePhoneNumber,
      isForceSend = this.enableForceSendFeatureForCompliancePurpose,
      recipient = recipient,
      id = id,
      mailDomainFilterService = if (this.enableForceSendFeatureForCompliancePurpose) {
        this.mailDomainFilterService
      } else {
        null
      },
      mailRetryService = mailRetryService,
      title = title,
      body = body,
      param = param,
      reservedAt = reservedAt,
    )
  }
}

이제는 코드의 사용자가 깊게 고민하지 않아도 편리하게 사용할 수 있겠네요. intellij 같은 IDE의 도움을 받으면 mail 객체에 .(점)만 찍으면 사용할 수 있는 메서드가 나오고, 메서드 이름을 보고 충분히 기능을 유추할 수 있으니까요. 게다가 이제는 send() 메서드의 주의 사항을 더 기억하지 않아도 돼요. 그냥 사용하고 싶은 기능을 호출하기만 하면 되거든요.

mail
  .enableSmsFallbackFeature("010-1234-5678")  // 이 메서드를 호출하지 않아도 괜찮다
  .enableForceSendFeatureForCompliancePurpose()  // 이 메서드를 호출하지 않아도 괜찮다
  .send(...)

(3) 가장 중요한 인자만 남긴다

몇 주간에 걸쳐 동료에게 맥락을 물으며 리팩토링하니 다른 개발자들이 충분히 납득할 수 있는 형태가 됐어요. 이제 send() 메서드에 어떤 인자가 있을지 상상이 되나요?

class Mail(
  private val mailDomainFilterService: MailDomainFilterService,
  private val mailRetryService: MailRetryService,
  private val param: Map<Any, Any>?,
) {
  private var enableForceSendFeatureForCompliancePurpose: Boolean = false
  private var smsFallbackFeaturePhoneNumber: String? = null
  private var scheduleSendReservedAt: Instant? = null
  private var enableRetryFeature: Boolean = false

  fun enableSmsFallbackFeature(smsFallbackFeaturePhoneNumber: String): Mail {
    this.smsFallbackFeaturePhoneNumber = smsFallbackFeaturePhoneNumber
  }

  fun enableForceSendFeatureForCompliancePurpose(): Mail { 
    this.enableForceSendFeatureForCompliancePurpose = true
  }

  fun enableScheduleSendFeature(reservedAt: Instant): Mail { 
    this.scheduleSendReservedAt = reservedAt 
  }

  fun enableRetryFeature(): Mail { 
    this.enableRetryFeature = true
  }

  fun send(
    recipient: String,
    title: String,
    body: String,
  ) {
    sendInternal(
      phoneFallback = this.smsFallbackFeaturePhoneNumber != null,
      phoneNumber = this.smsFallbackFeaturePhoneNumber,
      isForceSend = enableForceSendFeatureForCompliancePurpose,
      id = Random.nextLong(),  // 내부 구현에서 랜덤으로 생성해서 사용함
      mailDomainFilterService = if (this.enableForceSendFeatureForCompliancePurpose) {
        this.mailDomainFilterService
      } else {
        null
      },
      mailRetryService = if (this.enableRetryFeature) {
        this.mailRetryService
      } else {
        null
      },
      title = title,
      body = body,
      param = emptyMap(),
      reservedAt = this.scheduleSendReservedAt,      
  }

  fun templateSend(
    recipient: String,
    title: String,
    body: String,
    param: Map<Any, Any>
  ) {
    sendInternal(
      // ...
      param = param
      // ...
    )
  }
}

sendInternal 메서드 구현을 전혀 건드리지 않고도 이젠 코드에 내가 원하는 값만 넣을 수 있게 됐어요. 코드를 읽기도, 사용하기도 편리해졌죠.

// 가장 간단하게 사용할 때
mail.send(
  recipient = "jaeeun.na@tosspayments.com",
  title = "안녕하세요",
  body = "메일 본문입니다"	
)

// 지원하는 모든 기능을 사용할 때
mail
  .enableSmsFallbackFeature("010-1234-5678")
  .enableForceSendFeatureForCompliancePurpose()
  .enableScheduleSendFeature(Instant.now().plus(Duration.ofHours(2)))
  .enableRetryFeature()
  .send(
    recipient = "jaeeun.na@tosspayments.com",
    title = "안녕하세요",
    body = "메일 본문입니다"
  )

Mail 생성자에 의존성이 생겼으니 조삼모사가 아니냐고 반문할 수도 있겠네요. 함수 인자에 의존성을 주입하는 것은 코드 사용자에게 부담을 주지만, 객체에서 의존성을 관리하면 코드 작성자가 그 부담을 감당할 수 있어요. 코드 사용자 입장에서는 더 편리해지죠. 중복으로 호출되던 의존성 주입 코드를 한곳으로 모은 효과도 있고, 객체 간의 표현도 더 풍부해졌죠.

정리하기

지금까지의 과정을 정리해 볼게요.

  1. 메일을 보내기 위해 mail.send() 메서드를 재사용하려고 했다.
  2. 사용자에게 친절하지 않은 코드 때문에 mail.send()를 사용하기 위해 너무 많은 맥락을 알아야만 했다.
  3. 팀 내부의 도메인 지식을 습득했다.
  4. 습득한 지식을 가지고 코드를 개선했다.

가장 중요한 과정은 무엇이었을까요? 팀원들이 저처럼 도메인 지식을 다시 학습하지 않아도 되도록 코드를 리팩토링한 네 번째 과정이 가장 중요합니다. 만든 개발자만 이해하는 코드는 좋지 않아요. 개발자가 요구사항을 위해 사용해야 하는 코드를 쉽게 읽을 수 있는 상황이 가장 바람직하죠. 그런 환경이 개발자가 가장 생산적으로 활동할 수 있는 환경이고요. 리팩토링 후 달라진 개발자의 환경을 비교해 볼까요.

Q. 이 프로젝트에서 메일을 보내려면 어떻게 해야 해요?

  • Before: 일단 Mail 클래스의 send() 메서드 통해서 보낼 수 있는데요, id는 꼭 랜덤하게 넣어야 하고요, 예약 메일이 아니면 reservedAtnull을 넣으세요. 아, 혹시 수신자 메일 도메인이 어떻게 되나요? 강제로 보내야 할 때가 있어서요.
  • After: Mail 클래스가 제공하는 공개 메서드 목록을 보세요.

도메인 지식과 코드가 아주 간단해졌죠? 이 리팩토링 과정을 돕기 위해 디자인 패턴과 클린 아키텍처, 도메인 주도 개발(DDD) 등이 존재합니다. 구현을 어떻게 할지는 당신의 손에 달려있습니다. 코드를 읽는 동료 개발자를 항상 고객처럼 생각하세요.

👉 이런 문제 해결에 관심이 있다면 토스페이먼츠 서버 챕터에 지금 합류하세요!

Writer 나재은 Edit 한주연

댓글 0댓글 관련 문의: toss-tech@toss.im
연관 콘텐츠
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.