지나가던 개발(zigae)

프론트엔드 클린코드 - 복잡한 네이밍 회피

2023년 12월 15일 • ☕️☕️ 12 min read

clean code

필자를 포함한 우리 모두는 프로그래밍을 하면서 변수, 함수, 클래스, 파일 등의 네이밍이 가장 어려운 문제 중 하나라는 것을 알고 있다. 아마도 대부분은 프로그래밍을 처음 시작했을 때 다음과 같은 코드를 작성했을 것이다.

import os

def read_file(fp):
    try:
        with open(fp, 'rb') as file:
            buf = file.read(1024)  
            return buf
    except Exception as e:
        print(f"{fp}: {e}")

def check_format(buf, fm):
    for fm in fms:
        if buf.startswith(fm['Signature']):
            if fm['Signature2Offset'] > 0:
                if buf.find(fm['Signature2'], fm['Signature2Offset']) == -1:
                    continue
            return True
    return False

fm = [
    {
      'Signature': b'\x89PNG',
      'Signature2': b'IHDR',
      'Signature2Offset': 0,
      'Extensions': ['png']
    },
]

fp = 'path/to/directory'
for file_name in os.listdir(fp):
    fp = os.path.join(fp, file_name)
    if os.path.isfile(fp):
        buf = read_file(fp)

# 중략...

필자가 학부생 때 작성했던 코드이다. 솔직히 말해서 이 코드가 무엇을 하는 코드인지 기억이나지 않지만 해석하고 싶지도 않다. 그 이유는 의미 없는 단일 문자(i,j)와 약어(buf), 두문자어(e, fp, fm)등 코드를 읽기 어렵게 만드는 요소들이 많기 때문이다. (그래도 함수명은 어느 정도 의미를 부여했다.)

필자는 한때 매우 짧은 이름을 주로 사용하고 코멘트나 테스트를 전혀 작성하지 않은채 개발 했다. 시간 내에 문제를 풀어야하는 알고리즘을 주로 풀었기 때문에 네이밍에 대한 고민을 하지 않기 위해 그런 것이다. 이러한 코드는 다른 사람을 이해시키기 위해 많은 시간을 투자해야 한다. 또한, 코드를 수정하거나 리팩토링 할 때도 많은 시간을 투자해야 한다. 종종 과거에 풀었던 코드의 버그를 추적할 때는 마치 어셈블리어를 읽는 것 같은 느낌을 받기도 했다.

최근 개발을 시작하는분들은 이러한 극단적 사례를 경험하지 않았을 것이다. 그 이유는 전반적으로 수준이 올랐기 때문이다. 하지만 클린코드를 지향하는 개발자들도 네이밍에 대한 고민을 하지 않는다면 클린코드를 작성하기는 어렵다.

이중 부정 Boolean

다음 코드를 살펴보자.

validateForm(values) {
  let noErrorsFound = true;
  const errorMessages = [];

  if (!values.firstName) {
    errorMessages.push('이름을 입력해주세요.');
    noErrorsFound = false;
  }
  if (!values.lastName) {
    errorMessages.push('성을 입력해주세요.');
    noErrorsFound = false;
  }
  if (!noErrorsFound) {
    this.set('error_message', errorMessages);
  }

  return noErrorsFound;
}

이 코드에 대해 어떻게 생각하는가? 많은 것을 말할 수 있지만 먼저 다음 줄에 대해 생각해보자.

