사이트픽 블로그
← 목록으로

개발일지 2026-07-14

피아노 학원 플랫폼 개발일지 (2026-07-14)

개요

학생 앱의 수강권 결제 플로우(KSNET PG 연동) 를 화면 캡처에 맞춰 2단계로 재구성하고, 결제 과정에서 나온 실전 버그 4종을 순차적으로 잡았다. 주요 이슈는 ① KSNET 필수 파라미터(주문자명) 누락, ② 결제 내역이 엉뚱한 테이블에서 조회됨, ③ 결제 콜백 CORS 차단, ④ 결제 완료 후 검은 오버레이 잔존, ⑤ 결제 내역에 미완료 PENDING 이 잔뜩 쌓여 보이는 문제였다.


수정/생성 파일 목록

프론트 (student)

  • student/src/pages/TicketPurchasePage.tsx (수정) — 결제 1단계(수강권 선택)로 단순화, 결제 화면으로 이동만.
  • student/src/pages/TicketCheckoutPage.tsx (신규) — 결제 2단계(수단 선택·약관·결제 버튼). KSNET 결제창 호출, 주문자 정보 채움, 완료 후 오버레이 정리.
  • student/src/App.tsx (수정) — /ticket-checkout 라우트 추가.
  • student/src/pages/MyPaymentsPage.tsx (수정) — 결제 내역 소스를 payments → ticket_orders(getMyOrders)로 교체.

백엔드 (piano-backend)

  • config/SecurityConfig.java (수정) — 수강권 결제 콜백 경로 /api/v1/public/ticket/payment/reply 를 PG 콜백용 CORS(모든 origin 허용, credentials off)로 등록.
  • mapper/TicketOrderMapper.xml (수정) — 학생 주문 조회에서 카드 미완료(PENDING) 주문 제외.

파일별 상세

1. 결제 플로우 2단계 분리 (TicketPurchasePage → TicketCheckoutPage)

원래 "수강권 결제하기" 버튼이 곧바로 결제로 이어지는 구조였는데, 사용자가 보내준 캡처는 ① 수강권 선택 → ② 결제수단 선택·최종 결제 의 2단계였다. 그래서 1단계 화면은 선택만 하고 sessionStorage 로 대상을 넘긴 뒤 2단계로 라우팅하도록 바꿨다.

// TicketPurchasePage.tsx — 1단계: 선택 후 결제 화면으로 이동
const goCheckout = () => {
  if (!selected) return;
  sessionStorage.setItem('checkoutTarget', JSON.stringify({
    id: selected.id,
    name: selected.name,
    amount: selected.effectivePrice ?? selected.price ?? 0,
    perSessionPrice: selected.perSessionPrice ?? null,
  }));
  navigate('/ticket-checkout');
};

2단계(TicketCheckoutPage)는 주문 내역 요약 + 결제 수단(카드/계좌이체) 라디오 + 약관 동의 + 하단 결제 버튼으로 구성했다. 카드 선택 시 KSNET 결제창을 띄우고, 계좌이체는 입금자명을 받아 요청을 접수한다.

// App.tsx
<Route path="/ticket-checkout" element={<TicketCheckoutPage />} />

2. KSNET 필수 파라미터 — 주문자명 누락 (sndOrdername 오류)

첫 실결제 시도에서 KSNET 이 주문자명오류 sndOrdername() 을 뱉었다. KSNET 은 sndOrdername(주문자명)을 필수로 요구하는데 빈 문자열로 넘겼기 때문이다. useAuth() 로 로그인 학생 정보를 가져와 이름/휴대폰(숫자만)/이메일을 채웠고, 정보가 없으면 결제를 막는 방어 로직을 넣었다.

if (!user) { setToast('로그인 정보를 불러오는 중입니다...'); return; }
const orderName = (user.name ?? '').trim();
if (!orderName) { setToast('주문자 정보(이름)가 없어 결제를 진행할 수 없습니다.'); return; }
const orderMobile = (user.phone ?? '').replace(/[^0-9]/g, '');
const orderEmail = (user.email ?? '').trim();

const fields: Record<string, string> = {
  sndStoreid: prep.storeId,
  sndOrdernumber: prep.orderNo,
  sndGoodname: prep.goodName,
  sndAmount: String(prep.amount),
  sndOrdername: orderName,   // ← 필수! 학생명
  sndEmail: orderEmail,
  sndMobile: orderMobile,
  sndReply: prep.replyUrl,
  // ...
};

