회사에서는 주로 디자인과 프론트 개발을 해왔지만, 팀 규모가 작은 만큼 한 사람이 다양한 역할을 해야 했고, 그렇게 백엔드 개발에도 참여하게 되었다.
물론 AI와 함께하지만, Spring도 Kotlin도 백엔드도 하나도 모르는 상황에서 개발하는 것은 쉬운 일이 아니었다 .. 🥹 생각 없이 AI만 돌려도 어떻게든 만들어지긴 하겠지만, 실제 운영 중인 서비스이기도 하고 이번을 계기로 백엔드 공부를 해보는 것이 나에게 좋은 시간이 될 것 같아, 개발하며 공부한 기초적인 내용들을 정리해보려고 한다.
그렇게 처음으로 가벼운 기능 하나를 통째로 맡게 되었다. 기관이 세무 증빙으로 쓰는 PDF 서류를 발급하는 API였는데, 요구사항 중에 이런 게 있었다.
서류마다 문서번호가 찍히는데, 결번이 있으면 안 된다는 것.
발급 과정은 [1. 번호 생성 → 2. PDF 만들기 → 3. 발행 기록 저장] 세 단계인데, 번호를 생성한 뒤 PDF 생성이 실패하면? 이미 만들어둔 번호만 허공에 사라진다. 결번이 생기는 것이다. 이 질문 하나를 따라가다 보니 트랜잭션이라는 개념을 좀 더 자세히 공부하게 되었다. 그 과정을 정리해둔다.
전부 성공하거나, 전부 없던 일로
트랜잭션(transaction)은 여러 DB 작업을 하나의 묶음으로 만들어서 전부 성공하거나 전부 취소되게 하는 장치다. 보통 예시는 계좌이체다. "내 잔액 -1만원"과 "상대 잔액 +1만원"은 두 개의 작업인데, 첫 번째만 성공하고 서버가 죽으면 돈이 증발한다. 그래서 둘을 한 묶음으로 묶고, 중간에 뭐가 실패하면 묶음 전체를 되돌린다(롤백). 이 성질을 원자성(Atomicity)이라고 부른다.
스프링에서는 함수에 @Transactional을 붙이면 그 함수의 시작부터 끝까지가 한 묶음이 된다. 그러니 내 문제의 답도 간단해 보였다. 번호 생성부터 저장까지를 한 함수에 넣고 @Transactional을 붙이면, PDF가 실패할 때 번호까지 같이 롤백되어 결번이 없다.
여기까지는 예상대로였다. 예상과 달랐던 건 그다음이다.
그런데 왜 유스케이스에는 안 붙였나
처음에는 유스케이스 전체에 @Transactional을 붙이는 게 자연스러워 보였다. 발급이라는 작업 전체가 한 묶음이기도하고, 그렇게 이미 짜여있는 코드가 많았다.(코드를 짤 줄 모르니 일단 원래 있는 코드를 참고..)
그런데 발급의 첫 단계는 외부 결제사 API에서 충전 내역을 전부 가져오는 일이고, 기간이 길면 수 초에서 수십 초가 걸린다.
문제는 트랜잭션이 열려 있는 동안 DB 커넥션 하나를 계속 붙잡고 있다는 점이다. DB 커넥션은 서버 전체가 나눠 쓰는 한정된 자원이라, 외부 API 응답을 기다리는 수십 초 동안 커넥션을 헛으로 점유하면 다른 요청들이 커넥션을 못 받아 밀린다. 발급 몇 건이 겹치는 것만으로 서버 전체가 느려질 수 있다.
그래서 구조를 쪼갰다. 유스케이스는 트랜잭션 없이 느린 일(외부 조회, 데이터 조립)을 먼저 끝내고, 운명을 같이해야 하는 세 단계만 별도 클래스(Writer)의 한 묶음으로 넘긴다.
트랜잭션 경계는 "작업 전체를 묶는 것"이 아니라 함께 되돌려야 하는 것만 묶는 것이었다. 묶음이 클수록 안전한 게 아니라, 묶음이 길수록 자원을 오래 붙잡는 비용이 있다.
@Transactional의 정체는 프록시다
Writer를 왜 굳이 별도 클래스로 뗐는지도 처음엔 몰랐다. 같은 클래스 안에 private 함수로 두고 @Transactional을 붙이면 안 되나?
안 된다... 😅 @Transactional은 프록시(proxy)라는 방식으로 동작한다. 스프링은 이 어노테이션이 붙은 클래스 앞에 대리인 객체를 세워두고, 호출이 들어오면 대리인이 먼저 받아서 "트랜잭션 열기, 진짜 함수 실행, 커밋 또는 롤백"을 수행한다. 그런데 같은 클래스 안에서 자기 함수를 부르면 대리인을 거치지 않고 직통으로 간다. 어노테이션이 조용히 무시된다. 이걸 self-invocation 문제라고 부른다.
코드는 돌고, 트랜잭션만 없는 채로 돈다. 요청이 성공하는 동안에는 티가 나지 않는다. 어느 날 중간 단계가 실패했을 때, 앞 단계의 저장만 확정된 반쪽짜리 데이터가 DB에 남고 나서야 트랜잭션이 처음부터 없었다는 걸 알게 된다. 그래서 트랜잭션이 필요한 부분은 반드시 다른 빈으로 분리하는 게 스프링의 정석이고, Writer는 그 결과물이다.
전파, 요구사항이 옵션을 고른다
트랜잭션이 열려 있는 상태에서 @Transactional이 붙은 다른 함수를 부르면, 그 함수는 선택지가 있다. 기존 묶음에 합류할 수도 있고, 자기만의 묶음을 따로 열 수도 있다. 이 선택을 전파(propagation)라고 한다.
| 옵션 | 뜻 | 운명 |
|---|---|---|
REQUIRED (기본) | 열린 묶음이 있으면 합류, 없으면 새로 열기 | 부모와 함께 커밋·롤백 |
REQUIRES_NEW | 항상 전용 묶음을 새로 열기 | 부모와 분리, 먼저 커밋 |
MANDATORY | 반드시 열린 묶음에 합류, 없으면 예외 | 부모와 함께 + 단독 호출 금지 |
다른 코드에는 번호를 생성하는 채번기가 이미 하나 있었고, 그건 REQUIRES_NEW였다. 거래번호를 독립 묶음으로 따로 커밋해서, 부모 트랜잭션이 나중에 실패해도 번호 소비는 그대로 남는다. 결번이 생기는 방식이다. 그 도메인은 결번이 허용되어서 그게 맞는 선택이었다.
이번 요구 사항은 결번 금지였기 때문에 답은 분리가 아니라 합류다. 채번을 Writer의 묶음 안에서 실행하면 번호 +1은 커밋 전까지 예약 상태고, PDF가 실패하면 예약까지 함께 사라진다.
합류만 원하면 기본값 REQUIRED로도 되는데 굳이 MANDATORY를 쓴 건 안전핀이다. REQUIRED는 묶음이 없으면 조용히 혼자 새 묶음을 연다. 나중에 누가 이 채번기를 트랜잭션 밖에서 단독 호출하면, 결번 방지 보장이 소리 없이 깨진다. MANDATORY는 그런 호출을 즉시 에러로 터뜨려서 "이 함수는 반드시 발급 묶음 안에서만 불러라"를 코드가 강제한다.
같은 채번인데 한쪽은 REQUIRES_NEW, 한쪽은 MANDATORY. 전파 옵션은 결국 실패했을 때 이 기록이 남아야 하는지, 사라져야 하는지를 따라가면 자연스럽게 정해지는 것이었다.
롤백에도 조건이 있다
공부하다 알게 된 함정 하나. @Transactional은 예외가 나면 무조건 롤백하는 게 아니다. 기본 동작은 RuntimeException과 Error일 때만 자동 롤백이고, checked 예외(IOException 같은)는 롤백 없이 커밋된다. 필요하면 rollbackFor로 지정해야 한다.
Kotlin에는 checked 예외 강제가 없어서 사실상 대부분 자동 롤백 경로를 타지만, Java 라이브러리가 던지는 checked 예외를 잡지 않고 흘려보내는 코드라면 "예외가 났는데 커밋되는" 상황이 가능하다. 롤백은 공짜 보장이 아니라 조건이 있는 계약이었다.
트랜잭션이 못 하는 것
마지막 질문은 이거였다. 외부 결제사 API 호출에는 트랜잭션을 안 걸어도 되나?
걸어도 소용이 없다. 롤백은 DB가 자기 안의 변경을 취소해주는 기능이라 관할이 DB 내부뿐이다. HTTP 요청은 이미 상대 서버에 도착한 뒤라 트랜잭션이 어떻게 해도 되돌릴 수 없다. 그래서 보호는 트랜잭션이 아니라 순서로 한다. 되돌릴 수 없는 일(외부 조회)을 먼저 다 끝내고, 되돌릴 수 있는 일(DB 쓰기)만 마지막에 묶는다.
외부 API 연동을 REQUIRES_NEW로 분리하라는 글도 있었는데, 처음엔 우리 선택과 모순처럼 보였다. 뜯어보니 목적이 다르다. 그 글은 외부에 쓰기(결제 실행)를 할 때 "시도했다는 기록"을 부모 롤백과 무관하게 남기려는 것이고, 우리는 외부에서 읽기만 하니 남길 기록 자체가 없어서 밖으로 빼는 게 답이었다. 같은 도구라도 문제가 다르면 답이 달라진다.
요구를 다시 묻자, 묶음이 줄었다
여기까지 만들고 백엔드 동료에게 리뷰를 요청했는데, 리뷰가 설계의 전제를 건드렸다.. ! 🧐
"결번 금지가 정말 필요한 요구인가?" 순번(CRB-1234-0001)은 "번호가 빠짐없이 이어져야 한다"는 요구를 스스로 만든다. 그 요구를 지키느라 기관마다 번호표 행을 잠가 발급을 한 줄로 세웠고, 번호가 PDF 본문에 인쇄되니 렌더까지 그 잠금 안에 들어가 있었다.
번호를 발행일 + 랜덤 코드(CRB-20260824-K7M3F9)로 바꾸면 그 요구 자체가 사라진다. 번호를 DB 없이 만들 수 있으니 번호표 테이블도, 잠금도, MANDATORY 채번기도 전부 필요 없어졌다. 렌더도 트랜잭션 밖으로 나갔다. "몇 번째 발급인지"는 발행대장의 행 수를 세면 되고, 실패한 발급은 롤백돼 행이 남지 않으니 오히려 순번보다 정확하다. (유일성은 확률에 맡기지 않고 UNIQUE 제약이 최종 방어선으로 남는다.)
그 결과 묶음이 줄었다.
흥미로운 건 트랜잭션이 "필요 없어진" 게 아니라는 점이다. 대장과 별지는 여전히 운명 공동체라 Writer의 @Transactional은 그대로 남았다. 사라진 것은 전파를 결정할 일이었다. MANDATORY라는 답을 고민하게 만들었던 중첩 구조 자체가 없어지니, 남은 묶음 하나는 기본값으로 충분했다. 전파 옵션은 트랜잭션끼리 겹치는 구조를 만들었을 때만 청구되는 비용이었던 셈이다!!
어노테이션이 아니라 결정이었다
기능 하나를 끝내고 나서 깨달은 게 있다. 백엔드 개발에서 중요한 건 문법이나 어노테이션을 아는 것보다 "어떻게 구현할 것인가"를 정하는 일인 것 같다. 나는 "문서번호는 순차적이어야 하고 결번이 없어야 한다"는 정책을 주어진 전제로 받아들이고, 그 안에서만 열심히 풀었다. 잠금을 배우고, 전파 옵션을 고르고, 채번기를 만들면서. 그런데 리뷰가 던진 질문은 그 전제 바깥에 있었다. 그 요구가 정말 필요한가? 누군가 정한 정책이라도 구현하는 사람이 한 번 더 검토하고 다른 방향을 떠올려봤어야 했는데, 순차와 결번이라는 단어에 사로잡혀 다른 길을 생각하지 못했다는 게 후회로 남는다. 다음부터는 요구사항을 받으면 그 안에서 푸는 방법을 찾기 전에, 그 요구 자체가 복잡함을 만들고 있지는 않은지부터 의심해보는 습관을 가져야겠다.
다만 이 헛수고(?)가 아깝지는 않다. 순번을 지키려고 애쓴 덕분에 트랜잭션과 전파와 잠금을 문법이 아니라 필요에 의해 배웠고, 그 코드가 지워진 뒤에도 배운 것은 고스란히 남았다. 지워질 코드를 만들어본 것도 공부라면, 꽤 값진 공부였다고 생각한다 🥲