Engineering
SwiftLint 캐싱을 통한 Incremental Build 최적화하기
2025년 1월 22일
원문에서 보기 ↗
generated by DALL·E
안녕하세요, 29CM 모바일팀의 iOS 개발자 김우성입니다. 이번 글에서는 SwiftLint 와 관련된 개선 작업을 통해 팀의 생산성을 향상시키고자 했던 내용을 다뤄보려고 합니다.
iOS 팀에서는 대부분 SwiftLint 를 사용하실 텐데요, 저희 팀에선 모듈화를 해나가는 과정에서 SwiftLint 로 인해 증분 빌드 시간이 계속해서 영향을 받는 것을 발견해 이를 개선해 증분 빌드 속도를 개선했습니다.
이번 글에선 기존 SwiftLint 를 사용하던 방식에서의 이슈를 먼저 소개드리고, 이를 어떻게 개선했고 그로 인한 사이드 이펙트는 무엇이었으며, 최종적으론 어떻게 마무리했는지에 대해 말씀드리려고 합니다.
SwiftLint 사용과 증분 빌드 이슈
SwiftLint의 일반적인 사용
SwiftLint는 iOS 프로젝트에서 코드 스타일과 품질을 유지하는 데 필수적인 도구입니다. 일반적으로 Xcode Build Phase 에 Run Script Phase 명령어를 추가하여 사용하며, .swiftlint.yml 설정 파일을 통해 검사 규칙을 커스터마이징하는 것이 일반적입니다.
저희 29CM iOS팀 역시 SwiftLint 레포지토리 에서 권장하는 설정과 함께 팀의 코드 스타일 가이드라인을 반영하여 SwiftLint를 사용하고 있었습니다.

스크립트를 만들어 Path 설정, GitHub 대응, 타입별 swiftlint.yml 분기 등을 처리합니다.

https://github.com/realm/SwiftLint
SwiftLint 로 인한 증분 빌드 영향
하지만 이렇게 설정한 경우 SwiftLint 는 컴파일 시마다 전체 코드베이스를 검사하기 때문에 증분 빌드에서 항상 일정 시간이 소요되는 문제가 있었습니다.
특히, 모듈화된 프로젝트에서는 모듈의 수가 늘어날수록 각 모듈에 대한 SwiftLint 실행이 따로 일어나기에모듈 개수가 적을 때에 비해서 시간이 더 증가하게 됩니다. 저희는 모듈화를 지속적으로 해나가는 팀이었기에, 팀의 일상적인 코딩 생산성을 위해 이 이슈를 개선할 필요가 있다고 판단했습니다.
빌드 속도는 팀의 생산성과 직결되는 중요 요소라는 생각과, 증분 빌드는 일정적인 코딩에서 사용하는 방식이라 클린 빌드에 비해 더 중요하다는 생각이 있었기 때문이기도 합니다.
맥북 성능마다 시간의 차이는 있었지만 SwiftLint 로 인한 순수 소요시간이 M1~M3 Pro 맥북 기준으로 15초~30초 정도였습니다. 이는 빌드가 완료된 상태에서 코드 수정 없이 바로 다시 빌드를 수행할 때의 시간으로, 빌드 로그를 보면 모든 모듈에 대해 SwiftLint 가 도는 데 소요된 시간임을 확인할 수 있었습니다.
SwiftLint 로컬 캐싱 구현
앞서 언급한 현상을 개선하기 위해 SwiftLint 를 캐싱할 방법이 없을지 고민했습니다. 그러던 와중 Run script 쪽에서 Based on dependency analysis 라는 옵션을 발견했는데요, 이는 불필요한 스크립트 수행을 막기 위한 옵션으로 Input File Lists 의 변화가 발생할 때에만 스크립트를 수행하도록 만들어 줍니다.
이 옵션을 활용한다면 필요한 시점에 필요한 곳에서만 SwiftLint 를 돌릴 수 있지 않을까 싶었는데요, 테스트를 해 보니 해당 옵션은 Input File Lists 가 존재해야만 Phase 를 돌릴 수 있었기에 모든 타겟들에 대해 Input/Output 파일을 적절하게 만들어 줄 필요가 있었습니다.

