통계 배치의 실패 처리를 점검하다가 이상한 구조를 발견했다. 다른 서버가 응답하지 못하면, 배치는 0 을 저장하고 “성공”으로 끝난다. Slack 알림도 오지 않는다.
아무도 실패했다고 말하지 않는데 데이터는 망가지는 구조였다.
구조
통계는 매일 새벽 배치가 미리 집계해 둔다. 원천 데이터가 두 서버의 DB 에 나뉘어 있어서, 집계 서버가 다른 서버의 API 를 불러 숫자를 받아 온다.
[집계 배치 서버] [운영 어드민 서버]
aggregate()
├─ 자기 DB 조회
├─ Feign 호출 ─────────────────────→ 자유학습 집계 API
└─ 결과 저장 (기존 값 지우고 새로 넣기)
원인 1: 실패가 “빈 목록 성공”으로 돌아왔다
호출받는 쪽 컨트롤러가 이렇게 생겼었다.
@GetMapping("/free-study/restudy")
public ResponseEntity<ApiResponse> getRestudy(...) {
try {
return ApiResponse.success(dao.getRestudy(...));
} catch (Exception e) {
log.error("집계 실패", e);
return ApiResponse.success(Collections.emptyList()); // ← 실패인데 성공
}
}
Feign fallback 도 같았다.
@Override
public ApiResponse<List<RestudyDto>> getRestudy(...) {
return ApiResponse.success(Collections.emptyList()); // 서버가 죽어도 "0건 성공"
}
배치 입장에서는 “0명이 재학습했다” 는 정상 응답을 받는 것이다. 그러면 기존 값을 지우고 0 을 저장한다. 결과는 성공이니 Slack 알림도 없다.
“일단 화면은 안 깨지게” 넣었던 방어 코드가, 틀린 숫자를 정답처럼 저장하게 만드는 구조였다.
원인 2: 외부 응답을 기다리는 동안 DB 커넥션을 붙잡고 있었다
@Transactional(transactionManager = "masterTransactionManager")
public void aggregate(String statYm) {
List<Row> mine = repo.findXxx(statYm); // DB
List<Dto> theirs = boClient.getRestudy(statYm); // HTTP — 최대 30초 대기
repo.deleteByStatYm(statYm); // DB
repo.saveAll(merge(mine, theirs)); // DB
}
메서드 전체가 트랜잭션이라, 외부 서버가 느리면 그동안 master 커넥션 하나를 잡고 아무것도 안 한다. 집계기 여러 개가 동시에 그러면 풀이 마른다.
해결
1. 실패는 실패로
// 호출받는 쪽: 실패는 500
} catch (Exception e) {
log.error("집계 실패", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, "집계 실패");
}
// Feign fallback: 빈 성공 대신 error
@Override
public ApiResponse<List<RestudyDto>> getRestudy(...) {
return ApiResponse.error("503", "운영 어드민 응답 없음");
}
배치는 error 를 받으면 예외를 던진다. 저장하지 않으니 직전 값이 그대로 남고, 배치 결과가 오류로 기록돼 Slack 알림이 온다.
“빈 값”과 “실패”를 구분하는 게 핵심이다. 정말로 0명인 날과 서버가 죽은 날은 같은 응답이면 안 된다.
2. 조회는 트랜잭션 밖, 저장만 트랜잭션
@Component
public class RestudyAggregator {
@Lazy @Autowired
private RestudyAggregator self; // 자기 자신의 프록시
// 트랜잭션 없음 — 외부 호출은 여기서 끝낸다
public void aggregate(String statYm) {
List<Row> mine = repo.findXxx(statYm);
List<Dto> theirs = fetchOrThrow(statYm); // 실패면 여기서 예외, 저장 안 함
self.replace(statYm, merge(mine, theirs)); // 저장만 트랜잭션
}
@Transactional(transactionManager = "masterTransactionManager")
public void replace(String statYm, List<Stat> rows) {
repo.deleteByStatYm(statYm);
repo.saveAll(rows);
}
}
this.replace() 로 부르면 프록시를 거치지 않아 @Transactional 이 무시된다(self-invocation). 그래서 @Lazy 로 자기 자신의 프록시를 받아 self.replace() 로 부른다. 별도 클래스로 빼도 된다.
이제 트랜잭션은 “지우고 넣기” 몇 밀리초 동안만 커넥션을 잡는다.
3. 큰 목록은 나눠서
대상 회원 목록을 통째로 보내면 요청이 커진다. 2,000명씩 나눠 보내고 배치에서 합쳤다. 한 조각이라도 실패하면 위와 같이 전체를 실패로 처리한다. 일부만 성공한 숫자는 틀린 숫자다.
4. 다시 안 생기게 테스트로 고정
누군가 나중에 aggregate 에 @Transactional 을 다시 붙이지 않도록, 리플렉션으로 확인하는 테스트를 넣었다.
@Test
void aggregate_는_트랜잭션으로_감싸지_않는다() throws Exception {
Method m = RestudyAggregator.class.getMethod("aggregate", String.class);
assertNull(m.getAnnotation(Transactional.class));
assertNull(RestudyAggregator.class.getAnnotation(Transactional.class));
}
결과
- 외부 서버를 부르는 집계기에서 트랜잭션 안 HTTP 호출 0건
- 외부 서버가 실패하면 직전 값 유지 + 오류 알림. 더 이상 0 이 조용히 저장되지 않는다
- 정상일 때 저장되는 값은 바뀌기 전과 같음(테스트 27건)
배운 것
- “안 깨지게” 넣은 빈 응답은 실패를 숨긴다. 화면은 안 깨지지만 데이터가 깨진다. 그리고 아무도 모른다.
- 빈 값과 실패는 다른 응답이어야 한다. 0명인 날과 서버가 죽은 날을 구분할 수 없다면 그 지표는 믿을 수 없다.
@Transactional범위 = DB 커넥션 점유 시간. 외부 호출은 트랜잭션 밖으로 빼자. 예전 글에서 배운 걸 이번엔 구조로 막았다.- self-invocation 은 트랜잭션을 조용히 끈다. 같은 클래스 안에서 부를 땐 프록시를 거치게 하자.