Loading the catalog…
Loading the catalog…
배경 / 문제 상황 전광판(LCD)에 광고를 어떤 순서로 재생할지 정하는 "편성"을 관리하는 기능이 있다. 지금까지는 편성을 만들거나 바꿔도 LCD 단말이 그걸 알 방법이 없어서, 단말이 주기적으로 폴링해서 바뀐 걸 알아채는 구조였다. 이번에 편성이 생성·수정·삭제되는 시점에 대상 LCD로 "갱신됐다"는 신호를 MQTT로 즉시 보내는 기능을 연결했다. 단순히 "저장 성공하면 발행 한 번 호출" 정도로 끝날 줄 알았는데, 막상 만들다 보니 "언제 대상 목록을 확보해야 하는가", "발행이 실패하면 저장 결과는 어떻게 되어야 하는가" 같은 타이밍 문제가 더 까다로웠다. 접근 방법 가장 먼저 정한 건 신호의 내용이었다. 편성 데이터 자체를 MQTT payload에 실어 보내는 대신, "바뀌었다"는 신호만 보내고 단말이 REST API로 다시 조회하게 했다. MQTT는 트리거 역할만 하고 실제 데이터는 기존 REST 계약이 그대로 담당하는 구조다. 이렇게 하면 payload 크기나 포맷을 신경 쓸 필요가 없고, 단말이 신호를 못 받아도 다음 폴링 때 REST로 알아서 따라잡을 수 있다. 그다음 문제는 "발행이 실패하면 편성 저장(mutation)도 실패로 봐야 하는가"였다. 저장은 이미 DB에 커밋된 상태인데 발행 실패로 GraphQL 에러를 던지면, 관리자 입장에서는 "저장이 안 됐구나" 오해하고 같은 편성을 다시 등록할 수 있다. 그래서 발행은 저장 결과에 영향을 주지 않기로 했다 — 발행 모듈이 아예 연결 안 되어 있어도( getDownlinkPublisher() 가 undefined) 조용히 스킵하고, 발행 중 에러가 나도 로그만 남기고 저장 응답은 그대로 성공을 돌려준다. 실제로 막힌 부분은 수정(update)과 삭제(delete)에서 "언제 대상 LCD 목록을 조회해야 하는가"였다. 수정 : 편성의 대상 LCD를 바꾸는 경우, DB 트랜잭션은 기존 대상 행을 지우고 새 대상 행을 넣는다. 트랜잭션이 끝난 뒤에 대상을 조회하면 이미 새 목록만 남아 있어서, "이번에 빠진 LCD가 누구인지"를 영영 알 수 없다. 빠진 LCD에도 신호를 보내야 그 화면이 옛 편성을 계속 트는 대신 재조회를 시도하기 때문에, 이 목록은 트랜잭션을 시작하기 전에 미리 확보해둬야 했다. 삭제 : 반대로 삭제는 순서가 뒤집힌다. 신호를 먼저 보내고 삭제가 실패하면, 단말은 이미 지워진 것처럼 재조회를 시도하는데 서버에는 편성이 그대로 남아있는 상태가 된다. 그래서 삭제는 DB에서 완전히 확정된 뒤에만 신호를 보내도록 했다. 마지막으로, 편성 이름이나 설명(description)만 바뀐 경우는 발행을 생략했다. 이 필드들은 단말이 재생하는 내용에 영향을 주지 않으므로, 매번 발행할 이유가 없었다. 구현하면서 참고한 기존 코드는 lcdNotice.publish.service.ts 에 있던 "교체 전/후 대상을 비교해서 빠진 대상을 찾는" 로직이었다. 거의 그대로 재사용할 수 있었지만, 3줄 남짓한 짧은 로직 때문에 서로 다른 도메인(공지 vs 편성) 모듈을 의존 관계로 묶고 싶지 않아서 그대로 복붙하듯 별도로 다시 구현했다. Before / After Before (편성 저장만 하던 코드, updateAdSchedule 일부): const updated = await withPgTransaction(async (client) => { const updateSql = await mybatisMappers('adScheduleWriteMapper', 'updateAdSchedule', { ... }); // ... 대상 LCD 행을 지우고 새로 넣는 처리 }); if (!updated) return buildResponse('IS_NOT_EXIST_DATA'); return await getAdScheduleDetail(args.adScheduleId, companyId); After (발행 연결 후): // 🔴 교체 전 대상을 여기서 잡는다. 트랜잭션이 대상 행을 지우고 새로 넣으므로, 그 뒤에 // 조회하면 "빠진 단말"을 영영 알 수 없다. const targetsBefore = hasTargetLcdIds ? await loadAdSchedulePublishTargets(args.adScheduleId) : []; const updated = await withPgTransaction(async (client) => { // ... 기존 저장 로직 동일 }); if (!updated) return buildResponse('IS_NOT_EXIST_DATA'); // 방송기간(items) 또는 대상(targetLcdIds) 중 하나라도 바뀌었을 때만 발행한다 — // scheduleName/description만 바뀌면 단말에 영향이 없으므로 생략한다. if (hasItems || hasTargetLcdIds) { const version = await loadPlaylistVersion(args.adScheduleId); const publisher = getDownlinkPublisher(); const afterRows = await loadAdSchedulePublishTargets(args.adScheduleId); if (hasTargetLcdIds) { const removed = diffRemovedTargets(targetsBefore, afterRows); if (removed.length > 0) await publishAdSchedulePlaylist(removed, version, publisher); } await publishAdSchedulePlaylist(afterRows, version, publisher); } return await getAdScheduleDetail(args.adScheduleId, companyId); 삭제 쪽도 같은 원칙(대상은 미리 확보, 신호는 확정 후 발송)으로 구성했다: // 🔴 삭제 전에 대상을 확보한다. deleteAdSchedule은 is_active='F'로 만들고 발행 대상 // 조회는 활성 대상만 보므로, 삭제 후에는 어느 단말에 신호를 보내야 하는지 알 수 없다. const [targets, playlistVersion] = await Promise.all([ loadAdSchedulePublishTargets(adScheduleId), loadPlaylistVersion(adScheduleId), ]); const result = await queryDatabase(sql); // 실제 삭제 실행 if (result.rowCount !== 1) return buildResponse('IS_NOT_EXIST_DATA'); // 삭제가 확정된 뒤에 신호를 보낸다. 순서를 뒤집으면 삭제가 실패했는데 단말만 재조회를 // 시도하는 상태가 될 수 있다. await publishAdSchedulePlaylist(targets, playlistVersion, getDownlinkPublisher()); 결과 생성/수정(대상 변경 포함)/삭제 각각을 로컬에서 실행해 대상 LCD와 대상에서 빠진 LCD 모두에 신호가 발행되는지, 발행 모듈을 일부러 끊어놓았을 때도 저장 자체는 정상 응답을 반환하는지 확인했다. 배운 점 / 트레이드오프 "저장은 트랜잭션, 발행은 그 결과에 의존하는 후속 작업"이라는 조합에서는, 실제 로직의 난이도가 "발행을 어떻게 호출할까"가 아니라 "발행에 필요한 정보(대상 목록)를 트랜잭션의 어느 시점에 붙잡아 둘까"에 있었다. 트랜잭션이 상태를 지우고 다시 쓰는 구조라면, 그 상태에 의존하는 후속 작업은 트랜잭션 전후로 스냅샷을 따로 챙겨야 한다는 걸 체감했다. 발행 실패를 저장 실패와 분리한 판단은 "사용자가 오해해서 중복 생성하는 상황"을 막기 위한 것이었는데, 반대급부로 발행이 조용히 실패해도 겉으로는 아무 문제 없어 보인다. 지금은 로그만 남기고 있어서, 발행 실패가 누적되는지 감시할 별도 지표는 아직 없다 — 운영 중 발행 실패율을 볼 방법은 다음 과제로 남는다. 기존 코드( lcdNotice.publish.service.ts )와 거의 같은 로직을 굳이 재사용하지 않고 다시 짠 선택은 지금 시점에는 맞다고 보지만, 나중에 발행 대상이 세 번째 도메인으로 늘어나면 그때는 공통 유틸로 뽑는 게 나을 수도 있다. 지금은 두 곳뿐이라 추상화 비용이 재사용 이득보다 커 보여서 미뤘다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
편성 저장은 성공했는데 알림 발행이 실패하면? — DB 저장과 MQTT 발행을 분리하며 겪은 타이밍 문제. 배경 / 문제 상황 전광판(LCD)에 광고를 어떤 순서로 재생할지 정하는 "편성"을 관리하는 기능이 있다. 지금까지는 편성을 만들거나 바꿔도 LCD 단말이 그걸 알 방법이 없어서, 단말이 주기적으로 폴링해서 바뀐 걸 알아채는 구조였다. 이번에 편성이 생성·수정·삭제되는 시점에 대상 LCD로 "갱신됐다"는 신호를 MQTT로 즉시 보내는 기능을 연결했다. 단순히 "저장 성공하면 발행 한 번 호출" 정도로 끝날 줄 알았는데, 막상 만들다 보니 "언제 대상 목록을 확보해야 하는가", "발행이 실패하면 저장 결과는 어떻게 되어야 하는가" 같은 타이밍 문제가 더 까다로웠다. 접근 방법 가장 먼저 정한 건 신호의…
Open source