[ezcode] Spring Boot MongoDB 트랜잭션 도입

NCOOKIE·2025년 10월 7일

ezcode

목록 보기
7/8

도입 이유

현재 ezcode 프로젝트에서는 ActiveMQ를 통해서 알림 이벤트가 들어오면 먼저 MongoDB에 알림 데이터를 저장하고 대상 유저에게 메시지를 전송한다.

만약 이벤트 처리 중 에러가 발생하면 해당 이벤트는 DLQ에 들어가서 여러 번 실행되는데, 문제는 MongoDB에도 데이터가 여러 번 저장된다. 기본적으로 MongoDB는 트랜잭션을 지원하지 않기 때문이다.

@JmsListener(destination = NOTIFICATION_QUEUE_CREATE)
public void handleNotificationCreateEvent(String message) {

	NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class);
	NotificationDocument notification = notificationService.createNewNotification(event);

	messagingTemplate.convertAndSendToUser(
		event.principalName(),
		"/queue/notification",
		NotificationResponse.from(notification)
	);
}

MongoDB 트랜잭션 동작 방식

Standalone

MongoDB는 기본적으로 Standalone 모드로 실행된다.

이 모드는 개발 및 테스트용으로는 유용하지만, 단일 장애 지점(SPOF, single point of failure)이 될 수 있으므로 어려우므로 실제 배포 환경에서 추천되는 선택지는 아니다.
또한 여러 문서에 걸친 복잡한 작업을 하나의 단위로 묶어 성공과 실패를 보장해 줄 핵심적인 메커니즘이 존재하지 않으므로 트랜잭션 또한 지원되지 않는다.

그래서 공식 문서에서는 프로덕션 환경에서는 replica set으로 전환할 것을 권장한다.

replica set

replica set이란, 동일한 데이터 복제본을 여러 서버에 저장하여 데이터 안정성과 고가용성을 확보하는 기술이다.
만약 메인으로 사용하던 노드에 장애가 발생하더라도 다른 복제본이 그 역할을 대신해줘서 서비스가 중단되지 않도록 한다.

replica set은 세 가지 역할로 구성된다.

  • Primary Node(주 노드)
    • 오직 하나만 존재하며, 모든 데이터 변경(쓰기) 요청을 처리
    • 변경된 내용을 다른 복제본들에게 알려주는 역할
  • Secondary Node(보조 노드)
    • 주 노드의 데이터를 그대로 복제하여 만약을 대비
    • 만약 주 노드에 장애가 발생하면 투표를 통해 주 노드로 선출됨
  • Arbiter (중재자)
    • 데이터는 보유하지 않고 선거에만 참여함
    • 투표를 하는 노드 수가 짝수일 때 결과가 확정되지 않는 상황을 방지
    • 필수는 아니고 선택사항

oplog

주 노드에서 데이터를 쓰고, 보조 노드로 oplog(Operation Log)를 통해 데이터를 복제한다.

oplog는 다음과 같은 순서로 동작한다.

  1. 주 노드에 데이터 변경(쓰기, 수정, 삭제 등)이 발생
  2. 주 노드는 이 작업을 자신의 oplog에 기록
  3. 보조 노드들은 이 oplog를 계속 지켜보다가, 새로운 작업 기록이 생기면 가져와서 자신의 노드에 그대로 적용

트랜잭션 동작

트랜잭션 중에는 주 노드 데이터를 쓰고, oplog를 통해 보조 노드에 복제한다. 이렇게 동기화를 해서 데이터의 스냅샷 상태를 보장할 수 있다.

Standalone의 경우 복제본이 없으니 당연히 oplog도 없고, 트랜잭션도 지원되지 않는다.

Spring Boot MongoDB 트랜잭션 적용

아래 내용은 https://velog.io/@juhwanheo/Spring-Boot-MongoDB-Transaction 내용을 참고해서 내 로컬 개발 환경(Windows 11, docker)에 맞게 수정했다.

테스트 코드 작성

