Uploaded avatar of sdball

cal로 윤년 찾기

@sdball
2년 초과 전

이 글은 원래 Stephen의 웹사이트에 올렸던 글로, 허락을 받아 (약간 고쳐서) 이곳에 다시 올려요.

cal 소개

macOS와 Linux 명령줄에 달력이 있다는 거, 알고 있었어요?

$ cal

     July 2021
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31

tldr를 잠깐 살펴보면 이 유틸리티가 얼마나 유용한지 알 수 있어요.

$ tldr cal

cal

Prints calendar information.

- Display a calendar for the current month:
    cal

- Display previous, current and next month:
    cal -3

- Display a calendar for a specific month (1-12 or name):
    cal -m month

- Display a calendar for the current year:
    cal -y

- Display a calendar for a specific year (4 digits):
    cal year

- Display a calendar for a specific month and year:
    cal month year

- Display date of Easter (Western Christian churches) in a given year:
    ncal -e year

그런데 딱 하나 없는 게 있어요. 바로 윤년이에요!

물론 cal은 윤년을 알고 있어요. 그렇지 않으면 달력이라고 하기도 어려울 테니까요. 하지만 윤년을 목록으로 보여 주는 방법은 없어요.

꼼수로 윤년 알아보기

직접 만들어 봐요! 달력 라이브러리가 있는 진짜 프로그래밍 언어를 써도 되지만, 명령줄 도구들을 조립해서 뭔가를 만드는 건 재미있고, 이 계산을 실제 서비스에 넣을 것도 아니잖아요.

먼저, 윤년의 지표는 2월이라고 정해요.

$ cal 02 2021

   February 2021
Su Mo Tu We Th Fr Sa
    1  2  3  4  5  6
 7  8  9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28

$ cal 02 2020

   February 2020
Su Mo Tu We Th Fr Sa
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29

"29"를 검색해서 윤년을 알아볼 수 있을까요? 어쩌면요!

$ cal 02 2020 | grep -q 29 && echo "LEAP" || echo "normal"

LEAP

$ cal 02 2021 | grep -q 29 && echo "LEAP" || echo "normal"

normal

여기까지는 괜찮아 보이죠? 그런데 분명 문제가 보일 거예요.

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 LEAP
2030 year

2029년은 윤년이 절대 아니에요. 2029에 들어 있는 29 때문에 걸린 것뿐이에요.

$ cal 02 2029

   February 2029
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

"February"가 들어간 줄을 아예 빼면 그 성가신 결과를 없앨 수 있어요.

$ cal 02 2029 | grep -v Feb

Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

이제 저는 결과에서 "29"가 나오면 그건 정말 윤년이라고 봐요.

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -v Feb | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 year

이게 실제 윤년과 맞아떨어질까요?

Wolfram Research의 윤년 페이지에는 21세기 전반의 윤년이 이렇게 나와 있어요.

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

우리 방식은 어떤지 볼까요

$ for year in {2000..2051}; do cal 02 "$year" | grep -v Feb | grep -q 29 && echo $year; done
2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

됐어요!

그런데 이렇게 29를 grep하는 건 너무 쉬워요. 더 엉뚱하게 가 봐요!

더 엉뚱한 꼼수로 윤년 알아보기

윤년과 평년의 차이는 29일이라는 하루가 더 있다는 점이에요. cal의 출력에서 문자 수를 세어 윤년을 알아낼 수 있을까요?

할 수 있어요. 그런데 먼저 넘어야 할 함정이 하나 있어요. 어떤 뜻인지 보여 드릴게요.

2020년은 윤년이고 2021년은 윤년이 아니라는 걸 알고 있어요. 이 두 해의 2월 출력에서 문자 수 차이를 확인해 봐요. 편리한 wc 유틸리티를 쓰면 되는데, 바이트 문자를 세려면 wc -c를 쓰면 돼요.

$ printf "hello" | wc -c

5

"hello"는 문자 5개예요 (echo 대신 printf를 쓴 이유는, 그렇지 않으면 줄바꿈 문자까지 함께 세어지기 때문이에요). 좋아요!

$ cal 02 2020 | wc -c

184
$ cal 02 2021 | wc -c

184

여기서 걸리는 게 있어요. 두 달의 출력은 서로 다른데(2020년에는 29일이 있죠) 문자 수는 똑같아요. cal이 형식을 일정하게 맞추려고 공백을 넣기 때문이에요. 공백도 숫자만큼이나 셀 수 있는 문자예요.

그럼 어떻게 해야 할까요?

공백을 전부 지워 버리면 돼요! 그러면 세고 싶은 문자만 남아요.

