결제 시스템의 이론 (Spring)

“사업”이란 간단하게 수익을 얻기 위해 어떤 재화 또는 용역(서비스)를 하는 것이다. 수익을 얻으려면 상대방에게서 돈을 받아오는 절차가 필요하다. 이번 글은 크게 두 부분으로 나눠 결제 시스템 자체에 대해 다뤄보고, Java Spring에서 접근 및 구현하는 것을 다뤄볼 예정이다.

결제 시스템?

온라인 결제에는 여러 방법이 존재한다. 크게 PG, MoR, 그리고 가상화폐(Crypto)로 구별하면 된다.

PG (Payment Gateway)

일반적으로, 그리고 정상적인 사업자라면 기본적으로 쓰게 될 결제 시스템이다. PG는 Payment Gateway의 준말로서, 다들 줄여서 말하곤 한다.

이름 그대로 PG는 결제의 단일 진입지점 역할을 한다. 아마 인터넷으로 물건을 사 본 사람이라면 “실시간 계좌 결제”, “카드 결제”, “휴대폰 소액 결제”, “무통장 결제” 등등의 수 많은 결제 수단을 봤을 것이다. 이 수많은 결제 수단을 한번에 관리해 주는곳이 PG이다.

대표적인 PG로는 다음이 있다

  • IamPort (써본적 있음)
  • bootpay (써본적 있음)
  • NicePay

만약 PG가 없다면 어떻게 될까? 우리가 직접 카드사에 가맹점 신청을 하고, 각 카드사에서 요구하는 기준에 따라 매번 결제 모듈을 개발해야 했을 것이다. 특히, 카드사쪽 결제 API는 보안을 위해 IP whitelist 시스템 등을 운영하고, 요구하는 보안 수준도 높을 것이다. 이 모든것을 맞춰서 매번 개발하는것은 비용 및 시간에 큰 부담이 될 것이다. 또한 결제 모듈은 시간이 지나고, 보안 취약점이 나옴에 따라 업데이트해 줘야 한다.

실제 PG 심사를 해보면 각 결제수단 업체에 가맹점 승인을 받는 절차가 있다. 우리는 PG에 서류를 내지만, 실제로는 PG가 각 카드사·통신사 등등에 대리 신청 및 승인을 받아오는 구조이다. 가맹점 승인을 받고나서도 PG사에서 개발 및 인증받은 솔루션으로 실 결제를 하게 된다.

핀테크쪽 보안 규제·심사만 봐도 빡센데, 레거시 결제 시스템쪽은 더 심할것이 자명하다.
특히, 법적 규제 측면에서도 카드번호 등의 정보를 직접 수집하려면 매우 힘들다. (신고업이 아니라 금융위 허가업 영역이다.)

PG를 붙이면 카드 결제, 무통장 입금 등등의 집금 업무가 가능해진다. 그래서 제대로 사업을 할 예정이고, 사업자등록증 까지 발급 받았다면 PG를 쓰는게 가장 평범하다.

lemon squeezy (MoR 계열)

문제는 (1)개인 이거나 (2)해외 결제를 받아야 할 경우이다. 특히, 국내에서 사이드 프로젝트로 서비스 및 프로그램을 만드는 사람이 문제가 된다.

필자도 사이드 프로젝트를 하는데 있어서 가장 큰 문제가 결제 수단을 추가하는 것이었다. 사이드 프로젝트에 결제 기능이 있긴 하지만, 실 사용자가 없기 때문에 사업자등록증을 낼만한 수준도 아니다. 그렇다고 “결제 원하시면 직접 메일 보내주세요” 할 수는 없는 노릇이지 않는가?

이때 사용할 수 있는 방법이 MoR 서비스이다. 대표적으론 Lemon Squeezy가 있다.

MoR은 Merchant of Record의 줄임말이다. 카드 결제후 문자를 보면 GS25서대문창천점 이런 식으로 어디서 결제했는지가 나온다. 이것이 카드사에서 승인한 가맹점 정보이다. MoR 서비스들은 자신을 가맹점으로 등록하고, 본인들 이름으로 카드 결제를 받는다. 이후 수수료, 세금, 실비를 다 땐 금액을 정산해서 송금해 준다.

그렇기에, 카드사에 가맹점 등록하기는 번거로울 때 사용할 수 있는 방법이 MoR이다. 물론 MoR내에서도 간단한 심사가 있다. 필자의 사이드 프로젝트는 트래픽 방식 과금 + 스트리밍 서비스 라는 조건 때문에 MoR에서도 거절 당했다.