if (!noErrorsFound) {

“오류가 발견되지 않은 경우” 라는 이중 부정은 벌써 뇌를 불편한게 한다. !no를 화면에서 가리고 싶은 심정이 든다. 대부분의 부정 Boolean은 긍정 Boolean으로 변환하면 코드 가독성을 향상시킬 수 있다.

validateForm(values) {
  let errorFound = false;
  const errorMessages = [];

  if (!values.firstName) {
    errorMessages.push('이름을 입력해주세요.');
    errorFound = false;
  }
  if (!values.lastName) {
    errorMessages.push('성을 입력해주세요.');
    errorFound = false;
  }
  if (!errorFound) {
    this.set('error_message', errorMessages);
  }

  return errorFound;
}

긍정적인 변수명과 긍정적인 조건문은 일반적으로 부정적 표현보다 읽기 쉽다.

이때 우리는 errorFound 변수가 전혀 필요하지 않고, errorMessages 배열에서 파생될 수 있다는 것을 알아차렸을 것이다.

validateForm(values) {
  const errorMessages = [];

  if (!values.firstName) {
    errorMessages.push('이름을 입력해주세요.');
  }
  if (!values.lastName) {
    errorMessages.push('성을 입력해주세요.');
  }
  if (!errorFound) {
    this.set('error_message', errorMessages);
    return false
  }
  return true
}

또한 사이드이펙트를 분리하고 코드를 더 쉽게 테스트할 수 있도록 this.set() 메소드르 분리했다. 오류가 없을 때 빈 배열을 설정하는 것이 더 안전해 보인다.

getErrorMessages(values) {
  const errorMessages = [];

  if (!values.firstName) {
    errorMessages.push('이름을 입력해주세요.');
  }
  if (!values.lastName) {
    errorMessages.push('성을 입력해주세요.');
  }

  return errorMessages;
}

validateForm(values) {
  const errorMessages = this.getErrorMessages(values);
  this.set('error_message', errorMessages);

  return errorMessages.length === 0;
}

여기까지 리팩토링을 하면서 errorFound 변수를 제거하고 errorMessages 배열을 반환하는 getErrorMessages() 메소드를 추가했다. 이제 validateForm() 메소드는 오류 메시지를 설정하고 오류가 발견되지 않았는지 여부를 반환한다.

변수의 스코프가 클수록 변수명이 길어진다.

필자의 경험상 변수의 스코프가 짧을수록 그 변수명도 짧아진다. 한 줄로 된 아주 짧은 변수 변수명도 괜찮다. 오히려 선호하는 편이다.

const inputRange = Object.keys(TRANSITION).map(x => parseInt(x, 16));

const breakpoints = [
  BREAKPOINT_MOBILE,
  BREAKPOINT_TABLET,
  BREAKPOINT_DESKTOP
].map(x => `${x}px`);

예시 코드에서의 변수 x의 역할은 명확하다. 이처럼 상위 함수에 전체 이름을 가지고 있는 경우 x 정도의 변수명은 꽤 괜찮아 보인다.

또 일부 개발자들은 _ 를 선호하는데, 이는 특히 Lodash 에서 자주 사용 되는 패턴이다. 외에도 정렬 및 비교 함수에 변수명을 a 또는 b 로 사용하는 경우도 있다. 실제로 코드를 읽는데 큰 무리가 없으며 이러한 패턴은 매우 일반적이다.

const sorted = _.sortBy(array, item => item.name);
dates.sort((a, b) => a - b);

그러나 스코프가 길어지고 변수가 여러 개인 경우 짧은 변수명은 혼동될 수 있다.

다음 고객이 포인트 카드를 가지고 있는지 확인하는 함수를 살펴보자.


const hasDiscountedProduct = stores => {
  let hasDiscount = false;
  const storeIds = Object.keys(stores);
  for (let i = 0; i < storeIds.length; i++) {
    const store = stores[storeIds[i]];
    if (store.products) {
      for (let j = 0; j < store.products.length; j++) {
        const product = store.products[j];
        if (product && product.isDiscounted) {
          hasDiscount = true;
          break;
        }
      }
    }
    if (hasDiscount) {
      break;
    }
  }
  return hasDiscount;
};

우리는 코드의 설명을 봤음에도 해당 코드가 무슨 동작을 하는지 이해하는 것은 매우 어렵다. 아무런 의미 없는 변수명이 주된 이유 중 하나이다.

이제 이 코드를 약간 리팩토링 해보자.

const hasDiscount = customers => {
  return Object.values(customers).some(customer => {
    return customer.ages?.some(
      ageGroup => ageGroup.customerCards.length > 0
    );
  });
};

리팩터링된 코드는 앞서 작성 되었던 코드의 절반도 되지 않고, 훨씬 더 명확하다.

“어떤(some) 연령 그룹에서 하나 이상의 포인트카드를 가진(some) 고객이 있는지 확인한다.” 라는 의미를 가지고 있다. 이제 코드를 읽는 것이 훨씬 쉬워졌다.

짧은 변수명의 가장 정통적인 경우 중 하나는 반복문이다. 반복문에서는 i 또는 j, k 를 사용하는 것이 일반적이다. 중첩된 반복문이 아닌 짧은 루프에서는 코드를 읽는데 어려움이 없다. 이는 프로그래머가 해당 블록을 보는 것에 너무 익숙하기 때문이다. 하지만 중첩된 반복문에서는 변수명을 더 명확하게 작성하는 것이 좋다.

const customerIds = Object.keys(costomers);
for (let i = 0; i < costomers.length; i += 1) {
  costomers[customerIds[i]].reset();
}

const customerIds = Object.keys(costomers);
for (let key = 0; key < costomers.length; key += 1) {
  costomers[customerIds[key]].reset();
}

확실히 i 는 반복문에서 익숙한 변수명이지만, keys 라는 변수명을 사용하면 더 명확하게 코드를 읽을 수 있다. 하지만 대부분의 최신 언어를 사용하면 반복문을 사용하지 않고 반복 작업을 실행할 수 있다.

const customerIds = Object.keys(costomers);
customerIds.forEach(key => {
  costomers[key].mockReset();
});

더 많은 예는 반복문 회피글을 참고하자.

축약어와 두음어

코드를 지옥으로 보내는 길은 약어로 가득 찬 변수명을 사용하는 것이다. OTC, RN, PSP, SD 가 무슨 의미인지 알고 있는가? 아마도 대부분은 모를 것이다. 왜냐하면 필자도 모른다. 그러나 모두 하나의 프로젝트에서 가져온 것이다. 그렇기 때문에 필자는 코드뿐만 아니라 모든 문서에서 약어를 사용하지 않으려고 노력한다.

예외적으로 허용되는 약어 목록이 있다. 해당 목록의 예는 Apple에서 찾을 수 있고, 좋은 예가 될 수 있다고 생각한다.

약어 전체 단어
alt alternative
app application
arg argument
err error
info information
init initialize
lat latitude
lon longitude
max maximum
min minimum
param parameter
prev previous (특히 next와 짝으로 사용 될 때)

외에도 일반적으로 사용하는 약어들이 있다. HTML, HTTP, JSON, URL, XML 등이 있다. 그리고 특정 프로젝트에서 사용 되는 약어가 발생할 수 있지만 당연히 문서화 되어야 한다.(신규입사자에게 매우 유용할 것이다.)

접두사 및 접미사

필자는 변수 및 함수 명에 몇몇 접두사를 고정적으로 사용한다.

  • boolean 의 경우 is, has 를 사용한다.(eg. isReady, hasError)
  • 반환하는 순수 함수의 경우 get 을 사용한다.(eg. getCustomer, getProduct)
  • 스토어 상태 및 React 상태의 경우 set 을 사용한다.(eg. setCustomer, setProduct)
  • 서버로부터 데이터를 가져오는 함수의 경우 fetch 를 사용한다.(eg. fetchCustomer, fetchProduct)
  • 데이터를 특정 타입으로 변환하는 함수의 경우 to 를 사용한다.(eg. toString, hexToRgb)
  • 이벤트 및 핸들러의 경우 onhandle 을 사용한다.(eg. onSubmit, handleClick)

이러한 규칙을 스스로에게 적용하면 코드를 더 쉽게 읽을 수 있고 갋을 반환하는 순수함수와 사이드이펙트를 가지는 함수를 쉽게 구분할 수 있다.

그러나 두개 이상의 접두사와 결합하는 것은 좋지 않다. 예를들어 getIsCustomerFilter, getHasShowPassword 와 같은 명을 종종 볼 수 있는데, 그냥 isCustomerFilter, hasShowPassword 와 같이 접두사를 하나로 줄이는 것이 좋다. 그러나 setIsVisibleisVisible과 함께 사용하는 것은 괜찮다.

const [isVisible, setIsVisible] = useState(false);

예외적으로 HTML의 <button disabled /> 와 같은 어트리뷰트의 경우 is prefix 를 생략하는 것을 선호한다. 유사하게 리액트 컴포넌트에도 적용 가능하다.

const Button = ({ disabled, fullWidth, loading }) => {
  return <button disabled={disabled} fullWidth={fullWidth} loading={loading} />;
};

너무 많은 규칙을 기억하는 것은 인지부하를 증가시키기 때문에 너무 지나칠 필요는 없다. 다행히 거의 잊혀진 규칙도 있다. 헝가리 표기밥으로, 각 변수명 앞에 타입이 붙이는 것이다. eg. arru8Number(부호가 없는 8비트 숫자), usName(안전하지 않은 문자열) 등이 있다. C와 같은 언어에서는 이러한 규칙이 필요했지만, 현재는 더 이상 필요하지 않다.

비교적 최근에 보이는 헝가리 표기법은 Typescript Interface 앞에 I 를 붙이는 것이다.

interface ICustomer {
  id: string;
  name: string;
}

다행히 요즘 대부분 FE 개발자는 이를 삭제하는 것을 선호한다.

interface Customer {
  id: string;
  name: string;
}

리액트에서 업데이트 처리

동일한 객체의 이전 버전을 기반으로 객체를 업데이트 하는 함수를 상상해보자.

setCount(prevCount => prevCount + 1);

다음 카운트 값을 반환하는 간단한 카운터 함수이다. prev 접두어를 사용하면 이 값이 최신아 아님을 분명히 알 수 있다.

마찬가지로, 값이 아직 정용되지 않았고 함수를 통해 값을 수정하거나 업데이트 하는 경우를 생각해보자.

const ReactExample = React.memo(({ code }) => {
  return <pre>{code}</pre>;
}, (prevProps, nextProps) => {
  return prevProps.code === nextProps.code;
});

code 가 변경되지 않은 경우 불필요한 렌더링 비용을 지불하지 않기 위해 React.memo() 를 사용한다. 이 경우 prevPropsnextProps 를 비교하는 함수를 전달한다. next 와 prev 접두어는 React 개발자들이 널리 사용하는 규칙이다.

잘못된 네이밍

잘못된 네이밍은 코드를 이해할 기회조차 주지 않는다.

const TIMEZONE_CORRECTION = 60000;
const getUTCDateTime = datetime =>
  new Date(
    datetime.getTime() -
      datetime.getTimezoneOffset() * TIMEZONE_CORRECTION
  );

getTime()함수가 milliseconds 를 반환하고, getTimezoneOffset() 함수가 분을 반환한다는 것을 알고 있다면, 분에 1분의 millisconds인 60000을 곱해야 한다. TIMEZONE_CORRECTION 이라는 네이밍은 혼란을 야기한다. 이를 수정 하면 다음과 같다.

const MILLISECONDS_IN_MINUTE = 60000;
const getUTCDateTime = datetime =>
  new Date(
    datetime.getTime() -
      datetime.getTimezoneOffset() * MILLISECONDS_IN_MINUTE
  );

코드를 이해하기 훨씬 쉬워졌다.

추상적이고 부정확한 변수명

추상적이고 부정확한 변수명은 잘못된 변수명만큼 도움이 되지 않는다.

추상적인 네이밍은 너무 포괄적이라 데이터에 대한 유용한 정보를 제공할 수 없다. (eg. data, list, array, object, item) 이러한 변수명의 문제는 모둔 변수에 data가 포함되어 있고, 모든 배열이 list라는 것이다. 변수명은 그것이 어떤 종류의 data 또는 list 인지 알려줘야 한다. data, list 와 같은 변수명은 x, y, z / foo, bar, baz 와 다를게 없다.

특히 FE 개발자들은 백엔드 개발자들과 다르게 데이터를 초기에 처리하여 의미를 부여하는 경우가 없기 때문에 data 라는 변수명은 쓸 일이 없다고 봐야한다. 이러한 네이밍은 다음과 같은 유틸리티 함수의 경우엔 적잡하다.

function arrayToObject(array) {
  return array.reduce((acc, item) => {
    acc[item.id] = item;
    return acc;
  }, {});
}

여기에서 array는 무엇을 담게 될지 아직 모르기 때문에 array라고 표현하는 것이 적합하다. arrayToObject 함수의 맥락에서는 중요하지 않다.

일반적인 단어 사용

멋지고 기발하지만 오해의 소지가 있는 단어를 사용하는 대신 프로그래밍 및 도메인에서 잘 알려져 있고 널리 채택된 단어를 사용하는 것이 좋다. 이는 영어가 모국어가 아닌 한국 개발자들에게 특히 문제가 되는데 우리는 희귀하고 모호한 단어를 많이 알지 못하기 때문이다.

좋은 예로 scry를 사용한 React 코드베이스가 있다. ‘예언하다’ 라는 의미이다. 필자도 리액트를 사용하면서 이 단어를 처음 보았다. 필자뿐만 아니라 다른 사람들도 이 단어를 모르는듯하다. 참고

동일한 도메인에 단일 단어 사용

동일한 개념에 다른 단어를 사용하는 것은 매우 혼란스럽다. 코드를 읽는 입장에서는 단어가 다르기 때문에 이 단어들은 동일하지 않다고 생각할 수 있으며 둘 사이의 차이점을 이해하려고 시간을 투자하게 될 것이다. 특히 에디터 검색을 통해 같은 용례를 찾기 어렵게 만든다.

쌍으로 사용되는 단어

반대되는 작업을 수행하거나 특정 범위의 대척점에 있는 값을 보유하는 변수 또는 함수 쌍을 만드는 경우 단어를 쌍으로 사용하는 것이 좋다. 두 개의 변수가 서로 연관되어 있음을 알 수 있기 때문이다.

단어 반대 단어
add remove
begin end
create destroy
enable disable
first last
get set
increment decrement
insert delete
lock unlock
minimum maximum
next previous
old new
open close
read write
show hide
start stop
target source

개발 할 때 보편적으로 사용되는 단어 쌍이다.

필자는 add <=> delete, createa <=> remove 로 쌍을 지어 사용하는 케이스를 보면 간혹 불편함을 느낀다.