pub 버전 관리(Versioning)

pub 버전 관리(Versioning)

pub 패키지 매니저는 버전 관리를 다루는 데 도움을 줘요. 이 가이드에서는 버전 관리의 역사와 pub가 이를 다루는 방식을 조금 설명해요. 다소 심화된 내용이라고 볼 수 있어요. pub가 왜 그렇게 설계됐는지 궁금하다면 계속 읽어 보세요. pub를 사용하는 데 관심이 있다면 다른 문서를 참고하는 게 좋아요.

출처: Pub versioning

본문

현대 소프트웨어 개발, 특히 웹 개발은 기존 코드를 아주 많이 재사용하는 데 크게 의존해요. 여기에는 과거에 직접 쓴 코드뿐 아니라, 큰 프레임워크부터 작은 유틸리티 라이브러리까지 제3자가 만든 코드도 포함돼요. 애플리케이션이 수십 개의 패키지와 라이브러리에 의존하는 것은 드문 일이 아니에요.

이것이 얼마나 강력한지 과소평가하기 어려워요. 작은 웹 스타트업이 몇 주 만에 사이트를 만들어 수백만 사용자를 얻는 이야기를 볼 때, 그들이 이를 이룰 수 있는 유일한 이유는 오픈 소스 커뮤니티가 그들의 발치에 소프트웨어의 향연을 차려 놓았기 때문이에요.

하지만 이는 공짜로 오지 않아요. 코드 재사용에는 도전이 있어요, 특히 유지 관리하지 않는 코드를 재사용할 때요. 앱이 다른 사람들이 개발하는 코드를 사용할 때, 그들이 코드를 바꾸면 어떻게 될까요? 그들은 우리 앱을 망가뜨리고 싶지 않고, 우리도 분명 그러길 원하지 않아요. 이 문제를 버전 관리로 해결해요.

이름과 번호(A name and a number)

외부 코드의 어떤 부분에 의존할 때, "내 앱은 widgets를 써"라고만 말하지 않아요. "내 앱은 widgets 2.0.5를 써"라고 말하죠. 이런 이름과 버전 번호의 조합은 변경 불가능한 코드 덩어리를 고유하게 식별해요. widgets를 업데이트하는 사람들은 원하는 모든 변경을 할 수 있지만, 이미 출시된 버전은 건드리지 않겠다고 약속해요. 그들은 2.0.6이나 3.0.0을 내놓을 수 있고, 우리가 사용하는 버전은 변하지 않으므로 우리에게는 전혀 영향이 없어요.

그 변경들을 실제로 얻고 싶을 때는 언제든지 앱을 더 새로운 버전의 widgets로 가리킬 수 있고, 그렇게 하기 위해 그 개발자들과 조율할 필요도 없어요. 하지만 이렇게 해서 문제가 완전히 해결되지는 않아요.

이 가이드에서 논의하는 버전 번호는 패키지 파일 이름에 설정된 버전 번호와 다를 수 있어요. -0이나 -beta가 포함될 수 있는데, 이런 표기법은 의존성 해석에 영향을 주지 않아요.

공유 의존성 해석하기(Resolving shared dependencies)

특정 버전에 의존하는 것은 의존성 그래프가 정말로 의존성 트리일 때는 잘 동작해요. 앱이 여러 패키지에 의존하고, 그 패키지들이 또 자신의 의존성을 갖는 식으로 이어져도, 그 의존성들이 겹치지만 않는다면 모두 잘 동작해요.

다음 예시를 생각해 보세요. 앱이 widgetstemplates를 사용하고, 둘 다 collection을 사용해요. 이것을 **공유 의존성(shared dependency)**이라고 불러요. 이제 widgetscollection 2.3.5를 쓰고 templatescollection 2.3.7을 쓰고 싶다면 어떻게 될까요? 버전에 합의하지 못하면 어떡하지요?

공유하지 않는 라이브러리(npm의 방식)

한 가지 옵션은 앱이 collection의 두 버전을 모두 사용하게 하는 거예요. 라이브러리의 두 복사본을 서로 다른 버전으로 갖게 되고, widgetstemplates는 각각 원하는 버전을 받게 돼요.

이것이 npm이 node.js에서 하는 방식이에요. Dart에서도 동작할까요? 이 시나리오를 고려해 보세요.

  • collection이 어떤 Dictionary 클래스를 정의해요.
  • widgets는 자신의 collection 복사본(2.3.5)에서 그 인스턴스를 얻어 my_app에 전달해요.
  • my_app은 그 dictionary를 templates로 보내요.
  • templates는 다시 그것을 자신의 버전 collection(2.3.7)으로 내려 보내요.
  • 그것을 받는 메서드는 그 객체에 대해 Dictionary 타입 애노테이션을 갖고 있어요.