쉽게 생각하면 샵인샵(Shop-in-Shop) 구조이다. 현실에서도 이런 구조가 있다. 백화점·아울랫이 대표적이다.
아울랫에서 옷이나 가방을 사면, 대게 카드에 실제 매장 이름 대신 “아울랫 이름”이 찍힌다.
사상애플아울렛 이름으로 카드 가맹점 등록을 하고, 그 안에서 셈소나이트, HUSKY 등의 실제 상점이 “사상애플아울랫” 가맹점 정보로 카드 결제를 한다. 추후 애플아울랫은 수수료와 실비를 제외한 금액을 정산한다.

한편, 위에서 (2)해외결제 를 이야기 했는데, 국내에서는 해외카드 결제를 받기가 힘들다. 인터넷으로 알아본것에 따르면 해외 카드의 경우, 카드사 취소가 나면 얄짤없이 금액이 환수되고 수수료도 무는 것 같았다. 이런저런 리스크가 있어서 꺼려하는것 같다. 그래서 그런지, 국내 업체들이 해외 결제를 받을때는 Paypal을 주로 쓰더라. 이때에도 MoR을 쓸 수 있다.

단, 실제 세금률과 수수료를 비교해보면 매출의 10~20%가 사라진다. 특히 결제 금액이 작을 경우에는 (고정비가 붙기 때문에) 수수료 퍼센트가 매우 높게 느껴진다. 수수료만 제외하면 짜피 카드 실비와 해외송금 수수료, 그리고 해당 결제의 세금 처리비용이기 때문에 큰 차이는 안난다.

국내향 매출의 경우에는 부가세 상계가 안되므로 10%가 더 붙는 느낌이 들 수도 있다.

MoR 또한 PG와 개발·연동 절차가 동일하다. 결국 MoR도 카드사가 보는 결제 책임자가 달라졌을뿐이다. 최종 사용자·개발자 입장에서는 똑같은 PG이다.

Crypto Payment (PG)

블록체인쪽은 집금 방법이 두가지 있다.

  1. 블록체인 PG를 사용
  2. 직접 블록체인을 감시

개발만 잘 한다면 PG를 굳이 쓸 필요는 없다. PG를 쓰면 “최소 결제 금액”과 “1% 정도의 결제 수수료”가 생긴다. 예를 들어 대부분의 Crypto PG에서는 USDT-TRX 기준 9$ 정도의 최소 결제금액 제약이 있다. (이는 서비스 업체, 가스비 및 환율에 따라 매번 달라진다)

PG 사용

1번을 쓸 경우에는 어쨋든 PG하고 동일하게 구현하면 된다. 특히, NowPayments의 경우에는 Fiat Money (USD, KRW)로 invoice를 만들 수 있다. 그러면 결제 시점의 환율에 맞춰서 코인을 보내면 된다.

이것저것 알아본 결과

  • NowPayments가 제일 잘 만들었다. 결제 모듈이나 처음부터 USD, KRW 단위로 입력하는거나. 문제는 한글이 안된다.
  • cryptomus 도 한글이 되긴 한데, “사용자가 직접 언어 설정을 바꿔야 한다”는 문제가 있다.
  • Pilsio는 결제창이 한글이 된다. 하지만 조금 엉성하다.

next에 i18n 라이브러리를 붙였던데,, 알아서 브라우저의 언어로 설정하게만 했어도 cryptomus를 선택했을 것 같다.

가상 화폐는 결제 승인에 수초에서 수십분이 걸릴 수 있다. 그러므로, PG사는 주로 콜백을 통해 결제 성공을 알려준다. (IPN, Instant Payment Notification등의 이름을 사용).

일반적인 웹 어플리케이션이라면 수십분 동안 “결제가 완료됐는지” 확인하기가 번거로울 것이다. PG는 콜백만으로 결제를 처리할 수 있다는 점과, 환율에 따라 실시간으로 금액 계산을 해준다는데서 1% 정도의 수수료를 먹는다.

직접 블록체인 감시

블록체인은 자랑하는것 처럼 누구나 결제 기록을 볼 수 있다.

