GROWTH LOG STORIES

함께 만든 성장을,
기록으로 연결합니다.

AI와 함께 배우고, 직접 만들고, 서로 나눈
그로스로그 멤버들의 생생한 성장 기록을 만나보세요.

성장 기록
5기+이어진 커뮤니티
HEALTHIO
PROJECT

사진 한 장으로 시작하는 건강 루틴, Health IO

대표 이야기 읽기

ALL STORIES

성장의 순간을
한곳에서 만나보세요.

프로젝트의 시작부터 모임에서 나눈 인사이트까지,
그로스로그가 함께 만든 기록을 모았습니다.
개의 성장 기록

성장일지

[GL 3기] 결제는 됐는데 예약이 안 된 돈을 되돌리기, 그리고 초기 서비스에 맞는 검색엔진 고르기 (성장일지 6회차 큐레이션 ②) 🌱

GROWTH LOG🌱 2026. 8. 7. 05:36

안녕하세요! 그로스로그 입니다!😊 🌱

반복되는 CS 문의에서 출발해 자동 환불 구조를 만든 기록, 그리고 "우리 규모엔 이게 과하다"에서 시작한 검색엔진 선택. 3기 6회차 두 번째입니다.

성장일지는 멤버들이 2주에 한 번씩 자신의 성장을 기록하는 활동이에요. 매 회차 운영진이 모든 글을 읽고, 그중 특히 마음에 남은 글을 함께 골라 큐레이션합니다.

앞선 큐레이션에서 실행 컨텍스트와 모니터링을 다뤘는데요, 이번 두 편은 실제 서비스를 운영하다 마주친 문제에서 출발합니다.

 

01

💳 [결제 오류 대응] 나이스페이 망취소 API 도입기

조성호 님

이런 내용이에요
CS 문의가 반복적으로 들어옵니다. "결제는 됐는데 예약이 안 됐고, 환불도 안 된다"는 내용이었어요.
원인은 모바일에서 자주 나는 세션 만료였습니다. 결제사 화면으로 넘어갔다 돌아오는 사이 세션이 끊겨서, 결제는 성공했는데 예약 생성 API가 호출되지 않는 상태가 남았던 거죠.
해결책으로 망취소(자동 결제 취소) 로직을 도입합니다. 스케줄러가 주기적으로 "결제됐는데 예약 없는" 건을 찾아 자동으로 취소해요.

돈이 걸린 문제입니다. 그리고 정상 경로가 아니라 그 바깥에서 생긴 문제예요.

조금 더 들어가 보면

이 문제의 구조부터 보겠습니다. 결제 연동에는 두 시스템이 관여합니다. 우리 서버와 결제 대행사요.

정상 흐름은 이렇습니다. 사용자가 결제창을 열고 → 결제사 화면에서 결제하고 → 우리 서버로 돌아와서 → 예약이 생성됩니다.

문제는 마지막 두 단계 사이에서 생겼습니다. 글의 설명이 정확해요.

"모바일 환경에서는 세션 만료(Session Out) 현상이 자주 발생하여, 결제 대행사 화면으로 이동 후 결제는 정상적으로 이루어졌지만, 당사 서버로는 예약 번호 등의 핵심 데이터가 전달되지 않는 문제가 있었다."

모바일에서 결제창은 대개 외부 앱이나 새 창으로 열립니다. 그 사이 원래 브라우저가 백그라운드로 밀리고, 시간이 지나면 세션이 끊겨요. 돌아왔을 때는 이미 남이 되어 있는 겁니다.

그러면 돈은 빠져나갔는데 우리 시스템엔 아무 기록이 없는 상태가 남습니다. 사용자 입장에서는 결제됐다는 문자만 오고 예약은 없죠. CS가 안 들어올 수 없는 상황입니다.

🧭 "망취소"라는 개념

여기서 나오는 게 망취소(net cancel)입니다. 일반 결제 취소와는 성격이 조금 달라요.

일반 취소는 거래가 정상적으로 끝난 뒤 되돌리는 것입니다. 반면 망취소는 "승인은 났는데 우리 쪽에서 처리를 못 했으니 없던 일로 하자"에 가깝습니다. 통신이 끊기거나 후속 처리가 실패했을 때 쓰는 안전장치예요.

결제 연동에서 이게 왜 필요한가 하면, 두 시스템이 동시에 성공하는 걸 보장할 수 없기 때문입니다. 결제사에서 승인이 나는 순간과 우리 DB에 예약이 저장되는 순간 사이에는 반드시 틈이 있고, 그 틈에서 뭐든 잘못될 수 있어요. 네트워크가 끊기거나, 서버가 재시작되거나, 세션이 만료되거나요.

