김솔비 블로그
기술 블로그

카톡 테마 하나 올리는 데 1시간 → 13분: 나만 쓰는 멀티플랫폼 게시 도구 만들기

22분 읽기

들어가며

나는 솔파니스토어라는 이름으로 카카오톡 테마를 만들어 무료로 나누거나 판다. 테마 하나를 완성하면 끝이 아니다. 같은 소식을 세 곳에 따로 올려야 한다.

  1. 네이버 블로그: 정해진 글 양식이 있다. 제목, 인사, 미리보기 사진 2열 콜라주, 받는 방법, 다운로드 링크 카드, 적용 방법 이미지, 주의사항, 채널 링크, 같은 시리즈의 다른 버전 보러가기까지. 글자 크기와 색도 맞춘다. 태그는 25개.
  2. 포스타입: 양식이 또 다르다. 공개 분량과 구독자 전용 분량을 나누고, 인용 말풍선과 정사각형 그룹 이미지를 쓴다. 태그는 5개.
  3. 인스타그램: 캡션, 해시태그, 4:5 캐러셀. 원본이 세로로 긴 캡처라 사진마다 잘라야 한다.

같은 내용을 플랫폼마다 다른 형식, 다른 이미지 비율, 다른 태그 규칙으로 세 번 만드는 일이었다. 상품 하나에 한 시간 가까이 걸렸고, 이미지나 링크를 빠뜨리는 실수도 잦았다.

자동화하려고 보니 벽이 있었다. 네이버 블로그의 글쓰기 API는 2020년에 종료됐고, 포스타입은 공개 API가 없다. 그래서 직접 만들었다. 이름은 postflow다. 사용자는 나 한 명이다.

어디까지 자동화할까

처음에 정한 한 문장이 있다.

상품 1개당 작업을 이미지 편집 + 문구 3번 작성 + 업로드 3번에서 입력 1번 + 발행 버튼 2번으로 줄인다.

  • 인스타그램은 공식 API가 있으니 끝까지 자동으로 올린다.
  • 네이버 블로그·포스타입은 Mac의 브라우저에서 글쓰기 화면을 열고 제목·본문·사진·태그를 다 채운 다음, 발행 직전에서 멈춘다. 발행 버튼은 내가 누른다.

왜 마지막 클릭을 남겼나.

  • 공식 API가 없는 곳을 브라우저로 채우는 일이다. 자동 발행까지 하면 대량 게시처럼 보일 수 있고, 계정이 막히면 스토어 홍보 채널을 잃는다.
  • 발행 전에 한 번 훑어보는 게 실제로 쓸모 있었다. 오타나 어색한 AI 문장을 고치는 데 몇십 초면 된다.
  • 로그인도 내가 평소 쓰는 Chrome 창에서 직접 한다. 도구는 그 세션을 이어 쓸 뿐이다.

그리고 "나만 쓴다" 는 전제가 많은 결정을 단순하게 만들었다. 로그인 계정은 하나, 공개 인터넷에는 열지 않음, 알림은 Mac 알림 센터만.

무엇을 만들었나

시스템 구성

postflow 시스템 구성

구성 요소는 세 곳에 나뉘어 있다.

  • 라즈베리파이(집): Spring Boot 서버, PostgreSQL, 이미지가 들어 있는 외장 SSD. 등록·확인 화면과 작업 큐, 이미지 변환, 인스타 게시, 발행 확인이 모두 여기서 돈다.
  • Mac(내 작업 컴퓨터): 에이전트가 launchd로 상주한다. 서버에 작업이 있는지 묻고, 내가 로그인해 둔 Chrome으로 에디터를 채운다.
  • 외부: Gemini(초안 문구), Instagram API, 그리고 발행을 확인하는 블로그 RSS와 포스타입 채널 페이지.

서버는 사설망 안에서만 열려 있다. 밖에서 들어올 수 있는 길은 인스타그램이 이미지를 가져가는 /media/{토큰}.jpg 하나다. 이 주소도 게시하는 동안만 열고, 끝나면 닫는다.

상품 하나 올리는 과정

① 상품 등록. 사진을 끌어다 놓고 순서를 정한다. 시리즈는 한 번 만들어 두면 다음부터 고르기만 하면 된다.

② 사진 확인·조정. 사진마다 인스타(4:5)용과 블로그(1:1)용으로 자를 위치를 정하고, 인스타에 올릴 사진만 고른다.

상품 화면의 이미지 영역

비율 조정 창

③ 플랫폼 탭. 본문은 원문과 같은 글자 크기·색으로 미리 보인다. 빠진 게 있으면 노란 경고가 뜬다.

네이버 블로그 탭

④ Mac에서. [에이전트로 보내기]를 누르면 Mac의 Chrome에서 글쓰기 화면이 열리고, 다 채워진 채 발행 창에서 멈춘다. 나는 훑어보고 [발행]만 누른다.

에이전트가 채운 네이버 블로그 에디터

⑤ 대시보드. 발행이 감지되면 ✅로 바뀌고, 이번 달 아낀 시간과 상품 하나에 든 내 시간이 쌓인다.

대시보드

게시 작업은 상태 머신이다

플랫폼마다 게시 하나가 publish_job 행 하나다. 상품 하나면 네이버·포스타입·인스타 세 행이 생긴다.

이 글의 코드는 설명에 필요한 부분만 간추렸다.

게시 작업 상태 변화

java
/** NONE(작업 없음) → READY(대기) → CLAIMED(입력·게시 중) → FILLED(발행 대기) → PUBLISHED / FAILED */ public enum Status { NONE, READY, CLAIMED, FILLED, PUBLISHED, FAILED }

FILLED가 이 도구의 핵심 상태다. 에이전트는 일을 다 했지만 게시는 아직 안 된 상태, 즉 사람의 클릭을 기다리는 상태를 따로 둔 것이다. 대시보드의 "할 일"과 "아낀 시간" 계산이 모두 이 상태를 기준으로 움직인다.

작업 가져가기는 트랜잭션 하나로

에이전트가 "할 일 있나?" 하고 물으면 서버는 가장 오래 기다린 READY 하나를 CLAIMED로 바꿔서 넘긴다.

java
@Transactional public Optional<AgentJob> claimNext() { touch(); // 에이전트 마지막 접속 시각 (대시보드의 '에이전트 켜짐' 표시) return jobs.findFirstByStatusAndPlatformInOrderByUpdatedAtAsc(Status.READY.name(), AGENT_PLATFORMS) .map(job -> { job.move(Status.CLAIMED, null); return payload(job); // 제목 · 블록 목록 · 태그 · 이미지 경로 }); }

에이전트는 하나뿐이라 행 잠금까지는 걸지 않았다. 대신 멈춘 작업을 치우는 스케줄러를 따로 두었다. Mac이 잠들거나 에이전트가 죽으면 작업이 CLAIMED에 영원히 머물기 때문이다.

java
@Scheduled(fixedDelay = 300_000) // 5분마다 @Transactional public void failStuckJobs() { for (PublishJob j : jobs.findAll()) { if (j.status() != Status.CLAIMED) continue; boolean ig = Platform.INSTAGRAM.key().equals(j.getPlatform()); if (j.getUpdatedAt().isBefore(now.minusMinutes(ig ? 15 : 20))) { j.move(Status.FAILED, "에이전트가 20분 넘게 응답이 없습니다 ..."); } } }

상태 이력은 DB 트리거가 남긴다

상태를 바꾸는 경로가 많다. 에이전트 보고, 인스타 게시 스레드, 화면 버튼, 발행 감지 스케줄러. 서비스 코드 곳곳에 "이력 남기기"를 넣으면 언젠가 하나를 빠뜨린다. 그래서 이력은 DB가 남기게 했다.

sql
CREATE OR REPLACE FUNCTION record_job_event() RETURNS trigger AS $fn$ BEGIN IF TG_OP = 'INSERT' OR NEW.status IS DISTINCT FROM OLD.status THEN INSERT INTO job_event (product_id, platform, status, at) VALUES (NEW.product_id, NEW.platform, NEW.status, now()); END IF; RETURN NEW; END $fn$ LANGUAGE plpgsql; CREATE TRIGGER publish_job_event AFTER INSERT OR UPDATE ON publish_job FOR EACH ROW EXECUTE FUNCTION record_job_event();

뒤에 나올 "아낀 시간"은 전부 이 job_event에서 계산한다. FILLED가 찍힌 시각과 PUBLISHED가 찍힌 시각의 차이가 내가 확인하고 발행하는 데 쓴 시간이다.

Mac 에이전트: 폴링, 클립보드, 셀렉터

Mac 에이전트 작업 순서

왜 폴링인가

서버가 에이전트에게 일을 밀어 넣는(push) 구조가 더 즉각적이다. 하지만 Mac은 노트북이라 꺼지고, 잠들고, 네트워크가 바뀐다. 서버가 Mac에 접속하려면 Mac에 들어오는 포트를 열어야 한다. 반대로 에이전트가 30초마다 묻는(pull) 구조는 Mac이 깨어 있을 때만 일하고, 들어오는 연결이 하나도 필요 없다. 30초 지연은 사람이 발행 버튼을 누르는 시간에 비하면 아무것도 아니다.

폴링 요청에는 에이전트가 확인한 네이버·포스타입 로그인 상태도 같이 실어 보낸다. 서버는 이걸 연동 화면과 대시보드 경고에 쓴다. 로그인이 풀린 상태에서 작업을 받으면 에이전트는 에디터를 열지 않고 바로 FAILED로 보고한다.

HTML은 클립보드로 넣는다

네이버 SmartEditor ONE도, 포스타입도 화면 뒤에 자기 문서 모델이 따로 있다. contenteditable의 DOM을 직접 고치면 그 모델과 어긋날 위험이 있다. 에디터가 정식으로 받아들이는 입력은 붙여넣기다. 그래서 브라우저 클립보드에 text/html과 text/plain을 함께 넣고 Cmd+V를 누른다.

java
public void pasteHtml(Frame frame, String rawHtml) { // 태그 사이 줄바꿈을 에디터가 빈 문단으로 한 번 더 넣지 않도록 뺀다 String html = rawHtml.replaceAll(">\\s+<", "><"); String text = toPlainText(html); // 태그를 뗀 일반 텍스트 (간추림) frame.evaluate(""" async ([html, text]) => { await navigator.clipboard.write([new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([text], { type: 'text/plain' }), })]); }""", List.of(html, text)); page.keyboard().press("Meta+V"); }

이 방식이면 굵게, 인용, 링크, 그리고 뒤에서 설명할 인라인 스타일(글자 크기·색)이 에디터의 정상 경로로 들어간다. 사진은 숨은 <input type="file">에 setInputFiles로 넣고, 이어진 사진은 한 번에 올려서 네이버에서는 콜라주, 포스타입에서는 그룹 이미지가 되게 한다.

에디터 구조는 코드가 아니라 YAML에

에디터 화면은 언제든 바뀐다. 요소 위치(셀렉터)를 코드에 박아 두면 바뀔 때마다 빌드하고 배포해야 한다. 그래서 플랫폼별 YAML로 뺐다. 값은 후보 목록이고, 앞에서부터 먼저 보이는 요소를 쓴다.

yaml
# naver.yml title: - ".se-documentTitle .se-text-paragraph" - ".se-title-text" body: - ".se-component.se-text .se-text-paragraph" groupImages: "true" # 이어진 사진은 한 번에 올린다 imageConfirm: - "text=콜라주" # 여러 장을 올리면 뜨는 첨부 방식 선택 publishedUrlPattern: "^https://(m\\.)?blog\\.naver\\.com/(PostView\\.naver\\?.*logNo=\\d+.*|{blogId}/\\d+.*)$"

셀렉터를 고치는 일 자체를 쉽게 하려고 에이전트에 inspect 명령도 넣었다. 글쓰기 화면을 열어 요소 목록을 파일로 저장한다. 에디터가 바뀌면 이 목록을 보고 YAML만 고친다.

발행 감지는 두 겹

내가 발행 버튼을 누르면 에디터 창의 주소가 글 주소로 바뀐다. 에이전트는 채워 둔 창을 들고 있다가 주소가 publishedUrlPattern에 맞으면 PUBLISHED와 글 주소를 보고한다.

그런데 이 감시는 에이전트 프로세스 안에만 있다. 에이전트를 다시 켜면 창이 닫히고 감시도 사라진다. 실제로 그렇게 발행 하나를 놓쳤다. 그래서 서버 쪽에 두 번째 그물을 쳤다. 3분마다 FILLED로 남은 작업을 네이버 블로그 RSS와 포스타입 채널 페이지에서 찾는다.

java
/** 입력이 끝난 뒤(1분 여유) 올라온 글 중 제목에 버전명이 있는 가장 이른 글 */ static Optional<Post> find(List<Post> posts, String version, OffsetDateTime filledAt) { String v = normalize(version); return posts.stream() .filter(p -> p.at() != null && !p.at().isBefore(filledAt.minusMinutes(1))) .filter(p -> !v.isEmpty() && normalize(p.title()).contains(v)) .min(Comparator.comparing(Post::at)); }

제목으로 맞추지 않는 이유는 발행 직전에 제목을 고칠 수 있어서다. 버전명("별 배경")은 거의 바뀌지 않고, "입력이 끝난 뒤에 올라온 가장 이른 글"이라는 조건이 같은 시리즈의 예전 글을 걸러 낸다.

함정이 하나 있었다. 포스타입의 datePublished는 한국 시각인데 끝에 UTC 표시 Z가 붙어 온다. 그대로 파싱하면 9시간 뒤의 글이 된다. 오프셋을 떼고 한국 시각으로 읽는다.

원문과 똑같은 모양으로

가장 공을 들인 부분이다. 네이버 블로그에 이미 내가 쓴 글이 여러 편 있었고, 새 글도 그 글과 똑같아 보여야 했다.

원문을 읽어서 양식을 뽑았다

글 이미지는 복사해 붙여 넣을 수 없어서, 글 주소를 주고 원문 HTML을 직접 읽었다. 글 3~4편을 비교해서 고정된 부분(받는 방법, 주의사항, 채널 링크)과 바뀌는 부분(시리즈명, 버전명, 보러가기 이름)을 나눴다. 고정된 부분은 템플릿이 되고, 바뀌는 부분은 변수가 됐다.

AI(Gemini)는 소개 문단, 인스타 한 줄, 롱테일 태그만 쓴다. 다운로드 방법이나 링크처럼 틀리면 안 되는 정보는 전부 템플릿에 고정했다. 직접 고친 문단은 "다시 쓰기"를 눌러도 건드리지 않는다.

글자 크기·색을 옮기는 작은 문법

원문은 문단마다 글자 크기와 색이 다르다. 마크다운으로는 표현할 수 없어서 템플릿용 표시를 만들었다.

{24 #666666}**아이폰 카톡테마 무료배포**      ← 줄 끝까지 24px, 회색
{#608cba}[포스타입](주소){/} 접속            ← 줄 안의 일부만
{16 #555555}                                ← {/}까지 이어지는 기본 서식

이 표시를 인라인 스타일(<span style="font-size:24px;color:#666666">)로 바꿔서 에디터에 붙여 넣는다. 이 방식을 고르기 전에 네이버 에디터가 붙여 넣은 HTML의 크기·색·배경을 그대로 유지하는지부터 실제로 확인했다.

블록으로 쪼개서 순서대로 넣는다

완성된 HTML을 한 번에 붙이면 사진이 안 들어간다. 그래서 본문을 블록으로 쪼갰다.

  • 글 조각 (HTML)
  • 이미지 자리 ([이미지 1], [인사 이미지 1], [적용 방법 이미지])
  • 링크 카드

에이전트는 "붙여넣기 → 사진 올리기 → 붙여넣기 → 링크 카드…"를 블록 순서대로 반복한다. 이어진 사진은 한 번에 올려서 네이버에서는 콜라주, 포스타입에서는 정사각 그룹이 되게 했다. 에디터에 붙이면 문단이 달라붙어서, 문단 사이에는 빈 문단을 직접 넣는다.

템플릿에서 블록까지

CommonMark로 파싱한 최상위 노드를 하나씩 HTML로 그려 보고, 이미지 자리나 링크 카드 자리이면 거기서 끊는다.

java
private static final Pattern IMAGE_SLOT = Pattern.compile( "^<p>\\[(이미지 (\\d+)|(?:인사|오브젝트) 이미지 (\\d+)|적용 방법 이미지)\\]</p>\\s*$"); private static final Pattern EMBED = Pattern.compile( "^<figure class=\"embed\" data-url=\"([^\"]+)\"></figure>\\s*$"); for (Node n = markdown.parse(text).getFirstChild(); n != null; n = n.getNext()) { String rendered = html.render(n); if (EMBED.matcher(rendered).matches()) { flush(chunk); blocks.add(embed(...)); } else if (IMAGE_SLOT.matcher(rendered).matches()) { flush(chunk); blocks.add(image(...)); } else chunk.append(gap).append(rendered); // 글 조각은 이어 붙인다 }

[이미지 3]이 앞 문단에 붙어 있으면 한 문단으로 렌더링돼서 이 정규식에 걸리지 않는다. 실제로 적용 방법 이미지가 안 들어가던 버그의 원인이었다. 템플릿에서 이미지 자리는 반드시 빈 줄로 띄운다.

이미지 비율에서 벗어나기

원본은 대부분 세로로 긴 휴대폰 캡처다. 플랫폼마다 필요한 모양은 다르다.

쓰는 곳비율
인스타그램 캐러셀4:5 (1080×1350)
네이버 블로그 사진·포스타입 대표1:1
포스타입 본문가로 1080
  • 원본은 건드리지 않고 조정값만 저장한다. 방식, 영역, 배경색, 흐림 정도. 그래서 언제든 다시 변환할 수 있다.
  • 방식은 세 가지다. 가운데 자르기, 여백 채우기(가장자리에서 가장 많이 쓰인 색을 배경으로), 흐린 배경.
  • 처음에는 여백 채우기가 기본이었다. 실제로 써 보니 인스타에는 잘라서 올리는 게 나아서 가운데 자르기를 기본으로 바꾸고, 필요한 사진만 위치를 조정하게 했다.
  • 이미지 주소에 변환 시각을 붙였다. 다시 자른 뒤에도 브라우저가 옛 이미지를 보여 주는 문제를 막으려고.

여백 색은 가장자리에서 고른다. 카톡 테마 캡처는 배경이 단색에 가까운 경우가 많아서, 가장자리 픽셀의 최빈색으로 채우면 이어 붙인 티가 거의 안 난다. 색을 채널마다 상위 4비트로 묶어(16×16×16 칸) 세고, 가장 많이 나온 칸의 평균색을 쓴다. 완전히 같은 색만 세면 압축 노이즈 때문에 표가 흩어지기 때문이다.

java
public static String edgeColor(BufferedImage img) { Map<Integer, long[]> buckets = new HashMap<>(); // 칸 → [r합, g합, b합, 개수] int step = Math.max(1, Math.max(w, h) / 400); // 큰 이미지는 건너뛰며 샘플링 for (int x = 0; x < w; x += step) { addSample(buckets, img.getRGB(x, 0)); addSample(buckets, img.getRGB(x, h - 1)); } for (int y = 0; y < h; y += step) { addSample(buckets, img.getRGB(0, y)); addSample(buckets, img.getRGB(w - 1, y)); } long[] top = buckets.values().stream().max(comparingLong(a -> a[3])).orElseThrow(); return "#%02x%02x%02x".formatted(top[0] / top[3], top[1] / top[3], top[2] / top[3]); } // addSample: key = (r >> 4) << 8 | (g >> 4) << 4 | (b >> 4)

흐린 배경은 줄였다 늘리기로 만든다. 가우시안 커널을 직접 돌리는 대신, 틀을 꽉 채우게 확대한 이미지를 1/N로 줄였다가 다시 늘리는 것을 두 번 반복한다. 보간이 알아서 번지게 해 준다. 그 위에 원본을 가운데 올리면 끝이다.

인스타 게시: 일회용 공개 주소

Instagram API는 이미지 파일을 받지 않는다. 공개 URL을 주면 인스타 서버가 가져가는 방식이다. 서버 전체를 공개하지 않으면서 이걸 하려고, 게시하는 동안만 사진마다 추측할 수 없는 주소를 열었다가 닫는다.

인스타 캐러셀 게시 순서

java
private String open(ProductImage img) { byte[] bytes = new byte[24]; random.nextBytes(bytes); // SecureRandom String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); String url = publicBase + "/media/" + token + ".jpg"; tx.executeWithoutResult(s -> imageRepo.findById(img.getId()).orElseThrow().openPublic(token, url)); return url; }

/media/{token}.jpg 요청은 토큰 형식(32자 이상의 URL-safe 문자)이 맞지 않으면 DB를 보지도 않고 404, 형식이 맞아도 DB에 그 토큰이 열려 있을 때만 이미지를 돌려준다. 게시가 성공하든 실패하든 finally에서 전부 닫는다.

캐러셀은 세 단계다. 사진마다 항목 컨테이너를 만들고(is_carousel_item=true), 그 ID들로 캐러셀 컨테이너를 만들고, 컨테이너의 status_code가 FINISHED가 될 때까지 기다렸다가 media_publish를 부른다. 기다리지 않고 바로 게시하면 "미디어가 준비되지 않았다"는 오류가 난다. 3분 안에 끝나지 않거나 EXPIRED가 오면 실패로 돌린다.

장기 토큰은 60일짜리다. 만료 10일 전부터 매일 새벽에 자동으로 연장하고, DB에는 AES-GCM으로 암호화해서 넣는다.

새로고침 없는 화면

상품 화면에는 버튼이 많다. 자를 위치 조정, 인스타 포함·제외, 썸네일 지정, 업로드, 삭제, 게시. 처음에는 전부 평범한 <form method="post"> → 서버 redirect였다. 누를 때마다 화면 전체가 다시 그려지고 스크롤이 맨 위로 튀었다.

서버 코드는 손대지 않고 브라우저에서만 해결했다. 폼 제출을 가로채 fetch로 보내고, redirect로 돌아온 전체 HTML에서 바뀐 영역만 골라 갈아 끼운다.

javascript
document.addEventListener('submit', async e => { const form = e.target; if (!form.closest('[data-region]') && !form.dataset.partial) return; // 일반 폼은 그대로 e.preventDefault(); const res = await fetch(form.action, { method: 'POST', body: new FormData(form, e.submitter), headers: { ...csrfHeaders(), 'X-Partial': '1' } }); if (new URL(res.url).pathname !== location.pathname) return location.href = res.url; // 다른 화면으로 가는 동작 const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); for (const id of regionsFor(form)) document.getElementById(id)?.replaceWith(doc.getElementById(id)); afterSwap(); // 드래그 정렬·탭 상태 다시 연결 });

htmx의 boost를 쓰면 한 줄이지만, 파일 다운로드 링크·확인 창·파일 업로드·스크롤 위치가 한꺼번에 꼬였다. 서버는 여전히 완성된 페이지를 그리고, 클라이언트가 필요한 조각만 가져가는 쪽이 디버깅하기 쉬웠다. AI 초안이나 인스타 게시처럼 오래 걸리는 작업은 끝났을 때 서버가 htmx 이벤트(job-done, ai-done)를 보내고, 그 영역만 다시 받는다.

실제로 터진 것들

에이전트가 3시간 동안 멈춰 있었다

이미지를 내려받다가 연결이 끊겼는데, 에이전트가 영원히 기다리고 있었다. Java HttpClient의 timeout은 응답 헤더가 올 때까지만 적용된다. 본문을 받는 도중에 끊기면 제한이 없다.

java
// 전: 헤더까지만 기다림 client.send(request, BodyHandlers.ofFile(path)); // 후: 전체 시간을 2분으로 자르고, 실패하면 3번까지 다시 client.sendAsync(request, BodyHandlers.ofFile(path)).get(120, TimeUnit.SECONDS);

같은 날 Mac이 잠들어서 다 채우고도 "완료" 보고를 못 하는 문제도 찾았다. 작업하는 동안만 caffeinate로 잠자기를 막고, 20분 넘게 멈춘 작업은 서버가 실패로 처리한다.

상태가 안 바뀌어서 매번 새로고침했다

게시 상태가 저절로 바뀌지 않아서 매번 새로고침해야 했다. 서버 로그를 보니 같은 에러가 169번 쌓여 있었다. Thymeleaf 조각을 돌려줄 때 매개변수 이름을 안 붙인 게 원인이었다.

html
<!-- 전 -->  th:replace="~{fragments :: panel(${job})}"
<!-- 후 -->  th:replace="~{fragments :: panel(job=${job})}"

비슷한 함정이 하나 더 있었다. 인스타 탭에 Mac 에이전트 패널이 같이 떠서 4초마다 에러가 났는데, th:replace가 th:if보다 먼저 실행되기 때문이었다. 조건을 바깥 th:block으로 옮겨서 해결했다.

템플릿 문법이 마이그레이션을 깨뜨렸다

기본 템플릿을 DB 마이그레이션으로 넣었더니 Flyway가 파일을 못 읽었다. 템플릿을 PostgreSQL 달러 인용($tpl$ … $tpl$)으로 감쌌는데, 템플릿이 {24 #666666}으로 시작하니 $tpl${24 …가 됐다. Flyway는 ${…}를 자리표시자로 해석한다. 문자열을 나눠 붙여서 피했다. 내가 만든 작은 문법이 다른 도구의 문법과 부딪힐 수 있다는 걸 배웠다.

에디터는 문서대로 움직이지 않는다

  • 네이버 글쓰기 페이지는 networkidle에 영영 도달하지 않는다. 계속 통신한다. DOMContentLoaded 기준으로 바꿨다.
  • 포스타입은 사진을 넣은 뒤 사진이 선택된 채로 남아서, 다음 붙여넣기가 사진을 덮어썼다. 사진 뒤에 Enter를 한 번 친다.
  • 포스타입은 본문의 주소를 보고 링크 카드를 알아서 만든다. 우리가 만든 카드와 합쳐 두 개가 됐다.
  • 포스타입 태그는 실제로 5개까지만 들어간다. 넣은 뒤 칩이 생겼는지 하나씩 확인한다.
  • 다운로드 링크 카드는 에디터가 링크 정보를 못 불러오면 [확인] 버튼이 비활성으로 남는다. 카드 하나 때문에 작업 전체가 실패하지 않게 건너뛰고 계속한다.

에디터 요소의 위치(셀렉터)는 YAML 파일로 뺐다. 에디터가 바뀌면 빌드 없이 파일만 고치면 된다.

인스타 API가 아무 이유 없이 막혔다

어느 날 게시가 API access blocked로 실패했다. 읽기 요청(/me)까지 전부 막혀 있었다. 대시보드에는 경고가 하나도 없었다. 코드가 아니라 토큰 문제라고 보고 토큰을 새로 발급하니 해결됐다. 이 일 뒤로 토큰 만료일과 연동 상태를 보여 주는 화면을 만들었다.

"안 된다"의 절반은 화면 설명 문제였다

"적용 방법 이미지가 안 들어가요", "인스타 첫 장이 저장이 안 돼요". 둘 다 코드 버그가 아니었다. 파일을 고르기만 하고 [올리기]를 따로 안 눌러서 저장된 적이 없었다. 고르면 바로 저장되게 바꿨다. 작은 화면 문구 하나가 "기능이 고장 났다"는 체감을 만든다.

아낀 시간을 믿을 수 있게 세기

"얼마나 줄었나"를 숫자로 보고 싶었다. 그런데 숫자는 금방 부풀려진다.

앞에서 본 job_event 이력이 재료다.

계산식

  • 게시 하나의 아낀 시간 = 손으로 하던 시간 − 내가 쓴 시간
    • 손으로 하던 시간: 네이버 30분, 포스타입 20분, 인스타 15분 (설정값)
    • 내가 쓴 시간: 네이버·포스타입은 입력이 끝난 뒤 발행을 누르기까지(확인·수정 시간). 20분이 넘으면 자리를 비운 것으로 보고 20분까지만 센다. 인스타는 버튼만 누르니 1분.
  • 한 달 = 게시들의 합 − 상품 등록 시간(5분) × 그 달 상품 수

계산 내역 화면에서 이 공식과 게시마다의 값을 그대로 보여 준다. 숫자는 계산식까지 보여 줘야 믿을 수 있었다.

계산 내역 화면

버린 지표

처음에는 "등록부터 세 곳 다 올리기까지 걸린 시간"을 쟀다. 그런데 오늘 등록하고 내일 올리면 하루가 통째로 들어간다. 기다린 시간이 섞이는 지표라서 버리고, "상품 하나에 든 내 시간"(등록 + 플랫폼마다 내가 쓴 시간)으로 바꿨다.

직접 손으로 올린 글은 "직접 올림"으로 표시해서 완료로는 치되 아낀 시간에서는 뺀다.

결과

상품 2개, 게시 6건을 올린 시점의 숫자다.

항목값
상품 하나에 든 내 시간평균 13분
손으로 할 때 (설정값)65분
포스타입: 입력이 끝난 뒤 발행까지 (실측)23초

상품 하나에 손이 가는 시간이 약 65분에서 약 13분으로 줄었다. 다만 "손으로 하던 시간"은 내가 정한 추정값이고, 시간 기록을 넣기 전 게시 몇 건은 기본값으로 계산했다. 손으로 한 번 실제로 재서 설정값을 맞추는 게 다음 할 일이다.

숫자보다 크게 느낀 건 부담이 줄어든 것이다. "올려야 하는데…"가 "등록하고 발행만 누르면 된다"가 됐다. 세 플랫폼 글이 늘 같은 양식이고, 빠진 이미지나 링크가 있으면 경고가 먼저 알려 준다.

회고

잘한 것

  1. 실제 에디터에 한 번 채워 보는 걸 기본으로 했다. 발행은 하지 않고 채운 화면만 스크린샷으로 확인했다. 이 과정이 버그를 가장 많이 잡았다. 에디터는 문서대로 움직이지 않는다.
  2. 마지막 클릭을 사람에게 남겼다. 1인 운영에는 "전부 자동"보다 "다 해 두고 확인만 사람"이 더 편하고 안전했다.
  3. 상태 기록을 트리거로 했다. 나중에 지표를 바꿔도 이력이 남아 있어서 다시 계산할 수 있었다.
  4. 사람이 기다리는 상태(FILLED)를 따로 뒀다. "자동 처리 중"과 "내 차례"가 구분되니 대시보드와 알림, 시간 계산이 모두 단순해졌다.

아쉬운 것

  1. 시간 기록을 늦게 넣었다. 처음부터 상태 변화를 기록했다면 초기 게시도 추정값이 아니었을 것이다.
  2. 휴대폰 알림을 만들었다가 지웠다. 알림 서버까지 띄웠는데, 생각해 보니 발행하려면 어차피 Mac 앞에 있어야 한다. 앱 하나를 더 깔 이유가 없어서 Mac 알림만 남겼다. 처음에 "알림을 받고 무엇을 하나"부터 물었으면 됐다.
  3. 같은 것을 다른 이름으로 불렀다. 나는 "오브젝트 이미지"라고 했는데 구현은 "인사 이미지"를 따로 만들었다. 둘은 같은 것이었다. 사용자의 단어를 처음부터 그대로 쓰는 게 낫다.

남은 숙제

  • 손으로 한 번 실제로 재서 "손으로 하던 시간" 맞추기
  • 같은 시리즈의 다른 버전은 버전명과 사진만 바꿔 복제하기
  • 네이버 탭에서 실제 블로그와 비슷한 미리보기
  • 에디터가 바뀌면 깨진다. 셀렉터를 YAML로 빼 두긴 했지만, 언젠가는 고쳐야 할 날이 온다.

마치며

이 도구를 만들면서 가장 많이 한 일은 코드 작성이 아니라 실물을 보는 것이었다. 원문 글의 HTML, 에디터 화면, 채워진 결과의 스크린샷, 서버 로그에 쌓인 에러 169개. 추측으로 만든 부분은 실물을 한 번 보면 대부분 틀려 있었다.

그리고 "나만 쓴다"는 한 문장이 생각보다 많은 걸 결정해 줬다. 공개 서비스였다면 계정, 검수, 약관, 서버 비용을 고민해야 했을 것이다. 혼자 쓰는 도구라서 내 작업 흐름에 딱 맞게, 빨리 만들 수 있었다.