1. 왜 mongoDB에서 트랜잭션을 도입하는거야?
우선 업무에서 왜 mongoDB를 사용하고 있는지 부터 이야기 해야 할 것 같아요.
일반적인 회사에서는 관계형 데이터베이스를 사용하고 있는데, 제가 머물고 있는 곳은 규모가 작은 스타트업이고 제품이 유저의 반응, 트렌드, 새로운 아이디어를 빠르게 반영 시켜야 만 했어요. 그렇기 때문에 스키마 변경에 자유로운 nosql을 선택하게 되었습니다.
그러나 서비스가 방향을 잡아가면서 점점 복잡해지고 결정적으로 유저의 재화가 소모되는 기능이 추가되면서 트랜잭션의 ACID속성을 필요로 하게 되었습니다. MongoDB는 태생은 ACID보다 BASE 속성(Basically Available, 일관성)을 우선시하여 설계되었기 때문에 트랜잭션을 지원하지만 선택사항으로 남아있습니다. 그렇기 때문에 2가지 중 하나를 선택해야하는 고민을 하게되었습니다. RDB로 마이그레이션 해야할까? 아니면 트랜잭션만 적용시켜서 nosql의 장점을 좀 더 유지해야 할까? 동료 개발자와 고민 끝에 트랜잭션을 적용하고 좀 더 nosql의 장점을 활용해보자는 결론이 나왔습니다.
mongoDB의 트랜잭션 지원 참조
단일 도큐먼트 트랜잭션 지원 & 다중 도큐먼트 트랜잭션 지원
https://www.mongodb.com/docs/manual/core/transactions/
2. @Transactional
그럼 우리는 Spring Boot를 이용하고 있으니 스프링에서 지원하는 트랜잭션을 적용하는 방법부터 알아볼게요.
@Transactional 동작 원리
Transactional 어노테이션을 달면 Spring은 해당 메서드를 AOP를 사용해서 프록시 객체를 생성하여 메서드가 호출될 때마다 Spring이 트랜잭션 시작 및 종료를 제어할 수 있습니다. 트랜잭션은 추상화된 TransactionManager의 의해 관리됩니다.
@Transactional을 사용하지 않은 트랜잭션 적용 코드
public class businessService {
// 트랜잭션 매니저
private final PlatformTransactionManager transactionManager;
public void basicLogic(String userId) throws SQLException {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 비즈니스 로직
bizLogic(userId);
transactionManager.commit(status); // 성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
private void bizLogic(String userId) throws SQLException {
...
}
}
@TranSactional을 적용한 코드
public class businessService {
@Transactional
public void basicLogic(String userId) throws SQLException {
bizLogic(userId);
}
private void bizLogic(String userId) throws SQLException {
...
}
}
보시는 바와 같이 @Transactional을 이용하면 Spring이 프록시 객체를 생성하여 set autocommit false;(수동 커밋 모드)
속성을 시작으로 성공하면 commit, 실패하면 rollback 과정을 수행할 수 있도록 도와줍니다. 트랜잭션 처리 로직을 분리하게 되는거죠.
3. mongoDB에 트랜잭션을 적용하기 위해서 필요한 것들
트랜잭션은 추상화된 TransactionManager가 관리한다고 했죠? 어라? 나는 Bean객체로 올린 적이 없는데?
Spring Boot는 현재 등록된 라이브러리를 보고 자동으로 스프링 컨테이너에 자동으로 올려줍니다.
PlatformTransactionManager
mongoTransactionManager
그러나 mongoDB는 트랜잭션은 선택이기때문에 Spring Boot에서 자동으로 올려주지 않기때문에 mongoDB의 트랜잭션을 관리하는mongoTransactionManager를 직접 스프링 컨테이너에 올려 주어야 합니다.
- application.yml
spring:
application:
name: basic-mongo
data:
mongodb:
uri: mongodb://root:root@localhost:27017/test?authSource=admin
database: test
- MongoConfig.class
@Configuration
@EnableTransactionManagement
public class MongoConfig extends AbstractMongoClientConfiguration {
@Value("${spring.data.mongodb.uri}")
private String connectionString;
@Value("${spring.data.mongodb.database}")
private String databaseName;
@Bean
public MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
return new MongoTransactionManager(dbFactory);
}
@Bean
public MongoTemplate mongoTemplate() {
return new MongoTemplate(mongoClient(), databaseName);
}
@Override
public MongoClient mongoClient() {
ConnectionString connectionString = new ConnectionString(this.connectionString);
MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
.applyConnectionString(connectionString)
.build();
return MongoClients.create(mongoClientSettings);
}
@Override
protected String getDatabaseName() {
return databaseName;
}
}
- @Configuration을 통해 설정클래스를 명시하고 스프링이 Bean객체를 등록할 수 있게 도와줍니다.
- @EnableTransactionManagement은 @Transactional을 찾아서 트랜잭션 범위를 활성화하는 기능을 합니다.
- @Value 어노테이션을 통해 yml파일에 지정된 값을 가져올 수 있도록 합니다.
- MongoDatabaseFactory는 com.mongodb.client.MongoDatabase 인스턴스 가져오도록 도움을 줍니다.
(mongoDB 데이터베이스의 논리적 연결)
어플리케이션을 실행해볼까요?
com.mongodb.MongoCommandException: Command failed with error 263 (ShardingOperationFailed): 'Transaction numbers are only allowed on a replica set member or mongos' on server localhost:27017. The full response is { "ok" : 0.0, "errmsg" : "Transaction numbers are only allowed on a replica set member or mongos", "code" : 263, "codeName" : "ShardingOperationFailed" }
예외가 터져버렸네요. 🙃
해당 예외는 레플리카 세트가 아닌 MongoDB 환경에서 트랜잭션을 관리하기 위해 MongoTransactionManager를 사용하는 경우 Spring Data MongoDB는 트랜잭션 시작 프로세스 중에 예외를 발생시킵니다.
replica set
예외를 통해서 mongoDB는 스프링부트에서 @Transactional을 사용하기 위해 replicaSet 환경을 구축해야한다는 것을 알았습니다. MongoDB 트랜잭션은 데이터의 일관성과 무결성을 유지하면서 원자적으로 실행해야 하는 여러 작업을 트랜잭션에 포함하는 경우가 많기 때문에 리플리카 세트 내에서 작동하도록 설계되었다고 하네요.
https://www.mongodb.com/docs/manual/replication/
그래서 필요한 것을 정리하자면
1. 스프링 컨테이너에 mongoDB 트랜잭션을 관리하는 mongoTransactionManager 올리고
2. Transaction을 사용하기 위해 mongoDB를 리플리카 셋 환경으로 구축해야하겠네요.
3. @Transaction이 잘 동작하는지 확인
... 점점 일이 커지는데?
4. Docker로 리플리카 셋 (Replica Set) 환경 구성하기
기존에 사용하는 mongoDB를 Replica Set환경으로 변경하는 경우에는 꼭 mongoDB 데이터를 백업하시길 바랍니다.
실제 mongoDB 설치해서 환경을 만들 수 도 있는데요.
좀 더 편하게 환경을 구성하기 위해서 docker를 이용했습니다.
예제는 직접 회사코드를 사용할 수 없으므로, 로컬환경에 구성한 것으로 대체하겠습니다.
방식은 동일하니 참고 해주세요. 🙃
docker & docker compose 설치
docker나 docker compose는 brew로 설치하여도 상관 없습니다.
- brew install --cask docker
- docker -v
- brew cask는 Docker Desktop on Mac 도커를 설치해주며, docker-compose, docker-machine을 같이 설치 해줌
- sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
mongo image 가져오기
- docker pull mongo
docker compose 만들기
- docker를 세팅할 경로에서 sudo vim docker-compose.yml
- docker-compose 파일을 생성해주고
version: "3.1"
services:
mongodb1:
image: mongo
container_name: mongo1
hostname: mongo1
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: root
command: mongod --replSet rs0 --keyFile /etc/mongodb.key --bind_ip_all
volumes:
- ./db1:/data/db
- ~/.ssh/mongodb.key:/etc/mongodb.key
mongodb2:
image: mongo
container_name: mongo2
hostname: mongo2
restart: always
ports:
- "27018:27018"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: root
command: mongod --replSet rs0 --keyFile /etc/mongodb.key --bind_ip_all
volumes:
- ./db2:/data/db
- ~/.ssh/mongodb.key:/etc/mongodb.key
replica set 인증에 사용될 key file 생성
# mongodb 키 생성
sudo openssl rand -base64 756 > ~/.ssh/mongodb.key
# 권한 설정
sudo chmod 400 ~/.ssh/mongodb.key
# 제대로 key가 생성되었는지 확인
cat ~/.ssh/mongodb.key
docker compose 백그라운드로 실행
docker-compose up -d
docker container가 정상적으로 올라왔는지 확인
docker ps -a
docker container안의 mongo를 들어가봅시다
# docker container 접속
docker exec -it mongo1 /bin/bash
# root 계정 몽고 쉘 접속
mongosh -u root -p root
리플리카셋 초기화
- use admin
- rs.initiate() (초기화)
- direct가 시간이 지나면 direct: primary로 변환됩니다.
# admin 데이터베이스 사용
use admin
# replication 초기화
rs.initiate()
# mongo2 복제세트 추가
rs.add({_id: 1, host: "mongo2:27017"})
리플리카 셋 설정 확인
# 리플리카 셋 설정 정보 확인
rs.config()
# 리플리카 셋 상태정보 확인
rs.status()
LocalDB에 연결하기 위해 hosts File 수정
sudo vim /etc/hosts
# localhost 추가
127.0.0.1 mongo1
mongo Compass 로 Local DB 접속
- 엔드포인트 : mongodb://root:root@localhost:27017/?authSource=admin&readPreference=primary&replicaSet=rs0
접속 완료 확인
5. 트랜잭션 적용되었는지 테스트
Member
@Document("Member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Member {
@Id
@EqualsAndHashCode.Include
private ObjectId id;
private String username;
private Integer age;
private Boolean isDeleted;
@Builder
public Member(ObjectId id, String username, Integer age, Boolean isDeleted) {
this.id = id;
this.username = username;
this.age = age;
this.isDeleted = isDeleted;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.JSON_STYLE)
.append("id", id)
.append("isDeleted", isDeleted)
.toString();
}
}
MemberService
- bizLogic 메서드를 호출하면 Member를 저장하고 무조건 RuntimeException이 발생하도록 작성
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberMongoRepository memberMongoRepository;
@Transactional
public void bizLogic(Member member) {
memberMongoRepository.save(member);
if (true) {
throw new RuntimeException();
}
}
}
Test code
- @Transactional 어노테이션이 붙어있는 bizLogic에서 예외를 발생시키면 Member가 저장되지 않고 rollback이 수행되는지 확인
@Slf4j
@SpringBootTest
class MongoTransactionTest {
@Autowired
MemberMongoRepository memberMongoRepository;
@Autowired
MemberService memberService;
@Test
public void transaction_rollback_test() {
long initialCount = memberMongoRepository.count();
log.info("initialCount : " + initialCount);
try {
Member member = Member.builder().username("white paper").age(20).isDeleted(false).build();
memberService.bizLogic(member);
} catch (Exception exception) {
log.info("transaction rollback");
}
long rollbackCount = memberMongoRepository.count();
log.info("rollbackCount : " + rollbackCount);
Assertions.assertThat(initialCount).isEqualTo(rollbackCount);
}
}
테스트코드를 통해 정상적으로 transaction rollback이 동작하는 것을 확인할 수 있게 되었습니다.
추가로 다중 도큐먼트 트랜잭션에서는 데이터베이스 DDL (컬렉션 생성, Index 생성 등등)에 제한이 있으니
직접 생성해주어야 합니다. 참고 링크
Reference
https://www.mongodb.com/docs/manual/replication/
https://www.mongodb.com/docs/manual/core/transactions/
https://www.baeldung.com/spring-data-mongodb-transactions - eaeldung 트랜잭션 적용
https://rastalion.me/mongodb-transaction-management/
https://ozofweird.tistory.com/entry/MongoDB-%EC%9E%A0%EA%B8%88-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98
Spring Boot 환경에서 mongoDB 트랜잭션을 적용하는 방법을 공유해보았는데요.
mongoDB를 사용하면서도 참 모르는게 많았구나.!! 라는 생각이 들더라구요.
항상 기술을 사용하기에 앞서 깊게 알고 사용하는 것과 모르고 사용하는 것은 참 많은 차이가 있는 것 같아요.
더 열심히 공부해야겠습니다. 부족한 글 읽어 주셔서 감사합니다. 또한 잘못된 내용 있으면 지적해주시면 감사하겠습니다. 🙏
하얀종이개발자
'Spring' 카테고리의 다른 글
SpringDataJPA(스프링데이터JPA)를 뜯어보자 (0) | 2023.06.05 |
---|---|
JPA n+1 문제는 왜 생기는걸까? (0) | 2023.05.06 |
서블릿(Servlet)이 뭔지 궁금해? (0) | 2023.03.09 |
의존관계주입 or 의존성주입 with spring #3 (0) | 2023.01.24 |
SOLID에 대해서 쉽게 알려줄게 with spring #2 (0) | 2023.01.23 |