티스토리 뷰

반응형

✏️ 배경

학교 축제 부스에서 사용할 앱을 만들었다. 핵심 기능은 쿠폰 구매 → 프로필 조회 흐름인데, 유저가 돈을 입금하면 쿠폰이 자동으로 충전되는 구조가 필요했다.

처음엔 단순하게 생각했다. "입금 확인 → 쿠폰 충전"이라는 흐름 자체는 간단해 보였으니까. 문제는 그걸 자동으로 만들어야 한다는 거였다. 부스 운영 중에 매번 핸드폰을 들여다보며 수동으로 확인하고 충전할 수는 없지 않은가. 운영자도 사람인지라 부스를 즐겨야 한다.

결제 자동화를 어떻게 구현할지 고민하다가 꽤 우여곡절이 있었다. 코드까지 다 짜놓고 반려를 맞기도 했고, 대안을 찾다가 이런 앱이 있었나 싶은 것도 발견했다. 그 과정을 기록으로 남긴다.

✏️ 1단계: Portone으로 정석대로 해보자

가장 먼저 떠오른 건 당연히 PG사 연동이었다. 국내에서 많이 쓰는 Portone(포트원) 을 선택했다. 개발자라면 한 번쯤 들어봤을 그 서비스다.

사업자 등록 + API 키 발급

Portone을 실사용하려면 사업자 등록증이 필요하다. 테스트 모드는 바로 쓸 수 있지만, 실결제는 심사를 받아야 하기 때문이다. 사업자 등록을 마치고 테스트용 API 시크릿 키를 발급받아서 코드를 작성하기 시작했다.

PortOne 클라이언트 구현

결제 검증의 핵심은 프론트에서 결제가 완료된 후 서버에서 Portone API를 직접 호출해 실제로 결제됐는지 확인하는 것이다. 프론트가 보내주는 paymentId만 믿으면 위변조가 가능하기 때문이다. 클라이언트 단은 얼마든지 조작이 가능하다는 걸 항상 전제해야 한다.

// PortOneClient.java
@Component
public class PortOneClient {

    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);

    private final WebClient webClient;
    private final PortOneProperties portOneProperties;

    public PortOnePaymentResponse getPayment(String paymentId) {
        try {
            PortOnePaymentResponse response = webClient.get()
                    .uri("/payments/{paymentId}", paymentId)
                    .header("Authorization", "PortOne " + portOneProperties.apiSecret())
                    .retrieve()
                    .onStatus(
                            HttpStatusCode::isError,
                            r -> r.bodyToMono(String.class)
                                    .flatMap(body -> handleError(r.statusCode(), body))
                    )
                    .bodyToMono(PortOnePaymentResponse.class)
                    .block(REQUEST_TIMEOUT);

            if (response == null || response.id() == null) {
                throw new GeneralException(ErrorStatus.PORTONE_REQUEST_FAILED);
            }
            return response;
        } catch (WebClientResponseException e) {
            log.error("[PortOne] 결제 조회 실패 status={}", e.getStatusCode());
            throw new GeneralException(ErrorStatus.PORTONE_REQUEST_FAILED);
        }
    }
}

결제 검증 서비스

프론트가 결제를 완료하면 서버에 paymentId를 보내고, 서버는 Portone API를 호출해 결제 상태(PAID) 와 금액이 일치하는지 검증한 뒤 쿠폰을 충전하는 구조다.

여기서 한 가지 주의할 점이 있다. 외부 API 호출은 트랜잭션 밖에서 처리해야 한다. Portone 서버가 느리거나 타임아웃이 발생했을 때, DB 트랜잭션이 계속 열려 있으면 커넥션 풀이 고갈될 수 있기 때문이다.

// 외부 API 호출과 검증은 트랜잭션 밖에서 수행하고, DB 반영은 PaymentWriter에 위임합니다.
public PaymentVerifyResponse verifyPayment(Long userId, String paymentId, PaymentVerifyRequest request) {
    CouponProduct couponProduct = request.couponProduct();

    // 1. DB에서 기존 결제 확인 — 이미 PAID면 PortOne 호출 없이 멱등 성공으로 반환합니다.
    Optional<Payment> existingPayment = paymentRepository.findByPaymentIdAndUserId(paymentId, userId);
    if (existingPayment.isPresent() && existingPayment.get().getStatus() == PaymentStatus.PAID) {
        Payment payment = existingPayment.get();
        int remainingCouponCount = userRepository.findById(userId)
                .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND))
                .getRemainingCouponCount();
        return new PaymentVerifyResponse(
                payment.getPaymentId(),
                payment.getStatus().name(),
                payment.getPaidAmount(),
                0,
                remainingCouponCount
        );
    }

    // 2. PortOne 외부 API 호출 + 순수 검증 (트랜잭션 밖)
    PortOnePaymentResponse portOnePayment = portOneClient.getPayment(paymentId);
    validatePayment(portOnePayment, couponProduct);

    // 3. DB 저장 + 쿠폰 충전 (트랜잭션 안)
    return paymentWriter.completePayment(userId, paymentId, couponProduct, portOnePayment);
}