문자를 지우는 데 좋은 유틸리티는 tr 명령어예요. tr은 문자열 데이터를 변환해요. 예를 들어 a를 모두 A로 바꿀 수 있어요.

$ echo "aardvark" | tr a A

AArdvArk

지정한 문자를 전부 삭제할 수도 있는데, 이게 바로 우리가 원하는 거예요.

우리 경우에는 종류를 가리지 않고 모든 공백 문자(스페이스, 탭, 줄바꿈 등)를 지우고 싶어요. 이럴 때 딱 쓸 수 있는 게 문자 클래스라는 편리한 묶음이에요. [:space:] 문자 클래스가 저런 여러 가지 공백 문자를 전부 가리켜요.

$ cal 02 2020 | tr -d '[:space:]'

February2020SuMoTuWeThFrSa1234567891011121314151617181920212223242526272829
$ cal 02 2021 | tr -d '[:space:]'

February2021SuMoTuWeThFrSa12345678910111213141516171819202122232425262728

좋아요! 이제 문자 수를 세서 윤년을 찾을 수 있을까요? 물론이죠!

$ cal 02 2020 | tr -d '[:space:]' | wc -c

75
$ cal 02 2021 | tr -d '[:space:]' | wc -c

73

이제 윤년을 출력해 봐요!

$ for year in {2000..2051}; do
  cal 02 "$year" |
    tr -d '[:space:]' |
    wc -c |
    grep -q 75 && echo "$year";
done

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

꼼수에 꼼수를 더했더니 잘 되네요!

어느 쪽이 더 빠르다고 생각해요? 네, 저도 잘 모르겠어요. "February를 지우는" 방식이 아마 더 빠를까요? 그래도 큰 차이가 있다면 좀 놀랄 것 같아요.

놀라지 말고 직접 확인해 봐요!

여러 명령줄 호출의 성능을 평가할 수 있게 해 주는 hyperfine이라는 멋진 유틸리티가 있어요

어느 쪽이 더 빠를까요? hyperfine이 알려 줘요

두 명령과 함께 hyperfine에 넘기는 플래그는 이래요.

--style basic
    Plain output styling
--export-markdown hyperfine.md
    Export results as markdown
--warmup 5
     Do five runs before benchmarking
--ignore-failure
     Ignore non-zero exits

우리가 관심 있는 건 한 해를 계산하는 데 걸리는 시간이지, 연도 루프를 만드는 데 걸리는 시간이 아니에요. 그래서 각 방식으로 특정 연도의 윤년 여부를 확인하는 것만 벤치마크할게요.

$ hyperfine --style basic --export-markdown hyperfine.md --warmup 5 --ignore-failure "cal 02 2050 | grep -v Feb | grep -q 29" "cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75"

Benchmark #1: cal 02 2050 | grep -v Feb | grep -q 29
  Time (mean ± σ):       1.8 ms ±   0.3 ms    [User: 0.7 ms, System: 1.9 ms]
  Range (min … max):     1.0 ms …   3.6 ms    493 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Benchmark #2: cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75
  Time (mean ± σ):       1.9 ms ±   0.4 ms    [User: 0.9 ms, System: 2.7 ms]
  Range (min … max):     0.7 ms …   5.9 ms    811 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Summary
  'cal 02 2050 | grep -v Feb | grep -q 29' ran
    1.07 ± 0.28 times faster than 'cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75'
명령어 평균 [ms] 최소 [ms] 최대 [ms] 상대
cal 02 2050 | grep -v Feb | grep -q 29 1.8 ± 0.3 1.0 3.6 1.00
cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75 1.9 ± 0.4 0.7 5.9 1.07 ± 0.28

예상대로 실제 걸리는 시간은 두 방식이 거의 차이가 없어요. 그래도 "February를 지우는" 방식이 아주 조금 더 빠를 수는 있어요.

이걸로 명령줄 도구 조립 여정은 끝이에요.

마무리

  • 기존 도구들을 데이터 파이프라인으로 조립하면 새로운 명령줄 기능을 만들 수 있어요
  • 약간의 창의력을 더하면 단순한 조각들로 놀랄 만큼 복잡한 결과를 얻을 수 있어요
  • hyperfine은 명령줄 명령의 성능을 측정하는 데 유용한 도구예요

다음 단계

  • cal 명령어를 이것저것 써 보세요.
  • 1년 전체의 공백을 뺀 문자 수를 세는 파이프라인을 만들어 봐요: cal 2020
  • 1750년부터 1759년까지 연도마다 이 1년치 문자 세기 파이프라인을 돌려 봐요. 뭔가 이상한 점이 보이나요?
2024년 01월 30일 · 유용했나요?