Dart가 보기에 collection 2.3.5collection 2.3.7은 완전히 무관한 라이브러리예요. 한쪽에서 Dictionary 클래스의 인스턴스를 가져와 다른 쪽의 메서드에 전달하면, 그것은 완전히 다른 Dictionary 타입이에요. 따라서 받는 라이브러리에서 Dictionary 타입 애노테이션과 일치하지 못하게 돼요. 아이고.

이런 이유(그리고 같은 이름의 여러 버전이 있는 앱을 디버깅하려는 골치 아픔) 때문에, 우리는 npm의 모델이 잘 맞지 않는다고 결론냈어요.

버전 잠금(Version lock, 막다른 방식)

대신, 패키지에 의존할 때 앱은 그 패키지의 단일 복사본만 사용해요. 공유 의존성이 있을 때 그에 의존하는 모든 것이 어떤 버전을 쓸지 합의해야 해요. 합의하지 못하면 오류가 나요.

하지만 이것이 실제로 문제를 해결하지는 않아요. 실제로 그 오류를 만나면 해결할 수 있어야 하거든요. 이전 예시의 상황에 빠졌다고 해 볼게요. widgetstemplates를 쓰고 싶은데, 그 둘이 서로 다른 버전의 collection을 사용하고 있어요. 어떻게 하죠?

정답은 그 중 하나를 업그레이드하려는 거예요. templatescollection 2.3.7을 원해요. 그 버전과 함께 동작하도록 업그레이드할 수 있는 더 나중 버전의 widgets가 있나요?

많은 경우 답은 "아니오"예요. widgets를 개발하는 사람들의 관점에서 보세요. 그들은 코드에 새 변경이 포함된 새 버전을 내놓고 싶고, 가능한 한 많은 사람이 업그레이드할 수 있길 원해요. 현재 collection 버전에 머무르면 현재 버전 widgets를 쓰는 사람이라면 누구든 새 버전을 그대로 깔아 쓸 수 있어요.

그들이 collection에 대한 의존성을 업그레이드한다면, widgets를 업그레이드하는 모든 사람이 원하든 원하지 않든 따라 업그레이드해야 해요. 그것은 고통스러우므로 의존성 업그레이드를 꺼리게 만드는 요인이 생겨요. 이것을 **버전 잠금(version lock)**이라고 불러요. 모두가 의존성을 앞으로 옮기고 싶지만, 첫 발을 내딛으면 다른 사람들도 따라 해야 하기 때문에 아무도 첫 발을 내딛지 못하는 상황이죠.

버전 제약(Version constraints, Dart의 방식)

버전 잠금을 해결하기 위해, 우리는 패키지들이 의존성에 두는 제약을 느슨하게 해요. widgetstemplates가 자신과 동작하는 collection 버전의 범위를 모두 나타낼 수 있다면, 의존성을 더 새로운 버전으로 옮길 수 있는 충분한 여지가 생겨요. 그들의 범위가 겹치는 한, 둘 다 만족시키는 단일 버전을 여전히 찾을 수 있어요.

이것은 bundler가 따르는 모델이고 pub의 모델이기도 해요. pubspec에서 의존성을 추가할 때 수용할 수 있는 버전의 범위를 지정할 수 있어요. widgets의 pubspec이 이렇게 생겼다면:

dependencies:
  collection: '>=2.3.5 <2.4.0'

collection에 대해 버전 2.3.7을 고를 수 있을 거예요. 단일 구체 버전이 widgetstemplates 두 패키지의 제약을 모두 만족시키는 거죠.

시맨틱 버전(Semantic versions)

패키지에 의존성을 추가할 때 때로는 허용할 버전 범위를 지정하고 싶을 거예요. 어떤 범위를 골라야 할지 어떻게 알까요? 앞으로 호환 가능해야 하므로, 이상적으로는 아직 출시되지 않은 미래 버전까지 아우르는 범위가 좋아요. 그런데 아직 존재하지도 않는 어떤 새 버전과 패키지가 동작할지 어떻게 알까요?