그래서 "실패했을 때 되돌리는 경로"를 미리 만들어둬야 합니다. 이 글이 한 일이 그겁니다.

📝 원문에서는 이렇게 처리합니다

핵심은 바로 취소하지 않는다는 점입니다.

private static final long SCHEDULE_DELAY_15_MS = 15 * 60 * 1000;

public void scheduleNetCancel(NetCancelRequest request) throws NicePayException {
    ValidCheck.validNullCheck(request.getNetCancelPK());

    scheduler.schedule(() -> {
        try {
            processNetCancel(request);
        } catch (Exception e) {
            log.error("[nice pay] net cancel fail cause : ", e);
        }
    }, new Date(System.currentTimeMillis() + SCHEDULE_DELAY_15_MS));  // 15분 (지정)
}

15분 뒤에 확인합니다. 이 유예 시간이 중요해요.

왜냐하면 늦게라도 예약이 만들어질 수 있기 때문입니다. 사용자가 잠깐 앱을 나갔다가 돌아와서 완료할 수도 있고, 네트워크가 느려서 응답이 늦게 도착할 수도 있죠. 바로 취소해버리면 정상 거래까지 되돌리게 됩니다.

그리고 15분 뒤 실제 판단이 이뤄집니다.

if (VOByPk != null &&
    "결제 실패 또는 특정 결제 상태값".equals(VOByPk.getPaySt()) &&
    StringUtills.isEmpty(VOByPk.get예약값())) {
    // 망취소 진행
} else {
    log.info("[nice pay] 망 취소 필요 없는 PK : {}", request.getNetCancelPK());
}

조건을 두 개 다 만족해야 취소합니다. 결제 상태가 비정상이고, 예약값이 비어 있어야 해요. 하나라도 어긋나면 건드리지 않습니다.

돈을 되돌리는 자동화에서는 이 신중함이 필수입니다. 잘못 취소하면 정상 이용자의 예약이 사라지니까요. "애매하면 아무것도 하지 않는다"가 안전한 기본값입니다.

그리고 위조를 막는 서명이 들어갑니다.

/* 망취소 hex(sha256(인증토큰+결제요청가맹점아이디+결제금액+결제일자+결제요청가맹점키)) */
String plainText = VOByPk.getAuthToken() + "MID" + VOByPk.getAmt()
                 + VOByPk.getEdiDate() + "상점키";
String hashingText = Hashing.encrypt(plainText.getBytes());

상점키가 해시 재료에 들어간다는 게 핵심입니다. 상점키는 우리와 결제사만 아는 값이에요. 그래서 이 서명은 우리만 만들 수 있습니다. 결제 금액을 몰래 바꿔 요청해도 서명이 안 맞아 거절되죠.

한 가지 눈에 띄는 디테일도 있습니다.

headers.setContentType(new MediaType("application", "x-www-form-urlencoded",
                                     Charset.forName("EUC-KR")));

EUC-KR입니다. 요즘 API는 대부분 UTF-8인데, 국내 결제 시스템에는 오래된 인코딩이 남아 있어요. 이걸 안 맞추면 한글이 깨져서 응답이 이상하게 옵니다. 문서를 정확히 읽어야 알 수 있는 부분입니다.

큐레이터 노트

"정상 경로 바깥"을 다룬 글이라 뽑았습니다.

기능을 만들 때 우리는 잘 되는 경우를 먼저 만듭니다. 결제가 되고, 예약이 생기고, 완료 화면이 뜨는 흐름이요. 그런데 실제 운영에서 CS를 만드는 건 그 흐름을 벗어난 경우들입니다.

이 글은 CS 문의가 쌓여서 시작됐습니다. 만들 때는 안 보였던 문제가 사용자가 쓰기 시작하니 드러난 거예요. 그리고 원인을 세션 만료까지 정확히 짚었습니다.

15분 유예를 둔 판단이 특히 좋았어요. 자동화의 어려운 점이 여기 있습니다. 너무 빨리 반응하면 정상 건을 건드리고, 너무 늦으면 사용자가 이미 화가 나 있죠. 적당한 지점을 정하고 그 이유를 갖는 게 설계입니다.

돈을 다루는 기능은 특히 틀렸을 때의 비용이 큽니다. 조건을 두 개 걸고, 애매하면 로그만 남기고 넘어가는 구조가 그 무게를 아는 사람의 코드예요.

원문 읽으러 가기  ↗sunghomong.github.io/posts/service-nicePay_java01/
 

02

🔍 Meilisearch를 이용한 검색엔진

사범기 님

