Android 개발/Gradle・빌드

초록불 뒤에 숨은 JAR: Gradle 의존성 검증 실전 삽질기

stackD 2026. 9. 12. 18:00
반응형

 

빌드 초록불은 "안 바뀌었다"가 아니라 "아무도 안 봤다"는 뜻일 수 있습니다. 좌표만 같으면 내용이 통째로 바뀌어도 콘솔은 그냥 조용하거든요.

 

build.gradle에 박아둔 버전 숫자가 안전을 보장한다고 저도 오래 믿었어요. 근데 그 버전이 가리키는 진짜 파일이 어제 받은 그 파일과 같다는 보장은, 사실 어디에도 없더라고요. 그래서 얼마 전에 사이드 프로젝트 하나에 Gradle 의존성 검증(dependency verification) 을 직접 붙여봤습니다. 켜는 명령부터 빌드가 통째로 막히던 자리, 체크섬만 볼지 서명까지 볼지, 그리고 CI 자동화까지 겪은 걸 정리해뒀어요.

 

Gradle 의존성 검증이 없으면 저 JAR을 아무도 안 봅니다

의존성 트리를 한번 펼쳐보면 300개가 훌쩍 넘어갑니다. 300개가 어느 정도냐면, 제가 직접 고른 라이브러리는 열 몇 개인데 그게 끌고 오는 하위 의존성까지 합치면 그렇게 불어나는 거예요. 그런데 이 300개 중에 "이 파일이 진짜 그 파일 맞나" 확인하는 지점은 기본 상태에서 0개입니다.

 

좌표(group:name:version)만 같으면 알맹이가 바뀌어도 매니저는 그냥 받아옵니다. 여기서 무서운 게 dependency confusion, 그러니까 이름 바꿔치기 공격이에요. 공격자가 사내 비공개 패키지와 똑같은 좌표로 공개 저장소에 악성 버전을 올리면, 매니저가 공개 저장소를 먼저 해석해서 조용히 받아버리는 경우가 생깁니다.

 

xz나 SolarWinds 사고 이후로 이게 남 얘기가 아니게 됐지요. 검증의 두 축은 이렇게 나뉩니다.

 

  1. 체크섬은 "안 바뀌었나"만 봅니다. 무결성 확인이에요.
  2. PGP 서명은 "누가 만들었나"를 봅니다. 진위 확인이지요.

 

 

verification-metadata.xml 만들고 검증 켜는 최소 절차

켜는 건 의외로 파일 하나입니다. $PROJECT_ROOT/gradle/verification-metadata.xml 이 존재하면 그때부터 매 빌드 전에 모든 아티팩트를 검증해요. 부트스트랩 명령은 이겁니다.

 

./gradlew --write-verification-metadata sha256,pgp --export-keys

 

SHA-256 체크섬과 PGP 서명을 같이 생성해줍니다. 여기서 하나 짚고 갈 게, MD5랑 SHA1은 충돌 취약점이 있어서 쓰면 안 됩니다. 반드시 SHA-256이나 SHA-512로 가셔야 해요. 뒤에 붙인 --export-keys 옵션이 있어야 공개키가 gradle/verification-keyring.keys 로 실제 내보내져 저장되는데, 이 keyring 파일을 저장소에 같이 커밋해두면 키서버 의존을 줄일 수 있습니다.

 

근데 여기가 함정이에요. "켰다"가 곧 "안전하다"는 아니거든요. 부트스트랩은 그냥 편의 도구입니다. 만약 그 명령을 돌리는 순간 이미 오염된 걸 받고 있었다면, 악성 체크섬을 정답이라고 신뢰 목록에 박아버리는 셈이 돼요. Gradle 공식 문서에도 이 도구가 수동 검증을 대체하지 않는다고 못 박아뒀습니다.

 

서명 검증 실패로 빌드 막힐 때 뚫는 법

처음 켜고 빌드 돌리면 빨간불이 우수수 떨어집니다. 저도 여기서 살짝 멘붕이 왔어요. 근데 이 실패 대부분은 공격이 아니라 노이즈였습니다.

 

저장소마다 POM 파일이 미묘하게 다른 경우가 많습니다. 의미 없는 차이인데도 서명 검증이 걸려서 빨간불이 뜨는 거예요. 그러니까 "빨간 빌드=공격"이 아니라, 이 노이즈를 걸러내는 게 진짜 비용이더라고요.

 

키서버 이슈도 만납니다. 빠진 키를 자동으로 질의하다 보면 빌드가 느려지고, 요청이 몰리면 서버가 차단하기도 합니다. 그래서 로컬 keyring을 저장소에 체크인해서 오프라인으로 돌리는 게 해법이에요.

 