// PortOne 응답의 결제 상태와 금액을 검증합니다.
private void validatePayment(PortOnePaymentResponse payment, CouponProduct couponProduct) {
    if (!PAID_STATUS.equals(payment.status())) {
        throw new GeneralException(ErrorStatus.PAYMENT_STATUS_INVALID);
    }
    Long actualAmount = payment.amount() != null ? payment.amount().total() : null;
    if (!couponProduct.getPrice().equals(actualAmount)) {
        throw new GeneralException(ErrorStatus.PAYMENT_AMOUNT_MISMATCH);
    }
}

결제창 HTML 생성

결제창은 Portone Browser SDK를 이용해 서버에서 HTML을 생성해서 내려주는 방식으로 구성했다.

// PortOne Browser SDK를 로드하자마자 결제창을 여는 최소 HTML을 생성합니다.
@Transactional
public String buildCheckoutHtml(Long userId, CouponProduct couponProduct) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
    if (user.getPhoneNumber() == null || user.getPhoneNumber().isBlank()) {
        throw new GeneralException(ErrorStatus.PAYMENT_PHONE_NUMBER_REQUIRED);
    }

    String paymentId = generatePaymentId(userId);
    paymentRepository.save(Payment.ready(paymentId, user, couponProduct));

    String storeId = jsString(portOneProperties.storeId());
    String channelKey = jsString(portOneProperties.channelKey());
    String redirectUrl = jsString(portOneProperties.redirectUrl());
    String safePaymentId = jsString(paymentId);
    String orderName = jsString(couponProduct.getOrderName());
    String productCode = jsString(couponProduct.name());
    String customerName = jsString(user.getName() != null ? user.getName() : "테스트 유저");
    String customerEmail = jsString(user.getEmail() != null ? user.getEmail() : "test@example.com");
    String customerPhoneNumber = jsString(user.getPhoneNumber());

    return """
            <!doctype html>
            <html lang="ko">
            <head>
              <meta charset="UTF-8" />
              <meta name="viewport" content="width=device-width, initial-scale=1.0" />
              <title>SSUPICK 쿠폰 결제</title>
              <script src="https://cdn.portone.io/v2/browser-sdk.js"></script>
            </head>
            <body>
              <noscript>결제를 진행하려면 JavaScript를 활성화해야 합니다.</noscript>
              <script>
                const paymentId = "%s";
                const couponProduct = "%s";

                window.addEventListener("load", async () => {
                  const response = await PortOne.requestPayment({
                    storeId: "%s",
                    channelKey: "%s",
                    paymentId,
                    orderName: "%s",
                    totalAmount: %d,
                    currency: "KRW",
                    payMethod: "CARD",
                    redirectUrl: "%s",
                    customer: {
                      fullName: "%s",
                      email: "%s",
                      phoneNumber: "%s"
                    }
                  });

                  console.log("paymentId:", paymentId);
                  console.log("couponProduct:", couponProduct);
                  console.log("response:", response);
                });
              </script>
            </body>
            </html>
            """.formatted(
            safePaymentId,
            productCode,
            storeId,
            channelKey,
            orderName,
            couponProduct.getPrice(),
            redirectUrl,
            customerName,
            customerEmail,
            customerPhoneNumber
    );
}

 

쿠폰 상품은 세 가지 티어로 구성했다.

public enum CouponProduct {
    COUPON_1(1, 1000L, "프로필 조회 쿠폰 1개"),
    COUPON_4(4, 3000L, "프로필 조회 쿠폰 4개"),
    COUPON_8(8, 5000L, "프로필 조회 쿠폰 8개");
}

 

심사 반려

코드까지 다 짜놓고 실결제 심사를 넣었더니 반려가 왔다.
"해당 서비스는 만남주선 서비스로 분류되어 결제 서비스 제공이 불가합니다."

학교 축제 부스앱이었을 뿐인데... 프로필을 보고 매칭하는 구조가 문제였던 것 같다. Portone 입장에서는 납득이 가는 결정이지만, 이미 코드까지 다 짜둔 상황에서 받은 반려라 꽤 허탈했다. 코드는 멀쩡한데 쓸 수가 없는 상황이 된 것이다.

