목록으로

AOP로 결제 세션을 자동 관리하게 만든 이야기

3

AOP로 결제 세션을 자동 관리하게 만든 이야기

2025-03-07 ~ 2025-11-17

결제는 왜 한 번에 안 끝나는가

오프라인 바코드 결제를 생각해보자.

사용자가 매장에서 QR을 찍으면 바로 결제가 되는 게 아니다. 실제로는 이런 단계를 거친다.

1. 결제 시작 (START) — 주문 정보 생성
2. 한도 조회 (LIMIT) — 일/월 한도 확인
3. 사용자 인증 (AUTH) — PIN 검증
4. 충전 인증 (CHARGEAUTH) — 거래소 연동
5. 결제 확정 (BILL) — 최종 승인

각 단계는 별도의 HTTP 요청이다. 앱이 서버를 5번 호출한다. 문제는, 2단계에서 확인한 한도 정보를 5단계에서도 알아야 한다는 것이다.

HTTP는 무상태(stateless) 프로토콜이라 이전 요청의 데이터를 기억하지 못한다. 그래서 중간 상태를 어딘가에 저장하고, 다음 단계에서 꺼내 써야 한다.

이걸 세션 관리라고 한다.


세션 관리의 원시적 방법

가장 단순한 방법은 매 단계마다 직접 세션을 저장하고 로드하는 것이다.

fun barcodeStart(req: Request): Response {
    val session = BarcodeSession(orderid = req.orderid)
    sessionRepository.save(tid, session.toJson(), expireTime)  // 직접 저장
    return Response(tid)
}

fun barcodeAuth(tid: String, req: Request): Response {
    val session = sessionRepository.findByTid(tid)  // 직접 로드
        ?: throw SessionNotFoundException()
    if (session.isExpired()) throw SessionExpiredException()
    session.count++
    // ... 비즈니스 로직 ...
    sessionRepository.save(tid, session.toJson(), expireTime)  // 다시 저장
    return Response(tid)
}

fun barcodeBill(tid: String, req: Request): Response {
    val session = sessionRepository.findByTid(tid)  // 직접 로드
        ?: throw SessionNotFoundException()
    // ... 비즈니스 로직 ...
    sessionRepository.delete(tid)  // 직접 삭제
    return Response(tid)
}

동작은 한다. 하지만 문제가 있다.

  1. 모든 메서드에 세션 저장/로드/삭제 코드가 반복된다
  2. 만료 체크, 사용 횟수 검증 같은 보일러플레이트가 비즈니스 로직을 가린다 (보일러플레이트란 거의 변하지 않고 여러 곳에서 반복되는 정형화된 코드를 말한다)
  3. 새 결제 타입(온라인, 충전)을 추가할 때마다 같은 코드를 복사해야 한다

AOP로 해결하기

Spring AOP(Aspect-Oriented Programming)는 "관심사 분리"를 위한 기술이다. → AOP는 "관점 지향 프로그래밍"이라는 뜻으로, 여러 메서드에 공통으로 들어가는 부가 기능(로깅, 보안 등)을 한 곳에 모아서 관리하는 기법이다. 핵심 아이디어는 간단하다: 메서드 실행 전후에 자동으로 코드를 끼워넣는다.

로깅, 트랜잭션, 보안 체크처럼 비즈니스 로직과 무관하지만 반복되는 작업을 분리할 때 쓴다.

세션 관리도 마찬가지다. "이 메서드 실행 전에 세션을 로드하고, 실행 후에 저장해라"를 자동화할 수 있다.


커스텀 어노테이션 설계

두 개의 어노테이션을 만들었다.

@SessionWriteInfo — 메서드 실행 후 세션을 저장한다.

@SessionWriteInfo(
    sessionName = "BarcodeTransactionSession",  // 세션 식별자
    expireMinutes = 40,                         // 만료 시간
    preserveExpireTime = false                  // 기존 만료 시간 유지 여부
)

@SessionLoadInfo — 메서드 실행 전에 세션을 로드한다.

@SessionLoadInfo(
    tidPosition = 0,    // 메서드 파라미터 중 TID가 몇 번째인지
    count = 5,          // 최대 사용 횟수
    delete = false      // 실행 후 세션 삭제 여부
)

실제 사용 모습

어노테이션을 붙이면 비즈니스 로직만 남는다.

// 1단계: 결제 시작 — 세션 생성
@SessionWriteInfo(sessionName = "BarcodeTransactionSession", expireMinutes = 40)
fun barcodeStart(req: BarcodeStartRequest): Response {
    val session = BarcodeTransactionSession(orderid = req.orderid)
    requestBean.initPersist(session)  // "이걸 저장해줘"라고 AOP에 전달 (requestBean은 요청 스코프 빈으로, 하나의 HTTP 요청 안에서 AOP와 비즈니스 로직이 데이터를 주고받는 임시 저장소 역할을 한다)
    return Response(tid)
}