트랜잭션이 정상적으로 적용되는지 테스트 코드를 통해 확인하려고 한다.

@SpringBootTest
@ActiveProfiles("test")
public class MongoTransactionTest {

	@Autowired
	private NotificationMongoRepository mongoRepository;

	@Test
	@Transactional
	void transactionTest() {
		mongoRepository.save(NotificationDocument.from(
			NotificationCreateEvent.of(
				"test@test.com",
				NotificationType.COMMUNITY_DISCUSSION_VOTED_UP,
				null
			)
		));
	}
}

스프링에서는 기본적으로 MongoDB 트랜잭션을 지원하지 않는다. 때문에 이대로 실행하면 에러가 발생할 것이다.

Spring Configuration 추가

@Configuration
public class MongoTransactionConfig {

	@Bean
	public MongoTransactionManager transactionManager(MongoDatabaseFactory mongoDatabaseFactory) {
		return new MongoTransactionManager(mongoDatabaseFactory);
	}
}

트랜잭션 어노테이션을 사용하기 위해 설정을 해줬다.

이후 테스트 코드 실행하면, 기본적으로 설치된 mongodb에서는 트랜잭션을 지원하지 않기 때문에 replica set을 구성하라고 에러 메시지가 뜰 것이다.

docker-compose를 사용하여 MongoDB replica set 구성

docker-compose

  • docker-compose.yml
    • replica set은 primary 노드 1개, secondary 노드 2개해서 총 3개로 구성했다.
    • 각자 다른 포트를 할당해줘야 한다.
services:
    rs01p:
        image: mongo:6.0
        container_name: rs01p
        ports:
            - "10021:10021"
        volumes:
            - ./replicaset/rs01p:/data/db
            - ./scripts:/scripts
            - ./js:/js
        command:
            - '--port'
            - '10021'
            - '--replSet'
            - 'rs01'
            - '--bind_ip_all'
    rs01s:
        image: mongo:6.0
        container_name: rs01s
        ports:
            - "10022:10022"
        volumes:
            - ./replicaset/rs01s:/data/db
        command:
            - '--port'
            - '10022'
            - '--replSet'
            - 'rs01'
            - '--bind_ip_all'
    rs01a:
        image: mongo:6.0
        container_name: rs01a
        ports:
            - "10023:10023"
        volumes:
            - ./replicaset/rs01a:/data/db
        command:
            - '--port'
            - '10023'
            - '--replSet'
            - 'rs01'
            - '--bind_ip_all'

테스트를 위한 스크립트 작성

아래 스크립트들은 사용하지 않고 터미널 상에서 직접 타이핑해도 되지만, 나는 설정 과정에서 시행착오를 겪느라 초기화 및 재설정을 하는 스크립트를 작성해줬다.

  • restart-mongodb-replica.sh
    • 환경 재설정 시 직접 실행하게 될 스크립트 파일이다.
    • 본인의 환경에 맞게 path, name 등을 설정해주자
    • docker 컨테이너 정지, 삭제, 재시작 등 수행 후 컨테이너 내부의 mongo에 접속해서 설정을 진행한다.
DATA_FILE_PATH="./replicaset"
DOCKER_FILE_PATH="./docker-compose.dev.yml"
MONGO_PRIMARY_NAME="rs01p"
REPLICA_INIT_FILE_PATH="./scripts/rs-init.sh"
MONGO_CREATE_USER_FILE_PATH="./scripts/mongo-create-user.sh"

UP_CONTAINER_DELAY=10
REPLICA_CONFIG_DELAY=25

echo "****************** Reset docker container Shell Script ******************"
echo "Data File Path: ${DATA_FILE_PATH}"
echo "Docker File Path: ${DOCKER_FILE_PATH}"
echo "MongoDB Primary name: ${MONGO_PRIMARY_NAME}"
echo "Replica set init Script File Path: ${REPLICA_INIT_FILE_PATH}"
echo "Mongo create user file path: ${MONGO_CREATE_USER_FILE_PATH}"