이를 해결하려면 버전 번호가 무엇을 의미하는지 합의해야 해요. 여러분이 의존하는 패키지의 개발자들이 "뒤로 호환되지 않는 변경을 만들면 major 버전 번호를 올리겠다고 약속할게요"라고 말한다고 상상해 보세요. 그들을 신뢰한다면, 여러분의 패키지가 그들의 2.3.5와 동작하는 걸 안다면 3.0.0까지 동작할 거라 믿을 수 있어요. 범위를 이렇게 설정할 수 있죠:

dependencies:
  collection: ^2.3.5

참고: 이 예시는 caret 구문을 사용해 버전 범위를 표현해요. ^2.3.5 문자열은 "2.3.5부터 3.0.0(3.0.0은 제외)까지의 모든 버전 범위"를 의미해요. 더 자세한 내용은 caret 구문 섹션을 참고하세요.

그러려면 그런 약속의 집합을 만들어야 해요. 다행히도 똑똑한 다른 사람들이 이 모든 것을 정리하고 **시맨틱 버전 관리(semantic versioning)**라고 이름 붙였어요. 이것은 버전 번호의 형식과, 더 나중 버전 번호로 증가할 때의 정확한 API 동작 차이를 설명해요. pub는 버전이 그렇게 포맷되도록 요구하고, pub 커뮤니티와 잘 어울리려면 여러분의 패키지도 그것이 명시한 의미론을 따라야 해요. 여러분이 의존하는 패키지들도 그것을 따른다고 가정해야 해요. (그렇지 않다는 걸 알게 되면 저자에게 알려 주세요!)

시맨틱 버전 관리가 1.0.0 이전 버전 사이의 어떤 호환성도 약속하지 않지만, Dart 커뮤니티의 관례는 그 버전들도 의미론적으로 취급하는 거예요. 각 숫자의 해석이 한 칸씩 아래로 이동할 뿐이에요: 0.1.2에서 0.2.0으로 가는 것은 주요 변경(breaking change)을 나타내고, 0.1.3으로 가는 것은 새 기능을, 0.1.2+1로 가는 것은 공개 API에 영향을 주지 않는 변경을 나타내요. 단순하게 하려고, 버전이 1.0.0에 도달한 후에는 + 사용을 피하세요.

이제 버전 관리와 API 진화를 다루는 데 필요한 거의 모든 조각을 갖췄어요. 이들이 어떻게 어울리고 pub가 무엇을 하는지 보죠.

제약 해석(Constraint solving)

패키지를 정의할 때 그 직접 의존성(Immediate dependency) — 직접 사용하는 패키지들 — 을 나열해요. 각 패키지에 대해 패키지가 허용하는 버전 범위를 지정해요. 그 각 의존 패키지는 다시 자신의 의존성을 가질 수 있어요. 이것을 **transitive 의존성(transitive dependency)**이라고 불러요. pub는 이것들을 따라가서 앱의 전체 의존성 그래프를 구축해요.

그래프의 각 패키지에 대해 pub는 그것에 의존하는 모든 것을 살펴봐요. 그들의 모든 버전 제약을 모아서 동시에 해석하려고 해요. 기본적으로 그 범위들을 교차시키는 거죠. 그런 다음 pub는 그 패키지에 대해 출시된 실제 버전들을 보고 모든 제약을 충족하는 가장 최근의 것을 선택해요.

예를 들어 의존성 그래프에 collection이 있고 세 패키지가 그것에 의존한다고 해 볼게요. 그들의 버전 제약은 다음과 같아요.

>=1.7.0
^1.4.0
<1.9.0

collection의 개발자들은 다음 버전들을 출시했어요.

1.7.0
1.7.1
1.8.0
1.8.1
1.8.2
1.9.0

이 모든 범위에 들어맞는 가장 높은 버전 번호는 1.8.2이므로 pub는 그것을 선택해요. 즉 앱과 앱이 사용하는 모든 패키지는 모두 collection 1.8.2를 사용하게 돼요.

제약의 맥락(Constraint context)

패키지 버전 선택이 그 패키지에 의존하는 모든 패키지를 고려한다는 사실은 중요한 결과를 가져와요. 패키지에 대해 선택될 특정 버전은 그 패키지를 사용하는 앱의 전역 속성이라는 거예요.

다음 예시가 이것이 무엇을 의미하는지 보여 줘요. 두 앱이 있다고 해 볼게요. 그 pubspec들은 다음과 같아요.

name: my_app
dependencies:
  widgets:
name: other_app
dependencies:
  widgets:
  collection: '<1.5.0'

둘 다 widgets에 의존하고, widgets의 pubspec은 다음과 같아요.

