동료가 컨벤션을 자꾸 까먹는다면

신원규·2024년 11월 10일
3
post-thumbnail

여러분들은 팀원이 반복적으로 소스코드에 사소한 실수를 내는걸 경험해 본적이 있으실까요?

혹은 글을 읽는 여러분들이 그러한 실수를 반복하신 경험은 없으실까요?

예를 들어보면,
카멜 케이스로 클래스 이름을 작성하기로 했는데, 스네이크 케이스로 이름을 작성한다던지,
클래스 내부 메소드 이름을 알파벳 순서대로 정렬하기로 했지만, 이를 지키지 않는다던가 하는 경우라던지요.

저는 팀원 전부가 다 같이 특정 부분에서 반복적으로 지키기로 한 컨밴션을 놓치는 경험을 했었습니다.
바로, flutter 패키지의 의존성을 관리하는 pubsepc.yaml 의 dependency 패키지 선언을 알파벳 순서대로 정렬해놓기로 한 것이였는데,  약속이 잘 지켜지지 않았었습니다.

글을 읽는 여러분들에게 익숙한 js 로 설명하자면,

package.json 에 dependency 를 추가할 때, 패키지 순서를 알파벳 순서로 정렬하기로 약속했었습니다.

이러한 컨밴션을 약속한 이유는, 프로젝트가 의존하는 패키지의 개수가 세자리수를 넘어가며 한 눈에 어떤 패키지를 의존하고 있는지 알 수 없었기 때문입니다.
떄문에, 이미 의존하고자 하는 패키지를 추가하려고 하기도 했었고, 더 이상 필요 없는 패키지를 관성적으로 의존하던 적도 있었죠.

따라서 파일의 길이를 줄일 순 없지만, 적어도 어떤 패키지가 어디쯤 있을지 알 수 있도록 의존하는 패키지 들을 알파벳 순으로 관리하자고 팀원 모두가 동의하였습니다.

하지만, 대부분의 좋은 목표(운동, 퇴근하고 공부하기, ..)를 다짐한 뒤의 우리의 결과와 비슷하게,
좋은 의도와는 다르게 이 컨벤션은 잘 지켜지지 못했었습니다.
왜냐면, flutter pub get xx 와 같이 커맨드를 통해 의존하는 패키지를 추가하는 케이스라던가,
(이 땐 pubspec.yaml 의 최하단에 추가되게 됩니다.)

급하게 의존하는 패키지를 추가할 경우. 하나하나 알파벳 순서를 생각해서 써넣기는 상당히 귀찮은 일이였거든요.

또한 패키지 순서를 정렬하지 않는다고, 당장 프로젝트 코드가 멈추지는 않기 때문이였습니다.
작업을 수행하며 패키지가 필요해진 시점에 먼저 추가하고 병합 직전 수정해야지! 라는 생각을 하지만,
그 뒤 많은 작업을 수행하며, 병합 시점에는 이 변경이 있었다는걸 잊어버리게 됬던거죠.

이런 사소하고 반복적인 잘못은 어떻게 방지할 수 있을까요?
PR 리뷰때 이를 체크하는게 아마 가장 처음으로 생각할 수 있는 방법일겁니다.

하지만, 제 경험으로는 이러한 반복적이고 치명적이지 않은 문제에 대한 재발방지의 대책으로 PR 리뷰를 적용하는것은 해결책이 되지 못했습니다.

사람의 주의력, 집중력은 의지와 근성, 노력 여하에 따라서 무한히 쓸 수 있는 자원이라고 생각되기도 하지만
다른 모든 자원들과 동일하게 하루, 혹은 그 주에 쓸수 있는 양이 정해진 한정적인 자원이기 때문입니다.
특히 재미 없고, 반복적인 업무를 타의적으로 수행할 때 주의력은 매우 빨리 고갈되어 버리죠.

사소하고, 반복적인 교정을 PR 리뷰의 책임으로 미루어버린다면 몇차례 반복한 뒤에는 타성적이 되기 쉽고,
학창시절 방 청소 하라는 어머니의 잔소리를 듣는 경험이 그리 유쾌하지는 않았던 것 처럼,
오고가는 피드백이 건설적인 영향력을 주기보다는 불쾌하고 무력감을 주는 경우가 잦아질 수 있었습니다.

이러한 문제를 어떻게 해결해야하는걸까요?

반복적이지만, 치명적이지 않으니, 그냥 무시하고 넘어가도 되는 문제들 일까요?

