개발 환경
- Spring Boot + JPA
- PostgreSQL
- HikariCP(Connection Pool)
- SSE(Server-Sent Events)
1. 문제 상황
써봄 프로젝트에서 사용자가 특정 글에 반응을 남기면 실시간으로 글 작성자에게 알림을 전송하는 기능을 구현하려고 했습니다.
초기 코드 구조
@Service
public class NotificationService {
private final NotificationRepository notificationRepository;
private final SseEmitterService sseEmitterService;
@Transactional
public void sendNotification(Long userId, String message) {
// 1. 알림 데이터를 DB에 저장
Notification notification = Notification.builder()
.userId(userId)
.message(message)
.createdAt(LocalDateTime.now())
.build();
notificationRepository.save(notification);
// 2. SSE를 통해 실시간 전송
sseEmitterService.send(userId, notification);
}
}
API 개발 후 프론트 팀원이 연동을 진행하던 중, 처음에는 잘 동작하지만 시간이 지날 수록 응답이 느려지고 결국 테스트 서버가 먹통이 되는 문제가 발생했습니다.
- 처음에는 정상 동작하다가 점진적으로 느려짐
- DB Connection Timeout 발생
- 서버는 정상이지만 DB 접근에서 병목
- 테스트 서버 재시작 후 동일 패턴 반복
2. 원인
처음에는 DB Connection Pool 설정 문제인 줄 알고 maximumPoolSize, connectionTimeout 등을 조정해 봤지만 문제는 계속 발생했습니다.
HikariCP 로그를 확인해보니 Active Connection이 계속 증가하다가 Pool이 고갈되는 패턴이 반복되고 있었습니다. 이상한 점은 DB 작업 자체는 빠른데(ms) Connection이 오래 점유되고 있다는 것이었습니다.
이때 문득 관성으로 붙이는 @Transactional 때문일 수도 있겠다는 생각이 들었습니다.
트랜잭션의 동작 원리
Spring의 @Transactional은 다음과 같이 동작합니다.
1. 메서드 시작 → 트랜잭션 시작, DB 커넥션 획득
2. 메서드 내부 로직 실행
3. 메서드 정상 종료 → COMMIT, DB 커넥션 반환
4. 예외 발생 → ROLLBACK, DB 커넥션 반환
즉, DB 커넥션은 트랜잭션이 살아있는 동안 계속 점유됩니다.
문제 원인
초기 코드에서 @Transactional이 메서드 전체에 걸려 있었고, 프론트에서 api 연동 작업을 하며 계속 타임아웃이 일어나고 있었기 때문에 DB 커넥션이 점유된 채로 유지되고 있었습니다.
@Transactional // ← 여기서 DB 커넥션 획득
public void sendNotification(Long userId, String message) {
notificationRepository.save(notification); // DB 작업 (0.01초)
sseEmitterService.send(userId, notification); // 네트워크 I/O (1~2초)
// ↑ 이 작업이 끝날 때까지 DB 커넥션 계속 점유
}
// ← 여기서야 비로소 DB 커넥션 반환
트랜잭션과 네트워크 I/O를 같은 범위에서 처리하고 있어 트랜잭션이 불필요하게 길어진 것이 원인이었습니다.
3. 해결 방법
트랜잭션과 네트워크 I/O를 분리하는 것이 필요했습니다. DB 작업은 트랜잭션 안에서 처리하되, SSE 전송은 트랜잭션 커밋 후에 실행되도록 로직을 개선했습니다.
@Service
public class NotificationService {
private final NotificationRepository notificationRepository;
private final SseEmitterService sseEmitterService;
@Transactional
public void createReactionNotification(Post post, User actor, ReactionType newType) {
// 1. DB 작업: 알림 저장
Notification notification = Notification.of(receiver, post, newType, count);
Notification savedNoti = notificationRepository.save(notification);
// 2. 트랜잭션 커밋 후 SSE 전송되도록 등록
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
sendNotificationAfterCommit(savedNoti);
}
}
);
// 여기서 트랜잭션 종료 → DB 커넥션 반환
}
// 트랜잭션 밖에서 실행됨
private void sendNotificationAfterCommit(Notification notification) {
sseEmitterService.send(notification.getReceiver().getUserId(), notification);
}
}
@Transactional메서드 안에서 모든 로직 처리TransactionSynchronizationManager를 사용해 커밋 후 실행할 작업 등록- SSE 전송은
afterCommit()시점에 실행되어 트랜잭션과 분리 - DB 커넥션은 트랜잭션 커밋 즉시 반환됨
동작 흐름 비교
개선 전:
@Transactional 시작
→ DB 커넥션 획득
→ 알림 저장 (0.01초)
→ SSE 전송 (1~2초) ← 여기서도 계속 커넥션 점유
→ 트랜잭션 종료, 커넥션 반환
총 커넥션 점유 시간: 1~2초
개선 후:
@Transactional 시작
→ DB 커넥션 획득
→ 알림 저장 (0.01초)
→ afterCommit 콜백 등록
→ 트랜잭션 종료, 커넥션 반환 ← 즉시 반환!
afterCommit 실행 (트랜잭션 밖)
→ SSE 전송 (1~2초) ← 커넥션과 무관
총 커넥션 점유 시간: 0.01초
4. 결과
알림 발생 시 전체 시스템에 영향을 주지 않으면서 동시 접속자가 증가해도 안정적으로 운영할 수 있도록 만들었습니다.
- DB Active Connection이 정상 범위(5~10개)로 유지
- Connection Timeout 문제 완전히 해결
- 동시 접속자 증가 시에도 안정적 운영
- 서버 재시작 없이 장시간 안정 운영 가능
프론트 팀원 테스트 결과 더 이상 느려지거나 타임아웃이 발생하지 않는 것을 확인했습니다.
5. 정리
트랜잭션 안에서 하면 안 되는 것들
- 외부 API 호출
- 이메일 전송
- 파일 업로드
- SSE 전송
- Websocket 전송
- 메시지 큐 전송
- 긴 대기(Thread.sleep())
이유
이러한 작업들은 모두 네트워크 I/O이거나 시간이 오래 걸리는 작업입니다. 트랜잭션은 DB 커넥션을 점유하므로 트랜잭션 안에서 시간이 오래 걸리는 작업을 하면 여러 사용자가 서비스를 사용할 때 Connection Pool 고갈로 인해 전체 시스템 장애가 발생할 수 있습니다.
해결 방법
1. 메서드 분리 방식
DB 작업 메서드에만 @Transactional 적용하여 네트워크 I/O 메서드와 완전히 분리
2. TransactionalSynchronization 방식 (이번에 적용한 방법)
@Transactional 메서드 안에서 afterCommit() 콜백 등록
트랜잭션 커밋 성공 후에만 네트워크 I/O 실행(트랜잭션 일관성 보장)
Spring의 TransactionSynchronizationManager :
트랜잭션 생명주기의 특정 시점에 콜백을 실행할 수 있게 해줍니다.
비동기가 아닌 지연 실행의 개념이며 같은 스레드에서 순차 실행합니다.
3. 비동기 처리(@Async)
네트워크 I/O를 별도 스레드에서 비동기로 처리(단, 에러 처리와 재시도 로직 필요)
트랜잭션 사용 원칙
- 트랜잭션 범위는 DB 작업으로만 최소화
- 네트워크 I/O는 트랜잭션 밖에서 처리
- 외부 시스템 호출 실패가 트랜잭션에 영향 없도록 설계
- 필요시 보상 트랜잭션(Compensation) 패턴 고려
느낀 점
그동안 관성으로 붙였던 @Transactional에 대해 깊이 있게 공부할 수 있었던 경험이었습니다. 이 경험을 통해 트랜잭션 범위를 항상 최소화하는 습관의 중요성, 네트워크 I/O와 DB 작업은 항상 분리해야 한다는 점, 문제 발생 시 로그와 모니터링을 통한 체계적 분석의 필요성을 배웠습니다.
특히 “왜 이 어노테이션을 붙이는가?“에 대한 명확한 이유 없이 관성적으로 코드를 작성하는 것의 위험성을 깨달았습니다. 앞으로는 항상 이유가 있는 코드 작성을 습관화하려고 합니다.
처음에는 “Pool 크기를 늘리면 되겠지”라고 쉽게 생각했지만, 근본 원인을 파악하고 해결하는 과정에서 더 많이 배웠습니다. SSE 알림 기능 개발 시 같은 문제로 고민하시는 분들께 도움이 되었으면 좋겠습니다.