name: widgets
dependencies:
  collection: '>=1.0.0 <2.0.0'

other_app 패키지는 collection 자체에 직접 의존해요. 흥미로운 점은 그것이 widgets보다 collection에 대해 다른 버전 제약을 갖고 있다는 거예요.

즉, widgets 패키지를 단독으로 보기만 해서는 그것이 어떤 버전의 collection을 쓸지 알 수 없어요. **맥락(context)**에 달려 있어요. my_app에서는 widgetscollection 1.9.9를 사용해요. 하지만 other_app에서는 otherapp이 두는 다른 제약 때문에 widgetscollection 1.4.9를 얻게 돼요.

이것이 각 앱이 자신만의 package_config.json 파일을 갖는 이유예요. 각 패키지에 대해 선택되는 구체 버전이 포함하는 앱의 전체 의존성 그래프에 달려 있기 때문이에요.

내보낸 의존성에 대한 제약 해석(Constraint solving for exported dependencies)

패키지 저자는 패키지 제약을 신중하게 정의해야 해요. 다음 시나리오를 고려해 보세요. bookshelf 패키지는 widgets에 의존해요. 현재 1.2.0인 widgets 패키지는 export 'package:collection/collection.dart'를 통해 collection을 내보내며, collection은 2.4.0이에요. pubspec 파일들은 다음과 같아요.

name: bookshelf
dependencies:
  widgets: ^1.2.0
name: widgets
dependencies:
  collection: ^2.4.0

그런 다음 collection 패키지가 2.5.0으로 업데이트돼요. collection 2.5.0에는 sortBackwards()라는 새 메서드가 포함돼요. bookshelfsortBackwards()를 호출할 수 있는데, 그것이 widgets가 노출하는 API의 일부이기 때문이에요. bookshelfcollection에 대한 transitive 의존성만 갖고 있을 뿐인데도 말이죠.

widgets가 버전 번호에 반영되지 않는 API를 갖고 있기 때문에, bookshelf 패키지를 사용하고 sortBackwards()를 호출하는 앱은 크래시할 수 있어요.

API를 내보내면 그 API가 패키지 자체에 정의된 것처럼 취급되지만, API가 기능을 추가할 때 버전 번호를 올릴 수는 없어요. 즉 bookshelfsortBackwards()를 지원하는 widgets 버전이 필요하다고 선언할 방법이 없어요.

이런 이유로, 내보낸 패키지를 다룰 때는 패키지 저자가 의존성의 상한과 하한을 더 좁게 유지하는 것이 권장돼요. 이 경우 widgets 패키지의 범위를 좁혀야 해요.

name: bookshelf
dependencies:
  widgets: '>=1.2.0 <1.3.0'
name: widgets
dependencies:
  collection: '>=2.4.0 <2.5.0'

이것은 widgets에 대해 하한 1.2.0, collection에 대해 2.4.0을 의미해요. 누군가 collection 2.5.0을 출시하면 pub는 widgets를 1.3.0으로 업데이트하고 해당 제약도 함께 업데이트해요. 이 관례를 사용하면 하나가 직접 의존성이 아니어도 사용자가 두 패키지의 올바른 버전을 갖게 됨을 보장해요.

잠금 파일(Lockfiles)

그래서 pub가 앱의 버전 제약을 해석한 다음에는요? 그 결과는 앱이 직접 또는 간접적으로 의존하는 모든 패키지의 완전한 목록과, 앱 제약과 함께 동작할 그 패키지의 최상의 버전이에요.

각 패키지에 대해 pub는 그 정보를 가져와 그것에서 콘텐츠 해시(pub.dev가 유지 관리하는, 패키지 무결성을 검증하는 SHA256 해시)를 계산하고, 둘 다 앱 디렉토리의 pubspec.lock이라는 잠금 파일에 기록해요. pub가 앱의 .dart_tool/package_config.json 파일을 만들 때 잠금 파일을 사용해 각 패키지의 어떤 버전을 참조할지 알아요. (pub가 어떤 버전을 선택했는지 궁금하면 잠금 파일을 읽어 알아낼 수 있어요.)

pub가 하는 다음 중요한 일은 잠금 파일을 손대지 않는 것이에요. 앱의 잠금 파일이 생기면 pub는 여러분이 알려 주기 전까지는 그것을 건드리지 않아요. 이것이 중요해요. 의도하지 않게 앱에서 무작위 패키지의 새 버전을 갑자기 쓰기 시작하지 않는다는 뜻이니까요. 앱이 잠기면, 직접 잠금 파일을 업데이트하라고 하기 전까지는 잠긴 상태로 유지돼요.