아마 패키지 순서정도야 그렇게 넘어갈 수 있을것 같습니다.
중복해서 같은 패키지를 의존하면 IDE 가 알려주기도 하고, 패키지를 의존하고 있는지 한눈에 알기 어려울 뿐이지,
찾기 기능등을 통해 확인해볼수도 있는거니까요.

만약 그렇다면 우리는 소스 코드의 결함을 어디까지 양보할 수 있을까요?
사소한 결함들 하나하나가 모여 반년, 혹은 수년이 지난다면, 그 과정에서 작성된 소스코드는 유지보수성을 심각하게 저해하는 요인이 될 수 있지 않을까요?

저는 어떤 문제가 당장 프로젝트 운영에 치명적이지 않다고 해서 관리하지 않아도 되는것은 아니라고 생각합니다.

방 청소를 하루 거른다고 큰 문제를 일으키진 않는다고, 방 청소를 영영 안하면 어떻게 될까요?
언젠가는 쌓인 먼지와 쓰래기들이 큰 문제로 다가올 수 있을것이고, 이때서 뒤늦게 문제를 해결하려면 매우 큰 비용이 들지 않을까요?

저는 해결하기 어려운 문제를 마주치면, 먼저 문제가 발생하는 구조를 분석해보려 합니다.
어떤 맥락에서 왜 이런 문제가 발생하는지를 이해한다면 보다 적절한 해결책을 찾아 낼 수 있다고 생각하기 때문입니다.
우리의 문제를 딱 한마디로 요약하자면 프로젝트의 소스코드 관리비용 절감으로 정리 할 수 있겠네요.

비용을 절감하는 대표적인 방법은, 반복적인 업무를 자동화하거나, 필요없는 과정을 단축시키는 방법을 생각해 볼 수 있을겁니다.

그럼 먼저 프로젝트의 구조에 대해 한번 살펴봅시다.
문제의 대상을 잘 파악할 수 있다면 자동화, 혹은 과정 개편 등 어떤 전략을 통해 문제를 해결할 수 있는지 아이디어를 생각 해볼 수 있을겁니다.

프로젝트 소스코드의 공통점.

이 글을 읽는 여러분들이 관리하시는 프로젝트는 대부분 현대 프로그래밍 아키택처를 준수 할 것이고 Modular programming 을 적극적으로 활용할 것이라 기대할 수 있을 겁니다.

git, 혹은 이와 비슷한 버전관리 시스템을 사용할 것이고,
melos, turborepo 와 같은 mono repo 관리도구를 적용 했을 수도 있겟죠.

또한, 작성된 소스코드는 유닉스 커맨드 명령어를 통해, 빌드되고, 실행될것입니다.
e.g) npm install, npm start, melos bootstrap, flutter run, ...

package.json, pubspec.yaml 과 같은 파일 내부에서 커맨드의 동작을 수정할 수 도 있을겁니다.

{ // package.json
	...
  "scripts" : {
	  "dev": "vite" ==> "echo starting react project && vite"
  }
}

output

/opt/homebrew/bin/npm run dev

> untitled@0.0.0 dev
> echo start react product && vite

start react product <- 새로 생긴 부분!

  VITE v5.4.10  ready in 114 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

이렇게 각 커맨드에 해당하는 동작을 유닉스 다중 명령어등을 통해 변경하거나, 확장 할 수 있습니다.

TMI)

  • 여러 명령어들을 성공 실패와 상관 없이 모두 실행하려면, ; 키워드를 사용할 수 있습니다.
    $ echo "a"; echo "b"; echo "c"
  • 여러 명령어들을 순차적으로 실행하며, 이전 명령어가 성공할 때만 이어 실행하고 싶다면,
    && 키워드를 사용 할 수 있습니다.
    $ tsc && vite
  • 여러 명령어들을 순차적으로 실행하되, 이전 명령어가 성공하면 뒤의 명령을 실행하지 않고 싶다면
    || 키워드를 사용 할 수 있습니다.
    tsc || echo 컴파일에 실패했습니다.

|, & 와 같은 키워드도 있지만.. 이는 궁금하시면 직접 찾아보시는걸로.

보다 복잡한 쉘 명령을 내리고 싶다면, .sh 파일을 생성해, 쉘 스트립트를 작성할 수 있습니다.

# my_script.sh

# lint 실행
echo "ES lint 를 실행합니다..."
npx eslint src/

# 타입 체크
echo "TS 타입 체크를 실행합니다..."

# 환경 변수 로드
export $(cat .env | xargs)

