배포 파이프라인은 거의 끝까지 정상적으로 진행됐다.
테스트가 통과했고 Docker 이미지도 만들어졌다. ECR 업로드와 ECS 강제 배포도 성공했다. 그런데 마지막 Verify ECS service 단계에서 배포가 멈췄다.
처음 보이는 에러는 타임아웃이었다.
The action 'Verify ECS service' has timed out after 12 minutes.
하지만 타임아웃은 원인이 아니었다. 새로 배포한 애플리케이션이 시작되지 못하고 종료된 결과였다.
실패 당시 PRIMARY deployment의 상태는 다음과 같았다.
{
"status": "PRIMARY",
"rolloutState": "IN_PROGRESS",
"desiredCount": 1,
"runningCount": 0,
"pendingCount": 0,
"failedTasks": 1
}
ECS가 원하는 태스크는 1개였지만 실행 중인 태스크도, 시작을 기다리는 태스크도 없었다. 이미 한 차례 기동에 실패한 상태였다.
그런데 ECS 서비스 전체의 runningCount는 여전히 1로 표시됐다. 이전 버전의 태스크가 계속 실행되고 있었기 때문이다.
이 상태만 보면 서비스가 정상적으로 살아 있는 것처럼 보인다.
서비스 전체 runningCount = 1
신규 PRIMARY deployment runningCount = 0
배포 성공 여부를 확인할 때 서비스 전체 상태만 봐서는 안 되는 이유다. 새로 생성된 PRIMARY deployment를 따로 확인해야 한다.
원인은 RemoteAiConversationClient가 필요로 하는 ObjectMapper Bean이었다.
프로젝트는 Spring Boot 4를 사용하고 있었다. Spring Boot 4의 기본 Jackson 자동 구성은 Jackson 3 계열을 사용한다.
문제는 기존 AI 클라이언트가 Jackson 2의 타입을 사용하고 있었다는 점이다.
import com.fasterxml.jackson.databind.ObjectMapper;
Jackson 3의 패키지는 다음과 같다.
import tools.jackson.databind.ObjectMapper;
클래스 이름은 둘 다 ObjectMapper지만 완전히 다른 타입이다.
Spring Boot는 Jackson 3의 ObjectMapper를 자동으로 등록했다.
하지만 RemoteAiConversationClient가 요구한 것은 Jackson 2의 com.fasterxml.jackson.databind.ObjectMapper였다.
Spring 컨테이너 입장에서는 주입할 수 있는 Bean이 없었다. 그 결과 원격 AI 클라이언트를 생성하는 과정에서 NoSuchBeanDefinitionException이 발생했고, ApplicationContext가 기동하지 못했다.
흐름을 정리하면 다음과 같다.
ECS가 새 컨테이너 실행
→ Spring Boot 애플리케이션 기동
→ RemoteAiConversationClient 생성 시도
→ Jackson 2 ObjectMapper 주입 시도
→ 해당 타입의 Bean 없음
→ ApplicationContext 기동 실패
→ 컨테이너 종료
→ ECS failedTasks 증가
인프라 문제처럼 보였지만 실제 원인은 애플리케이션의 의존성 주입 실패였다.
배포 전에 실행한 테스트는 모두 통과했다.
이유는 기존 @SpringBootTest가 원격 AI 모드로 ApplicationContext를 실행하지 않았기 때문이다. 테스트 환경에서는 RemoteAiConversationClient가 활성화되지 않았고, 따라서 Jackson 2 ObjectMapper Bean이 없어도 문제가 드러나지 않았다.
운영 배포에서는 다음 설정으로 원격 클라이언트가 활성화됐다.
landit.ai.client-mode=remote
테스트와 배포 환경이 서로 다른 Bean 생성 경로를 사용한 셈이다.
문제를 재현하기 위해 ApplicationContext 테스트도 실제 배포 설정과 맞췄다.
@ActiveProfiles("test")
@SpringBootTest(properties = "landit.ai.client-mode=remote")
class LanditBeApplicationTests {
@Autowired
private RemoteAiConversationClient remoteAiConversationClient;
@Test
void remoteAiClientModeLoadsApplicationContext() {
assertThat(remoteAiConversationClient).isNotNull();
}
}
이 테스트는 수정 전에는 ObjectMapper Bean 누락으로 실패했다. 운영 환경에서만 보이던 문제가 테스트에서도 그대로 재현됐다.
단순히 contextLoads()를 실행하는 것만으로는 부족했다. 배포 환경에서 활성화되는 조건부 Bean까지 실제로 생성해야 했다.
기존 AI 클라이언트의 타입을 한꺼번에 Jackson 3로 변경하는 대신, 필요한 Jackson 2 ObjectMapper Bean만 명시적으로 등록했다.
// Jackson 2 기반 외부 API 클라이언트용 ObjectMapper Bean을 제공한다.
package com.landit.landitbe.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfiguration {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
변경 범위를 좁힌 이유가 있다.
Jackson 2와 Jackson 3가 함께 존재하는 상황에서 전체 직렬화 구성을 섣불리 변경하면 다른 API 응답이나 메시지 변환에도 영향을 줄 수 있다. 이번 장애의 직접적인 원인은 RemoteAiConversationClient가 요구하는 Jackson 2 Bean의 부재였다.
그래서 해당 타입의 Bean만 제공했다.
수정 후에는 원격 AI 모드의 ApplicationContext 테스트와 전체 Gradle 테스트가 통과했다.
애플리케이션 기동 실패와 별개로 배포 검증 스크립트에도 문제가 있었다.
ECS는 이미 다음 상태를 보여주고 있었다.
runningCount = 0
pendingCount = 0
failedTasks = 1
이 정도면 새로운 태스크가 실패했다고 바로 판단할 수 있다. 하지만 기존 스크립트는 failedTasks 값을 정상적으로 처리하지 못했다.
실제 로그에는 다음 오류가 반복됐다.
line 57: [: : integer expression expected
AWS CLI 조회 결과가 빈 값이나 None으로 들어오자 Bash의 정수 비교가 실패한 것이다. 스크립트는 실패 태스크를 발견하고 종료하지 못했고, ECS 상태를 계속 조회하다가 제한 시간에 도달했다.
그래서 failedTasks를 비교하기 전에 안전하게 정규화했다.
case "$failed_tasks" in
"" | "None" | "null" | *[!0-9]*) failed_tasks=0 ;;
esac
실패 태스크가 확인되면 더 이상 기다리지 않도록 조건도 바꿨다.
if [ "$failed_tasks" -gt 0 ]; then
echo "PRIMARY ECS deployment has failed tasks"
print_service_state
print_service_events
exit 1
fi
이제 같은 문제가 발생하면 의미 없는 타임아웃 대신 PRIMARY deployment의 상태와 최근 ECS 이벤트를 남기고 즉시 실패한다.
검증 스크립트가 애플리케이션 장애를 막아 주지는 않는다. 하지만 장애를 12분짜리 타임아웃으로 숨기지 않고, 처음 실패한 시점에 드러내 준다.
첫 번째는 테스트 환경에서 실제 배포 조건을 활성화해야 한다는 점이다.
@SpringBootTest가 통과했다는 사실만으로 운영 설정의 ApplicationContext까지 정상이라고 볼 수 없다. 프로파일이나 프로퍼티에 따라 Bean 구성이 달라진다면 배포 환경과 같은 조건으로 기동하는 테스트가 필요하다.
두 번째는 같은 이름의 클래스라도 패키지가 다르면 전혀 다른 타입이라는 점이다.
Jackson 2: com.fasterxml.jackson.databind.ObjectMapper
Jackson 3: tools.jackson.databind.ObjectMapper
Spring Boot 메이저 버전을 올릴 때는 의존성 버전만 확인해서는 부족하다. 자동 구성으로 등록되는 Bean의 실제 타입과 기존 코드가 주입받는 타입도 함께 확인해야 한다.
세 번째는 ECS 서비스 전체 상태와 신규 deployment 상태를 분리해서 봐야 한다는 점이다.
이전 태스크가 살아 있으면 서비스의 runningCount는 정상처럼 보인다. 하지만 PRIMARY deployment의 runningCount=0, pendingCount=0, failedTasks>0라면 새 버전은 이미 실패한 상태다.