앱용 패키지라면 잠금 파일을 소스 제어 시스템에 체크인하세요! 그러면 팀의 모든 사람이 앱을 빌드할 때 모든 의존성의 정확히 같은 버전을 사용하게 돼요. 또한 앱을 배포할 때 이것을 사용해서 프로덕션 서버가 개발할 때 사용하는 것과 정확히 같은 패키지를 사용하도록 보장할 수 있어요.

문제가 생길 때(When things go wrong)

물론 이 모든 것은 의존성 그래프가 완벽하다고 가정해요. 버전 범위와 pub의 제약 해석, 시맨틱 버전 관리가 있어도 버전 분열(versionitis)의 위험에서 완전히 벗어날 수는 없어요. 다음 문제 중 하나를 만날 수 있어요.

분리된 제약(Disjoint constraints)이 있을 수 있어요

앱이 widgetstemplates를 사용하고 둘 다 collection을 사용한다고 해 볼게요. 그런데 widgets1.0.02.0.0 사이의 버전을 요구하고 templates3.0.04.0.0 사이를 원해요. 그 범위들은 아예 겹치지 않아요. 동작할 수 있는 가능한 버전이 없어요.

출시된 버전을 포함하지 않는 범위가 있을 수 있어요

공유 의존성에 모든 제약을 합친 뒤 >=1.2.4 <1.2.6이라는 좁은 범위가 생겼다고 해 볼게요. 빈 범위는 아니에요. 의존성의 1.2.4 버전이 있었다면 완벽했을 거예요. 하지만 그들은 그 버전을 출시하지 않았을 수도 있어요. 대신 1.2.3에서 바로 1.3.0으로 갔어요. 범위 안에 아무것도 없는 상태가 된 거죠.

불안정한 그래프(Unstable graph)가 있을 수 있어요

이것은 단연 pub의 버전 해석 과정에서 가장 까다로운 부분이에요. 그 과정은 "의존성 그래프를 구축한 다음 모든 제약을 해석하고 버전을 고른다"고 설명됐지만, 실제로는 그렇게 동작하지 않아요. 버전을 하나도 고르기 전에 전체 의존성 그래프를 어떻게 구축할 수 있을까요? pubspec 자체가 버전 특정적이에요. 같은 패키지의 다른 버전은 다른 의존성 집합을 가질 수 있거든요.

패키지 버전을 선택하면서 의존성 그래프의 모양 자체가 바뀌어요. 그래프가 바뀌면 제약도 바뀌고, 그래서 다른 버전을 선택하게 되고, 그러면 다시 원점으로 돌아가게 돼요.

때때로 이 과정은 안정적인 해로 수렴하지 못해요. 심연을 들여다봅시다.

name: my_app
version: 0.0.0
dependencies:
  yin: '>=1.0.0'
name: yin
version: 1.0.0
dependencies:
name: yin
version: 2.0.0
dependencies:
  yang: '1.0.0'
name: yang
version: 1.0.0
dependencies:
  yin: '1.0.0'

이 모든 경우에 앱에 대해 동작할 구체 버전의 집합은 없어요. 이런 일이 생기면 pub는 오류를 보고하고 무슨 일이 일어나고 있는지 알려 줘요. 분명 여러분을 "되는 것 같지만 안 되는" 이상한 상태에 방치하지 않아요.

요약(Summary)

요약하면:

  • 코드 재사용에는 장점이 있지만, 패키지는 독립적으로 진화할 수 있어야 해요.
  • 버전 관리는 그 독립성을 가능하게 해요. 단일 구체 버전에 의존하는 것은 유연성이 부족해요. 공유 의존성과 결합되면 버전 잠금으로 이어져요.
  • 버전 잠금에 대처하려면 패키지는 버전 범위에 의존해야 해요. 그러면 pub가 의존성 그래프를 따라가며 최상의 버전을 골라 줘요. 적절한 버전을 고르지 못하면 pub가 알려 줘요.
  • 앱이 의존성에 대해 확고한 버전 집합을 갖게 되면 그 집합은 잠금 파일에 고정돼요. 이로써 앱을 실행하는 모든 머신이 모든 의존성의 같은 버전을 사용하게 보장돼요.

pub의 버전 해석 알고리즘에 대해 더 배우려면 Medium의 PubGrub 문서를 참고하세요.

더 알아보기