자동화 slecs

Playwright 드라이버 자동 탐지·재시작

목차

PlaywrightBrowserPool.java를 건드리기 시작한 계기는 단순했다. 드라이버 프로세스가 조용히 죽어도 풀이 그걸 감지하지 못하고 있었다. 요청을 보내면 응답이 없고, 설정해둔 타임아웃이 터진 다음에야 에러가 올라오는 구조. 오류 자체는 결국 잡히지만 그 사이 요청 하나는 이미 날아간 뒤다. 이 패턴이 운영 중에 반복되면 결국 사람이 프로세스 상태를 직접 확인하고 재기동하게 된다. 그게 싫어서 자동화했다.

PlaywrightBrowserPool 헬스체크와 드라이버 재시작

Playwright를 장시간 운영하다 보면 브라우저 인스턴스가 내부적으로 깨지는 케이스가 생각보다 자주 생긴다. 메모리 사용량이 조금씩 늘면서 느려지거나, 페이지 컨텍스트가 오염되거나, 가장 골치 아픈 건 드라이버 프로세스가 OS 레벨에서 이미 종료됐는데 Java 쪽에선 연결이 살아있다고 착각하는 케이스다. 이 마지막 경우는 에러 로그도 제때 안 올라온다. 풀에 등록된 인스턴스 수는 멀쩡해 보이는데 실제로는 좀비 슬롯이 섞여있는 상황.

기존 코드는 풀에서 인스턴스를 꺼낼 때 별도 검증 없이 그대로 쓰는 구조였다. 대부분의 경우엔 문제없지만, 드라이버가 비정상 상태면 첫 번째 액션에서 예외가 터진다. try-catch로만 처리하면 예외는 잡히지만 해당 인스턴스가 풀에 다시 반납되는 문제가 따라온다. 다음 요청이 같은 깨진 인스턴스를 받게 되고, 같은 오류가 반복된다. 결국 풀이 깨진 인스턴스로 가득 차는 시나리오다.

개선한 방향은 두 가지였다.

  • 풀에서 인스턴스를 꺼낼 때 가벼운 헬스체크를 먼저 실행한다
  • 헬스체크 실패 시 해당 인스턴스는 폐기하고 새 드라이버를 기동한다

헬스체크는 최대한 가볍게 유지해야 했다. 페이지를 열거나 JS를 실행하는 수준이면 정상 요청 비용과 크게 다를 게 없다. 브라우저 컨텍스트에 실제로 접근이 가능한지만 확인하는 수준으로 제한했다.

private boolean isAlive(BrowserContext context) {
    try {
        context.pages(); // 연결 상태 확인용 가벼운 호출
        return true;
    } catch (PlaywrightException e) {
        log.warn("[BrowserPool] 컨텍스트 비정상 감지 - 폐기 예정: {}", e.getMessage());
        return false;
    }
}

인스턴스를 반납할 때도 같은 검증을 넣었다. 작업이 끝난 인스턴스가 정상 상태면 풀에 돌려보내고, 비정상이면 즉시 폐기한다. 이렇게 하면 이미 깨진 인스턴스가 풀에 쌓이는 상황을 막을 수 있다. 이 흐름이 없으면 풀 크기는 유지되는 것처럼 보이지만 실제 가용 슬롯은 줄어든다.

재시작 로직을 동기식으로 할지 비동기로 할지는 고민이 있었다.

방식 장점 단점
동기식 재시작 구현 단순, 별도 상태 관리 불필요 재시작 완료 전까지 해당 슬롯 블로킹
비동기 재시작 재시작 중에도 다른 슬롯은 계속 동작 재시작 중인 슬롯 상태를 별도 관리해야 함

동시 요청이 폭발적으로 몰리는 구조가 아니고, 재시작 시간이 수초 이내면 감당 가능한 수준이었다. 구현 복잡도를 낮추는 게 지금 단계에서 더 맞는 판단이었고 동기식으로 결론냈다. 비동기로 가면 재시작 중인 슬롯을 "예약됨" 같은 상태로 별도 추적해야 하는데, 그 상태 관리 코드가 버그를 더 만들 가능성이 있었다.

메시지 자동수령 로직과 내부 클래스 설계

이번에 같이 들어간 메시지 자동수령 로직은 드라이버 재시작과 별개 맥락이다. 특정 조건이 충족됐을 때 메시지를 자동으로 수령 처리하는 흐름이 필요했고, 이 판단 로직을 내부 클래스에서 담당하게 했다.

