마더케이 통합 CS 관리 시스템 개발 기록 (2) 두 번 달지 않는 리뷰 답변 봇

리뷰 답글도 CS다 1편에서 CS 인입 기록과 AS 출고 처리를 묶은 이야기를 했다. 그 작업을 하는 동안 CS 쪽에서 계속 나온 요청이 하나 더 있었다. 네이버 스마트스토어 리뷰 답글이다. 리뷰는 매일 쌓이는데 답글은 판매자센터에 들어가 한 건씩 달아야 했고, 어떤 리뷰에 답했는지는 CS 기록과 따로 놀았다. 그래서 스마트스토어 전용 리뷰 답변 봇을 만들었다. 미답변 리뷰를 읽어 AI로 답글 초안을 만들고, 판매자센터 화면에서 실제로 게시하고, 처리 결과를 DB에 남겨 CS 화면에서 조회하게 하는 구조다. 기존에 다른 채널용 리뷰 도구가 있었지만 그 파일과 테이블은 건드리지 않고 네이버 전용 파일( api/naver_review_reply_bot.py )을 따로 두었다. 이 글은 그 봇을 만들면서 가장 오래 붙잡고 있던 문제, "게시가 됐는지 어떻게 확신하나"에 대한 기록이다. 요구사항: 두 번 달면 안 된다 답글을 자동으로 단다는 것 자체는 어렵지 않다. 어려운 건 실패했을 때다. 답글이 두 번 달리면 고객이 보는 화면에 그대로 남는다. 반대로 실제로는 달렸는데 실패로 기록되면, 다음 실행에서 같은 리뷰에 또 단다. 그래서 처음부터 몇 가지 규칙을 정했다. 3~5점 리뷰만 자동 답글 대상으로 하고, 1~2점은 manual_review 로 분리해 담당자가 직접 답한다. 게시 버튼은 리뷰당 딱 한 번만 누른다. 결과가 불확실하면 다시 누르지 않고 needs_review 로 남긴다. 로그인한 계정과 스토어가 설정과 다르면 게시하지 않는다. 추가 인증 화면이 뜨면 봇은 멈추고 사람에게 넘긴다(종료 코드 2). 기본 실행은 읽기만 한다. 초안 저장은 --draft , 실제 게시는 --send 를 붙여야 한다. 설계: 상태가 곧 안전장치 봇은 Selenium으로 전용 크롬 프로필을 띄워 판매자센터에 들어가고, 리뷰 상세 모달의 "다음 리뷰" 버튼으로 현재 검색 범위를 순회한다. 처리 이력은 naver_review_ai_replies 테이블에 스토어명+리뷰번호를 복합 기본키로 저장한다. 각 리뷰는 아래 상태 중 하나에 있다. 상태 의미 draft 초안 생성됨, 게시 전 submitting 게시 버튼을 누르기 직전에 점유함 completed 실제 화면에서 답글 본문 일치 확인 needs_review 게시 결과 불확실, 사람이 확인해야 함 manual_review 1~2점, 담당자 답변 대상 generation_skipped 답글 생성 실패, 사유 기록 후 건너뜀 핵심은 draft → submitting 전환이다. 게시 버튼을 누르기 전에 조건부 UPDATE로 상태를 바꾸고, 영향받은 행이 정확히 1개일 때만 진행한다. 두 프로세스가 동시에 같은 리뷰를 잡으려 해도 하나만 통과한다. 프로세스가 중간에 죽어서 submitting 에 머문 리뷰는 자동으로 재시도하지 않는다. def claim(self, rid): with self.conn.cursor() as c: c.execute("UPDATE naver_review_ai_replies SET status='submitting' " "WHERE store_name=%s AND review_id=%s AND status='draft'", (self.store, rid)) return c.rowcount == 1 # send() 안에서 if not history.claim(review['review_id']): raise NeedsHuman('이미 처리 중이거나 확인 대기인 리뷰') 구현 과정과 시행착오 1. 게시는 됐는데 실패로 판정됐다 첫 실제 게시 테스트에서 답글은 판매자센터에 정상적으로 달렸다. 그런데 봇은 완료 판정에 실패했다. 게시 후에는 작성 버튼이 사라지고 "답글 수정" 버튼과 입력한 본문이 보이는지로 성공을 판단했는데, 게시 직후 화면이 전환되면서 그 조건을 기다리던 대기가 시간 초과로 끝난 것이다. 여기서 쉬운 선택은 대기 시간을 늘리거나 실패 시 다시 게시하는 것이었다. 둘 다 위험했다. 다시 누르면 중복 답글이 될 수 있다. 그래서 대기가 실패하면 같은 리뷰를 새로 조회해서, 리뷰 번호가 같고 답글 상태이며 입력란의 값이 DB에 저장한 본문과 정확히 같을 때만 완료로 처리하도록 바꿨다. 그래도 일치하지 않으면 needs_review 로 남기고 실행 전체를 멈춘다. 이미 게시 시도 이력이 있는 리뷰를 사람이 확인한 뒤 복구할 수 있도록 --verify-posted --review-id 옵션도 추가했다. 이 모드는 절대 새 답글을 쓰지 않고, 본문이 일치할 때만 completed 로 바꾼다. 첫 테스트 건도 이 방식으로 재조회해 실제 답글과 DB 본문이 일치함을 확인하고 완료로 복구했다. 2. 재확인을 하니 배치가 1건에서 끝났다 재조회 검증을 넣고 서버에서 배치로 돌리자 새로운 증상이 나왔다. 한 건을 재확인한 다음부터 봇이 "목록 끝"으로 판단하고 종료했다. 원인은 검증 방식에 있었다. 재조회할 때 같은 탭에서 페이지를 새로고침하고 리뷰 번호로 검색했는데, 그 순간 원래 목록이 검색 결과 1건으로 줄어 버렸다. 상세 모달의 "다음 리뷰"가 더 이상 다음 리뷰를 가리키지 않았던 것이다. 검증은 임시 탭에서 하도록 바꿨다. 새 탭을 열어 리뷰 목록에 들어가 해당 리뷰만 검색해 확인하고, finally 에서 탭을 닫고 원래 탭으로 돌아온다. 검증 도중 예외가 나도 원래 목록 탭으로 복귀한다. 종료 로그도 [STOP] limit_reached (설정 건수 도달)와 [STOP] end_of_review_list (목록 끝)로 나눠서, 조기 종료가 다시 생기면 어느 쪽인지 바로 보이게 했다. def verify_published(self, review_id, reply): # 같은 탭에서 검색하면 원래 목록의 '다음 리뷰' 문맥이 사라진다 original = self.driver.current_window_handle self.driver.switch_to.new_window('tab') try: self.driver.get(REVIEWS_URL) self.find_review(review_id) if not self.first(): return False current = self.read() fields = self.css(TEXTAREA, self.modal()) return (current['review_id'] == review_id and current['is_replied'] and bool(fields) and fields[0].get_attribute('value') == reply) finally: self.driver.close() self.driver.switch_to.window(original) 같은 수정에서 화면 확인 함수의 작은 문제도 고쳤다. 페이지가 바뀌는 순간 body 요소를 읽으면 오래된 요소 참조 예외가 나서 확인 단계가 통째로 죽었다. 본문을 읽을 수 있을 때까지 기다리도록 바꾸고, 그 상황을 흉내 낸 단위 테스트를 붙였다. 3. 성공 팝업이 연속 게시를 막았다 탭 문제를 고치고 나니 이번엔 첫 건 게시 뒤 다음 리뷰로 넘어가지 못했다. 판매자센터가 게시 성공 후 "답글이 등록되었습니다." 안내 모달을 띄우는데, 이 모달이 "다음 리뷰" 버튼을 가리고 있었다. 모달을 닫는 건 쉽다. 신경 쓴 건 무엇을 닫느냐였다. "확인" 버튼이 있는 대화상자를 전부 눌러 버리면, 언젠가 "답글을 등록하시겠습니까?" 같은 확인창까지 봇이 승인하게 된다. 그래서 텍스트가 성공 안내와 정확히 일치하는 모달만 닫고, 그 외의 확인창은 건드리지 않게 했다. 이 함수는 게시 완료를 기다리는 동안과 다음 리뷰로 넘어가기 직전에 호출한다. def dismiss_registered_notice(self): """관찰된 게시 성공 안내만 닫는다. 제출 확인창은 절대 누르지 않는다.""" for dialog in self.css('.seller-layer-modal[role="dialog"]'): if dialog.is_displayed() and dialog.text.strip() == '×\n답글이 등록되었습니다.\n확인': dialog.find_element(self.By.XPATH, './/button[normalize-space(.)="확인"]').click() self.wait.until(closed) # 모달이 사라지거나 참조가 끊기면 닫힌 것으로 본다 print('[NOTICE] reply_registered_acknowledged') 테스트에는 성공 안내와 등록 확인창을 함께 넣고, 성공 안내만 클릭되고 확인창은 손대지 않는지를 검증했다. 완료 건마다 [SENT] id=... 로그도 남긴다. 4. 생성 실패가 전체를 멈추지 않게 초기에는 답글 생성 중 하나라도 실패하면 실행이 멈췄다. 의미를 알 수 없는 글자만 있는 리뷰, 빈 응답, 머리말이 붙은 답글, 금지 표현이 들어간 답글 같은 것들이다. 이런 건 게시 안전과는 무관한데 배치 전체를 세웠다. 그래서 실패를 두 종류로 나눴다. 생성 단계의 실패는 generation_skipped 와 사유를 저장하고 다음 리뷰로 넘어간다. 다음 실행에서도 같은 건을 다시 생성하지 않는다. 반대로 DB 기록 실패, 계정·스토어 불일치, 추가 인증, 게시 결과 불확실은 여전히 전체 실행을 멈춘다. 중복 게시나 다른 계정 게시로 이어질 수 있는 실패만 멈춤 대상으로 남긴 것이다. 생성 지침에서 상투적인 기쁨 표현을 빼고 게시 직전에도 검사하며, AI 답변에 직원이 검수했다는 식의 문구는 넣지 않는다. 결과 CS 화면( cs_review_02.php )에서 리뷰 원문·상품명·평점·답글과 초안 대기, 게시 진행 중, 게시 결과 확인 필요, 담당자 답변 필요, 게시 완료 상태를 목록·검색·통계·엑셀로 볼 수 있다. 과거 리뷰 테이블이 있으면 합산하고, 같은 리뷰는 새 봇 이력을 우선한다. AI 호출마다 응답 ID, 모델, 입력·캐시·출력 토큰과 계산 비용을 따로 저장하고, 응답 ID를 기본키로 써서 중복 합산을 막는다. 품질 검사에서 탈락한 호출도 사용량은 먼저 기록한다. 사용량이 없는 과거 답글은 추정하지 않고 "미집계"로 표시한다. 서버에서는 헤드리스로 3분 주기, 배치당 최대 100건으로 돌고 중복 실행을 막아 두었다. 회고 이 봇에서 가장 중요한 코드는 답글을 쓰는 부분이 아니라 "모르겠으면 멈추는" 부분이었다. 게시 결과를 화면 신호 하나로 판정하려던 첫 설계는 판매자센터의 화면 전환 하나에 무너졌다. 결국 믿을 수 있는 건 같은 리뷰를 다시 읽어 저장한 본문과 비교하는 것뿐이었다. 세 번의 수정이 모두 화면 동작을 직접 겪은 뒤에 나왔다는 점도 남는다. 검증용 재조회가 목록을 좁혀 버릴 줄은 배치로 돌려 보기 전엔 몰랐다. 다시 한다면 처음부터 "다음 리뷰 문맥을 바꾸는 동작"을 따로 목록으로 정리하고, 탭을 분리하는 걸 기본 설계로 두겠다. 그리고 판매자센터에 공식 답글 API가 쓸 수 있는 형태로 열려 있는지를 먼저 더 깊게 확인했을 것이다. 화면 자동화는 화면이 바뀌는 순간 다시 손봐야 한다. 기간: 2026.09 ~ 2026.09 기술: Python, Selenium, PyMySQL, OpenAI API, PHP 8, MariaDB, unittest 관련 프로젝트: 마더케이 통합 CS 관리 시스템