sleep 1;

echo "****************** Stop docker container ******************"
docker-compose -f ${DOCKER_FILE_PATH} stop
echo "****************** Completed Stop docker container ******************"

sleep 1;

echo "****************** Down docker container ******************"
docker-compose -f ${DOCKER_FILE_PATH} down
echo "****************** Completed Down docker container ******************"

sleep 1;

echo "****************** Remove Data ******************"
rm -rf ${DATA_FILE_PATH}
echo "****************** Completed Remove Data ******************"

sleep 1;

echo "****************** Up docker container ******************"
docker-compose -f ${DOCKER_FILE_PATH} up -d 
echo "****************** Completed Up docker container ******************"

echo "****** Waiting for ${UP_CONTAINER_DELAY} seconds ******"
sleep $UP_CONTAINER_DELAY;

echo "****************** Run Replica Set Shell Script ******************"
docker exec -i ${MONGO_PRIMARY_NAME} bash < ${REPLICA_INIT_FILE_PATH}

echo "****** Waiting for ${REPLICA_CONFIG_DELAY} seconds for replicaset configuration to be applied ******"
sleep $REPLICA_CONFIG_DELAY

echo "****************** Run Create DB User Shell Script ******************"
docker exec -i ${MONGO_PRIMARY_NAME} bash < "${MONGO_CREATE_USER_FILE_PATH}"

echo "****************** Completed Replica Shell Script ******************"
  • scripts/rs-init.sh
    • docker-compose에서 설정한 서비스 이름과 포트 등이 일치하도록 주의하자
    • priority를 할당해 primary, secondary 등이 설정되도록 한다.
mongosh --port 10021 <<EOF
use ezcode;
var config = {
    "_id": "rs01",
    "version": 1,
    "members": [
        {
            "_id": 1,
            "host": "rs01p:10021",
            "priority": 2
        },
        {
            "_id": 2,
            "host": "rs01s:10022",
            "priority": 1
        },
        {
            "_id": 3,
            "host": "rs01a:10023",
            "priority": 0
        }
    ]
};
rs.initiate(config);
rs.status();
db.setProfilingLevel(2);
db.getProfilingStatus();
EOF
  • scripts/mongo-create-user.sh
    • 인증용 사용자를 생성한다.
    • db 이름이나 role은 임의로 수정하면 된다.
mongosh --port 10021 <<EOF
use admin;
db.createUser({
    user: "mongo",
    pwd: "mongo123",
    roles: [
      {
        role: "dbOwner",
        db: "ezcode",
      },
    ],
});
db.getUsers();
EOF
  • hosts 설정
    • mongo 인스턴스를 docker 내부에 띄워서 실행하고 있다. 때문에 properties, yml 등에서 localhost로 접속하려고 하면 connection timeout 또는 refused 에러가 발생한다.
    • rs01p, rs01s 같은 이름은 도커 내부 네트워크 안에서만 통용되는 이름이기 때문에 스프링 -> 도커 접속 시 몽고 서버를 찾지 못해 시간 초과 에러 등이 발생한다.
    • 이를 방지하기 위해 hosts 파일을 수정해야 한다. (윈도우 기준 C:\Windows\System32\drivers\etc\hosts)
# MongoDB Docker Replica Set for Local Dev
127.0.0.1   rs01p
127.0.0.1   rs01s
127.0.0.1   rs01a

환경 설정 시 트랜잭션이 정상적으로 동작한다면 최초 1번만 스크립트 실행하면 되고, 그게 아니라면 나처럼 여러 번 실행해가며 어디가 문제인지 찾아봐야 한다.

스프링 설정

  • applicatoin-test.properties
spring.data.mongodb.uri=mongodb://mongo:mongo123@rs01p:10021,rs01s:10022,rs01a:10023/ezcode?authSource=admin&replicaSet=rs01

적용 확인