✏️ 2단계: 대안 찾기 - 카카오뱅크 알림으로 결제 감지?

PG사 연동이 막히니 다른 방법을 찾아야 했다. 여러 방법을 뒤지다가 흥미로운 아이디어를 발견했다.

안드로이드에서는 카카오뱅크 입금 알림(푸시 알림)에 접근성 서비스(Accessibility Service) 를 통해 접근할 수 있다. 즉, 알림이 뜨는 순간 그 내용(입금자명, 금액)을 파싱해서 서버로 보내주는 로직을 구현할 수 있다는 것이다.

이 방식을 사용하면 PG사 없이도 계좌이체로 결제 감지가 가능하다. 직접 안드로이드 앱을 만들어서 서버에 웹훅으로 쏴주는 구조를 상상하며 코드를 설계하기 시작했다.

카카오뱅크 앱 입금 알림
    → 안드로이드 Accessibility Service 감지
    → 우리 서버 /api/webhook/bank 로 POST
    → 쿠폰 자동 충전

서버 쪽 웹훅 수신 엔드포인트는 이렇게 만들었다.
Bank-Webhook-Secret 헤더로 인증하고, BankDepositWebhookRequest에는 입금자명, 금액, 입금 시각 등을 담는 구조다. 외부에서 임의로 호출하는 걸 막기 위해 시크릿 헤더 검증을 넣은 것이다.

구조 자체는 그럴듯했다. 그런데 직접 안드로이드 앱을 만들려고 본격적으로 파다 보니...

// BankWebhookController.java
@RestController
@RequestMapping("/api/webhook/bank")
public class BankWebhookController {

    @PostMapping
    public ResponseEntity<ApiResponse<BankDepositWebhookResponse>> receive(
            @RequestHeader("Bank-Webhook-Secret") String webhookSecret,
            @Valid @RequestBody BankDepositWebhookRequest request
    ) {
        BankDepositWebhookResponse response = bankDepositService.receive(webhookSecret, request);
        return ApiResponse.success(SuccessStatus.BANK_DEPOSIT_WEBHOOK_SUCCESS, response);
    }
}

 

✏️ 3단계: 직접 만들 필요도 없었다 - RT PAY 발견

이미 이 기능을 해주는 앱이 있었다.

RT PAY — 카카오뱅크 알림을 감지해서 지정한 서버 URL로 결제 내역을 전송해주는 안드로이드 앱이다. 앱에서 서버 URL만 등록해두면 입금 알림이 올 때마다 자동으로 쏴준다. 개인이 공개한 도구인데, 이런 게 존재한다는 것 자체가 꽤 신선했다.

RT PAY는 자체적인 검증 프로토콜이 있다. 단순히 "입금됐어요"를 쏴주는 게 아니라, 우리 서버가 그 내역을 RT PAY 검증 서버에 다시 확인하는 2단계 구조다. 중간에 검증 서버를 한 번 더 거치기 때문에, 누군가 우리 서버에 임의로 POST 요청을 날려서 쿠폰을 먹으려는 시도를 막을 수 있다.

1. RT PAY 앱이 우리 서버의 /checkpay.jsp로 입금 내역을 보낸다
2. 우리 서버는 이를 받아서 RT PAY 검증 서버(rtpay.net/CheckPay/checkpay.php)에 다시 확인 요청을 보낸다
3. 검증이 통과되면 쿠폰을 충전하고 "OK"를 응답한다

 

RT PAY 클라이언트

ugrd 파라미터로 테스트/실서버를 분기하는 구조다. 개발 중엔 테스트 URL로, 실서비스 때는 실서버로 자동으로 바라보게 해뒀다.

// RtPayClient.java
@Component
public class RtPayClient {

    private static final String CHECK_PAY_URL = "https://rtpay.net/CheckPay/checkpay.php";
    private static final String TEST_CHECK_PAY_URL = "https://rtpay.net/CheckPay/test_checkpay.php";

    public Map<string, object=""> checkPay(Map<string, string=""> parameters) {
        String targetUrl = resolveCheckPayUrl(parameters);
        return postForm(targetUrl, toFormData(parameters));
    }

    private String resolveCheckPayUrl(Map<string, string=""> parameters) {
        String ugrd = parameters.get("ugrd");
        // ugrd 11, 12는 테스트 모드
        if ("11".equals(ugrd) || "12".equals(ugrd)) {
            return TEST_CHECK_PAY_URL;
        }
        return CHECK_PAY_URL;
    }
}
</string,></string,></string,>

 