SwiftLint Phase
위와 같은 형태가 되도록 해야 하는데, 두 가지 기능이 필요했습니다.
1. 기준이 되는 최초의 Input File Lists 파일 (xcfilelist)
2. 파일이 수정될 때 해당 모듈의 SwiftLint 를 새로 돌릴 수 있도록 Input File List 갱신
이를 위해 xcfilelist 를 갱신해 주는 스크립트를 우선 개발한 뒤, 위 두 가지 상황에서 공통으로 사용할 수 있도록 개발을 진행했습니다.
아래의 스크립트는 특정 타겟을 기준으로 master 브랜치와 현재 작업 디텍토리를 비교해 현 타겟 내 변경사항이 있다면 이 목록을 xcfilelist 에 기록하도록 해 주는 스크립트입니다.
#!/bin/bash
# Script 캐싱을 위한 xcfilelist 와 Output 정의
SWIFTLINT_XCFILELIST_FILE="${FOLDER_PATH}/${TARGET_NAME}_swiftlint.xcfilelist"
SWIFTLINT_OUTPUT_FILE="${FOLDER_PATH}/${TARGET_NAME}_swiftlint_static_output"
…
# 현재의 파일 변경사항을 Git Diff 파일과 xcfilelist 파일에 새로 저장
echo "Generating new SwiftLint xcfilelist"
echo "$ALL_MODIFIED_SWIFT_FILES" > "$SWIFTLINT_XCFILELIST_FILE"
# Xcode 가 매번 스크립트를 실행하지 않도록 빈 Output 파일 생성
touch "${SWIFTLINT_OUTPUT_FILE}"
그리고 이 스크립트를 1번 상황에서 사용할 수 있도록 아래와 같은 스크립트를 같이 개발했습니다. 모든 타겟에 대해 처리를 해주어야 했기에, 최대한의 속도를 위해 CPU 코어 수를 구해 병렬로 스크립트를 돌리도록 만들어 주었습니다.
#!/bin/bash
# CPU 코어 수 가져오기
CORE_COUNT=$(sysctl -n hw.ncpu)
…
# 루프 폴더를 기준으로 'Project.swift' 파일이 있는 모든 폴더 리스트
PROJECT_FOLDERS=$(find ./ -name "Project.swift" -exec dirname {} \; | sed 's|\.\./||')
# 각 폴더에 대해 코어 수 만큼 병렬로 스크립트 실행
echo "$PROJECT_FOLDERS" | xargs -I {} -P $CORE_COUNT bash "$PARALLEL_EXECUTION_SCRIPT" "$ABSOLUTE_SCRIPT_FILE_PATH" {}
2번 상황은 Scheme 설정에 있는 Build Pre-actions 를 활용했습니다. 해당 액션을 통해 특정 타겟 내 변경사항이 있다면, Build 전에 Input File Lists 를 갱신해 주게 됩니다. 그래서 Based on dependency analysis 옵션으로 인해 해당 타겟은 빌드 시 SwiftLint 를 돌려 주게 됩니다.

위 두 가지 방식으로 SwiftLint 를 로컬 캐싱하도록 하여 평소 개발시에는 대부분의 타겟에서 SwiftLint 를 돌리지 않도록 바꾸었는데요, 적용 전 SwiftLint 소요 시간이 15~30초 사이였다면 적용 후에는 1~2초 로 바뀌었습니다.
큰 차이가 아니라고 볼 수도 있지만, 증분 빌드의 일반적인 소요 시간을 생각해볼 때 매 빌드마다 이만큼의 시간이 줄어드는 건 증분 빌드 속도의 쾌적함에서 생각보다(?) 차이가 있었습니다 😅
SwiftLint 로컬 캐싱으로 인한 이슈 및 한계
하지만 로컬 캐싱 도입 후 몇몇 파일에서 Lint 체크가 제대로 안 되는 문제가 발생했습니다. 실시간으로 확인하지 않고 xcfilelist 가 바뀔 때마다 SwiftLint 가 돌 다 보니 이로인한 경고나 오류를 발견하는데 시차가 생기는 경우가 종종 있었습니다.
이로 인해 master 브랜치에 머지된 후 다른 개발자가 경고를 발견하거나, 일정 수 이상 모아서 한 번에 수정하는 PR을 올리는 상황이 발생했습니다. 가끔은 컴파일 오류를 유발하는 린트 오류도 있어서, 급하게 컴파일을 되게 만드는 PR을 올려야 하는 경우도 발생했습니다.
캐시를 활용하는 이상 감안할 수도 있는 부분이었지만, 문제는 이러한 현상이 일상화되면서 팀 차원에서 린트에 대한 민감도가 점점 줄어드는 현상도 같이 생기게 되었습니다.

