티스토리 뷰
여러 작업 중 하나라도 실패하면 어떻게 될까? 백엔드에서 트랜잭션을 이해해야 하는 이유 — FastAPI · Spring Boot · Node.js
octo54 2026. 4. 7. 11:22여러 작업 중 하나라도 실패하면 어떻게 될까? 백엔드에서 트랜잭션을 이해해야 하는 이유 — FastAPI · Spring Boot · Node.js
repository까지 나누고 나면, 이제 슬슬 백엔드가 “진짜 실무 냄새”를 내기 시작합니다.
그리고 거의 반드시 마주치는 주제가 하나 있어요.
바로 트랜잭션(Transaction) 입니다.
이 말을 처음 들으면 좀 거창하게 느껴져요.
저도 처음엔 그랬습니다.
뭔가 은행 시스템, 결제 시스템, 엄청 무거운 엔터프라이즈 얘기 같았거든요.
근데 막상 서비스 개발을 하다 보면 생각보다 너무 빨리 등장합니다.
예를 들면 이런 상황이요.
- 회원가입하면서 사용자 저장 + 프로필 저장
- 주문 생성하면서 주문 저장 + 재고 차감
- 결제 성공 처리하면서 결제 상태 변경 + 포인트 적립
- 게시글 삭제하면서 댓글도 같이 정리
- 여러 테이블을 같이 변경해야 하는 관리자 기능
이때 가장 무서운 건
앞의 작업은 성공했는데 뒤의 작업이 실패하는 경우입니다.
예를 들어 주문은 저장됐는데 재고 차감이 실패했다?
혹은 사용자 테이블에는 저장됐는데 프로필 테이블 저장이 실패했다?
이런 상태가 되면 데이터가 진짜 애매하게 망가집니다.
그래서 트랜잭션이 필요합니다.
Spring Framework 공식 문서는 트랜잭션 관리가 Spring의 가장 강력한 기능 중 하나라고 설명하고, 일관된 프로그래밍 모델과 선언적 트랜잭션 관리를 제공합니다. (Home)
SQLAlchemy 문서는 Session 이 하나의 논리적 트랜잭션을 추적하며 Session.begin() 과 context manager 패턴을 통해 commit/rollback 흐름을 제어한다고 설명합니다. (SQLAlchemy)
Node.js 쪽은 Express 자체가 트랜잭션을 제공하는 건 아니고, 실제로는 사용하는 DB/ORM이 트랜잭션을 담당합니다. 예를 들어 Prisma 공식 문서는 $transaction 과 interactive transaction을 통해 여러 작업을 하나의 단위로 묶을 수 있다고 설명합니다. (Prisma)
이번 글에서는 이걸
FastAPI / Spring Boot / Node.js 세 가지 기준으로 풀어보겠습니다.
트랜잭션을 아주 쉽게 설명하면
저는 트랜잭션을 보통 이렇게 설명합니다.
“이 작업들은 같이 성공하거나, 같이 실패해야 한다”는 약속
이게 핵심이에요.
예를 들어 아래 두 작업이 있다고 해볼게요.
- 주문 저장
- 재고 차감
이 둘은 논리적으로 한 묶음입니다.
주문만 저장되고 재고는 안 줄어들면 안 되고,
재고만 줄고 주문은 안 저장돼도 안 됩니다.
즉 둘은 하나의 작업 단위로 묶여야 해요.
Prisma 공식 문서도 트랜잭션을 “일련의 read/write 작업이 전부 성공하거나 전부 실패하는 단위”라고 설명합니다. (Prisma)
왜 서비스 계층과 트랜잭션이 같이 이야기될까
이건 진짜 중요합니다.
많은 분들이 처음엔 이렇게 생각해요.
“트랜잭션은 DB 얘기니까 repository에서 처리하면 되는 거 아닌가?”
근데 실무에서는 보통 그렇지 않습니다.
왜냐하면 트랜잭션은 ‘쿼리 하나’의 문제가 아니라 ‘업무 흐름 하나’의 문제이기 때문이에요.
예를 들어 회원가입에서 이런 흐름이 있다고 해보죠.
- 이메일 중복 검사
- 사용자 저장
- 사용자 프로필 저장
- 가입 이벤트 기록
이건 repository 하나가 담당할 일이 아니라
서비스가 조합하는 비즈니스 흐름입니다.
그래서 트랜잭션도 보통 서비스 계층에서 잡습니다.
Spring 문서도 @Transactional 을 보통 서비스 계층의 public 메서드에 적용하는 패턴을 설명하고, 프록시를 통해 외부 호출 경계에서 트랜잭션이 시작된다고 안내합니다. (Home)
이 감각이 진짜 중요해요.
- repository는 DB 접근
- 서비스는 업무 흐름 조합
- 트랜잭션은 그 업무 흐름의 원자성 보장
즉, 트랜잭션의 중심은 대체로 서비스 계층 입니다.
이번 글에서 맞출 예제
이번 글에서는 회원가입 + 프로필 생성 흐름으로 맞추겠습니다.
요구사항은 간단합니다.
- 사용자 저장
- 사용자 프로필 저장
- 둘 중 하나라도 실패하면 전체 실패
- 둘 다 성공했을 때만 최종 성공
이걸 보면 바로 감이 옵니다.
트랜잭션이 필요한 이유가요.
1) FastAPI에서 트랜잭션 이해하기
FastAPI 자체는 트랜잭션 프레임워크가 아니라 웹 프레임워크입니다.
그래서 실제 트랜잭션은 보통 SQLAlchemy 같은 ORM의 Session 이 담당합니다. FastAPI 공식 SQL database 예제도 DB 세션을 dependency로 주입하는 구조를 보여주고 있습니다. (Home)
SQLAlchemy 공식 문서는 Session 이 하나의 가상 트랜잭션을 추적하며, context manager와 Session.begin() 사용을 권장합니다. 또한 Session 은 하나의 단일 트랜잭션 단위를 대표하는 stateful 객체라고 설명합니다. (SQLAlchemy)
이번 글에서는 이해를 돕기 위해 실제 DB 대신 가짜 session 객체로 구조를 먼저 보여드릴게요.
핵심은 문법이 아니라 어디서 commit/rollback을 결정하느냐 입니다.
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── signup.py
│ ├── db/
│ │ └── session.py
│ ├── repositories/
│ │ ├── profile_repository.py
│ │ └── user_repository.py
│ ├── schemas/
│ │ └── signup.py
│ ├── services/
│ │ └── signup_service.py
│ ├── dependencies.py
│ └── main.py
└── requirements.txt
requirements.txt
fastapi
uvicorn[standard]
pydantic
email-validator
app/db/session.py
class FakeSession:
def __init__(self) -> None:
self.user_table: list[dict] = []
self.profile_table: list[dict] = []
self._next_user_id = 1
self._staged_users: list[dict] = []
self._staged_profiles: list[dict] = []
def begin(self) -> None:
self._staged_users = []
self._staged_profiles = []
def commit(self) -> None:
self.user_table.extend(self._staged_users)
self.profile_table.extend(self._staged_profiles)
self._staged_users = []
self._staged_profiles = []
def rollback(self) -> None:
self._staged_users = []
self._staged_profiles = []
def create_user(self, email: str, password_hash: str, nickname: str) -> dict:
user = {
"id": self._next_user_id,
"email": email,
"password_hash": password_hash,
"nickname": nickname,
}
self._next_user_id += 1
self._staged_users.append(user)
return user
def create_profile(self, user_id: int, bio: str) -> dict:
profile = {
"user_id": user_id,
"bio": bio,
}
self._staged_profiles.append(profile)
return profile
def find_user_by_email(self, email: str) -> dict | None:
all_users = self.user_table + self._staged_users
return next((u for u in all_users if u["email"] == email), None)
app/schemas/signup.py
from pydantic import BaseModel, EmailStr, Field
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=100)
nickname: str = Field(min_length=2, max_length=20)
bio: str = Field(min_length=1, max_length=200)
class SignupResponse(BaseModel):
id: int
email: EmailStr
nickname: str
bio: str
app/repositories/user_repository.py
from app.db.session import FakeSession
class UserRepository:
def __init__(self, session: FakeSession) -> None:
self.session = session
def find_by_email(self, email: str) -> dict | None:
return self.session.find_user_by_email(email)
def save(self, email: str, password_hash: str, nickname: str) -> dict:
return self.session.create_user(email, password_hash, nickname)
app/repositories/profile_repository.py
from app.db.session import FakeSession
class ProfileRepository:
def __init__(self, session: FakeSession) -> None:
self.session = session
def save(self, user_id: int, bio: str) -> dict:
if bio == "fail":
raise ValueError("프로필 저장 중 오류가 발생했습니다.")
return self.session.create_profile(user_id, bio)
app/services/signup_service.py
import hashlib
from app.db.session import FakeSession
from app.repositories.profile_repository import ProfileRepository
from app.repositories.user_repository import UserRepository
from app.schemas.signup import SignupRequest, SignupResponse
class SignupService:
def __init__(
self,
session: FakeSession,
user_repository: UserRepository,
profile_repository: ProfileRepository,
) -> None:
self.session = session
self.user_repository = user_repository
self.profile_repository = profile_repository
def signup(self, request: SignupRequest) -> SignupResponse:
if self.user_repository.find_by_email(str(request.email)) is not None:
raise ValueError("이미 가입된 이메일입니다.")
self.session.begin()
try:
password_hash = hashlib.sha256(
request.password.encode("utf-8")
).hexdigest()
saved_user = self.user_repository.save(
str(request.email),
password_hash,
request.nickname,
)
saved_profile = self.profile_repository.save(
saved_user["id"],
request.bio,
)
self.session.commit()
return SignupResponse(
id=saved_user["id"],
email=saved_user["email"],
nickname=saved_user["nickname"],
bio=saved_profile["bio"],
)
except Exception:
self.session.rollback()
raise
app/dependencies.py
from app.db.session import FakeSession
from app.repositories.profile_repository import ProfileRepository
from app.repositories.user_repository import UserRepository
from app.services.signup_service import SignupService
session = FakeSession()
def get_signup_service() -> SignupService:
user_repository = UserRepository(session)
profile_repository = ProfileRepository(session)
return SignupService(session, user_repository, profile_repository)
app/api/signup.py
from fastapi import APIRouter, Depends, HTTPException, status
from app.dependencies import get_signup_service
from app.schemas.signup import SignupRequest, SignupResponse
from app.services.signup_service import SignupService
router = APIRouter(prefix="/api/signup", tags=["signup"])
@router.post("", response_model=SignupResponse, status_code=status.HTTP_201_CREATED)
def signup(
request: SignupRequest,
signup_service: SignupService = Depends(get_signup_service),
) -> SignupResponse:
try:
return signup_service.signup(request)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
app/main.py
from fastapi import FastAPI
from app.api.signup import router as signup_router
app = FastAPI(title="backend-series-fastapi-transaction")
app.include_router(signup_router)
핵심 포인트
여기서 중요한 건 딱 하나예요.
트랜잭션을 시작하고 끝내는 위치가 서비스에 있다는 점입니다.
이게 정말 중요합니다.
왜냐하면 서비스가 “사용자 저장 + 프로필 저장”이라는 하나의 비즈니스 흐름을 알고 있기 때문이에요.
실제 SQLAlchemy에서도 비슷한 감각으로 갑니다.
보통 Session.begin() 안에서 여러 repository 작업을 수행하고, 예외가 나면 rollback, 끝까지 성공하면 commit 하게 됩니다. SQLAlchemy 문서는 Session.begin() 과 context manager 패턴을 통해 이런 범위를 다루는 방식을 권장합니다. (SQLAlchemy)
2) Spring Boot에서 트랜잭션 이해하기
Spring은 이 주제에서 정말 강력합니다.
공식 문서도 트랜잭션 관리를 Spring의 핵심 기능 중 하나로 설명하고, 선언적 트랜잭션 관리와 @Transactional 을 중심으로 문서를 구성합니다. (Home)
그리고 실무에서는 거의 이 한 줄이 핵심이에요.
@Transactional
진짜로요.
물론 내부적으로는 훨씬 많은 일이 일어나지만,
개발자 입장에서는 서비스 메서드에 @Transactional 을 붙이는 것만으로
“여기서 시작한 작업은 하나의 트랜잭션으로 묶인다”는 의도를 표현할 수 있습니다.
Spring 문서는 @Transactional 이 보통 public 메서드에 적용되며, 프록시를 통과하는 외부 호출에서 인터셉트된다고 설명합니다. 또 thread-bound transaction으로 현재 실행 흐름의 데이터 접근 작업에 트랜잭션을 노출한다고 안내합니다. (Home)
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── BackendApplication.java
│ ├── controller/
│ │ └── SignupController.java
│ ├── dto/
│ │ ├── SignupRequest.java
│ │ └── SignupResponse.java
│ ├── repository/
│ │ ├── ProfileRepository.java
│ │ └── UserRepository.java
│ └── service/
│ └── SignupService.java
└── build.gradle
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
dto/SignupRequest.java
package com.example.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record SignupRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100) String password,
@NotBlank @Size(min = 2, max = 20) String nickname,
@NotBlank @Size(min = 1, max = 200) String bio
) {
}
dto/SignupResponse.java
package com.example.backend.dto;
public record SignupResponse(
Long id,
String email,
String nickname,
String bio
) {
}
repository/UserRepository.java
package com.example.backend.repository;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Repository
public class UserRepository {
private final List<Map<String, Object>> users = new ArrayList<>();
private long nextId = 1L;
public Optional<Map<String, Object>> findByEmail(String email) {
return users.stream()
.filter(user -> user.get("email").equals(email))
.findFirst();
}
public Map<String, Object> save(String email, String passwordHash, String nickname) {
Map<String, Object> user = Map.of(
"id", nextId++,
"email", email,
"passwordHash", passwordHash,
"nickname", nickname
);
users.add(user);
return user;
}
}
repository/ProfileRepository.java
package com.example.backend.repository;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Repository
public class ProfileRepository {
private final List<Map<String, Object>> profiles = new ArrayList<>();
public Map<String, Object> save(Long userId, String bio) {
if ("fail".equals(bio)) {
throw new IllegalArgumentException("프로필 저장 중 오류가 발생했습니다.");
}
Map<String, Object> profile = Map.of(
"userId", userId,
"bio", bio
);
profiles.add(profile);
return profile;
}
}
service/SignupService.java
package com.example.backend.service;
import com.example.backend.dto.SignupRequest;
import com.example.backend.dto.SignupResponse;
import com.example.backend.repository.ProfileRepository;
import com.example.backend.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
@Service
public class SignupService {
private final UserRepository userRepository;
private final ProfileRepository profileRepository;
public SignupService(UserRepository userRepository, ProfileRepository profileRepository) {
this.userRepository = userRepository;
this.profileRepository = profileRepository;
}
@Transactional
public SignupResponse signup(SignupRequest request) {
userRepository.findByEmail(request.email()).ifPresent(user -> {
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
});
String passwordHash = sha256(request.password());
Map<String, Object> savedUser = userRepository.save(
request.email(),
passwordHash,
request.nickname()
);
Map<String, Object> savedProfile = profileRepository.save(
(Long) savedUser.get("id"),
request.bio()
);
return new SignupResponse(
(Long) savedUser.get("id"),
(String) savedUser.get("email"),
(String) savedUser.get("nickname"),
(String) savedProfile.get("bio")
);
}
private String sha256(String raw) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest(raw.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 해시 생성 실패", e);
}
}
}
controller/SignupController.java
package com.example.backend.controller;
import com.example.backend.dto.SignupRequest;
import com.example.backend.dto.SignupResponse;
import com.example.backend.service.SignupService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/signup")
public class SignupController {
private final SignupService signupService;
public SignupController(SignupService signupService) {
this.signupService = signupService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public SignupResponse signup(@Valid @RequestBody SignupRequest request) {
return signupService.signup(request);
}
}
Spring Boot에서 꼭 기억할 포인트
Spring에서는 정말 이 감각이 중요합니다.
@Transactional 은 보통 서비스 메서드에 둔다.
왜냐하면 서비스 메서드가 “사용자 저장 + 프로필 저장”이라는 비즈니스 흐름의 경계이기 때문이에요.
그리고 Spring 문서에서 아주 중요한 포인트가 하나 더 있습니다.
@Transactional 은 프록시 기반이라 외부 호출을 통해 들어올 때 인터셉트됩니다. 즉, 같은 클래스 안에서 자기 메서드를 직접 호출하는 self-invocation은 기대대로 동작하지 않을 수 있습니다. (Home)
이건 실무에서 진짜 많이 헷갈리는 포인트예요.
또 Spring 문서는 전파(propagation)와 rollback 규칙도 자세히 다룹니다. 특히 논리적 트랜잭션과 물리적 트랜잭션의 차이, 그리고 propagation 설정이 어떻게 작동하는지 설명합니다. (Home)
초반에는 일단 이것만 기억해도 좋아요.
- 서비스 메서드에 @Transactional
- 예외가 발생하면 rollback
- 정상 종료하면 commit
3) Node.js에서 트랜잭션 이해하기
이 부분은 꼭 정확히 짚고 가야 해요.
Node.js나 Express 자체는 트랜잭션을 제공하지 않습니다.
트랜잭션은 실제로는 DB 드라이버나 ORM, 예를 들어 Prisma, TypeORM, Sequelize, Mongoose 같은 도구가 제공합니다.
Prisma 공식 문서는 $transaction([]) 과 interactive transaction을 지원하고, 작업 성격에 따라 nested writes, batch transaction, interactive transaction을 선택할 수 있다고 설명합니다. (Prisma)
즉 Node.js에서는 보통 이렇게 생각하면 됩니다.
- Express = HTTP 계층
- Service = 업무 흐름
- ORM/DB client = 실제 트랜잭션 수행
이번 예제는 Prisma 스타일을 흉내 낸 구조로 보여드릴게요.
추천 구조
node-backend/
├── src/
│ ├── db/
│ │ └── db-client.js
│ ├── repositories/
│ │ ├── profile.repository.js
│ │ └── user.repository.js
│ ├── routes/
│ │ └── signup.route.js
│ ├── services/
│ │ └── signup.service.js
│ └── server.js
└── package.json
package.json
{
"name": "backend-series-node-transaction",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node src/server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
src/db/db-client.js
export class FakeDbClient {
constructor() {
this.users = [];
this.profiles = [];
this.nextUserId = 1;
}
async transaction(callback) {
const stagedUsers = [];
const stagedProfiles = [];
const tx = {
findUserByEmail: (email) => {
const allUsers = [...this.users, ...stagedUsers];
return allUsers.find((user) => user.email === email) ?? null;
},
createUser: (email, passwordHash, nickname) => {
const user = {
id: this.nextUserId++,
email,
passwordHash,
nickname,
};
stagedUsers.push(user);
return user;
},
createProfile: (userId, bio) => {
if (bio === "fail") {
throw new Error("프로필 저장 중 오류가 발생했습니다.");
}
const profile = { userId, bio };
stagedProfiles.push(profile);
return profile;
},
};
const result = await callback(tx);
this.users.push(...stagedUsers);
this.profiles.push(...stagedProfiles);
return result;
}
}
src/repositories/user.repository.js
export class UserRepository {
findByEmail(tx, email) {
return tx.findUserByEmail(email);
}
save(tx, email, passwordHash, nickname) {
return tx.createUser(email, passwordHash, nickname);
}
}
src/repositories/profile.repository.js
export class ProfileRepository {
save(tx, userId, bio) {
return tx.createProfile(userId, bio);
}
}
src/services/signup.service.js
import crypto from "node:crypto";
export class SignupService {
constructor(dbClient, userRepository, profileRepository) {
this.dbClient = dbClient;
this.userRepository = userRepository;
this.profileRepository = profileRepository;
}
async signup({ email, password, nickname, bio }) {
return this.dbClient.transaction(async (tx) => {
const existingUser = this.userRepository.findByEmail(tx, email);
if (existingUser) {
throw new Error("이미 가입된 이메일입니다.");
}
const passwordHash = crypto
.createHash("sha256")
.update(password, "utf8")
.digest("hex");
const savedUser = this.userRepository.save(tx, email, passwordHash, nickname);
const savedProfile = this.profileRepository.save(tx, savedUser.id, bio);
return {
id: savedUser.id,
email: savedUser.email,
nickname: savedUser.nickname,
bio: savedProfile.bio,
};
});
}
}
src/routes/signup.route.js
import { Router } from "express";
export function createSignupRouter(signupService) {
const router = Router();
router.post("/api/signup", async (req, res) => {
const { email, password, nickname, bio } = req.body;
if (!email || !password || !nickname || !bio) {
return res.status(400).json({ message: "필수값이 누락되었습니다." });
}
if (password.length < 8) {
return res.status(400).json({ message: "비밀번호는 8자 이상이어야 합니다." });
}
try {
const result = await signupService.signup({
email,
password,
nickname,
bio,
});
return res.status(201).json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
}
});
return router;
}
src/server.js
import express from "express";
import { FakeDbClient } from "./db/db-client.js";
import { ProfileRepository } from "./repositories/profile.repository.js";
import { UserRepository } from "./repositories/user.repository.js";
import { createSignupRouter } from "./routes/signup.route.js";
import { SignupService } from "./services/signup.service.js";
const app = express();
const PORT = 3000;
const dbClient = new FakeDbClient();
const userRepository = new UserRepository();
const profileRepository = new ProfileRepository();
const signupService = new SignupService(
dbClient,
userRepository,
profileRepository
);
app.use(express.json());
app.use(createSignupRouter(signupService));
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Node.js에서 꼭 기억할 포인트
Node.js에서 중요한 건 이것입니다.
트랜잭션은 Express가 아니라 ORM/DB 클라이언트가 제공한다.
예를 들어 Prisma를 쓴다면 보통 이런 느낌으로 갑니다.
- prisma.$transaction([...]) : 독립적인 여러 작업 묶기
- prisma.$transaction(async (tx) => { ... }) : read-modify-write 같은 interactive transaction
Prisma 공식 문서도 작업 성격에 따라 nested writes, batch API, interactive transaction을 구분해 설명합니다. (Prisma)
즉 Node.js에서는
서비스가 트랜잭션 범위를 열고, repository는 tx 객체를 받아 쿼리한다
이 감각이 굉장히 중요합니다.
세 스택을 나란히 놓고 보면
같은 “트랜잭션”인데 감각은 꽤 다릅니다.
FastAPI
- 웹 프레임워크 자체보다 SQLAlchemy 같은 ORM의 Session이 중심
- 서비스에서 여러 repository 작업을 묶고 commit/rollback 결정
- Session.begin() 과 context manager 패턴이 핵심 (SQLAlchemy)
Spring Boot
- @Transactional 하나로 선언적 트랜잭션 관리가 가능
- 서비스 계층과 가장 잘 맞물림
- propagation, rollback 규칙까지 프레임워크 지원이 성숙함 (Home)
Node.js
- 프레임워크가 아니라 ORM/DB 클라이언트가 트랜잭션 담당
- 서비스에서 tx 범위를 열고 repository에 전달하는 방식이 흔함
- Prisma 같은 도구가 $transaction 을 제공 (Prisma)
트랜잭션에서 주니어가 자주 하는 실수
이건 진짜 많이 봤어요.
1. repository마다 따로 commit 해버림
그러면 이미 앞 단계가 확정돼 버려서, 뒤에서 실패해도 전체 rollback이 안 됩니다.
2. 서비스 밖에서 commit/rollback을 흩뿌림
트랜잭션 경계가 흐려져요.
보통은 서비스 흐름 단위로 묶는 게 낫습니다.
3. 외부 API 호출까지 긴 트랜잭션 안에 넣음
이건 꽤 위험합니다.
트랜잭션은 가능하면 짧게 유지하는 편이 좋고, 장시간 잡고 있으면 성능과 잠금 문제가 커질 수 있습니다. Prisma 문서도 long-running transaction이 성능 문제를 만들 수 있다고 경고합니다. (Prisma)
4. 트랜잭션이 “에러 처리”랑 같은 거라고 생각함
비슷해 보이지만 다릅니다.
예외 처리는 실패를 알려주는 거고,
트랜잭션은 실패했을 때 데이터 상태를 원래대로 되돌리는 문제예요.
5. 읽기 작업에도 무조건 무거운 트랜잭션을 걸려고 함
모든 작업에 같은 수준의 트랜잭션이 필요한 건 아닙니다.
Spring Data 쪽도 읽기 작업은 readOnly=true 같은 구성을 사용하기도 합니다. (Home)
트랜잭션을 쓸 때 기억할 현실적인 기준
초반에는 이 정도만 기억해도 꽤 좋습니다.
- 여러 저장/수정 작업이 하나의 업무라면 트랜잭션 고려
- 트랜잭션 경계는 보통 서비스 메서드
- repository는 트랜잭션 안에서 호출되는 구성요소
- 외부 API 호출은 가급적 트랜잭션 밖으로 빼는 방향 고민
- 트랜잭션은 짧고 명확하게
이게 정말 중요해요.
트랜잭션은 “많이 걸수록 좋은 안전장치”가 아니라
정확한 범위에만 거는 도구에 가깝습니다.
이번 글 핵심 정리
이번 글의 핵심은 딱 하나입니다.
트랜잭션은 여러 데이터 변경을 하나의 비즈니스 작업으로 묶어주는 장치다.
그래서 보통 서비스 계층과 같이 이야기됩니다.
오늘 글의 감각을 다시 압축하면 이렇습니다.
- 여러 저장 작업이 같이 성공/실패해야 하면 트랜잭션이 필요하다
- 트랜잭션 경계는 보통 서비스 계층에 둔다
- repository는 그 안에서 DB 작업을 수행한다
- FastAPI는 ORM Session 중심
- Spring Boot는 @Transactional 중심
- Node.js는 ORM/DB 클라이언트의 transaction API 중심
이 감각이 잡히면
나중에 주문, 결제, 포인트, 재고 같은 복잡한 기능을 설계할 때 훨씬 덜 흔들립니다.
다음 글 예고
다음 글에서는 이 흐름을 이어서
비밀번호 저장, 해시, 인증 기초로 넘어가 보겠습니다.
여기까지 오면 회원가입 구조는 거의 갖춰졌고, 이제 자연스럽게 이런 질문이 나와요.
- 비밀번호를 왜 그대로 저장하면 안 되지?
- SHA-256으로만 해시하면 충분한가?
- bcrypt, Argon2, PasswordEncoder는 언제 쓰지?
- 인증 로직은 서비스와 어떻게 연결하지?
다음 글에서는 FastAPI, Spring Boot, Node.js 각각에서
비밀번호를 안전하게 다루는 기본기를 정리해보겠습니다.
출처
- Spring Framework 공식 문서 — Transaction Management (Home)
- Spring Framework 공식 문서 — Using @Transactional (Home)
- Spring Framework 공식 문서 — Transaction Propagation (Home)
- Spring Framework Javadoc — @Transactional (Home)
- Spring Framework 공식 문서 — Programmatic Transaction Management (Home)
- SQLAlchemy 공식 문서 — Transactions and Connection Management (SQLAlchemy)
- SQLAlchemy 공식 문서 — Session Basics (SQLAlchemy)
- SQLAlchemy 공식 문서 — ORM Quick Start / Session context manager 권장 (SQLAlchemy)
- FastAPI 공식 문서 — SQL Databases 튜토리얼 (Home)
- Prisma 공식 문서 — Transactions and batch queries (Prisma)
- Prisma 공식 문서 — Long-running transactions 경고 (Prisma)
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, Transaction, 트랜잭션, 데이터일관성, ServiceLayer, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Prisma
- seo 최적화 10개
- SEO최적화
- PostgreSQL
- Docker
- ai철학
- 개발블로그
- DevOps
- node.js
- Python
- CI/CD
- nextJS
- llm
- 압박면접
- kotlin
- Express
- rag
- NestJS
- Redis
- JAX
- JWT
- 쿠버네티스
- 백엔드개발
- REACT
- fastapi
- Next.js
- 딥러닝
- flax
- 웹개발
- LangChain
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |

