하늘인편은 이렇게 작동해요

오유찬·2025년 3월 28일
post-thumbnail

이번 글에서는 하늘인편의 작동 방식과, 개발 과정에서 마주한 문제점들을 소개합니다.

하늘인편이란?

하늘인편은 공군 기본군사훈련단(기훈단) 사이트의 인터넷 편지 API를 대신 호출하여, 훈련병에게 편지를 전송해주는 서비스입니다.

구조를 설명하기에 앞서, 먼저 기훈단 API가 어떻게 작동하는지부터 살펴보겠습니다.

기훈단 API 소개

현재는 인터넷편지가 폐지되어서 해당 API는 더 이상 사용되지 않습니다.

기훈단 웹사이트는 훈련병의 이름과 생년월일을 쿼리스트링으로 받아, HTML 문서 내에 훈련병 정보를 포함하여 응답합니다. 예시는 다음과 같습니다:

GET /user/emailPicViewSameMembers.action?siteId=last2&searchName=김공군&searchBirth=20250327

이 HTML 문서에는 훈련병의 식별번호소대번호가 포함되어 있으며, 이는 편지를 전송할 때 반드시 필요한 값입니다. 아래는 응답 HTML 일부 예시입니다:

<li>
  <div class="photo png">
    <img
      src="/user/last2/images/emailPic/815453461/1342.jpg"
      alt="김공군 교육생 사진"
    />
  </div>

  <strong>오유찬</strong>

  <div class="info">
    <dl class="first">
      <dt>소속 </dt>
      <dd> : 신병 2대대 1중대 3소대 418호실 42번</dd>
    </dl>
    <dl>
      <dt>입대날짜</dt>
      <dd>: 2023-08-14</dd>
    </dl>
    <dl>
      <dt>수료예정날짜</dt>
      <dd>: 2023-09-15</dd>
    </dl>
  </div>

  <input
    type="button"
    class="choice"
    value="선택하기"
    onclick="resultSelect('815453461')"
  />
</li>

이 HTML에서 다음 정보를 파싱할 수 있습니다:

  • <dd> 태그 내 소속 정보에서 중대 / 소대 / 번호를 추출해
    "중대번호 + 소대번호 + 번호" 형태의 문자열 (예: 1342)을 생성합니다.

  • onclick 속성 값에서 훈련병 식별번호 (예: 815453461)를 추출합니다.

참고: <img> 태그에도 동일한 값이 포함되어 있지만, 이미지가 없을 수 있어 사용하지 않았습니다.

이제 훈련병의 식별번호를 구했으니, 이를 이용해 편지를 전송할 수 있습니다.

편지 전송 API

편지는 FormData를 사용한 POST 요청으로 전송됩니다.
요청에 포함되는 주요 필드는 다음과 같습니다:

필드명설명
title편지 제목
contents편지 본문
senderName발신자 이름
relationship훈련병과의 관계
password편지 조회용 비밀번호
senderAddr발신자 주소 (현재 날짜 및 시각으로 대체)
senderZipcode발신자 우편번호 ("하늘인편"으로 대체)
memberSeq훈련병 식별번호
sodae훈련병 소대번호

senderAddrsenderZipcode는 원래 훈련병이 답장을 보낼 주소와 우편번호를 의미하는 필드입니다.
하지만 실제로는 훈련병이 편지를 보내는 경우가 드물고, 대부분 주말에 휴대폰으로 연락하기 때문에 이 값들은 활용되지 않습니다.

따라서 이 필드들은 다음과 같은 목적으로 값을 설정했습니다:

  • senderAddr: 편지를 보낸 날짜 및 시각 (기존 편지에는 전송 시각이 빠져 있어 명시적으로 추가)
  • senderZipcode: "하늘인편" (이 편지가 하늘인편을 통해 전송되었음을 구분하기 위해 설정)

이렇게 작성된 편지는 매일 저녁, 조교가 수신 목록을 확인하고 직접 출력하여 훈련병에게 전달됩니다.

편지 발송 기간

기훈단 API는 입대 2주 후부터 훈련병 검색 및 편지 전송이 가능합니다.

따라서 사용자가 2주 이전에 작성한 편지는 즉시 전송하지 않고 DB에 보관한 뒤,
2주가 지난 후 훈련병 조회가 성공하면 대기 중인 편지를 일괄 발송하는 방식으로 처리했습니다.


문제점 발생

멀쩡해 보이는 인편 API에는 실제로 큰 문제점이 존재했습니다.

요청 실패가 간헐적으로 발생하며, 장애가 길게 지속됨

기훈단 서버는 불안정하여 요청 실패가 간헐적으로 발생합니다.
특정 시간대나 조건과 관계없이 발생하며, 최대 24시간 넘게 요청이 전송되지 않는 경우도 있었습니다.

실제로 제가 군 복무 중에도 해당 API를 통해 편지를 받은 적이 있었고,
그 당시에도 편지가 누락되는 문제가 반복적으로 발생한 것을 경험했습니다.

이러한 문제를 방지하기 위해, 편지가 유실되지 않는 구조를 설계했습니다.


재시도 큐 및 전송 실패 대응

  • 요청 실패 시: 편지를 큐에 저장하고,
    cron job을 통해 4시간 간격으로 재시도하도록 구성했습니다.

  • 데이터 저장: 요청 데이터는 휘발성 메모리가 아닌 DB에 저장됩니다.
    서버가 재시작되더라도 유실되지 않도록 하기 위함입니다.

  • 재시도 불필요한 경우 분기 처리:

    • 욕설, XSS, 비속어 등 필터링에 걸리는 경우는 큐에서 제거
    • 훈련병 조회 실패가 7일 이상 지속된 편지는 별도로 저장하고 재시도하지 않음

브라우저에서는 전송이 되지만 서버에서는 안 되는 경우

특정 오류 상황에서는 Node.js 서버에서는 편지가 전송되지 않지만,
브라우저에서는 정상적으로 전송되는 경우도 있었습니다.
(물론 둘 다 안 되는 경우도 존재합니다.)

이러한 상황을 대비해, 서버 장애가 너무 심각하게 길어질 경우를 대비하여
개발자도구에 붙여넣어 실행할 수 있는 전용 스크립트를 따로 만들어두었습니다.

이를 위해 브라우저 전송 작업이 필요한 시점에만
비밀키가 있어야 접근 가능한 임시 편지 조회 API를 잠시 열어두었고,
해당 스크립트를 통해 브라우저 환경에서 직접 편지를 전송할 수 있도록 대응했습니다.

기술 스택 및 구조 설계 소개

이번 글에서는 하늘인편 개발에 사용한 주요 기술 스택과 설계 구조에 대해 소개합니다.


프론트엔드

  • Framework: Next.js (App Router)
  • 상태 관리: zustand, react-query
  • CSS: Tailwind CSS

프론트엔드 개발에는 Next.js를 사용했습니다.
프론트와 API 서버를 한 프로젝트 안에서 통합 운영할 수 있어 초기 개발 속도에 유리했고, SSR/SSG도 자연스럽게 적용할 수 있는 구조였습니다.

상태 관리는 zustand와 react-query를 조합해 사용했고, 스타일은 Tailwind CSS를 통해 구성했습니다.

최적화를 위해 불필요한 리소스를 제거하고, 일부 웹폰트는 서브셋팅해 사용했습니다.


백엔드

백엔드도 Next.js를 기반으로 구성했습니다.
사지방 환경의 제약상 Spring 같은 프레임워크를 쓰기 어려웠고,
프론트엔드와 백엔드 서버를 분리해 운영하기에도 부담이 있어 Next.js의 API Route와 Server Action을 활용했습니다.

서버 구조는 Nest.js나 Spring처럼 controller / service / repository 구조로 나누어 관리했으며,
테스트용과 배포용 인스턴스를 구분해 의존성 주입도 구성할 수 있도록 설계했습니다.

Instrumentation

서버가 처음 시작되면 instrumentation.ts 파일의 코드가 먼저 실행됩니다.

편지를 재시도하는 cron job은 서버 시작과 동시에 실행되어야 했고,
관리자 페이지에서 중간에 시작하거나 중지할 수 있어야 했습니다.

하지만 instrumentation.ts에서 cron job을 직접 실행할 경우,
관리자 페이지에서 해당 로직에 접근하거나 제어할 수 없었습니다.
이에 따라 일정 시간 뒤에 cron job 시작 API를 호출하도록 구성했습니다.

또한, REST API와 Server Action은 서로 다른 런타임에서 작동하기 때문에,
관리자 페이지에서의 cron job 제어도 REST API로 구성했습니다.

결과적으로, instrumentation.ts에서는 일정 시간 지연 후
REST API 형태로 만든 cron job 시작 API를 호출하고,
해당 API 내부에서 실제 cron job을 실행하도록 구성했습니다.

참고로, 이 REST API는 편지 재시도용으로만 사용되며,
일반적인 서버 요청 로직은 모두 Server Action 기반으로 구성되어 있습니다.

Action Response 라이브러리

Server Action을 REST API처럼 편리하게 사용할 수 있도록
Action Response라는 자체 라이브러리를 개발해 사용했습니다.

→ GitHub: Action Response

이 방식은 타입 추론이 자동으로 되기 때문에 문법 안정성이 높고,
Server Action은 외부에서 직접 호출하기 어려운 구조라 보안 측면에서도 유리합니다.

기타

  • DB: PostgreSQL (Prisma ORM)
  • 로그: winston (클라우드 내 로컬 파일에 저장)

개발 환경에서는 Replit을 사용했고, PostgreSQL을 바로 붙일 수 있어 빠른 개발이 가능했습니다.


클라우드 및 배포

  • 서버: AWS EC2 t2.micro
  • DB: RDS → 비용 절감 위해 EC2 Docker 기반 PostgreSQL로 전환
  • CI/CD: GitHub Actions
  • HTTPS & Fallback: nginx

최초에는 RDS를 사용했지만, 프리티어 기간 종료 후 EC2 내부에서 PostgreSQL을 Docker로 띄워 운영비를 절감했습니다.
데이터는 EC2 내부 파일에 백업하고 있습니다.

nginx는 HTTPS 인증서 설정과, 서버 업데이트 시 fallback 화면을 띄우는 용도로 사용했습니다.
GitHub Actions는 main 브랜치 push 시 자동 배포가 트리거되도록 구성했습니다.

2024년 8월엔 구글 로그인과 S3를 통한 사진 업로드도 추가하였습니다.


위와 같은 구조로 2023년 11월 17일에 개발을 시작해 2024년 2월 5일에 배포하였고,
지금까지 계속 유지보수를 진행하고 있습니다.

이상으로 하늘인편의 전체 구조에 대한 소개를 마칩니다!

profile
대학생 개발자

0개의 댓글