지금까지 만든 MemberController는 MemberRepository를 직접 호출하는 구조였다. 회원 하나 저장하는 정도는 이렇게 해도 문제가 없다. 그런데 "회원가입하면 웰컴 포인트 1000점을 같이 적립해준다"처럼 저장할 게 두 개로 늘어나면 이야기가 달라진다.
결론만 이야기하면 이렇다. 컨트롤러에서 리포지토리 두 개를 순서대로 호출하면, 둘 중 하나가 실패해도 앞서 실행된 저장은 이미 커밋돼버릴 수 있다. 서비스 계층을 분리하고 @Transactional을 붙여야 "회원 저장 + 포인트 적립"이 하나의 단위로 묶여서 하나라도 실패하면 전부 롤백된다.
말로만 하면 감이 잘 안 와서 일부러 실수 하나를 코드에 심어놓고 @Transactional 없이 실행해서 정말로 회원은 저장되고 포인트만 실패하는지, 그 다음 @Transactional을 붙이면 정말로 둘 다 롤백되는지 본 글에서 직접 확인해보도록 하겠다.
준비물
| 항목 | 버전 | 비고 |
|---|---|---|
| JDK | 21 LTS | 지난 글과 동일 |
| Spring Boot | 3.3.4 | Spring Web + Validation + Data JPA |
| MariaDB | 10.11 | Ubuntu 24.04 apt 기본 패키지 버전 |
| MariaDB Connector/J | 3.4.1 | JDBC 드라이버 |
| 이전 프로젝트 | demo 프로젝트 | Member, MemberRepository, MemberController가 있는 상태 |
지난 글까지는 Member에 쿼리 메서드를 붙이는 데 집중했다면, 이번 글은 컨트롤러 구조 자체를 바꾸는 게 목표라 회원가입(POST /members) 엔드포인트 하나만 가지고 진행한다.
따라 하기
Step 1. 지금 구조의 문제 확인하기
지금까지 MemberController는 이렇게 생겼다.
@RestController
public class MemberController {
private final MemberRepository memberRepository;
public MemberController(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@PostMapping("/members")
public MemberResponse create(@Valid @RequestBody MemberRequest request) {
Member saved = memberRepository.save(new Member(request.name(), request.email(), request.age()));
return MemberResponse.from(saved);
}
}
회원 저장 하나만 할 때는 이 구조로 충분하다. 그런데 "가입 축하 웰컴 포인트 1000점 지급"을 추가하려고 저장할 대상이 하나 더 생기면, 컨트롤러가 리포지토리 두 개를 순서대로 호출하는 모양이 된다. 이때 두 번째 저장이 실패하면 어떻게 될지가 이번 글의 핵심 질문이다.
// 컨트롤러에서 이렇게 순서대로 호출하면?
memberRepository.save(member);
pointHistoryRepository.save(pointHistory); // 여기서 실패하면 위의 save는?
먼저 새 엔티티와 서비스 계층을 만들고, 실제로 실패시켜서 눈으로 확인해보자.
Step 2. 포인트 이력 엔티티 만들기
회원가입할 때마다 웰컴 포인트를 적립한 기록을 남기는 PointHistory를 추가한다.
package com.example.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long memberId;
@Column(nullable = false)
private Integer amount;
@Column(nullable = false, length = 5)
private String reason;
protected PointHistory() {
}
public PointHistory(Long memberId, Integer amount, String reason) {
this.memberId = memberId;
this.amount = amount;
this.reason = reason;
}
public Long getId() {
return id;
}
public Long getMemberId() {
return memberId;
}
public Integer getAmount() {
return amount;
}
public String getReason() {
return reason;
}
}
reason 컬럼 길이를 5로 잡아둔 게 보일 텐데, 이건 오타가 아니라 일부러 심어놓은 실수다. "웰컴포인트 지급"이라는 문자열은 한글 8글자라 5자짜리 컬럼에는 못 들어간다. 실무에서도 이런 식으로 컬럼 길이를 짧게 잡아놓고 나중에야 발견하는 실수가 종종 나온다. 이 상태로 진행해서 정말로 무슨 일이 벌어지는지 먼저 보고, 나중에 고친다.
PointHistoryRepository는 지금까지와 마찬가지로 인터페이스 하나면 끝난다.
package com.example.demo;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
}
Step 3. 서비스 계층 분리하기 (아직 @Transactional 없이)
컨트롤러가 리포지토리를 직접 부르는 대신, MemberService가 두 리포지토리를 순서대로 호출하도록 만든다. 여기서는 일부러 @Transactional을 아직 붙이지 않는다.
package com.example.demo;
import org.springframework.stereotype.Service;
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PointHistoryRepository pointHistoryRepository;
public MemberService(MemberRepository memberRepository, PointHistoryRepository pointHistoryRepository) {
this.memberRepository = memberRepository;
this.pointHistoryRepository = pointHistoryRepository;
}
public Member register(MemberRequest request) {
Member saved = memberRepository.save(new Member(request.name(), request.email(), request.age()));
pointHistoryRepository.save(new PointHistory(saved.getId(), 1000, "웰컴포인트 지급"));
return saved;
}
}
컨트롤러는 이제 서비스만 호출한다.
@RestController
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/members")
public MemberResponse create(@Valid @RequestBody MemberRequest request) {
Member saved = memberService.register(request);
return MemberResponse.from(saved);
}
}
여기까지 하면 이렇게 나와야 함 — 애플리케이션을 재시작하면member,point_history두 테이블이 생기고 정상적으로 뜨는 것을 확인할 수 있다. 문제는 이제부터 실제 요청을 보냈을 때다.
Step 4. 회원가입 요청 보내서 버그 재현하기
이제 회원가입을 요청해보자.
curl -X POST http://localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"홍길동","email":"hong@example.com","age":25}'
{"timestamp":"2026-07-04T19:08:47.757+00:00","status":500,"error":"Internal Server Error","path":"/members"}
500 에러가 떨어졌다. 애플리케이션 로그를 확인해보면 원인이 명확하게 찍혀 있는 것을 확인할 수 있다.
WARN o.m.jdbc.message.server.ErrorPacket : Error: 1406-22001: Data too long for column 'reason' at row 1
ERROR o.h.engine.jdbc.spi.SqlExceptionHelper : (conn=38) Data too long for column 'reason' at row 1
org.springframework.dao.InvalidDataAccessResourceUsageException: could not execute statement
[insert into point_history (amount,member_id,reason) values (?,?,?) returning id]
예상한 대로 reason 컬럼 길이 문제로 두 번째 저장이 실패했다. 그런데 여기서 진짜 문제를 확인해야 한다. 실패한 건 포인트 적립인데, 회원은 어떻게 됐을까? DB를 직접 열어봤다.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e "SELECT * FROM member; SELECT * FROM point_history;"
id name email age
1 홍길동 hong@example.com 25
(point_history: 빈 테이블)
500 에러가 떨어져서 클라이언트 입장에서는 "가입 실패"로 보이는데, DB에는 회원이 버젓이 저장돼 있는 것을 확인할 수 있다. 포인트는 하나도 안 쌓였는데 회원 정보만 남아있는, 딱 봐도 잘못된 상태다.
원인은 단순하다. MemberRepository.save()와 PointHistoryRepository.save()는 각각 별개의 트랜잭션으로 실행된다. Spring Data JPA가 만들어주는 리포지토리 구현체(SimpleJpaRepository)에는 save(), delete() 같은 메서드마다 자체적으로 @Transactional이 붙어 있어서 호출하는 쪽에 트랜잭션이 없으면 메서드 하나마다 트랜잭션을 열고 바로 커밋해버린다. 그러니 memberRepository.save()가 끝나는 순간 회원 저장은 이미 커밋 완료 상태고 그 다음 줄에서 pointHistoryRepository.save()가 실패해도 앞의 커밋을 되돌릴 방법이 없다.
Step 5. @Transactional 붙이고 다시 확인하기
MemberService.register()에 @Transactional만 붙인다.
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
// ...
@Transactional
public Member register(MemberRequest request) {
Member saved = memberRepository.save(new Member(request.name(), request.email(), request.age()));
pointHistoryRepository.save(new PointHistory(saved.getId(), 1000, "웰컴포인트 지급"));
return saved;
}
}
애플리케이션을 재시작하고 이번엔 다른 이메일로 다시 요청해보자. (컬럼 길이는 아직 고치지 않은 상태다.)
curl -X POST http://localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"홍길순","email":"gilsoon@example.com","age":31}'
{"timestamp":"2026-07-04T19:10:10.145+00:00","status":500,"error":"Internal Server Error","path":"/members"}
에러 응답 자체는 아까와 똑같이 500이다. 겉보기엔 달라진 게 없어 보이는데, 이번엔 DB를 확인해보자.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e "SELECT id, name, email, age FROM member;"
id name email age
1 홍길동 hong@example.com 25
이번엔 홍길순이 없다. @Transactional 하나 붙였을 뿐인데 회원 저장까지 같이 롤백된 것을 확인할 수 있다. @Transactional이 붙은 메서드 전체가 하나의 트랜잭션으로 묶이기 때문에, 안에서 예외가 터지면(체크 예외가 아닌 런타임 예외 기준) 메서드 안에서 실행된 모든 변경사항이 통째로 롤백된다.
기존에 남아있던 홍길동(id=1) 행은 Step 4에서 이미 커밋된 데이터라 이번 롤백과 무관하다. 트랜잭션은 "이번 요청 안에서 일어난 일"만 묶어줄 뿐, 이미 끝난 과거의 커밋을 되돌리진 못한다.
Step 6. 컬럼 길이 고치고 정상 동작 확인하기
이제 진짜 원인이었던 컬럼 길이를 고친다.
@Column(nullable = false, length = 50)
private String reason;
테이블을 새로 만들고(ddl-auto: update가 컬럼 길이 축소·확대를 항상 반영해주는 건 아니라서, 이번엔 point_history 테이블을 지우고 재시작했다) 다시 요청해보자.
curl -X POST http://localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"홍길순","email":"gilsoon@example.com","age":31}'
{"id":3,"name":"홍길순","email":"gilsoon@example.com","age":31}
이번엔 200이 정상적으로 떨어졌다. DB를 다시 확인해보자.
mysql -u demo -pdemo1234 --default-character-set=utf8mb4 demo -e \
"SELECT id, name, email, age FROM member ORDER BY id; SELECT id, member_id, amount, reason FROM point_history;"
id name email age
1 홍길동 hong@example.com 25
3 홍길순 gilsoon@example.com 31
id member_id amount reason
1 3 1000 웰컴포인트 지급
회원과 포인트 이력이 같이 저장된 것을 확인할 수 있다. 그런데 회원 id가 1 다음에 2를 건너뛰고 바로 3이다. Step 5에서 롤백됐던 홍길순 시도가 바로 이 id=2를 이미 소비했기 때문이다. MariaDB의 AUTO_INCREMENT는 트랜잭션이 롤백돼도 한 번 발급한 번호를 되돌려주지 않는다(성능을 위해 일부러 이렇게 설계돼 있다). 그래서 재시도가 성공한 회원의 id는 항상 하나씩 건너뛴 번호로 매겨진다. 처음 봤을 땐 "데이터가 빠진 건가" 싶어서 당황했는데, 트랜잭션 롤백과 AUTO_INCREMENT의 정상적인 동작이라는 걸 확인하고 나니 이해가 됐다.
요청이 처리되는 흐름
sequenceDiagram
participant C as MemberController
participant S as MemberService (@Transactional)
participant MR as MemberRepository
participant PR as PointHistoryRepository
participant DB as MariaDB
C->>S: register(request)
S->>MR: save(member)
MR->>DB: insert into member
S->>PR: save(pointHistory)
PR->>DB: insert into point_history
alt 둘 다 성공
S->>DB: commit (트랜잭션 종료 시)
else 하나라도 예외 발생
S->>DB: rollback (member insert까지 전부 되돌림)
end
전체 코드 (복붙용)
Member.java
package com.example.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private Integer age;
protected Member() {
}
public Member(String name, String email, Integer age) {
this.name = name;
this.email = email;
this.age = age;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public Integer getAge() {
return age;
}
}
PointHistory.java
package com.example.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long memberId;
@Column(nullable = false)
private Integer amount;
@Column(nullable = false, length = 50)
private String reason;
protected PointHistory() {
}
public PointHistory(Long memberId, Integer amount, String reason) {
this.memberId = memberId;
this.amount = amount;
this.reason = reason;
}
public Long getId() {
return id;
}
public Long getMemberId() {
return memberId;
}
public Integer getAmount() {
return amount;
}
public String getReason() {
return reason;
}
}
MemberRepository.java / PointHistoryRepository.java
package com.example.demo;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
}
package com.example.demo;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
}
MemberRequest.java / MemberResponse.java
package com.example.demo;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record MemberRequest(
@NotBlank(message = "name은 비어 있을 수 없습니다") String name,
@NotBlank(message = "email은 비어 있을 수 없습니다")
@Email(message = "email 형식이 올바르지 않습니다") String email,
@NotNull(message = "age는 비어 있을 수 없습니다")
@Min(value = 0, message = "age는 0 이상이어야 합니다") Integer age
) {
}
package com.example.demo;
public record MemberResponse(Long id, String name, String email, Integer age) {
public static MemberResponse from(Member member) {
return new MemberResponse(member.getId(), member.getName(), member.getEmail(), member.getAge());
}
}
MemberService.java
package com.example.demo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PointHistoryRepository pointHistoryRepository;
public MemberService(MemberRepository memberRepository, PointHistoryRepository pointHistoryRepository) {
this.memberRepository = memberRepository;
this.pointHistoryRepository = pointHistoryRepository;
}
@Transactional
public Member register(MemberRequest request) {
Member saved = memberRepository.save(new Member(request.name(), request.email(), request.age()));
pointHistoryRepository.save(new PointHistory(saved.getId(), 1000, "웰컴포인트 지급"));
return saved;
}
}
MemberController.java
package com.example.demo;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping("/members")
public MemberResponse create(@Valid @RequestBody MemberRequest request) {
Member saved = memberService.register(request);
return MemberResponse.from(saved);
}
}
build.gradle, application.yml, GlobalExceptionHandler.java는 지난 글과 동일하다.
실행 결과 확인
세 단계를 실제로 실행한 결과를 표로 정리했다.
| 단계 | 상태 | 요청 | 결과 |
|---|---|---|---|
| 1 | @Transactional 없음, 컬럼 길이 버그 있음 | POST /members (홍길동) | 500 에러, 그런데 회원은 DB에 저장됨, 포인트는 0건 |
| 2 | @Transactional 있음, 컬럼 길이 버그 있음 | POST /members (홍길순) | 500 에러, 회원도 저장 안 됨(롤백) |
| 3 | @Transactional 있음, 컬럼 길이 수정 | POST /members (홍길순) | 200, 회원(id=3)과 포인트 이력이 함께 저장됨 |
1번과 2번은 클라이언트 입장에서 똑같이 "500 에러"로 보이지만 DB에 남는 상태는 완전히 다르다는 걸 직접 확인했다. 겉으로 드러나는 에러 응답만 보고 "똑같은 실패겠지"라고 넘겼다면 회원은 있는데 포인트 내역만 없는 상태를 한참 못 찾았을 것 같다.
[TODO: 본인 환경에서 실행한 터미널 화면을 여기에 캡처해서 추가하세요]