열심히 제거했던 흔적들
SwiftLint CI 도입
일정 기간 캐싱을 활용해본 결과, 린트 해소에 대한 피로감과 린트 민감도 측면에서 앞에서 언급한 이슈를 추가로 개선할 필요가 있었고, 이에 SwiftLint CI 를 추가해 부족한 부분을 커버하고자 했습니다. (작업해 주신 iOS 동료 개발자분께 감사를 🙏)
CI는 일반적으로 널리 사용되는 danger-swift 라는 도구를 사용해 GitHub Actions Workflow 로 구축했습니다. Dangerfile.swift 는 대략 아래와 같은 형태입니다. (Workflow 파일은 단순해서 생략했습니다)
import Danger
let danger = Danger()
...
let allModifiedFiles = danger.git.modifiedFiles + danger.git.createdFiles
let swiftlintPath = "…"
...
SwiftLint.lint(
.files(otherFiles),
inline: true,
configFile: "SwiftLint/.swiftlint-danger.yml",
swiftlintPath: .bin(swiftlintPath)
)
CI는 Build 와 Lint 를 따로 두어서 컴파일 자체가 특별히 문제가 없는지도 확인하면서, Lint CI 에서는 변경된 파일들에 대해서만 SwiftLint 체크를 하도록 해 기존 Workspace 에 린트 오류가 특별히 없다는 전제 앞서 언급한 SwiftLint 캐싱으로 인한 이슈가 더이상 발생하지 않게 되었습니다.
또한 Build Post-action 과 백그라운드 실행 기능을 활용해 컴파일 완료와 별개로 싱크를 하도록 해 준다면 개발자의 개발 흐름과 무관하게 SwiftLint 캐싱 이슈를 더 보완할 수 있습니다. (프로젝트 빌드 캐싱 유지를 위해 불필요한 xcfilelist 재생성과 변조를 막기 위한 처리는 필요합니다)
마치며
이번에 소개드린 SwiftLint 로컬 캐싱과 그 영향을 SwiftLint CI 도입으로 해소한 작업들은 iOS 개발자가 일상적으로 가장 많이 하는 활동인 증분 빌드 시간 개선을 통해 팀의 개발 생산성을 향상시키는 데 기여했습니다.
SwiftLint 가 아니더라도 팀에서 다른 Build Phase 를 사용하시고 있으시다면 이번에 소개 드린 Based on dependency analysis 옵션, xcfilelist, Build Pre-actions등을 활용해 빌드 시간 개선을 시도해볼 수 있으리라 생각합니다.
오늘 소개드린 내용이 팀 개발 생산성 향상에 관심많은 개발자 분들께 도움이 되셨길 바라면서 이번 글을 마치겠습니다!
MUSINSA CAREER
함께할 동료를 찾습니다.
29CM팀은 ‘고객의 더 나은 선택을 돕는다’라는 미션으로 출발했습니다. 우리는 우리만의 방식으로 콘텐츠를 제공하며, 브랜드와 고객 모두에게 대체 불가능한 커머스 플랫폼을 만들어가고 있습니다. 이 미션을 이루기 위해 우리는 흥미로우면서도 복잡한 문제들을 해결하고 있습니다. 만약 우리와 함께 이 문제들을 해결해 보고 싶다면, 주저하지 말고 29CM에 합류하세요!
🚀 팀 무신사 채용 페이지 (무신사/29CM 전체 포지션 확인이 가능해요)
채용이 완료되면 공고가 닫힐 수 있으니 빠르게 지원해 주세요!