// 2단계: 인증 — 세션 로드 + 업데이트 + 저장
@SessionLoadInfo(tidPosition = 0, count = 5)
@SessionWriteInfo(sessionName = "BarcodeTransactionSession", preserveExpireTime = true)
fun barcodeAuth(tid: String, req: AuthRequest): Response {
    val session = requestBean.getSession(BarcodeTransactionSession::class)
    // 비즈니스 로직만 작성
    session.mycode = loginInfo.mycode
    requestBean.initPersist(session)
    return Response(tid)
}

// 5단계: 결제 확정 — 세션 로드 + 사용 후 삭제
@SessionLoadInfo(tidPosition = 0, count = 1, delete = true)
fun barcodeBill(tid: String, req: BillRequest): Response {
    val session = requestBean.getSession(BarcodeTransactionSession::class)
    // 결제 확정 로직
    return Response(tid)  // 메서드 종료 후 AOP가 세션 자동 삭제
}

세션 저장/로드/삭제/만료 체크/횟수 검증 코드가 전부 사라졌다. 개발자는 비즈니스 로직만 신경 쓰면 된다.


AOP가 뒤에서 하는 일

어노테이션을 보고 AOP가 자동으로 수행하는 일을 정리하면 이렇다.

@SessionLoadInfo 처리 (메서드 실행 전):

1. 메서드 파라미터에서 TID 추출 (tidPosition으로 위치 지정)
2. DB에서 세션 조회 → 없으면 NotExistSessionException
3. 만료 시간 체크 → 만료됐으면 SessionExpiredException
4. 사용 횟수 체크 → 초과하면 SessionTryExceedException
5. 사용 횟수 증가, isUse 플래그 설정
6. requestBean에 세션 데이터 세팅
7. 메서드 실행
8. delete=true면 세션 삭제

@SessionWriteInfo 처리 (메서드 실행 후):

1. 메서드가 성공적으로 완료되면 (onlySuccess=true 기본값)
2. requestBean에서 persist 객체 꺼내기
3. JSON으로 직렬화 (직렬화란 객체를 저장하거나 전송할 수 있는 문자열 형태로 변환하는 것이다)
4. DB에 저장 (TID, 세션 데이터, 만료 시간, 세션 이름)
5. preserveExpireTime=true면 기존 만료 시간 유지

실수했던 부분

이 설계가 완벽해 보이지만, 실제로 배포하고 나서 문제가 생겼다.

문제: 바코드 충전 세션이 로드되지 않음

원인을 찾아보니 @SessionLoadInfo(tidPosition = 0)에서 tidPosition이 잘못 설정되어 있었다.

// 문제 코드
@SessionLoadInfo(tidPosition = 0)  // 0번째 파라미터 = req
fun barcodeAuth(req: AuthRequest, tid: String): Response { ... }
// AOP가 req 객체를 TID로 읽으려고 해서 실패

메서드 파라미터 순서가 바뀌면 tidPosition도 바꿔야 하는데, 이걸 놓쳤다. 어노테이션 기반 설계의 함정 — 파라미터 순서에 의존하는 설정은 리팩토링에 취약하다.

이후에는 TID를 항상 첫 번째 파라미터로 통일하는 컨벤션을 세웠다.


온라인 결제에도 그대로 적용

바코드 결제용으로 만들었지만, 온라인 결제에도 어노테이션만 붙이면 동일하게 동작한다.

@SessionWriteInfo(sessionName = "OnlineTransactionSession", expireMinutes = 40)
fun onlineStart(req: OnlineStartRequest): Response { ... }

@SessionLoadInfo(tidPosition = 0, count = 10)
@SessionWriteInfo(sessionName = "OnlineTransactionSession", preserveExpireTime = true)
fun onlineLimit(tid: String, req: OnlineLimitRequest): Response { ... }

세션 이름만 다르고 나머지는 동일하다. 새 결제 타입을 추가할 때 세션 관리 코드를 한 줄도 작성하지 않아도 된다.


배운 것

  • 반복되는 횡단 관심사는 AOP로 분리하면 코드가 극적으로 깔끔해진다 (횡단 관심사란 로깅, 보안, 세션 관리처럼 여러 기능에 걸쳐서 공통으로 필요한 부가 기능을 말한다)
  • 하지만 어노테이션 파라미터가 런타임 값에 의존하면 버그를 찾기 어렵다 (tidPosition 같은 경우)
  • 팀 컨벤션(TID는 항상 첫 번째 파라미터)으로 실수를 방지하는 것도 설계의 일부다