3. 결제 내역이 엉뚱한 테이블에서 조회됨

"결제 완료하면 히스토리 쌓아야지?" 확인 결과, 결제 이력은 이미 ticket_orders 테이블에 잘 저장되고 있었다. 문제는 MyPaymentsPage 가 payments(원장 수기 입력용) 테이블을 조회하고 있어서 학생이 결제한 내역이 안 보였던 것. 조회 소스를 getMyOrders()(→ ticket_orders)로 바꿨다.

// MyPaymentsPage.tsx
import { getMyOrders, TicketOrderItem } from '../api/studentTickets';
const METHOD_LABEL = { CARD: '카드', TRANSFER: '계좌이체' };
const STATUS_LABEL = { PAID: '결제완료', PENDING: '입금대기', FAILED: '결제실패', CANCELLED: '취소' };
// getMyOrders().then(setOrders) 로 ticket_orders 를 최신순 표시

4. 결제 콜백 CORS 차단 (Invalid CORS request)

카드 결제 완료 후 KSNET 결제창(kspay.ksnet.to)이 우리 서버 /api/v1/public/ticket/payment/reply 로 cross-origin POST 를 보내는데 Invalid CORS request 로 막혔다. 원인은 CORS 설정에 학원 가입비 콜백 경로만 등록돼 있고 수강권 콜백 경로는 빠져 있어서, 기본 /api/** 정책(우리 SPA origin 만 허용)에 걸린 것.

// SecurityConfig.java — corsConfigurationSource()
// 외부 PG(KSNET) 콜백 전용: 모든 origin 허용, 인증쿠키 안 쓰므로 credentials=false
CorsConfiguration pgCallback = new CorsConfiguration();
pgCallback.setAllowedOriginPatterns(List.of("*"));
pgCallback.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
pgCallback.setAllowCredentials(false);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/v1/public/academy-signup/payment/reply", pgCallback);
source.registerCorsConfiguration("/api/v1/public/ticket/payment/reply", pgCallback); // ← 추가
source.registerCorsConfiguration("/api/**", config);

배포 후 kspay origin 으로 OPTIONS preflight 를 날려 200 을 확인했다.

5. 결제 완료 후 검은 오버레이 잔존

결제가 끝나고 "수강권이 발급되었습니다"가 떠도 화면에 검은 배경(DIM) 이 남았다. KSNET 결제창이 성공 시 부모창에 postMessage('KSNET_DONE') 만 보내고 자기 iframe·딤 레이어는 걷어가지 않아서다. KSNET_DONE 수신 시 잔여 DOM 을 넓게 잡아 제거하는 cleanupKsnetLayers() 를 추가했다.

function cleanupKsnetLayers() {
  const selectors = [
    '#KSPayIframe', '#KSPayWebForm', '[id^="KSPay"]', '[id^="kspay"]',
    'iframe[src*="kspay.ksnet.to"]', 'iframe[name="KSPayWebForm"]', /* ... */
  ];
  for (const sel of selectors) document.querySelectorAll(sel).forEach((el) => el.remove());
  document.body.style.overflow = '';
  document.documentElement.style.overflow = '';
  // 화면 전체를 덮는 fixed·고z-index·빈 오버레이 div 제거
  document.querySelectorAll<HTMLElement>('body > div').forEach((el) => {
    const s = getComputedStyle(el);
    const isFullDim = s.position === 'fixed' && parseInt(s.zIndex || '0', 10) >= 1000
      && el.offsetWidth >= innerWidth * 0.9 && el.offsetHeight >= innerHeight * 0.9
      && el.childElementCount === 0;
    if (isFullDim) el.remove();
  });
}

6. 결제 내역에 미완료 PENDING 이 잔뜩 쌓여 보임

"한 번만 결제했는데 내역이 왜 이렇게 많아?" — 조사 결과, 백엔드 prepareCardPayment 가 카드 결제창 진입 때마다 ticket_orders 에 PENDING 을 새로 INSERT 한다. 결제창을 열었다 취소/이탈하면 그 PENDING 이 그대로 남고, 조회 쿼리(findByStudent)에 status 필터가 없어 전부 표시된 것. 실제 결제(PAID)는 1건이어도 진입 시도마다 쌓였다.

