은행 앱에서 A가 B에게 10만 원을 송금하는 상황을 상상해 봅시다. 서버는 두 가지 쿼리를 실행합니다.
1. A의 통장에서 10만 원 차감 (UPDATE)
2. B의 통장에 10만 원 증가 (UPDATE)
그런데 1번 쿼리가 성공한 직후, 갑자기 데이터베이스 서버의 전원이 나갔습니다!
A의 돈은 10만 원이 증발했는데, B는 돈을 받지 못했습니다. 뉴스에 대서특필될 만한 엄청난 사고입니다. 이런 대형 사고를 막기 위해 데이터베이스는 "1번과 2번 쿼리를 하나로 묶어라. 둘 다 성공할 때만 최종 반영(Commit)하고, 하나라도 실패하면 1번 쿼리도 아예 없었던 일로 롤백(Rollback) 시켜라!"라는 논리적 작업 단위를 제공하는데, 이것이 바로 트랜잭션(Transaction)입니다.
스프링 부트(Spring Boot)에서는 이 복잡한 개념을 @Transactional 어노테이션 단 하나로 우아하게 해결합니다. 하지만 실무에서 이 어노테이션의 함정(예외 처리 방식, 프록시 원리)을 모르고 쓰면 데이터가 롤백되지 않고 그대로 DB에 꽂히는 대참사가 발생합니다.
1. 롤백의 배신: Unchecked Exception vs Checked Exception
실무 개발자들이 가장 많이 하는 실수 1위입니다. @Transactional을 붙여놓고 예외(Exception)가 터졌는데도 롤백이 안 되어 데이터가 꼬이는 현상입니다.
스프링의 트랜잭션은 RuntimeException(Unchecked Exception)과 Error가 발생했을 때만 자동 롤백을 수행합니다. 만약 파일 처리 중 발생하는 IOException이나 SQLException 같은 Checked Exception이 터지면,
스프링은 "어? 개발자가 의도한 에러인가 보네?" 하고 그냥 커밋(Commit)을 실행해버립니다!
@Service
public class TransferService {
// 최악의 코드: Checked Exception 발생 시 롤백 안 됨! A의 돈만 날아감!
@Transactional
public void transferMoney(Long from, Long to, int amount) throws IOException {
accountRepo.decrease(from, amount); // 1. A 돈 차감 성공 (DB 커밋 예약)
if (true) throw new IOException("네트워크 오류 발생!"); // 2. 에러 터짐!
// 하지만 IOException은 Checked 예외라 롤백되지 않고 1번 작업이 그대로 DB에 커밋됨!!
// 실무 표준 코드: 모든 예외(Exception.class)에 대해 무조건 롤백하라고 강제 명시!
@Transactional(rollbackFor = Exception.class)
public void transferMoneySafe(Long from, Long to, int amount) throws IOException {
accountRepo.decrease(from, amount);
if (true) throw new IOException("네트워크 오류 발생!"); // Exception.class 하위이므로 안전하게 롤백됨!
}
}
🪞 2. AOP 프록시의 한계: 내부 호출(Self-Invocation)의 덫
스프링의 @Transactional은 객체를 감싸는 프록시(Proxy)라는 가짜 객체가 동작하는 원리입니다. 외부(Controller)에서 Service를 호출할 때 프록시가 문지기 역할을 하며 트랜잭션을 열어줍니다.
그런데, 같은 클래스 내부에 있는 다른 메서드를 직접 호출(this.method())하면 어떻게 될까요? 프록시 문지기를 거치지 않고 클래스 내부에서 직통으로 넘어가버리기 때문에 트랜잭션이 아예 작동하지 않습니다! (이것도 실무 면접 단골 질문입니다.)
@Service
public class OrderService {
// 1. 컨트롤러가 호출하는 시작점 (트랜잭션 없음)
public void placeOrder() {
// 내부 메서드를 호출합니다 (this.saveOrder() 와 동일)
saveOrder();
}
// 2. 트랜잭션을 걸어둔 핵심 메서드
@Transactional
public void saveOrder() {
// 큰일 났습니다! 외부에서 프록시를 타고 들어온 게 아니라 내부에서 직통으로 호출했으므로,
// @Transactional 어노테이션이 완벽하게 무시되고 트랜잭션 없이 쿼리가 실행됩니다!
orderRepo.save(new Order());
}
}
해결책: 트랜잭션이 필요한 메서드는 반드시 다른 클래스로 분리하거나, 부득이하다면 자기 자신(Self)을 의존성 주입받아서 호출해야 프록시를 정상적으로 탈 수 있습니다.
3. 트랜잭션 전파(Propagation) 속성: 롤백의 연대 책임
트랜잭션 A가 진행 중인데, 그 안에서 트랜잭션 B를 또 호출하면 어떻게 될까요? "기존 트랜잭션에 합류할 것인가, 아니면 아예 새로운 독립된 트랜잭션을 팔 것인가?"를 결정하는 것이 전파(Propagation) 속성입니다.
- REQUIRED (기본값): 실무의 99%를 차지합니다. 이미 진행 중인 트랜잭션이 있으면 거기에 '합류'하고, 없으면 새로 만듭니다. 합류한 경우 B에서 에러가 터지면 A까지 전체가 다 롤백되는 연대 책임을 집니다.
- REQUIRES_NEW: 항상 무조건 새로운 '독립된' 트랜잭션을 새로 엽니다. A가 돌다가 B를 만나면 A를 멈춰두고 B 트랜잭션을 엽니다. B에서 에러가 터져도 B만 롤백되고 A는 무사히 진행(커밋)됩니다. (예: 결제가 실패하더라도, '결제 실패 로그'는 반드시 DB에 저장(커밋)해야 할 때 로그 저장 메서드에 이 속성을 줍니다.)
4. 마무리 및 주요 발전 방향
지금까지 데이터베이스의 무결성을 지키는 수호신이지만, Checked 예외와 내부 호출(Self-Invocation)이라는 무시무시한 함정을 숨기고 있는 @Transactional의 프록시 원리와 전파(Propagation) 속성 제어법에 대해 다루어 보았습니다. 이제 여러분은 돈이 날아가고 데이터가 꼬이는 최악의 대참사를 완벽히 방어할 수 있습니다.
트랜잭션은 철저하게 동기식(Synchronous)으로 동작합니다. 로직 하나가 끝날 때까지 유저는 화면 앞에서 멍하니 기다려야 하죠. 만약 회원가입이 완료된 직후 '축하 이메일 보내기' 기능이 추가되었는데, 메일 서버가 느려서 전송에 5초가 걸린다면? 유저는 회원가입 버튼을 누르고 5초 동안 멈춘 브라우저를 보며 이탈해버릴 것입니다. 이런 무거운 작업은 "일단 가입 완료 띄워주고, 메일 발송은 뒷단에서 조용히 몰래 처리해라!"라고 지시해야 합니다. 본 설명에서는 "유저를 기다리게 하지 마라! 메인 쓰레드의 짐을 덜어주는 비동기 프로그래밍의 마법!" Spring Boot @Async 설정과 스케줄링(@Scheduled) 백그라운드 처리 실무 완벽 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!
여러분이 야심 차게 런칭한 서비스의 회원가입 로직을 짜고 있습니다. 1. 유저 정보 DB 저장 -> 2. 포인트 지급 -> 3. 환영 이메일 발송 순서로 코드를 짰습니다. DB 저장은 0.1초면 끝나지만, 외부 메일 서버를 거치는 '이메일 발송'은 무려 3초가 걸립니다. 사용자가 [가입하기] 버튼을 누르면 이 모든 작업이 '직렬(동기)'로 실행되어 브라우저 화면이 3초 넘게 하얗게 멈춰있게 됩니다. 성질 급한 한국 유저들은 서버가 죽은 줄 알고 새로고침을 연타하다가 결국 이탈해 버릴 것입니다. 이럴 때 백엔드 개발자의 센스가 발휘되어야 합니다. "이메일 발송 같은 건 메인 응답에 영향을 주지 않도록 다른 쓰레드(알바생)에게 짬처리(?) 시키고, 나는 유저에게 0.1초 만에 '가입 성공!'을 외쳐주자!" 이것이 바로 비동기 처리(Asynchronous)의 핵심 철학입니다. 스프링 부트(Spring Boot)는 이 복잡한 멀티 쓰레딩 작업을 @Async 어노테이션 하나로 퉁쳐버리는 엄청난 마법을 제공합니다.
본 글에서는 "빠릿빠릿한 API 스피드를 위한 필수 스킬!" @Async 비동기 처리와 @Scheduled를 활용한 배치(Batch) 스케줄링 완벽 실무 가이드에 대하여 다뤄보겠습니다.
1. @EnableAsync: 비동기 엔진 활성화하기
비동기를 쓰려면 가장 먼저 스프링 부트 애플리케이션에 "지금부터 비동기 엔진을 켤게!"라고 선언해야 합니다. 그리고 무작정 켜기만 하면 스프링이 요청이 올 때마다 끝도 없이 쓰레드(알바생)를 무한 생성하여 메모리가 터질 수 있으므로, 쓰레드 풀(Thread Pool, 알바생 대기실)을 명시적으로 설정해 주는 것이 실무 기본기입니다.
@EnableAsync // 비동기 기능 활성화의 핵심!
@Configuration
public class AsyncConfig {
// 우리가 통제할 수 있는 쓰레드 풀(알바생 대기실)을 만듭니다.
@Bean(name = "mailExecutor")
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 기본으로 대기할 최소 알바생 수 (5명)
executor.setMaxPoolSize(30); // 일감이 폭주하면 최대 고용할 수 있는 알바생 수 (30명)
executor.setQueueCapacity(100); // 알바생이 다 바쁠 때 일감을 쌓아둘 대기줄(큐) 크기 (100개)
executor.setThreadNamePrefix("Mail-Async-"); // 로그에 찍힐 쓰레드 이름
executor.initialize();
return executor;
}
}
2. @Async의 마법: 메인 쓰레드는 퇴근해!
설정이 끝났으면 이메일 발송 메서드 위에 @Async를 달아주기만 하면 끝입니다. 그러면 메인 톰캣(Tomcat) 쓰레드는 이 메서드를 호출하는 즉시, 일을 백그라운드 쓰레드에게 던져버리고 뒤도 안 돌아보고 자신의 길을(클라이언트 응답) 떠납니다.
@Service
@RequiredArgsConstructor
public class EmailService {
// 이 어노테이션이 붙은 메서드는 메인 쓰레드가 멈춰 서서 기다리지 않고 즉시 통과해 버립니다!
// 아까 설정한 "mailExecutor" 알바생 그룹이 3초가 걸리든 10초가 걸리든 뒤에서 알아서 처리합니다.
@Async("mailExecutor")
public void sendWelcomeEmail(String email) {
try {
// 이메일 발송에 3초가 걸린다고 가정합시다.
Thread.sleep(3000);
log.info(" {} 님께 환영 이메일 발송 완료!", email);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
※ 주의 (AOP 프록시 함정): 이전 @Transactional과 마찬가지로 @Async 역시 프록시(Proxy) 기반으로 동작합니다. 따라서 같은 클래스 내부에서 자기 자신의 메서드를 호출하면 비동기가 씹히고 동기로 작동해버리는 함정이 똑같이 존재합니다! 비동기 메서드는 반드시 '다른 클래스'로 빼서 호출해야 합니다.
3. @Scheduled: 서버 안의 작은 크론(Cron) Job
"매일 자정(밤 12시)마다 기간이 만료된 쿠폰을 일괄 삭제해라", "1시간마다 방문자 통계를 갱신해라" 같은 주기적인 백그라운드 작업은 어떻게 처리할까요? 리눅스의 crontab을 써도 되지만, 스프링 부트 안에서는 @Scheduled 하나로 코드로 관리할 수 있습니다.
@EnableScheduling // 스케줄링 마법 활성화 (보통 설정 클래스나 Application 클래스에 붙임)
@Service
public class CouponBatchService {
// 1. Fixed Rate: 이전 작업이 안 끝났어도 무조건 5초(5000ms)마다 무자비하게(?) 실행!
@Scheduled(fixedRate = 5000)
public void runEvery5Seconds() {
log.info("5초마다 실행되는 핑(Ping) 테스트...");
}
// 2. Cron Expression: 실무에서 가장 많이 쓰는 크론 표현식! (초 분 시 일 월 요일)
// 아래 설정은 "매일 밤 자정(0초 0분 0시)에 실행해라!" 라는 뜻입니다.
@Scheduled(cron = "0 0 0 * * *")
public void deleteExpiredCoupons() {
log.info(" 매일 자정, 만료된 쿠폰 일괄 정리 배치 작업 시작!");
// DB 쿠폰 정리 로직...
}
}
4. 마무리 및 주요 발전 방향
지금까지 클라이언트의 인내심을 갉아먹는 무거운 I/O 작업(이메일, 알림톡, 통계 등)을 백그라운드 쓰레드 풀로 우아하게 던져버려 응답 스피드를 극한으로 끌어올리는 @Async 비동기 처리와, 주기적인 작업을 코드로 깔끔하게 관리하는 @Scheduled 스케줄링 기법에 대해 다루어 보았습니다. 이제 여러분의 서버는 지연 없는 쾌적한 핫스팟이 되었습니다.
비즈니스 로직도 완벽하고, 속도도 비동기로 빠르고 훌륭합니다. 이제 유저들이 신나서 회원가입을 하고 자신의 '프로필 이미지 사진'을 올리려고 합니다. 이때 사진 파일(JPEG, PNG)을 서버의 디스크(하드 드라이브)에 직접 저장하면 어떻게 될까요? 나중에 사용자가 늘어나서 서버를 3대로 늘리면(Scale-out), 1번 서버에 저장된 이미지가 2번 서버에는 없어서 사진이 깨지는 치명적인 '엑스박스' 참사가 벌어집니다!
다음글에서는 "이미지와 영상은 서버가 아니라 클라우드 창고에 쑤셔 넣어라!" 현대 백엔드 파일 업로드의 글로벌 스탠다드, AWS S3 버킷(Bucket) 연동과 MultipartFile 처리 실무 아키텍처에 대해 다루겠습니다.
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] Apache Kafka 연동 MSA의 이벤트 주도 비동기 통신 (0) | 2026.10.14 |
|---|---|
| [Spring Boot] 파일 업로드와 AWS S3 클라우드 사용하기 (0) | 2026.10.13 |
| [Spring Boot] OAuth 2.0 소셜 로그인 구현(카카오 네이버 구글 로그인) (0) | 2026.10.12 |
| [Spring Boot] Spring Security - JSON Web Token 구현 (0) | 2026.10.12 |
| [Spring Boot] Redis 캐싱(Caching)을 이용하여 응답 속도 단축하기 (0) | 2026.10.08 |