사범기 님 원문에 실린 이미지
이런 내용이에요
검색 기능을 붙이려는데 Elasticsearch는 우리 규모에 과하다는 판단에서 출발합니다.
ES는 안정적으로 돌리려면 최소 t3.medium 이상이 권장되는데, 초기 서비스에 그 비용을 쓰는 게 맞느냐는 거죠.
그래서 경량 검색엔진 Meilisearch를 골라 도커로 띄우고, 데이터를 넣고, 정렬 규칙까지 바꿔봅니다. 설치부터 커스텀 랭킹까지 한 편에 다 있어요.

앞선 글이 "운영 중에 터진 문제"였다면, 이 글은 "운영을 시작하기 전에 하는 선택"입니다.

조금 더 들어가 보면

글의 판단 기준이 명확합니다.

"MAU가 높거나 트래픽이 많은 서비스라면 ES를 사용하는 것이 안정성과 확장성 측면에서 효과적인 선택이 될 수 있습니다. 그러나 비교적 트래픽이 적거나 단일 서버 기반의 경량 서비스를 운영하는 경우에는, ES보다는 Meilisearch와 같은 경량 검색엔진이 더 효율적인 대안이 될 수 있습니다."

ES가 나쁘다는 게 아닙니다. 글도 "완성도 높은 오픈소스"라고 인정해요. 다만 지금 규모에 맞느냐를 묻는 겁니다.

ES가 무거운 이유가 있습니다. 원래 분산 환경에서 대용량 로그와 문서를 다루려고 만들어졌거든요. 클러스터링, 샤딩, 복제 같은 기능이 기본으로 들어 있고, JVM 위에서 돌아 메모리를 많이 씁니다. 문서 1만 건짜리 서비스에 그 구조를 얹으면 대부분이 놀고 있는 셈이죠.

그리고 이게 4회차의 BFF 이야기와 정확히 이어집니다. "클라이언트가 하나뿐이면 BFF는 복잡성만 는다"와 같은 판단이에요. 도구의 성능이 아니라 내 상황과의 궁합을 보는 겁니다.

📝 원문에서는 이렇게 시작합니다

도커 컴포즈 한 조각이면 뜹니다.

version: '3.1'

services:
  ms:
    image: getmeili/meilisearch:v1.14
    ports:
      - 7700:7700
    environment:
      - MEILISEARCH_MASTER_KEY=MASTER_KEY
    command:
      ['meilisearch', '--master-key=MASTERKEY']

이게 전부입니다. ES처럼 힙 크기를 잡고 노드 설정을 맞출 일이 없어요. 3회차의 도커 글과 이어지는 지점이기도 합니다. 컨테이너 하나로 검색엔진이 서는 거죠.

데이터도 JSON을 그대로 밀어 넣습니다.

[
  { "id": 1, "product": "치즈" },
  { "id": 2, "product": "우유" },
  { "id": 3, "product": "라면" }
]
curl --location 'http://localhost:7700/indexes/test/documents?primaryKey=id' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer MASTER_KEY' \
--data-binary 'test.json'

글이 URL 구조를 짚어준 게 친절합니다. indexes 다음은 인덱스 이름, documents 다음은 기본키 지정이라고요. 처음 보면 어디가 뭔지 헷갈리는 부분입니다.

ES와 비교하면 진입 장벽 차이가 분명합니다. ES는 매핑을 미리 정의하고 분석기(analyzer)를 설정해야 원하는 검색이 되는데, Meilisearch는 일단 넣으면 검색이 됩니다. 오타 교정도 기본으로 되고요.

🧭 랭킹 규칙 — 여기가 진짜 시작

이 글에서 눈여겨볼 부분은 ranking-rules를 건드린 대목입니다.

기본값은 id 순서인데, 이걸 product 오름차순으로 바꿔봅니다. 단순해 보이지만 검색엔진을 쓰는 이유가 여기 있어요.

검색은 "찾았다/못 찾았다"가 아니라 "무엇을 먼저 보여줄 것인가"의 문제입니다. 같은 키워드로 100개가 걸렸을 때 순서가 곧 품질이죠. Meilisearch는 이 순서를 규칙 목록으로 조절할 수 있게 해뒀습니다. 오타 개수, 단어 근접도, 속성 중요도 같은 걸 우선순위로 배열하는 방식이에요.

설치보다 이 부분이 진짜 작업입니다. 글도 그걸 알고 있어요. "추후에는 어떤 식으로 더 설정할 수 있는지 학습하여 글을 작성할 수 있도록 하겠습니다."

큐레이터 노트

규모에 맞는 선택을 한 기록이라 뽑았습니다.

기술을 고를 때 흔한 실수가 "큰 회사가 쓰는 걸 따라 쓰는 것"입니다. 그런데 그 회사는 그 규모의 문제를 풀고 있고, 그걸 감당할 인력과 비용이 있어요. 같은 도구가 작은 팀에서는 짐이 됩니다.

이 글은 비용을 근거로 들었습니다. t3.medium이라는 구체적인 인스턴스 사양까지요. "가벼워서 좋다"가 아니라 "우리 트래픽에는 이 비용이 과하다"는 판단이에요.

그리고 RediSearch 같은 다른 대안도 언급했습니다. 하나만 보고 결정한 게 아니라는 뜻이죠. 선택지를 알아본 뒤 고른 겁니다.

글 마지막의 이 문장도 좋았습니다.

"특정 문제나 궁금한 점을 검색하며 찾아보고 작성한 글입니다. 혹시라도 부정확한 정보를 전달드릴 수 있습니다. 틀린 부분이 있으면 댓글을 남겨주세요."

아직 배우는 중이라는 걸 밝히고 공개한 자세입니다. 완벽해질 때까지 안 쓰는 것보다, 지금 아는 만큼 쓰고 고쳐가는 쪽이 기록으로는 훨씬 낫습니다.

원문 읽으러 가기  ↗baam.tistory.com/422

💡 두 편을 겹쳐 읽으며

한 편은 결제 장애 대응, 한 편은 검색엔진 도입입니다. 상황이 다릅니다.

그런데 두 글이 서 있는 자리는 같아요. 둘 다 "서비스를 실제로 굴리는 사람"의 자리입니다.

조성호 님의 글은 CS 문의에서 시작합니다. 코드가 틀린 게 아니었어요. 정상 흐름은 잘 돌았습니다. 다만 사용자가 실제로 쓰는 환경(모바일, 외부 결제창, 끊기는 세션)에서 그 흐름이 깨졌을 뿐입니다.

사범기 님의 글은 비용에서 시작합니다. ES가 나쁘지 않다는 걸 알면서도, 초기 서비스가 감당할 무게인가를 물었어요.

둘 다 "기술적으로 맞는가"보다 "우리 상황에서 맞는가"를 물었습니다.

그리고 두 글이 공유하는 태도가 하나 더 있습니다. 적당한 지점을 정하고 그 이유를 가졌어요.

15분이라는 유예 시간은 절대적인 정답이 아닙니다. 5분이면 정상 건을 취소할 위험이 크고, 1시간이면 사용자가 이미 CS를 넣었겠죠. 그 사이 어딘가를 고른 겁니다. Meilisearch도 마찬가지예요. 트래픽이 커지면 언젠가 ES로 옮겨야 할 수도 있습니다. 지금은 이게 맞다는 판단인 거죠.

개발에는 정답이 없는 결정이 계속 나옵니다. 그때 필요한 건 정답이 아니라 근거를 가진 선택과, 나중에 바꿀 수 있는 여지입니다. 두 글 모두 그걸 갖췄어요.

이번 회차에서 가져갈 것

1. 정상 경로 말고 그 바깥을 설계하기. 결제·예약처럼 두 시스템이 관여하는 흐름에는 반드시 틈이 있습니다. "중간에 끊기면 어떻게 되돌릴 것인가"를 미리 정해두세요.

2. 자동 취소에는 유예 시간을 두기. 늦게라도 완료될 수 있습니다. 바로 취소하면 정상 건까지 되돌려요. 그리고 조건이 애매하면 아무것도 하지 말고 로그만 남기세요.

3. 서명에는 우리만 아는 값을 넣기. 상점키처럼 외부가 모르는 값이 해시 재료에 들어가야 위조를 막습니다. 금액만 해싱하면 소용없어요.

4. 도구는 규모에 맞춰 고르기. ES가 좋은 것과 우리 트래픽에 맞는 것은 다릅니다. 최소 권장 사양과 월 비용을 먼저 확인해보세요.

5. 검색은 "찾기"가 아니라 "순서". 결과가 나오는 것보다 무엇이 위에 오는가가 품질을 정합니다. 랭킹 규칙을 만져보는 게 진짜 작업이에요.


좋은 글 남겨주신 조성호 님, 사범기 님께 감사드립니다! 😊

다음 큐레이션으로 다시 찾아오겠습니다! 🌱

- 에디터 · 성장일지 큐레이터 -


※ 본 큐레이션은 각 저자가 공개한 글을 소개하는 것이며, 모든 원문의 저작권은 저자에게 있습니다. 저자 본인의 요청이 있을 경우 즉시 수정 또는 삭제합니다.

WRITE YOUR GROWTH

오늘의 배움을
내일의 성장으로.

기록은 나를 돌아보게 하고, 공유는 우리를 연결합니다.
당신의 다음 성장 이야기도 그로스로그에서 시작해보세요.

커뮤니티 둘러보기