기술적으로 이야기 하자면 gossip 프로토콜등을 통해 “결제” 정보를 모든 노드에게 알린다. 채굴자는 해당 결제 정보를 받아서 Git의 Commit message 마냥 추가해서 블록을 만든다. 이러한 결제 기록(=git commit log)은 누구나 볼 수 있으므로, 이 정보를 찾아서 쓰면 된다.

문제는 상술한 것 처럼, 결제 확인 기간이 길 수 있다는 것이다. 다음 블록이 나올때 까지의 시간도 길 수 있는데다가, 블록 충돌이 발생할 수도 있기 때문에 실제 거래 확정에는 조금 더 긴 시간이 필요하다. 메인 쓰레드에서 계속해서 검증을 하는것은 쓰레드풀을 낭비하는 원흉이 된다. 그러므로, 별도의 쓰레드나 비동기 큐를 사용해서 검증을 해야한다.

아는 분은 TRON 블록체인 API와 비동기 큐를 따로 Python으로 제작해서 결제를 구현하셨다.

정확한 결제 정보가 들어왔다면 결제 승인을 해 주면 된다. 다만에, USDT가 아닌 코인으로 받을때는 소숫점이 길어질 수 있다. 이에 대한 처리가 필요하다.

어쨋든 결제의 흐름은 동일하다

이런 저런 방법이 있지만, 결국 결제 방법은 동일하다.

종류움직이는 것결제 책임의 주체
일반 PG법정 통화우리가 카드사에 가맹점 등록을 하고, PG사 솔루션으로 결제
MoR법정 통화MoR 업체가 카드사에 가맹점 등록을 하고, MoR사 솔루션으로 결제. 결제의 책임도 MoR
가상화폐 PG가상화폐 : 증권PG사가 결제를 주도함
가상화폐 구현가상화폐 : 증권우리가 처음부터 끝까지 직접 구현함

조그마하게 서비스를 운영할 때는 MoR을 사용할 수 있다. MoR에서 승인 거절을 내면 가상화폐를 사용할 수도 있다. 하지만 매출이 발생한다면 제대로 사업자 등록증을 내고 PG를 붙이는게 맞다. 가상화폐로 결제를 받더라도 “일시적인 수익”에서 벗어난다면 법적으로 사업자 등록증을 내야한다.

법적이슈

사용자가 탈퇴해도 결제 기록을 보관 해야함

결제를 받게되면 전자상거래법을 지켜야 한다. 우선 결제한 기록은 최소 5년은 유지해야 한다. 사용자가 탈퇴해도 유지해야 한다.

① 법 제6조제3항에 따라 사업자가 보존하여야 할 거래기록의 대상ㆍ범위 및 기간은 다음 각 호와 같다. 다만, 법 제20조제1항에 따른 통신판매중개자(이하 “통신판매중개자”라 한다)는 자신의 정보처리시스템을 통하여 처리한 기록의 범위에서 다음 각 호의 거래기록을 보존하여야 한다. <개정 2016. 9. 29.>

  1. 표시ㆍ광고에 관한 기록: 6개월
  2. 계약 또는 청약철회 등에 관한 기록: 5년
  3. 대금결제 및 재화등의 공급에 관한 기록: 5년
  4. 소비자의 불만 또는 분쟁처리에 관한 기록: 3년

사업자가 보존하여야 할 거래기록 및 그와 관련된 개인정보(성명ㆍ주소ㆍ전자우편주소 등 거래의 주체를 식별할 수 있는 정보로 한정한다)는 소비자가 개인정보의 이용에 관한 동의를 철회하는 경우에도 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 등 대통령령으로 정하는 개인정보보호와 관련된 법률의 규정에도 불구하고 이를 보존할 수 있다.

즉, 상품 상세 페이지는 내용을 수정하더라도, 원본을 6개월 이상 유지해야한다. (versioning 해야한다). 사용자가 탈퇴해도 결제 기록은 5년간 냅둬야 한다. 상품 1:1 문의도 사용자가 탈퇴 했더라도 3년을 유지해야 한다.

또 다른 법에서는, 사용자가 탈퇴를 했을때 soft-delete 대신 비가역적 파기 또는 데이터의 분리를 요구한다. 그러므로 DB를 설계할 때 foregin key를 만들며 이 사항들을 고려해야 한다.

선불 충전

SaaS 같은 계열에서는 “미리 금액”을 충전하고, 서비스 사용에 따라 차감을 하는 경우가 종종 있다. 이것들은 선불전자지급수단이 된다.