RT PAY 서비스

중복 처리 방지를 위해 은행명 + 입금자명 + 금액 + 메모를 조합해 SHA-256 해시로 고유 이벤트 키를 만드는 게 포인트다. 같은 입금 이벤트가 네트워크 이슈 등으로 두 번 전달돼도, 이미 처리된 이벤트 키면 스킵한다. 멱등성을 보장하는 구조다.

컨트롤러는 RT PAY가 기대하는 /checkpay.jsp 경로를 그대로 사용한다. Spring에서 .jsp 경로도 @RequestMapping으로 잡을 수 있다.

// RtPayService.java
@Service
public class RtPayService {

    public RtPayCheckResponse check(Map<String, String> parameters, String requestUrl) {
        if (parameters.isEmpty()) {
            // 파라미터가 없으면 최초 URL 등록 요청으로 처리
            rtPayClient.setPath(requestUrl);
            return new RtPayCheckResponse("400", "NO");
        }

        // 등록된 키로 인증 검사
        if (!isValidKey(parameters.get("regPkey"))) {
            return new RtPayCheckResponse("400", "NO");
        }

        // RT PAY 검증 서버에 재확인
        Map<String, Object> rtPayResponse = rtPayClient.checkPay(parameters);
        String rcode = asString(rtPayResponse.get("RCODE"));
        if (!"200".equals(rcode)) {
            return new RtPayCheckResponse(rcode, "NO");
        }

        // 검증 통과 → 입금 처리
        BankDepositWebhookResponse response = bankDepositService.receiveVerified(new BankDepositWebhookRequest(
                buildEventKey(rtPayResponse),       // SHA-256 중복 방지 키
                asString(rtPayResponse.get("RNAME")),   // 입금자명
                parseAmount(rtPayResponse.get("RPAY")), // 입금액
                LocalDateTime.now(),
                asString(rtPayResponse.get("RTEXT"))    // 메모
        ));

        String pchk = "PROCESSED".equals(response.status()) ? "OK" : "NO";
        return new RtPayCheckResponse("200", pchk);
    }

    // 같은 입금 이벤트가 중복 처리되지 않도록 SHA-256으로 고유 키를 생성
    private String buildEventKey(Map<String, Object> rtPayResponse) {
        String raw = asString(rtPayResponse.get("RBANK")) + "|"
                + asString(rtPayResponse.get("RNAME")) + "|"
                + asString(rtPayResponse.get("RPAY")) + "|"
                + asString(rtPayResponse.get("RTEXT"));
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw.getBytes());
        return "rtpay-" + HexFormat.of().formatHex(digest);
    }
}
// RtPayController.java
@RestController
public class RtPayController {

    @RequestMapping("/checkpay.jsp")
    public ResponseEntity<RtPayCheckResponse> check(HttpServletRequest request) {
        RtPayCheckResponse response = rtPayService.check(
            extractParameters(request),
            request.getRequestURL().toString()
        );
        return ResponseEntity.ok(response);
    }
}

 

✏️ 4단계: 입금자 자동 매칭 + 쿠폰 충전 + 디스코드 알림

이제 입금이 감지됐을 때 실제로 누구에게 충전할지를 결정해야 한다. 계좌이체는 카드 결제와 달리 "누가 결제했는지"를 시스템이 자동으로 알 수 없다. 입금자가 직접 이름을 적어서 보내는 방식이기 때문이다.

입금자 자동 매칭

입금자명이 앱에 등록된 닉네임과 일치하면 자동으로 쿠폰을 충전한다. 일치하는 유저가 없거나 여러 명이면 ADMIN_REQUIRED 상태로 남겨두고 관리자가 수동 처리한다. 완전한 자동화가 불가능한 케이스를 위한 안전망이다.
유저 검색 시 공백과 대소문자를 무시(normalizeName)하는 이유는 입금자명을 사람이 직접 적다 보면 오탈자나 띄어쓰기 차이가 생기는 경우가 많기 때문이다. 어느 정도 유연하게 매칭하는 게 현실적이다.

private BankDepositWebhookResponse processNewEvent(BankDepositWebhookRequest request) {
        CouponProduct couponProduct = resolveCouponProduct(request.amount());
        BankDepositEvent event = BankDepositEvent.pending(
                request.eventKey(),
                BANK_NAME,
                request.depositorName(),
                request.amount(),
                request.depositedAt(),
                request.rawText(),
                couponProduct
        );
        BankDepositEvent savedEvent = bankDepositEventRepository.save(event);

        List<User> matchedUsers = userRepository.findDepositNicknameMatches(normalizeName(request.depositorName()));
        if (couponProduct != null && matchedUsers.size() == 1) {
            chargeCoupon(savedEvent, matchedUsers.get(0), couponProduct, "BANK_TRANSFER_AUTO_MATCH");
        } else {
            savedEvent.markAdminRequired();
        }

        return new BankDepositWebhookResponse(savedEvent.getId(), savedEvent.getStatus().name(), false);
    }

 

