2025. 3. 23. 19:59ㆍLegacy
개발자 G는 팀 내에서 다소 신용이 생겼다. 메시지 발송과 같은 장애건을 해결하기도 했고, 빠른 수정이 필요한 클라이언트 작업이나 DB 작업들을 팀의 색깔에 맞춰 군말 없이 수행했기 때문이다. G는 소스의 처참한 수준 때문에 혀를 차고 있었지만, 그걸 알 리 없는 팀장 A는 G에게 새로운 업무를 부여하기로 한다.
A : G님. 우리 앱에 푸시가 안와요. 한 번 봐줄 수 있어요?
G : 넵. 언제부터 발생한 이슈일까요?
A : G님 입사하기 몇달 전부터 실패하고 있어요.

G는 한번 더 실망했다. 도대체 어디서부터 잘못된 것인가. 이슈의 진행상황을 보기 위해 팀 문서를 한참동안 찾아다닌 G는 현재 상황을 정리했다.
1. API 서버에서 푸시 메시지를 보내고 싶을 때 통신하는 Push 서버가 있다.
2. Push 서버는 Firebase 토큰을 저장해 뒀다가 Request 에 맞춰 FCM(Firebase Cloud Messaging) 에 요청을 보내준다.
3. 이 Push 서버는 사내 개발자 인력이 부족해 외주로 개발했다(..). 인수 이후 유지보수되지 않고 있다.
4. 이슈를 G보다 먼저 받았던 개발자들은 해결하지 못하고 다른 업무를 하러 갔다.
5. 이슈 티켓에는 로그나, 분석한 흔적이 존재하지 않는다. '안되는데요' 만 써있다.
기본적인 기능이지만 외주개발을 주고 그것을 유지보수하지 않은 누군가에 대한 생각을 뒤로 미뤄두고, G는 장애의 원인을 살핀다.
1. Not found (404)
서버는 살아있으나 API 리소스를 찾지 못하고 있다. 호출 API 의 경로는 application.properties 파일에서 관리하고 있다.
PUSH_SERVER=http://api.gigyesik.com/push
http:// 요청을 https:// 요청으로 바꿔서 해결했다. 도메인에 SSL을 적용할 경우 관련 API 를 호출하는 곳들을 최신화해줘야 한다.
2. The service is currently unavailable (503)
위 404 에러를 해결한 후 일시적으로 DNS와 CDN 간 시점차이로 매핑이 되지 않아 발생. 잠시 후에 해결되었다.
3. Gateway timeout
API 엔드포인트를 매핑했지만 응답을 받지 못한다. FCM 에서는 'Unknown exception in request' 라는 알 수 없는 메시지를 보내주고 있다. G는 머리를 싸매고 구글링을 해보았지만, Unknown Error 의 원인은 너무나도 다양해서 G가 마주친 케이스와 동일한 케이스를 찾지 못했다. 스트레스 수치가 롤러코스터를 타다가 마지막 포기 상태에 들어갈 때쯤, G는 FCM 통신의 로그 레벨을 전부 DEBUG 로 설정하고 분석하기로 한다.
org.apache.hc.core5.reactor.IOReactorShutdownException: I/O reactor has been shut down
Apache HttpClient 에서 발생하는 에러다. 구글에서도 Apache 클라이언트를 사용해서 내부 로직을 처리하고 있구나. 이 에러는 I/O Reactor 라는 내부 컴포넌트가 이미 종료되었는데 작업을 시도하려고 하기 때문에 발생한다. 나는 메시지를 주고받으려 하는데 왜 종료된까. 몇 가지 케이스에 대해서 분석해본다.
1. Firebase 인스턴스의 명시적 종료 -> 명시적 종료 로직 없음
2. Firebase Admin SDK 버전 이슈 -> 최신 버전으로 마이그레이션 되어 있음
3. Firebase 인스턴스와 정상적으로 매핑되지 못함
G는 위 3의 케이스에 집중한다. FirebaseMessaging 인스턴스를 생성했지만, 사용하기 전에 종료되었다고 한다. 서버 빌드 시에 인스턴스를 Bean 으로 주입해 두는데, 새로 인스턴스를 생성하려 하는 이유는? '인스턴스가 싱글톤으로 관리되지 못하고 있기 때문'이다.
기존 코드
public abstract class FirebaseMessageConfig {
private final FirebaseMessaging firebaseMessaging;
public FirebaseMessageConfig(String app) {
FirebaseApp instance = FirebaseApp.getInstance(app);
this.firebaseMessaging = FirebaseMessaging.getInstance(instance);
}
...
}
역시 기존 로직은 싱글톤 패턴으로 관리되고 있지 않았다. 생성자를 호출하면 getInstance() 메서드를 사용하므로 객체를 생성할 때마다 새로운 인스턴스를 참조한다. 따라서 FCM에 요청을 보낼 때마다 새로운 Connection 을 생성하게 되고, Connection Pool 의 한도가 초과되어 I/O Reactor 가 종료된다. FCM 에서는 이것을 '너희 서버에 무엇인가 문제가 있다' 라는 'Unknown Error' 로 응답해주었다.
개선한 코드
public class FirebaseMessageConfig {
...
private FirebaseApp initialize(String appName, String serviceAccountPath, String databaseUrl) throws IOException {
// 이미 생성된 인스턴스가 있는지 확인
if (FirebaseApp.getApps().stream().anyMatch(app -> app.getName().equals(appName))) {
return FirebaseApp.getInstance(appName);
}
// 서비스 로딩
ClassPathResource classPathResource = new ClassPathResource(serviceAccountPath);
FileInputStream serviceAccountStream = new FileInputStream(classPathResource.getFile());
// 커넥션 풀 설정
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
// 유휴 커넥션 관리
connectionManager.closeExpiredConnections();
RequestConfig requestConfig = RequestConfig.custom()
...
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig)
...
.build();
HttpTransport httpTransport = new ApacheHttpTransport(httpClient);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccountStream))
.setDatabaseUrl(databaseUrl)
.setHttpTransport(httpTransport)
.build();
return FirebaseApp.initializeApp(options, appName);
}
...
}
싱글톤 패턴으로 구현하면서 Connection Pool 이 가득 차는 것을 방지하기 위한 세팅을 추가하였다.
1. 앱 이름을 Bean Name 으로 관리하고, 중복된 인스턴스가 있는지 확인한다.
2. 앱의 Key File 경로와 커넥션 풀, 유휴 커넥션 관리 설정들을 입력한 HttpClient 를 생성한다.
3. 각 옵션을 주입해서 FirebaseApp.initializeApp() 메서드를 실행한다.
Push 메시지 전송은 성공하였고, 이제 그동안 최신화되지 못한 DTO 들을 비즈니스 로직에 맞게 개편할 일들이 남았다.
문제가 생겼을 때 본질에 빠르게 접근할 수 있는 능력을 키워야 한다.
그걸 가능하게 해주는 것은 지식과 경험이다.
언제나 시간을 내서 기반 지식이 되는 머리아픈 것들을 소화하려고 해야 한다.
'Legacy' 카테고리의 다른 글
5. 이슈는 상시 있는 것 (hard coding) (1) | 2025.04.09 |
---|---|
3. 이젠 신입이 아니다 (scheduler, restTemplate) (0) | 2025.03.17 |
2. 첫 업무 (CASE WHEN END) (0) | 2025.03.10 |
1. 레거시 시스템 회사에 다닌다는 소설 (0) | 2025.03.10 |