이때, 선불전자지급수단이 다음의 범위에 벗어나게 되면 금융위에 신고를 해야한다. 금융위에 신고가 돼면 이런저런 제약이 많~이 생긴다.

가. 하나의 가맹점(가맹점의 사업주가 동일한 경우로 한정한다)에서만 사용되는 선불전자지급수단을 발행하는 자
나. 선불전자지급수단의 발행잔액 및 연간 총발행액(두 종류 이상의 선불전자지급수단을 발행한 경우 각각의 발행잔액 및 총발행액을 합산한 금액을 말한다)이 대통령령으로 정하는 금액 미만인 자
다. 이용자가 미리 직접 대가를 지불하지 아니한 선불전자지급수단으로서 이용자에게 저장된 금전적 가치에 대한 책임을 이행하기 위하여 대통령령이 정하는 방법에 따라 상환보증보험 등에 가입한 경우

이때, “나”의 발행잔액 및 총발행액 제한은 는 잔액 30억, 연간 500억의 거래규모라는 제한이다. 이 제한은 상당히 크므로 처음 사업에 발을 들여놓는 업체는 걱정할 필요가 없다.

허가제 제약은 풀리지만, 아래의 법 조항은 준용해야 한다. 각 항목을 보면 소규모 업체라도 지켜야 할 것들이다. 저것들을 가이드로 삼아서 개발하면 된다.

3항제1호 다목의 규정에 따라 등록이 면제된 선불전자지급수단을 발행하는 자에 대하여는 제4조, 제2장(제19조는 제외한다) 및 제3장(제21조제4항, 제21조의2, 제21조의3, 제23조 및 제25조는 제외한다), 제37조, 제38조, 제39조제1항ㆍ제6항, 제41조제1항, 제43조제2항ㆍ제3항, 제46조, 제46조의2 및 제47조의 전자금융업자에 관한 규정을 준용한다. 다만, 소속 임직원의 위법ㆍ부당한 행위로 지급불능 상태가 되는 등 대통령령이 정하는 금융사고가 발생하는 경우에는 제25조, 제39조제2항 내지 제5항 및 제40조제2항ㆍ제3항을 준용한다. <개정 2013. 5. 22., 2014. 10. 15.>

개발적인 측면

결제에 대한 법적 측면은 훑어 보았다. 우리는 법을 가이드라인으로 하여금 개발을 해야한다. 가이드라인도 모른채로 개발했다가 나중에 수정해야 하면 번잡하기 때문이다.

이제 개발적인 측면을 보자.

중요한 것은 “돈의 흐름”을 기록하는 것이다. 단순한 쇼핑몰이라면 돈의 흐름이 PG에게만 있다. 실질적으로 PG에서 돈이 움직이고, 우리는 PG에 “지급(집금) 신청(명령)”을 할 뿐이다. 그래서 결제 정보에 PG의 PaymentID만 보관하고 있어도 무방하다. 이 경우 DB 테이블 입장에서 “결제중” “결제 완료” “취소중” 정도의 상태만 추적하고, 사용자 정보, 총 금액, 그리고 결제 항목만을 추적하면 된다.

하지만 사이트 내에서 돈의 흐름이 또 있다면 본격적인 개발을 해야 한다. 예를 들어, 필자의 사이드 프로젝트는 PG에서 들어온 돈을 수수료 처리, 인프라를 제공한 제3자에게 전달 등등의 과정이 있다. 이런 경우에는 자금의 흐름이 어디서 어떻게, 얼마나 갔는지를 추적해야 한다.

사이드 프로젝트 요구사항: Public Pool의 장비를 사용하면, 해당 장비 소유주에게 사용량 만큼의 크레딧을 넘겨줘야 한다

이 경우 복식부기를 통해 어디서 어디로, 얼마나 넘어갔는지 기록하면 편하다. 특히 계정(회계 용어)을 구분해서 원장에 따라 금액을 조정하면 자금의 전체적인 규모와 위치를 알 수 있다.

중복 결제 관리

실질적으로 구현하다 보면 중복 결제 처리가 중요하다. 사용자가 새로고침을 두번 하면서 POST가 두번 들어올 수도 있고, API가 retry로직을 탈 수도 있다. 이 경우 모두 중복 결제 관리 로직을 타야한다.