쿠폰 충전 후 디스코드 웹훅 알림

충전이 성공하면 디스코드 채널로 알림이 날아온다. 부스 운영 중에 폰을 계속 보지 않아도 충전 현황을 실시간으로 확인할 수 있다. Discord Embed를 활용하면 꽤 예쁘게 꾸밀 수 있다. Discord Embed로 보내면 입금자명, 금액, 충전된 쿠폰 수, 유저 정보가 카드 형태로 뜬다. 축제 운영 중에 디스코드 알림이 울릴 때마다 "충전됐다!" 하는 느낌이 꽤 좋았다. discord 알림오는 형식을 조금 더 꾸미고자도 생각을 해보았는데 불필요한 리소스라고 생각해서 약간의 밤티같지만 저 상태로 두기로 결정했다.

// BankDepositService.java — chargeCoupon
private void chargeCoupon(BankDepositEvent event, User user, CouponProduct couponProduct, String memo) {
    event.markAutoMatched(user);
    user.increaseCouponCount(couponProduct.getCouponCount()); // 쿠폰 충전

    adminCouponAdjustmentRepository.save(AdminCouponAdjustment.create(/* 이력 기록 */));
    event.markProcessed(user, couponProduct);

    discordNotificationService.notifyCouponCharged(
        event.getDepositorName(),
        event.getAmount(),
        couponProduct.getCouponCount(),
        user
    );
}

// DiscordNotificationService.java
public void notifyCouponCharged(String depositorName, Long amount, int chargedCouponCount, User user) {
    webClient.post()
            .uri(discordProperties.couponWebhookUrl())
            .bodyValue(Map.of(
                "content", "쿠폰 충전이 완료되었습니다.",
                "embeds", List.of(Map.of(
                    "title", "쿠폰 충전 완료",
                    "color", 0x4CAF50,
                    "fields", List.of(
                        field("입금자", depositorName, true),
                        field("입금액", amount + "원", true),
                        field("충전 쿠폰 수", chargedCouponCount + "개", true),
                        field("닉네임", user.getNickname(), true),
                        field("현재 쿠폰 수", user.getRemainingCouponCount() + "개", true)
                    )
                ))
            ))
            .retrieve()
            .toBodilessEntity()
            .block();
}

 

 

전체 흐름 정리

[Portone 시도]
사업자 등록 → 테스트 API 키 발급 → 코드 작성 → 심사 → 반려 (만남주선 판정)
                                                              ↓
[대안 탐색]
안드로이드 카카오뱅크 알림 감지 가능하다는 발견
                                                              ↓
[RT PAY 발견]
안드로이드에 RT PAY 앱 설치 → 서버 URL 등록
                                                              ↓
[최종 흐름]
카카오뱅크 입금
    → RT PAY 앱 알림 감지
    → 우리 서버 /checkpay.jsp 로 전송
    → RT PAY 검증 서버(rtpay.net)에 재확인
    → 입금자 닉네임으로 유저 자동 매칭
    → 쿠폰 충전
    → 디스코드 웹훅 알림 전송

 

마치며

솔직히 처음부터 RT PAY로 갔으면 훨씬 빨리 됐겠지만, Portone을 먼저 시도한 게 나쁘진 않았다. 결제 검증 로직, 멱등성 처리, 외부 API 분리 같은 개념들을 직접 코드로 구현해보면서 익혔고, 그 경험이 RT PAY 연동에도 자연스럽게 녹아들었다.

PG사 심사를 통과하는 게 가장 깔끔한 방법이었겠지만, 이 경험 덕분에 계좌이체 기반의 대안적인 결제 자동화 방식을 배울 수 있었다. RT PAY는 상용 서비스가 아니라 개인이 공개한 도구인데, 이런 게 있다는 것 자체가 흥미로웠다.

결국 축제 당일에는 입금 → 자동 쿠폰 충전 → 디스코드 알림 파이프라인이 실제로 동작했고, 부스 운영 중에 손을 거의 대지 않아도 됐다. 처음 목표했던 결제 자동화를 어떻게든 달성한 셈이다. 그리고 생각보다 유저를 많이 모은 것 같아서 좋은 경험이었던 것 같다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함