내부 클래스로 분리한 이유는 두 가지였다. 수령 판단 로직이 단순하지 않아서 별도 단위로 다루고 싶었고, 외부에 노출할 필요 없는 구현 세부사항을 숨기고 싶었다. Spring Bean으로 따로 뽑으면 의존성이 늘어나고 관리 포인트가 추가된다. 지금 규모에선 내부 클래스가 균형점이었다. 클래스 하나가 좀 길어지는 단점이 있긴 한데, 과도한 분리가 더 나쁠 때도 있다.

트랜잭션 범위는 수령 로직 전체를 하나로 묶었다. 수령 상태 변경이 중간에 실패하면 롤백돼야 하기 때문이다. MyBatis 구조에서 이 범위를 명확히 하지 않으면 일부만 반영된 채 커밋되는 케이스가 생긴다. @Transactional 범위를 서비스 메서드 단위로 잡고, 내부 클래스 호출이 그 범위 안에 들어오게 구성했다. 내부 클래스를 별도 빈으로 분리하면 @Transactional 프록시 처리가 자연스럽게 되지만, 동일 클래스 내 호출은 프록시를 타지 않으므로 이 부분은 직접 확인하고 넘어갔다.

예외 처리도 선택이 필요했다. RuntimeException을 그냥 전파하면 상위 핸들러에서 통일 처리가 가능하고 구현이 단순하다. 반면 잡아서 응답으로 변환하면 클라이언트에 명확한 에러를 전달할 수 있지만, 변환 코드가 여러 곳에 생길 수도 있다. 수령 로직은 사용자 응답이 직접 연결되는 흐름이라 잡아서 변환하는 쪽을 택했다.

try {
    receiptProcessor.process(messageId);
} catch (ReceiptException e) {
    log.info("[AutoReceipt] 수령 처리 실패 - messageId={}, reason={}", messageId, e.getMessage());
    return ResponseEntity.status(HttpStatus.CONFLICT).body(ErrorResponse.of(e));
}

운영 로그엔 원본 예외를 INFO로 남기고 응답엔 상태 코드와 메시지만 내보내는 방식이다. 스택 트레이스를 통째로 로그에 박아놓으면 나중에 찾기가 오히려 힘들다. 중요한 입출력값과 실패 이유만 한 줄로 남기는 게 운영 중에 훨씬 유용하다.

build.gradle 변경과 로그 전략

build.gradle 변경은 Playwright 관련 의존성 정합성 작업이었다. Playwright는 브라우저 바이너리를 별도로 내려받는 구조라서, 라이브러리 버전이 바뀌면 실행 환경에서도 바이너리가 맞는지 확인이 필요하다. 버전 변경 후 로컬 기동 확인에서 멈추지 않고 개발 서버에서도 브라우저 실행이 실제로 되는지 한 번 더 확인하고 올렸다.

로그 전략은 이번 작업에서 특히 신경 썼다. 드라이버 자동 탐지가 동작하면 어떤 인스턴스가 비정상으로 판정됐는지, 재시작이 몇 번 발생했는지 INFO로 남긴다. 자동 복구 로직이 있어도 이 패턴이 지나치게 잦으면 근본 원인을 찾아야 한다는 신호다. 로그 없이 자동 복구만 해두면 문제가 있다는 걸 한참 뒤에야 알게 된다. 조용히 회복하는 것과 문제를 모르고 있는 것은 다르다.

배포는 로컬 기동 확인 후 개발 DB 반영, 그 다음 배포 순서로 처리했다. 작업 규모가 크지 않아서 검증 단계가 길진 않았지만, 풀 관련 변경이라 재시작 로직이 실제로 트리거되는지는 로컬에서 한 번 확인하고 넘겼다. 풀 코드는 생각보다 타이밍에 민감해서 코드 리뷰만으로는 잡기 어려운 케이스가 있다.

이 작업 이후로 드라이버 좀비 상태 때문에 수동으로 재기동하는 일이 줄었다. 자동 복구가 깔려있으면 새벽에 알림 보고 접속해야 하는 빈도가 달라진다. 운영 안정성 작업은 기능 추가보다 티가 덜 나지만, 이 차이가 쌓이면 결국 크게 드러난다.

댓글 0

첫 댓글 달아줘.