(이미 아는 사람은 당연하게 생각하겠지만) 가장 쉽고 안전한 방법은 준비 단계와 실행 단계를 나누는 것이다. 즉, 미리 Key를 발급해 놓는 것이다. 일반적인 PG라면 아래 같은 흐름이 될 것이다:

  1. 트랜잭션 ID를 생성한다. 이때 트랜잭션 정보를 기록하는 테이블에 row를 만들어둔다.
  2. 사용자는 트랜잭션 ID만을 이용해서 PG나 결제 시스템을 사용한다.
  3. 결제가 완료되면 success callback이 올 것이다. 이것으로 중복 처리를 체크한다
@Transactional
public void processPaymentSuccessCallback(int invoiceId, int pgPaymentId) {		
	// 실제로는 JPA에서 @Lock()을 이용해서 LOCK을 걸어야겠지만... 진도상 SQL 쿼리를 적음
	SELECT payment_status FROM invoice FOR UPDATE;
	
	if (payment_status.equals("COMPLETE")) {
		throw PaymentExcetion.DUPLICATED_PROCESS();
		// Exception이 throw되면 4xx, 5xx가 발생함. 그러면 실제 결제 처리는 안됨
	}

	invoice.setPaymentStatus('COMPLETE');
	repo.save(invoice);
}

위의 코드에서도 미리 invoice를 만들었기에, 중복 체크를 할 수 있는 공간이 생겨났다. 트랜잭션 또한 미리 ID를 발급 받는 로직을 넣으면 중복 체크를 할 수 있는 지점이 된다. 이 트랜잭션 ID를 사용자에게 주고, 매 요청에 트랜잭션 ID를 붙인다면 시스템이 중복 체크를 할 수 있다.

비슷한 이야기로,CockroachDB에서도 노드간 PK 경합을 막기 위해 ID를 선 할당 (범위 지정)을 한다.
node A에서 PK=1, node B에서 PK=1을 동시에 만들면 문제가 생길수도 있기 때문이다.

트랜잭션의 필요성

이제부터는 DB의 트랜잭션을 써야한다. 스프링에서는 @Transactional으로 사용할 수 있다. DB에는 All-or-Nothing이라는 개념이 있다. 프로그램이 실행중 오류가 발생하거나, DB가 작동하면서 오류가 발생할 경우가 생길 수 있다. 이때, 트랜잭션 단위로 DB에 적용될 쿼리가 취소(rollback) 된다.

이것은 스프링 단계 보다 DBMS 단계에서 보면 조금 더 이해가 잘 된다.

UPDATE account SET balance = balance + 1000 WHERE name = 'esukmean';

START TRANSACTION;
UPDATE account SET balance = balance + 1000 WHERE name = 'esukmean';
UPDATE account SET balance = balance - 1000 WHERE name = 'seokminlee';
INSERT INTO log VALUES('esukmean', 1234, NOW());

SELECT name, balance FROM acount FOR UPDATE;
UPDATE account SET balance = ? WHERE name = ?

COMMIT;

일련의 SQL에서 START TRANSACTIONCOMMIT 사이의 요청에 실패하면 모든 UPDATE 문이 취소된다. 맨 마지막 UPDATE 문에 문제가 있어도 위의 UPDATE, INSERT 문 까지 모두 취소된다.

스프링에서의 트랜잭션

스프링에서는 @Transactional 이라는 어노테이션이 있다. 이것을 메서드에 붙이면 “메서드 내에서 동작한 SQL 활동”이 START TRANSACTION 과 ~ COMMIT 사이에 들어가게 된다. 예를 들어서 아래 메서드를 보자

public class A {
  public void txEntry() {
        repo.save(Entity.of("asdf", 1111));
        // @Transactional을 AOP(Proxy)로 작동하기 때문에, 다른 클래스의 메서드를 호출해야 한다. 
        anotherClass.txTest();
    }
}

public class anotherClass {
    @Transactional
    public void txTest() {
         var entity = Entity.of("esukmean", 1234);
         repo.save(entity);

         var anotherEntity = repo.findByName('seokminlee');
         anotherEntity.setBalance(5555);
         repo.save(anotherEntity);
         // 편의를 위해 @Modify 설명을 생략함
    }
}

이것은 DB입장에서 아래와 같이 실행된다:

INSERT INTO account VALUES ('asdf', 1111); 
--실제로는 prepared statement이겠지만... 편의상 직접 파라메터로 넣음

START TRANSACTION;
INSERT INTO account VALUES ('esukmean', 1234);
SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
UPDATE account SET balance = 5555 WHERE id = 1;
COMMIT;