`$ ./my_script.sh && vite

하지만, 쉘 스크립트는 문법이 C 와 비슷하고, 여러분들이 주로 다루던 언어와는 약간 거리가 있을 수 있습니다.
이럴 때 사용할 수 있는 방법이 있는데요,

대부분의 언어는 그 언어를 활용해 쉘 스크립트를 작성할 수 있는 기능을 지원합니다.

javascript 를 사용하신다면 shell js 라는 도구가 있구요, dart 를 사용하신다면 dart shell 을 활용하실 수 있습니다.

이 뿐만 아니라, 대부분의 언어(dart, js, kotlin, java, switf, ...)는 쉘 스크립트를 지원합니다.

또한 git 은 git hooks 이라는 도구를 제공하는데요,
이 중 우리는 주로 사용하게 될 client hooks 에 집중해 보겠습니다.

이를 활용하기 위해선, git 디렉토리 하위에 hooks 라는 디렉토리에 스크립트를 작성해두면 되는데요,
기본적인 위치는 ./git/hooks 에 위치합니다.

이 디렉토리에 들어가보면 제동되는 다양한 예제들을 볼 수 있습니다.

$ .git/hooks ls
# stdout 다양한 예제들을 볼 수 있다.
applypatch-msg.sample     pre-commit.sample         prepare-commit-msg.sample
commit-msg.sample         pre-merge-commit.sample   push-to-checkout.sample
fsmonitor-watchman.sample pre-push.sample           sendemail-validate.sample
post-update.sample        pre-rebase.sample         update.sample
pre-applypatch.sample     pre-receive.sample

$ cat pre-commit.sample
#std out
# 커밋 직전에 이런 스크립트를 실행할 수 있다는 예제.
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

if git rev-parse --verify HEAD >/dev/null 2>&1
then
        against=HEAD
else
        # Initial commit: diff against an empty tree object
        against=$(git hash-object -t tree /dev/null)
fi

# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)

# Redirect output to stderr.
exec 1>&2

# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
        # Note that the use of brackets around a tr range is ok here, (it's
        # even required, for portability to Solaris 10's /usr/bin/tr), since
        # the square bracket bytes happen to fall in the designated range.
        test $(git diff-index --cached --name-only --diff-filter=A -z $against |
          LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
        cat <<\EOF
Error: Attempt to add a non-ASCII file name.

This can cause problems if you want to work with people on other platforms.

To be portable it is advisable to rename the file.

If you know what you are doing you can disable this check using:

  git config hooks.allownonascii true
EOF
        exit 1
fi

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

이를 활용해 스크립트를 설정해두면 커밋 직전, 커밋 직후, push 직전에 지정한 스크립트를 실행 시킬 수 있습니다.

그렇다면 git hooks, 각각의 언어로 작성된 shell sciprt, 패키지 매니저의 동작 재정의 등을 통해 문제를 해결 할 수 있지 않을까요?

문제 해결방법.

저는 위의 내용을 조사한 이후 하나의 아이디어를 생각할 수 있었습니다.

  1. git hooks 을 활용해 커밋 직전에 해당 변경사항 목록을 가져온 뒤
  2. 변경된 파일중 package.json, 혹은 pubspec.yaml이 있다면
  3. dependency 목록을 파싱해 와, alphanumeric asending 으로 정렬을 시켜준 뒤
  4. 정렬한 목록을 다시 파일에다 써 주는 스크립트를 작성하면 되지 않을까요?

이를 shell script 를 통해 작성하려면 조금 어려웠겟지만, dart shell 을 통해 작성하니 매우 쉽게 스크립트를 작성 할 수 있었습니다.

/// pre_commit_sciprt.dart
void main () {
  // multy module 구조여서, 프로젝트 내 여러 yaml 파일이 있습니다.
  final List<File> changedPubspecs = 
		// $ git diff --cached --name-only 를 dart code 로 실행하는 라인입니다.
    Process.runSync('git', ["diff", "--cached", "--name-only"]).
		// 실행한 결과(stdout 을 받아와 가공하는 코드) 
    let((res) => res.stdout
        .toSring() // 문자열로 변환 한 뒤 (dynamic => String),
        .trim() // 공백을 제거하고,
        .split('\n') // 개행문자를 기준으로 잘라
        .where(
          (e) => e.contains("pubspec.yaml")) // 그 중 pubspec 변경점이 있는지 확인
        .map((e) => File(e)) // 있다면 이를 파일로 변환
				.toList() // Iterable<File> -> List<File> 타입 변환
       );
  
  if (changedPubspecs.isEmpty) { // 아무 pubspec 도 변경되지 않았다면,
    exitCode = 0; // 정상 종료
    return; 
  }
  
 	for (final File yamlFile in changedPubspecs) {
    // 변경된 파일 내용을 문자열로 가져와 yaml parser 에게 넘겨줍니다.
    final YamlDocuments docs = loadYamlDocuments(yamlFile.readAsStringSync());
		
    void sortDependencies(final String key) {
				if (docs.contents.value[key] != null) {
          final Map deps = Map.from(docs.contents.value[key] as Map);
          // sort() 의 기본 정책이 alphanumeric ascending
          final sortedKeys = deps.keys.toList()..sort();
          
          // 정렬된 배열을 다시 yaml 인스턴스에 저장하는 코드
          for (final k in sortedKeys) {
            editor.remove([key, k]);
            editor.update([key, k], deps[k]);
          }
        }
      }
    
	   sortDependencies('dependencies');
	   sortDependencies('dev_dependencies');
    	
    	// 파일에 정렬된 내용을 쓴 뒤
			yaml.writeAsStringSync(editor.toString());
			// git add 로 스테이징 시켜줍니다.
    	Process.runSync('git', ['add', Yaml.path,])
    }
  	// 정상 종료 설정.
  	exitCode = 0;
  }
}

이제 작성한 코드를 .git/hooks에서 실행하도록 설정만 하면 되겠군요.

하지만 여기서 문제가 하나 있습니다.
바로, git hooks 는 repository clone 을 할 때, 복사되지 않는다는 특징을 가지고 있습니다.

(아마 악의적인 스크립트가 로컬에서 자동으로 실행되는 부분을 걱정한 듯 합니다.)

그렇다면, 우리가 작성한 pre-commit hook 을 각 로컬 환경에 자동으로 등록해줄 수 있는 스크립트를 작성하고,
이 스크립트를 패키지 매니저, 혹은 모듈 관리 툴 스크립트에 붙여, 자동으로 재설정 될 수 있도록 변경하면 되겠군요.

이왕 만든김에, 동료분이 작성한 훅이 너무 싫다고 할때, 이를 비활성화 할 수 있는 스크립트까지 같이 만들어보죠.

// setup_git_hooks_sciprt.dart
// pre-commit 훅을 설정하는 스크립트 
Future<void> main() async {
	final File preCommitHookFile = await _initFile();
  
  // window os 일때 권한 설정을 추가로 해줄 필요가 있음.
  if (!Platform.istWindows) {
    Process.runSync("chmod", ["a+x", preCommitHookFile.path]);
  }
  
  exitcode = 0;
}

Future<File> _initFile() async {
  final File gitPreCommitHooks = File('스트립트 위치');

  // pre-commit hook 이 없다면,
 	if (!gitPreCommitHooks.exists()) {
    // 만들어서 반환한다.
    await gitPreCommitHooks.create(recursive: true);
    return gitPreCommitHooks;
  } 
  // pre-commit hook 이 있다면
  // 삭제한 뒤 (이전 버전 스크립트 일 가능성이 있으니까)
  await gitPreCommitHooks.delete(); 
  // 새로 생성해
  await gitPreCommitHooks.create(recursive: true);
  // 반환합니다.
  return gitPreCommitHooks;
}
// reset_git_hooks.dart
// pre-commit hook 을 비활성화 하는 스크립트

void main() {
  final File gitPreCommitHooks = File("./git/hooks/pre-commit");
  
  // pre-commit hook 파일이 있다면 지워버립니다.
  if (gitPreCommitHooks.existsSync()){
    gitPreCommitHooks.deleteSync();
  }
  // 정상종료 설정
  exitCode = 0;
}

이제 만든 두 스크립트를 패키지 매니저의 커맨드와 연동해봅시다!
저는 유지보수 하는 프로젝트에서 melos 라는 멀티 모듈 관리 Cli tool 을 사용하고 있으므로,
melos.yaml 에 들어가서, 커맨드를 수정해줍니다.

# melos.yaml
...
init-project:
	run: melos clean && melos bootstrap && ... && dart run setup_git_hooks.dart

setup-git-hooks:
	run: dart run setup_git_hooks.dart

reset-git-hooks:
	run: dart run reset_git_hooks.dart

위와 같은 변경점을 리모트 저장소에 병합 한 이후에 팀원들에게는 의존성 관련 문제로 pull 받은 뒤 init-project 를 한번 실행해달라고 공지를 하면, 작성한 스크립트가 각각의 로컬환경에 등록될 수 있겠습니다!

이제 적어도 package 이름 정렬 문제로는 영영 골치를 안썩어도 되겠군요!

profile
생존형 개발자. 어디에 던져져도 살아 남는것이 목표입니다.

0개의 댓글