@Transactional
@JmsListener(destination = NOTIFICATION_QUEUE_CREATE)
public void handleNotificationCreateEvent(String message) {

	NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class);
	NotificationDocument notification = notificationService.createNewNotification(event);

	throw new RuntimeException();

	// messagingTemplate.convertAndSendToUser(
	// 	event.principalName(),
	// 	"/queue/notification",
	// 	NotificationResponse.from(notification)
	// );
}
  • 적용 전
  • 적용 후
    • 이벤트가 DLQ에 들어가서 여러 번 반복해서 실행되었음에도 몽고 DB에 데이터는 저장되지 않음

코드 개선

문제점

현재 코드에서는 @Transactional 메서드 안에서 DB 저장과 알림 발송이 순차적으로 일어난다.

@Transactional
public void handleNotificationCreateEvent(...) {
    // 1. 트랜잭션 시작
    
    // 2. DB에 저장 (아직 커밋되지 않고 트랜잭션 내에만 존재)
    NotificationDocument notification = notificationService.createNewNotification(event);

    // 3. 알림 메시지 발송 (이 작업은 롤백되지 않음!)
    messagingTemplate.convertAndSendToUser(...);
    
    // 4. 메서드 종료 시점에 트랜잭션 커밋
}

만약 3번에서 알림이 발송된 직후, 4번 커밋 단계에서 데이터베이스 문제 등 예측하지 못한 이유로 트랜잭션이 롤백된다면?

사용자는 알림을 받았지만, 정작 DB에는 해당 알림 데이터가 존재하지 않는 유령 데이터가 발생한다.

해결 방법

스프링에서 제공하는 @TransactionalEventListener를 사용하기로 했다. 이 어노테이션은 트랜잭션의 특정 시점(예: 커밋된 직후)에만 동작하는 이벤트 리스너를 만들어준다.

이를 적용하기 위해 기존의 코드를 단계별로 분리하기로 했다.

JMS 리스너 수정

JMS 리스너의 책임은 매우 단순해진다. 메시지를 받아 서비스를 호출만 하면 된다.

@JmsListener(destination = NOTIFICATION_QUEUE_CREATE)
public void handleNotificationCreateEvent(String message) {

	NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class);
	NotificationDocument notification = notificationService.createNewNotification(event);
}

서비스 로직에서 이벤트 발행

기존의 서비스 메서드가 DB 저장 후, 실제 객체를 반환하는 대신 이벤트를 발행(publish)하도록 수정한다.

트랜잭션 어노테이션은 서비스 메서드에 달아준다.

@Service
public class NotificationService {

	private final NotificationMongoRepository mongoRepository;
	private final ApplicationEventPublisher publisher;

	...

	@Transactional
	public void createNewNotification(NotificationCreateEvent event) {

		NotificationDocument savedNotification = mongoRepository.save(NotificationDocument.from(event));

		publisher.publishEvent(new NotificationSavedEvent(
			event.principalName(),
			NotificationResponse.from(savedNotification)
		));
	}
}
public record NotificationSavedEvent(

	String principalName,

	NotificationResponse response

) {
}

이벤트 리스너에서 알림 발송

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용해서 트랜잭션이 성공적으로 커밋되었을 때만 호출되는 것을 보장하는 리스너를 만들어준다.

@Component
@RequiredArgsConstructor
public class NotificationEventListener {

	private final SimpMessagingTemplate messagingTemplate;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void handleNotificationSavedEvent(NotificationSavedEvent event) {
		messagingTemplate.convertAndSendToUser(
			event.principalName(),
			"/queue/notification",
			event.response()
		);
	}
}

결과

이렇게 리팩토링함으로써 다음과 같은 장점을 얻을 수 있다.

  • 데이터 정합성 보장 : DB에 데이터가 확실히 저장된 경우에만 알림이 발송됨
  • 관심사의 분리 : DB 처리 로직과 메세지 발송 로직이 명확하게 분리되어 코드가 훨씬 깔끔하고 유지보수하기 좋아짐

참고

profile
일단 해보자

0개의 댓글