메서드 안에 있는 메서드에서(nested method) 발생한 SQL도 같은 Transaction에서 동작한다.

단, @Transactional은 Spring AOP에 의존한다. 즉, 객체가 Proxy로 접근돼야 효과가 있다. new AnotherMethod() 로 직접 클래스 인스턴스를 만들거나, 같은 클래스 내의 메서드를 호출 할 경우에는 Proxy를 타지 않기 때문에 @Transactional이 효과가 없다.

required vs requires_new

같은 메서드 내에서 트랜잭션 분리가 필요할 수도 있다. 아래는 트랜잭션에 실패하더라도 “어디까지는 실행 됐는지”를 표기하고자 했다. 만약 start에서 진행이 안됐다면 첫번째 repo.save에서, phase-A에서 멈췄다면 다음 findByName과 setBalance의 저장에서, end에서 끝났다면 정상 종료를 의미하려 했다.

public class anotherClass {
    @Transactional
    public void txTest() {
       var status = statusRepo.findById(10000);
       status.setTxStatus('start');
         statusRepo.save(status);

         var entity = Entity.of("esukmean", 1234);
         repo.save(entity);

         status.setTxStatus('phase-A');
         statusRepo.save(status);

         var anotherEntity = repo.findByName('seokminlee');
         anotherEntity.setBalance(5555);
         repo.save(anotherEntity);

         status.setTxStatus('end');
         statusRepo.save(status);
         // 편의를 위해 @Modify 설명을 생략함
    }
}

SQL로 보면 이럴 것이다.

START TRANSACTION;
  SELECT * from status WHERE id=10000;     
    UPDATE status SET status = 'start' WHERE id=10000;
  INSERT INTO account VALUES ('esukmean', 1234);
    UPDATE status SET status = 'phase-A' WHERE id=10000;

  SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
  UPDATE account SET balance = 5555 WHERE id = 1;
    UPDATE status SET status = 'end' WHERE id=10000;
COMMIT;

하지만, 지금에선 하나의 @Transactional으로 묶여있기 때문에 하나의 쿼리에서만 뻑나도 모든 기록이 rollback 된다. 원치않는 결과가 발생한다는 말이다.

이 경우, 트랜잭션 분리가 필요하다. @Transactional@Transactional(propagation = Propagation.REQUIRED) 가 기본값이다. Propagation.REQUIRED 일때는 아무리 많은 메서드를 만나도 하나의 트랜잭션 내에서만 작동된다.

DB 입장에서는 메서드가 flatten 돼 보인다.

Propagation.REQUIRES_NEW 를 사용하면 독립된 트랜잭션을 아예 새로 만든다. 그러므로, 아래와 같이 REQUIRES_NEW를 사용하는 메서드를 만들면 의도한 대로 작동한다. 단, 마찬가지로 AOP로 작동되므로 Proxy 객체로 연결되어 있어야 한다. 쉽게 다른 클래스로 분리가 필요하다.

@Transactional(propagation = Propagation.REQUIRED)
public void log_status(StatusEntity status) {
       status.setTxStatus('start');
         statusRepo.save(status);
}

이 경우, DB에서는 다음과 같이 보인다.

INSERT INTO account VALUES ('asdf', 1111); --실제로는 prepared statement이겠지만... 편의상 직접 파라메터로 넣음

START TRANSACTION;
    SELECT * from status WHERE id=10000;
    START TRANSACTION;
        -- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
        UPDATE status SET status = 'start' WHERE id=10000;
    END TRANSACTION;

    INSERT INTO account VALUES ('esukmean', 1234);

    START TRANSACTION;
        -- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
        UPDATE status SET status = 'phase-A' WHERE id=10000;
    END TRANSACTION;

    SELECT * FROM account where name = 'seokminlee'; -- pk가 숫자 1이라고 가정
    UPDATE account SET balance = 5555 WHERE id = 1;

    START TRANSACTION;
        -- 별개의 transaction에서 실행됨 (실제로는 새로운 연결이 생김)
        UPDATE status SET status = 'end' WHERE id=10000;
    END TRANSACTION;
COMMIT;

DB 입장에서는 Transaction 내에 Transaction이 있을 수 없다. 그래서 실제로는 새로운 DB연결을 열고, 거기서 쿼리를 실행한다.