결제 내역 화면은 "실제 결제된 것"만 보이면 되므로, 조회에서 카드 PENDING 만 제외했다. 계좌이체 PENDING("입금대기")은 의미가 있어 유지.

<!-- TicketOrderMapper.xml findByStudent -->
SELECT * FROM ticket_orders
WHERE academy_id = #{academyId} AND student_id = #{studentId}
  <!-- 카드 결제창만 열고 미완료(PENDING)로 남은 주문은 숨김 -->
  AND NOT (method = 'CARD' AND status = 'PENDING')
ORDER BY created_at DESC

데이터는 지우지 않고 조회에서만 제외했다(안전). 근본 개선(진입 시 기존 PENDING 재사용, 취소 시 CANCELLED 전환)은 후속 과제로 남겼다.


검증 & 배포

프론트(student) 배포

  • npm run build → build/ tar 패키징(COPYFILE_DISABLE=1, ._* 제외) → tar 내 번들 해시 검증.
  • EC2 /var/www/piano/student/ 기존 삭제 → 추출 → ._* 정리 → chown nginx:nginx.
  • https://mforet.kr/student/ticket-checkout = 200 확인.

백엔드 배포 (좀비 JVM 방지 절차)

systemctl restart 만 하면 예전 JVM 이 8080 을 물고 있어 Port 8080 was already in use 로 재시작 루프에 빠진다. 그래서 stop → 잔여 JVM kill → 포트 free 확인 → jar 교체 → start → 검증 순서로 진행.

# 정지 + 좀비 정리 (pkill 로 SSH 끊겨 exit 255 나는 건 정상)
sudo systemctl stop piano-api; sleep 2
pgrep -f piano-api.jar && sudo pkill -9 -f piano-api.jar
# 재접속: 8080 free 확인 후 jar 교체 → 시작
sudo ss -ltnp | grep :8080 || echo "8080 free"
sudo cp /tmp/piano-api-new.jar /opt/piano/piano-api.jar
sudo systemctl reset-failed piano-api; sudo systemctl start piano-api
  • ping = 200, auth/me = 401(토큰 없음 정상, hang/504 아님) 확인.
  • 리소스(XML)만 바뀐 배포는 gradle 이 UP-TO-DATE 로 스킵할 수 있어 --rerun-tasks 로 강제 재빌드했고, unzip -p jar ...TicketOrderMapper.xml | grep 으로 실제 jar 안에 반영됐는지 검증했다.

트러블슈팅 메모 (삽질 기록)

  • && + | tail 조합 사고: ./gradlew ... && ls -la jar | tail -5 처럼 묶으니 ls/-la 가 tail 인자로 먹혀 깨졌다. 빌드와 확인은 분리 실행하는 게 안전.
  • gradle UP-TO-DATE: XML 리소스만 고치면 bootJar 가 up-to-date 로 스킵 → 이전 jar 배포 위험. --rerun-tasks 로 강제 실행 + jar 내부 grep 검증.
  • KSNET vendor 스크립트 경로 변경: js/lib → js/vendors 로 바뀌어 구 경로는 404 → _pay 미정의 → 결제 실패. 로드 URL 을 vendors 로 맞춰둠.
  • pkill 후 SSH exit 255: pkill 이 그 SSH 세션까지 끊어서 나는 정상 종료 코드. 다음 명령은 재접속해서 이어간다.

결론 / 배운 점

  • 외부 PG 연동은 "필수 파라미터·콜백 CORS·잔여 DOM" 3종 세트가 항상 함정이다. 결제 성공 이후의 화면 정리(오버레이·스크롤 잠금 해제)까지가 결제 UX 의 일부다.
  • "결제 내역"과 "결제 시도"는 다르다. 결제창 진입마다 PENDING 을 INSERT 하는 설계에서는 조회 단에서 미완료를 걸러줘야 사용자가 혼란스럽지 않다. 데이터는 보존하되 표시만 거르는 게 안전한 1차 대응.
  • 리소스만 바뀐 배포는 빌드 캐시(UP-TO-DATE) 때문에 "빌드했는데 안 바뀌는" 착시가 생긴다. jar 산출물 내용을 직접 검증하는 습관이 중요하다.

댓글 0

  • 첫 번째 댓글을 남겨보세요.