
이슈 선정
https://github.com/spring-projects/spring-boot/issues/45306
Support CLI-option for "environment" in Buildpacks configuration · Issue #45306 · spring-projects/spring-boot
Context The Spring Boot plugins for Gradle and Maven allows configuring the Buildpacks task via properties in the build.gradle or pom.xml files. Some of the properties can be specified via convenie...
github.com
이 이슈의 내용은 Gradle 및 Maven용 Spring Boot 플러그인을 사용해 Buildpacks 작업을 구성할 수 있는데, CLI에서 환경 변수를 전달할 수 있다면 더 편리할 것 같다는 제안이었다.
기존 환경변수는 빌드 스크립트에서만 설정할 수 있었다.
bootBuildImage {
environment = [
"BP_JVM_VERSION": "21",
"BPE_DELIM_JAVA_TOOL_OPTIONS": " "
]
}
하지만 이런 방식은 bulid.gradle 파일을 매번 수정해야 하기 때문에 CI/CD 환경에서 불편해지고 Kubernetes 같은 개발도구 연동이 불편해진다.
이슈 제보자는 해당 기능이 필요하다고 판단되면 구현을 돕겠다는 의사를 밝혔고, 메인테이너 역시 방향성까지 제시했지만 약 9개월 동안 별다른 진전은 없었다.
이후 내가 이 이슈를 이어서 진행하겠다고 의견을 남겼고, 담당자로 배정받아 구현을 진행하게 되었다.
메인테이너가 제시한 방향은 기존의 환경 변수를 받는 메서드 구조를 유지하면서, CLI에서 전달된 값을 별도로 수집할 수 있도록 @Internal 타입의 ListProperty<String> 메서드를 추가해 보자는 것이었다.
이 요구사항을 구현하기 위해 먼저 Buildpacks 작업의 진입점 역할을 하는 BootBuildImage 클래스를 살펴보았다.

Gradle에서 Task는 하나의 의미 있는 빌드 행위를 나타내는 단위이며 객체로 관리하는데,BootBuildImage는 애플리케이션 이미지 빌드 책임을 가지는 커스텀 Task다.
이 클래스는 이미지 이름, 빌더, 환경 변수 등 빌드에 필요한 설정을 Property라는 별도의 자료구조 형태로 관리하며,@Input 어노테이션을 통해 해당 값들이 Task의 공식 입력임을 Gradle에 명시한다.
이 중 이미지 빌드에 사용되는 환경 변수는 다음 메서드를 통해 정의된다.
/**
* Returns the environment that will be used when building the image.
* @return the environment
*/
@Input
public abstract MapProperty<String, String> getEnvironment();
이 메서드는 Buildpacks 이미지 빌드 시 사용될 환경 변수 집합을 나타내며, @Input 어노테이션이 붙어 있기 때문에 Gradle은 이 값을 Task의 공식 입력값으로 인식한다.
따라서 이 값이 변경되면 Gradle은 이전 실행 결과를 재사용할 수 없다고 판단하고, bootBuildImage 작업을 다시 실행한다.
CLI에서 전달되는 환경 변수처럼 실행마다 달라질 수 있는 값을 무분별하게 @Input에 포함시키면, 결과적으로 캐시를 거의 활용할 수 없게 된다.
그렇기에 환경변수 옵션을 바꾸려면 저 메서드를 변경하면 된다. 하지만 메인테이너는 기존 옵션에 추가하는 방식을 원했다.
@Internal
public abstract ListProperty<String> getEnvironmentFromCommandLine();
1. 타입을 ListProperty<String>로
CLI 옵션을 --environment NAME=VALUE 같은 형태로 여러 번 줄 수 있기 때문에 문자열 리스트로 수집한다.
2. @Internal을 붙인 이유
CLI에서 넘어오는 값은 실행마다 달라질 수 있고 일회성일 가능성이 크다.
이 값을 @Input으로 취급해 버리면 up-to-date 체크와 빌드 캐시가 매번 깨질 수 있으므로 이 어노테이션을 통하여 Gradle에게 동작에는 쓰지만, 캐시 키 계산(입력)에는 포함하지 말라고 알려준다.
해결 방법
먼저 Gradle은 입력값을 getter로 전달받는데, 환경변수를 전달하는 메서드를 정의하고 그 getter안을 채우는 setter 메서드를 만들었다.
@Internal
public abstract ListProperty<String> getEnvironmentFromCommandLine();
@Option(option = "environment",description = "~~")
public void environment(List<String> environment) {
getEnvironmentFromCommandLine().addAll(environment);
}
그다음 빌드팩을 만드는 빌더 메서드를 수정하고 사용자의 입력값을 분리하는 헬퍼 메서드를 만들어 주었다.
private BuildRequest customizeEnvironment(BuildRequest request) {
Map<String, String> environment = getEffectiveEnvironment();
if (!environment.isEmpty()) {
request = request.withEnv(environment);
}
return request;
}
private Map<String, String> getEffectiveEnvironment() {
Map<String, String> environment = new java.util.LinkedHashMap<>();
Map<String, String> configured = getEnvironment().getOrNull();
if (!CollectionUtils.isEmpty(configured)) {
environment.putAll(configured);
}
List<String> fromCli = getEnvironmentFromCommandLine().getOrNull();
if (!CollectionUtils.isEmpty(fromCli)) {
for (String entry : fromCli) {
Map.Entry<String, String> parsed = parseEnvironmentEntry(entry);
environment.put(parsed.getKey(), parsed.getValue());
}
}
return environment;
}
private Map.Entry<String, String> parseEnvironmentEntry(String entry) {
int index = entry.indexOf('=');
if (index <= 0) {
throw new GradleException(
"Invalid value for option '--environment'. Expected 'NAME=VALUE' but got '" + entry + "'.");
}
String name = entry.substring(0, index);
String value = entry.substring(index + 1);
return Map.entry(name, value);
}
빌더 메서드 중 환경변수를 담당하는 customizeEnvironment가 getEffectiveEnvironment를 호출하면 getEffectiveEnvironment는
위에서 정의한 프로퍼티 메서드들을 각자의 자료구조 (Map, List)로 담는다. 그다음 메인테이너가 원하는 대로 원래 환경변수를 담던 방식대로 Map을 담고 추가 cli 환경변수 입력이 있다면 parseEnvironmentEntry로 들어온 입력값을 파싱 하여 다시 넘겨주게 된다.
또한 테스트 코드도 빠지지 않고 작성해 주었다!
@TestTemplate
void buildsImageWithMultipleCommandLineEnvironments() throws IOException {
writeMainClass();
writeLongNameResource();
BuildResult result = this.gradleBuild.build("bootBuildImage", "--environment", "BP_LIVE_RELOAD_ENABLED=true",
"--environment", "MY_CUSTOM_VAR=hello_world");
BuildTask task = result.task(":bootBuildImage");
assertThat(task).isNotNull();
assertThat(task.getOutcome()).isEqualTo(TaskOutcome.SUCCESS);
assertThat(result.getOutput()).contains("BP_LIVE_RELOAD_ENABLED=true");
assertThat(result.getOutput()).contains("MY_CUSTOM_VAR=hello_world");
removeImages(this.gradleBuild.getProjectDir().getName());
}
PR을 올린 결과 하루 만에 머지가 되었다!

느낀 점
간단한 기능이라고 생각하고 시작했지만, 실제로는 사용자 경험과 CLI 인터페이스 설계에 직접적인 영향을 주는 변경이라 긴장한 상태로 PR을 올리게 되었다.
특히 인상 깊었던 점은 Spring Boot 프로젝트의 PR 처리 방식이었다. 단순히 PR을 바로 merge 하는 구조가 아니라, 메인테이너가 PR을 닫고 변경 사항을 반영한 커밋을 직접 다시 올리는 흐름을 사용하고 있었다.
이 과정에서 “기여자는 아이디어와 방향을 제안하고, 최종 품질에 대한 책임은 프로젝트가 진다”는 스프링부트 운영진들의 철학을 체감할 수 있었다. 앞으로 스프링진영에 더 열심히 기여해보고 싶다!