하지만 이것도 조금 아쉽다. 이걸 언제 다 별개의 클래스로 분리하는가! 물론 목적(책임)이 다르므로 클래스를 분리하는게 맞긴하다. 그러나, 이정도는 충분히 하나의 클래스 내에서 돌릴만 하다. 여기까진 메서드만 분리해도 괜찮은 수준이다.

이 경우, 트랜잭션을 직접 만들어야 한다. Spring에서는 TransactionTemplate을 제공한다. 이것을 사용하면 아래와 같이 메서드 내에서 새로운 트랜잭션을 만들어 낼 수 있다. 조금 더 다듬어서 updateStatus 따위의 메서드로만 분리해도 훨씬 깔끔하고 편리해질 것이다. 그러면 같은 클래스의 메서드로도 requires_new를 흉내낼 수 있다.

@Transactional
public void txTest() {
    var status = statusRepo.findById(10000);

    requiresNewTx.execute(status -> {
        status.setTxStatus('start');
        statusRepo.save(status);
        return null;
    });

    var entity = Entity.of("esukmean", 1234);
    repo.save(entity);

    requiresNewTx.execute(status -> {
        status.setTxStatus('phase-A');
        statusRepo.save(status);
        return null;
    });

    var anotherEntity = repo.findByName('seokminlee');
    anotherEntity.setBalance(5555);
    repo.save(anotherEntity);

    requiresNewTx.execute(status -> {
        status.setTxStatus('end');
        statusRepo.save(status);
        return null;
    });
}

트랜잭션이 끝나면 dirty entity는 자동 저장

Data JPA에서 repo.findBy(~) 등으로 Entity를 조회하면 EntityManager에 붙게 된다. 해당 엔터티의 값을 수정하면 repo.save(~)로 직접 저장하지 않아도 트랜잭션이 끝났을때 반영이 된다.

즉, 다음과 같은 코드에서 account.setBalance()를 호출하면 accountRepository.save(account)를 직접 호출하지 않아도 DB에 반영이 된다. 어떻게 보면 ORM의 장점이라 할 수 있다.

이렇게 반영되는 코드를 짤 경우에는 DB에서는 SELECT ~ FOR UPDATE로 값을 부르는게 안전하다.
절대값(immeidate value, UPDATE ~ SET A = 100) 으로 저장할 경우에는 값 손실이 발생할 수 있기 때문이다.

단, 중요한 것은 트랜잭션일 때만 작동한다는 것이다. 좀 쎄게 말해자면, @Transactional 등으로 트랜잭션이 끝났음에도 repo.save(~)로 저장하지 않은 것들을 자동으로 저장하는 기능이다.

엄밀하게 말해보자면,,,

더 엄밀하게 말하자면, EntityManager가 자동 저장 및 데이터 저장의 주체이다. 그리고 이것은 트랜잭션 단위마다 생긴다. 조회한 엔터티는 각 EntityManager에 붙는다. 트랜잭션이 끝났을때 (EntityManger가 끝났을때) 데이터에 수정점이 있다면 데이터가 DB에 저장된다.

단순히 findBy~~() 로 조회한 Entity는 그 순간 EntitnyManger에 붙는다.(propagation = required 라고 생각하면 편하다) 그러나 트랜잭션속이 아니라면 그대로 detach되기 때문에 관리가 되지 않는다.

repo.save의 실제 구현코드

repo.save()를 했을때 되는것도 실제로는 repo.save(entity)@Transactional이기 때문이다. 즉, 순간적으로 트랜잭션 환경으로 변화하고, EntityManger 속에 들어가서 저장되는 것이다.

EntityManger가 하나의 트랜잭션 내에서 사용되는 엔티티들을 관리하는것을 안다면 다음의 코드또한 repo.findByAccount() 등으로 직접 DB에 호출하는 대신 EntityManager에서 엔티티를 조회하고, 없으면 실제 DB단 까지 찾아들어가게 할 수도 있다.

매번 회계 계정 정보를 들고오는 메서드

트랜잭션의 필요성

기술적 개념은 파트는 이정도 봤으면 대충 알겠다. 근데 진짜로 트랜잭션이 필요한가? 단순히 1개의 row만을 저장하고 끝이라면 트랜잭션이 필요없을 수도 있다. 하지만, 처음에도 언급했던 것 처럼 여러개의 쿼리를 하나처럼 (원자적으로) 실행 하려면 트랜잭션이 필요하다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Blue Captcha Image
Refresh

*

최신 글

목차