생성기와 codemod로 사용처 880곳 옮기기.
지난 편
아이콘 198개가 이름 문자열로 꺼내 쓰는 객체 하나에 묶여 있어서 모든 화면에 실렸다. 아이콘 하나를 모듈 하나로 나누고 사용처가 직접 import하게 바꾸기로 했고, 먼저 타입 검사를 우회하던 캐스팅을 없앴다.
한 줄 결론
아이콘 모듈은 생성기가 만들고 사용처는 codemod가 바꿨다. 두 도구가 SVG 파일명에서 같은 규칙으로 컴포넌트 이름을 만들게 해서 서로 어긋날 수 없게 했다.
생성기: SVG 하나에서 컴포넌트 하나
SVG 파일명에는 아이콘 이름과 variant가 들어 있다. - 앞이 이름이고 뒤가 variant다. 생성기는 이것을 PascalCase로 이어 쓰고 끝에 Icon을 붙인다.
chevronLeft.svg -> ChevronLeftIcon
arrowDown-thick.svg -> ArrowDownThickIcon
chevronRight-tight-small.svg -> ChevronRightTightSmallIcon
이 규칙은 디자인 파일의 아이콘 이름 체계와도 같다. 디자인 파일의 variant 이름이 Name=chevronDownThick처럼 이미 코드 식별자 모양이었다. 그래서 나중에 디자인 파일에서 아이콘을 직접 가져오게 될 때도 이 이름을 그대로 쓸 수 있다(4편).
생성되는 모듈은 이렇다.
import ArrowDownThickSvg from '../assets/arrowDown-thick.svg'
import type { SvgIconProps } from '../icon.props'
export const ArrowDownThickIcon = ({ className, ...props }: SvgIconProps) => (
<ArrowDownThickSvg className={cn('icon fill-current', className)} {...props} />
)
짧은 코드지만 지켜야 할 규칙이 네 가지 들어 있다.
- 기존
Icon과 같은 클래스를 같은 순서로 붙인다.icon클래스는 다른 컴포넌트가 하위(descendant) 셀렉터로 참조한다. 빠지거나 순서가 바뀌면 화면이 조용히 달라진다. - SVG를 문자열로 붙여 넣지 않고 import한다. 기존 SVGR과 svgo 처리를 그대로 거친다.
- 팩토리 함수 호출이 아니라 컴포넌트 선언이다.
createIcon(Svg)처럼 모듈 최상위에서 함수를 호출하면, 번들러가 그 호출에 부수효과가 있을 수 있다고 보고 모듈을 남길 수 있다. - props 타입을 별도 파일에 둔다. 기존 타입 파일은
typeof iconMap에서 타입을 파생한다. 생성 모듈이 그 파일을 import하면 198개를 다시 끌고 온다. 그래서SvgIconProps = SVGProps<SVGSVGElement>한 줄짜리 파일을 따로 만들었다.
생성 결과가 매번 같아야 한다
생성물을 커밋해 두고 CI에서 “SVG와 생성물이 맞는지”를 검사하려면, 같은 입력에서 항상 같은 출력이 나와야 한다. 기존 iconMap 생성기는 이 조건을 못 지키고 있었다.
포매터. 기존 생성기를 다시 돌리면 커밋된 파일과 204줄이 달랐다. 커밋된 파일은 pre-commit 훅에서 prettier를 거친 결과였고, 생성기 출력은 그 전 모양이었기 때문이다. 대부분은 import 정렬 플러그인이 순서를 바꾼 탓이었다. 생성기가 prettier를 직접 호출하게 바꾸자 기존 파일과 바이트 단위로 일치했다.
파일 순서. readdir 결과의 순서는 운영체제와 파일 시스템에 따라 다를 수 있다. 정렬하지 않으면 iconMap의 키 순서가 로컬에서는 맞고 CI에서만 달라진다. 결과를 정렬했다.
이름 충돌. arrowDown-thick.svg와 arrowDownThick.svg는 둘 다 ArrowDownThickIcon이 된다. 또 macOS와 Windows의 기본 파일 시스템은 대소문자를 구분하지 않아 AI.tsx와 ai.tsx가 서로를 덮어쓴다. 컴포넌트 이름이 겹치거나 파일명을 소문자로 바꾼 값이 겹치면 생성을 멈추게 했다.
이렇게 고친 뒤 --check 모드를 넣었다. 이 모드는 파일을 쓰지 않고 기대 출력과 디스크의 파일을 비교한다. 빠진 파일, 내용이 다른 파일, 생성 대상이 아닌데 남아 있는 파일을 출력하고 exit 1로 끝난다. CI에는 새 job을 만들지 않고 기존 lint job에 한 줄을 추가했다. SVG를 추가하거나 지우거나 생성물을 손으로 고치는 세 경우 모두 검사가 실패하는 것을 확인했다.
번들이 그대로인지 확인하기
이 단계는 새 파일만 추가하고 기존 경로는 건드리지 않는다. 그러니 앱 빌드 결과가 한 바이트도 달라지면 안 된다. 바뀌기 전과 후를 빌드해 산출물 15,262개 파일의 sha256을 비교했고, 차이는 0이었다.
처음 비교했을 때는 2,396개가 달랐다. 원인은 빌드 캐시였다. 한쪽은 캐시 없이, 다른 쪽은 캐시가 있는 상태로 빌드했던 것이다. 같은 캐시 조건으로 맞추자 차이가 사라졌다. 빌드 결과는 코드 말고 캐시 같은 조건에도 달라진다는 것을 이때 다시 확인했다.
codemod: 컴파일러 API로 JSX 바꾸기
생성 모듈이 생겼으니 이제 사용처를 바꿀 차례였다. 당시 <Icon> 호출은 876건이었다. 손으로 옮기면 default, thick, small, tight와 fill, outlined가 섞인 variant를 어딘가에서 틀리게 된다. 그래서 codemod를 만들었다.
도구는 jscodeshift나 ts-morph가 아니라 이미 루트에 있는 typescript 패키지의 컴파일러 API를 썼다. codemod는 로컬에서 몇 번 돌리고 끝낼 도구인데, devDependency를 추가하면 CI에도 매번 설치되기 때문이다. 새 의존성은 0개였다.
codemod가 하는 일은 다음과 같다.
-
이름 표를 SVG에서 만든다.
name과variant를 컴포넌트 이름으로 바꾸는 표를 손으로 쓰지 않고, 생성기와 같은 규칙으로 SVG 파일명에서 만든다. 그래서 생성기와 어긋날 수 없다. -
진짜
Icon만 찾는다. import 구문을 보고, DS의 아이콘 경로에서 온Icon의 지역 이름만 모은다.import { Icon as DsIcon }같은 별칭도 따라가고, 이름만 같은 다른 컴포넌트는 건드리지 않는다. -
나머지 속성은 원문을 잘라 붙인다.
name과variant만 빼고className이나 이벤트 핸들러는 소스 텍스트 그대로 옮긴다. 표현식을 다시 만들면 의미나 주석이 바뀔 수 있기 때문이다. -
variant가 삼항이면 엘리먼트 삼항으로 펼친다.
// 전 <Icon name="chevronRight" variant={size === 'large' ? 'thick' : 'tight-small'} className={iconClass} /> // 후 size === 'large' ? ( <ChevronRightThickIcon className={iconClass} /> ) : ( <ChevronRightTightSmallIcon className={iconClass} /> ) -
import를 정리한다. 그 파일에서
Icon을 더 쓰지 않으면 import에서 지운다. 단, JSX 태그 밖에서Icon을 값으로 쓰는 곳(createElement(Icon, ...)등)이 있으면 지우지 않는다. 새 아이콘은 파일 단위 경로로 import한다. 모든 아이콘을 다시 내보내는 배럴 파일로 import하면 198개를 함께 끌고 올 수 있기 때문이다. -
prettier를 돌린다. 출력이 pre-commit 포매팅과 어긋나지 않게 한다.
--targets로 바꿀 아이콘을 지정할 수 있게 해서, 3단계에서는 상위 5종만, 4단계에서는 나머지를 같은 도구로 바꿨다. 바꾼 뒤 한 번 더 돌려도 바뀌는 곳이 없는 것(멱등)도 확인했다.
상위 5종부터
876건 중 chevronLeft 하나가 277건이었고, 상위 5종을 합치면 508건(58%)이었다. 3단계에서 이 5종을 파일 383개에 걸쳐 먼저 바꿨다. 남은 약 370건은 4단계에서 같은 codemod로 대부분 바꾸고 나머지는 손으로 옮겼다.
포매터 노이즈는 따로 커밋했다
바꿀 파일 383개 중 264개가 이미 import 정렬 규칙에 어긋나 있었다. codemod가 prettier를 돌리면 이 정렬 변경이 실제 전환과 섞여서 diff가 +1415 / -1766이 된다.
그래서 대상 파일에 prettier만 적용한 style: 커밋을 먼저 쌓고, 그 위에 codemod 추가 커밋과 전환 커밋을 올렸다. 리뷰어는 뒤의 커밋만 보면 된다. 이후 단계에서도 MR마다 “포맷 정리, 도구, 기계적 전환, 수동 전환, 버그 수정” 순서로 커밋을 나눴다.
codemod가 조용히 놓친 것
codemod는 바꾸지 못한 곳을 파일:줄 - 사유로 보고하게 만들었다. 그런데 보고에서 빠지는 경우가 있었다.
- 이름이 삼항인 경우. 삼항 처리는
variant만 고려했다.name이 문자열 리터럴이 아니면 사유 없이 넘어가서 건너뛴 목록에도 안 나왔다. dry-run 결과로는 손으로 옮길 곳이 4곳이었는데 실제로는 14곳이었다. - 이미 import한 아이콘을 또 넣는 경우. 같은 아이콘을 이미 쓰던 파일에 같은 import를 한 번 더 넣었다. prettier가 둘을 import 문 하나로 합치면서
Identifier has already been declared에러가 났다. - JSX 안의 중괄호.
{cond && (<Icon />)}를{cond && ({a ? <X /> : <Y />})}로 바꾸면 안쪽{가 객체 리터럴로 읽힌다.{cond && (a ? <X /> : <Y />)}로 써야 한다. createElement(Icon). JSX가 아니어서 codemod가 보지 않는 자리였다. 별도 리뷰에서 발견해 손으로 옮겼다.
두 번째와 세 번째는 빌드 에러로, 네 번째는 리뷰에서 바로 드러났다. 위험한 것은 첫 번째처럼 보고 없이 지나가는 경우였다. 도구가 “몇 건 남았다”고 말하면 그 숫자가 맞는지 한 번은 직접 세어 보는 편이 낫다.
다음 편
3편은 전환 중에 생긴 예상 밖 번들 비용, 처음 만든 번들 검사가 회귀를 놓친 이유, 최종 결과를 다룬다.