자주 만나는 에러와 해결
에러 1: @Transactional을 붙였는데도 롤백이 안 될 때
이번 글의 예제는 서비스 메서드를 외부(컨트롤러)에서 직접 호출하는 구조라 문제없이 동작했다. 그런데 같은 클래스 안에서 this.register(request)처럼 자기 자신을 호출하면 @Transactional이 조용히 무시된다. @Transactional은 스프링이 만들어주는 프록시 객체가 트랜잭션을 열고 닫는 방식으로 동작하는데, 클래스 내부에서 this.로 호출하면 프록시를 거치지 않고 원본 메서드를 바로 호출하기 때문이다.
해결: 이번 글처럼 트랜잭션이 필요한 로직을 별도의 서비스 빈으로 분리해두면 이 문제 자체가 생기지 않는다. 서비스 계층을 나누는 게 단순히 "구조를 깔끔하게" 하는 것만이 아니라 이런 함정을 원천 차단하는 역할도 한다는 걸 알게 됐다.
에러 2: private 메서드에 @Transactional을 붙였는데 동작하지 않음
@Transactional
private Member registerInternal(MemberRequest request) { ... } // 동작 안 함
원인: 앞서 말한 프록시 방식과 같은 이유다. 스프링이 만드는 프록시는 원본 클래스를 상속(또는 인터페이스 구현)해서 메서드를 오버라이드하는 방식으로 동작하는데, private 메서드는 오버라이드가 불가능하니 프록시가 가로챌 방법이 없다.
해결: @Transactional이 붙는 메서드는 반드시 public이어야 한다. (protected, 패키지 접근제한자도 스프링 버전과 프록시 방식에 따라 제약이 있어서, 헷갈리지 않으려면 public으로 통일하는 게 마음 편하다.)
에러 3: 컬럼 길이 버그를 고쳤는데도 재시작 후 여전히 에러가 남
ddl-auto: update는 없는 테이블·컬럼을 추가해주긴 하지만 이미 있는 컬럼의 길이를 줄이거나 늘리는 변경은 데이터베이스와 Hibernate 버전에 따라 반영되지 않을 수 있다. 실제로 이번 글에서도 length를 5에서 50으로 바꾼 뒤 애플리케이션만 재시작했을 때는 컬럼이 그대로 varchar(5)로 남아 있었다.
해결: 개발 단계에서는 문제가 된 테이블을 지우고(DROP TABLE point_history;) 재시작해서 새로 만들게 하는 게 제일 빠르다. 운영 환경이라면 당연히 이런 식으로 테이블을 지우면 안 되고 Flyway나 Liquibase 같은 마이그레이션 도구로 ALTER TABLE을 명시적으로 실행해야 한다.
마무리
컨트롤러가 리포지토리를 직접 부르던 구조를 서비스 계층으로 분리하고, @Transactional 하나로 "회원 저장 + 포인트 적립"을 한 단위로 묶는 과정을 직접 확인했다. 컬럼 길이라는 아주 사소한 실수 하나로 "회원은 있는데 포인트만 없는" 애매한 상태가 만들어지는 걸 직접 보고 나니, 왜 여러 테이블에 걸친 저장 로직에는 트랜잭션 경계를 꼭 챙겨야 하는지 감이 확실히 잡혔다.
동시에 @Transactional이 만능은 아니라는 것도 다시 확인했다. 프록시 기반으로 동작하기 때문에 셀프 호출이나 private 메서드에는 무력하고 어디까지를 하나의 트랜잭션으로 묶을지(트랜잭션 경계)를 서비스 메서드 단위로 잘 설계해야 한다는 숙제가 남았다.
다음 단계로 해볼 것들.
- @OneToMany 연관관계: 회원 하나가 여러 개의 포인트 이력을 갖는 구조를,
Long memberId로 흉내내는 대신 진짜 연관관계 매핑으로 바꿔보기 - 트랜잭션 전파(propagation):
REQUIRES_NEW로 포인트 적립을 별도 트랜잭션으로 분리하면 회원가입은 성공하고 포인트만 나중에 재시도하는 구조를 만들 수 있는지 - 읽기 전용 트랜잭션: 조회 메서드에
@Transactional(readOnly = true)를 붙이면 실제로 뭐가 달라지는지
회원 id가 1에서 3으로 건너뛴 걸 보고 처음엔 데이터가 유실된 줄 알고 놀랐는데, AUTO_INCREMENT와 트랜잭션 롤백의 정상적인 상호작용이라는 걸 확인하고 나니 앞으로 이런 상황을 마주쳐도 당황하지 않을 것 같다.