그리고 진짜 알아둘 구멍이 하나 있어요. GHSA-j6wc-xfg8-jx2j 어드바이저리(github.com/gradle/gradle/security/advisories/GHSA-j6wc-xfg8-jx2j)인데, 서명 검증이 불가능한 구성에서는 체크섬 검증까지 조용히 건너뛸 수 있었다고 합니다. "켰으니 안심"이 오히려 위험할 수 있다는 얘기예요.

 

체크섬 vs PGP 서명, 팀 상황별 선택 기준

"체크섬만 있으면 충분하다"는 건 흔한 오해입니다. 체크섬은 진위를 못 막아요. 오염된 파일이 처음부터 정답으로 등록되면 체크섬은 그걸 그대로 통과시키니까요. 저자 키를 신뢰하는 서명(trusted-key) 방식이 진짜 공급망 방어에 가깝다고 봅니다.

 

다만 현실은 하이브리드예요. Maven Central에도 서명 없는 아티팩트가 많아서, 서명 없으면 체크섬으로 폴백하는 구조로 갈 수밖에 없더라고요.

 

또 하나 정면으로 다뤄지지 않는 게 metadata 검증 여부입니다. 기본값은 POM이랑 module metadata까지 다 검증해서 트리 전체를 기록하는데, 그러다 보면 XML이 수백에서 수천 줄까지 불어납니다. 이 metadata 검증을 끄면 파일 크기랑 유지보수 부담은 줄지만, 그만큼 보증 범위도 좁아지는 맞바꿈이 생겨요.

 

개인적으로는, 잦은 업그레이드에 소규모 팀이라면 서명 폴백 부담이 과할 수 있다고 봅니다. 이럴 땐 사내 저장소(Artifactory/Nexus) 프록시랑 네임스페이스 잠금을 같이 걸어서 이름 바꿔치기를 원천 차단하는 쪽이 더 현실적이었어요.

 

 

의존성 올릴 때마다 갱신되는 XML, CI에 맡긴 방법

체크섬은 릴리스마다 바뀝니다. 그래서 라이브러리 버전을 올릴 때마다 XML을 갱신해줘야 해요. 이걸 손으로 하면 금방 지칩니다.

 

레노베이트(Renovate)나 디펜다봇(Dependabot) 같은 봇이 버전을 올리면 XML도 따라 갱신돼야 하니까, 파이프라인에 --write-verification-metadata 스텝을 끼워 넣는 방식으로 엮었습니다. 레노베이트는 Gradle manager를 지원해서 이게 붙더라고요.

 

여기서 재현성 함정도 하나 만났어요. gradle #19228 이슈(github.com/gradle/gradle/issues/19228)인데, 빈 캐시로 돌릴 때랑 캐시가 찬 상태로 돌릴 때 기록되는 체크섬 집합이 달라진다고 합니다. 일부 .pom 이 캐시에 있으면 누락되는 식이라, CI랑 로컬 파일이 안 맞을 때 원인 추적이 까다로웠어요.

 

참고로 저는 CI에서 항상 깨끗한 캐시 기준으로 XML을 생성하도록 스텝을 고정해뒀습니다. 로컬에서 무심코 갱신한 파일이 커밋에 섞여 들어가는 걸 막으려고요.

 

자동화가 곧 안전은 아니라는 것도 겪고 나서 알겠더라고요. 검증은 "이후에 뭔가 바뀌면 잡아낸다"에는 강한데, "처음 신뢰를 세우는 것"은 결국 사람 몫입니다. 무해한 실패가 잦으면 습관적으로 무시하게 되고, 그러다 진짜 신호를 놓치는 일이 생길 수 있어요.

 

 

결국 초록불에 눈을 하나 심는 일

한 줄 실행했더니 커밋에 수천 줄짜리 XML이 붙었습니다. 보안은 리뷰어의 눈물로 산다는 말이 실감났어요. 그런데도 이걸 붙인 이유는 분명합니다. 검증 없이는 300개 중 어느 하나가 조용히 바뀌어도 콘솔이 초록불만 보여주니까요.

 

Gradle 의존성 검증이 만능은 아닙니다. 저장소 정책, SBOM, 스캐너(Snyk·Dependabot)까지 겹쳐 쌓아야 다층 방어가 완성되는 조합의 한 조각이에요. 그래도 이 조각 하나를 심어두면, 최소한 초록불이 "아무도 안 봤다"는 뜻으로 남는 일은 막을 수 있습니다.

 

이번엔 검증을 켜고 CI에 붙이는 데까지만 다뤘는데요. 이름 바꿔치기를 좌표 단계에서 잘라내는 네임스페이스 잠금(exclusive content filtering) 설정은 다음 글에서 더 깊이 파보려 합니다.

 

 

반응형
LIST