안녕하세요 오늘은 Insert/Update 쿼리와 트랜잭션 처리하는 법에 대해서 포스팅하도록 하겠습니다.
우선 트랜젝션이란, 데이터베이스의 상태를 변화시키기 위해 수행하는 작업 단위입니다. 쉽게 말씀드리자면 여러 쿼리들이 순차적으로 실행되는 하나의 작업 묶음이라고 보시면 되겠습니다. 트랜잭션은 다음의 4가지 특징을 지닙니다.
위의 4가지 특징을 만족하기 위해 트랜잭션에서는 Commit과 Rollback이라는 연산이 존재합니다. Commit이란 하나의 트랜잭션이 성공적으로 끝났고 DB가 일관성 있는 상태일 때 이를 알려주기 위해 사용되며, Rollback은 하나의 트랜잭션 처리가 비정상적으로 종료되어 트랜잭션 원자성이 깨진 경우 last consistent state( 예) 트랜잭션의 시작 상태)로 돌아가도록 하는 연산입니다. 백엔드 API를 개발할 경우 여러 쿼리들이 순차적으로 실행되는 경우가 많기 때문에 트랜잭션 관리는 매우 중요합니다.
Spring Boot에서는 @Transactional 어노테이션으로 트랜잭션을 굉장히 쉽게 지원합니다. 트랜잭션 처리는 Service 계층에서 주로 진행하며 클래스 위에 @Transactional 어노테이션을 추가하면 내부 메서드 실행 시 오류가 발생하면 실행 전으로 Rollback합니다. 이를 통해 굉장히 간편하게 트랜잭션을 처리할 수 있습니다.
그럼 Insert와 Update 쿼리를 사용하여 트랜잭션 처리 실습을 진행해보겠습니다. 이번 실습에서 만들어볼 트랜잭션은 사용자 정보를 추가하고 사용자 닉네임 정보를 추가적으로 입력받는다면 입력받은 이름으로 사용자 정보를 변경하는 과정을 하나의 API로 처리하려고 합니다.
@PostMapping("/user")
public String insertUser(
@RequestBody InsertUserRequestDTO insertUserRequestDTO,
@RequestParam(name = "changeTo", required = false) String name){
return userService.insertUser(insertUserRequestDTO,name);
}
UserController에 다음의 메서드를 추가합니다. 이 때 @RequestBody로 Post의 Body 안에 들어갈 데이터를 객체화하여 Service로 전달하고, @RequestParam은 URI 상의 Query String 값 (ex /user?name=test&birth=2023-01-12) 을 가져와 Service로 넘겨줍니다. 이 때 required 옵션을 통해 Query String이 존재한다면 해당 값을, 없다면 null을 리턴합니다.
@GetMapping("/user/{ID}")
public GetUserInfoResponseDTO getUserInfo(@PathVariable("ID") String ID){
return userService.getUserInfo(ID);
}
데이터를 입력받는 방법으로 @PathVariable 어노테이션도 존재합니다. 이 어노테이션은 URI 상의 Path Variable (ex /user/1) 값을 가져와 Service로 넘겨주는 역할을 담당합니다.
public Long insertUser(InsertUserRequestDTO insertUserRequest) {
StringBuilder query = new StringBuilder();
query.append("INSERT INTO User (email, password, name, code)");
query.append(" VALUES (?,?,?,?);");
KeyHolder keyHolder = new GeneratedKeyHolder();
PreparedStatementCreator preparedStatementCreator = (connection) -> {
PreparedStatement prepareStatement = connection.prepareStatement(query.toString(), new String[]{"ID"});
prepareStatement.setString(1, insertUserRequest.getEmail());
prepareStatement.setString(2, insertUserRequest.getPassword());
prepareStatement.setString(3, insertUserRequest.getName());
prepareStatement.setString(4, insertUserRequest.getCode());
return prepareStatement;
};
jdbcTemplate.update(preparedStatementCreator,keyHolder);
Long res = Objects.requireNonNull(keyHolder.getKey()).longValue();
return res;
}
public String updateUser(Long ID, String name){
StringBuilder query = new StringBuilder();
query.append("UPDATE User SET name = ? where ID = ?;");
PreparedStatementCreator preparedStatementCreator = (connection) -> {
PreparedStatement prepareStatement = connection.prepareStatement(query.toString());
prepareStatement.setString(1, name);
prepareStatement.setString(2, ID.toString());
return prepareStatement;
};
jdbcTemplate.update(preparedStatementCreator);
return "성공";
}
UserRepository에서 트랜잭션 내에서 실행할 쿼리 2개를 메소드로 생성합니다. 이 때 쿼리를 동적으로 처리하기 위해 PreparedStatement 객체를 이용합니다. PreparedStatement 객체를 통해 쿼리문에 ?로 변수를 입력받는 부분에 데이터가 채워진 쿼리로 바꾸어 리턴합니다. 또한 Update 쿼리를 보시면 파라미터로 ID 값을 받고 있는데, 위의 Insert 쿼리의 경우 Auto Increment 설정이 되어 있어 ID 값이 자동으로 할당됩니다. 이 때 자동적으로 할당된 ID값을 얻기 위해 KeyHolder 객체를 이용합니다. PreparedStatement 생성 시 KeyHolder에 저장할 값을 입력하고 (위의 코드에서는 ID입니다) jdbctemplate를 update하면 KeyHolder에 생성된 키값이 할당되어 값을 확인할 수 있습니다.
@Component("userService")
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public String insertUser(InsertUserRequestDTO insertUserRequestDTO, String name) {
// 첫 번째 쿼리 실행 결과 받아오기
Long insertUserID = userRepository.insertUser(insertUserRequestDTO);
String newName;
if(name==null) newName = "변경 성공";
else newName = name;
// 두 번째 쿼리 실행 결과 받아오기
String str = userRepository.updateUser(insertUserID, newName);
// 이상 없을 경우 성공 메시지 발송
return "성공";
}
}
UserServiceImpl 클래스에서 트랜잭션을 생성합니다. @Transactional 어노테이션을 할당한 후 메소드 내에 UserRepository에서 2개의 메소드를 가져와 실행시킵니다. 이 때 성공적으로 작동 시 "성공" 메시지를 리턴합니다.
@Test
public void updateUserTest(){
// 가상의 추가 데이터
Long ID = Long.parseLong(String.valueOf(237));
// 테스트로 실행해도 실제 DB에 적용됨
String message = userRepository.updateUser(ID,null);
// 추가된 데이터의 아이디값 확인
System.out.println("메시지 : " + message);
}
정상적으로 동작하는지 확인하기 위해 Repository, Service, Controller 테스트를 진행합니다. 우선 Repository의 경우 가상의 추가 데이터를 Insert 또는 Update 쿼리를 가지는 메소드에 넣어 실행시키면서 출력 결과를 확인합니다.
@Test
public void insertUserTest(){
InsertUserRequestDTO insertUserRequestDTO = new InsertUserRequestDTO("test","test","test","1z32s2se");
String str = userService.insertUser(insertUserRequestDTO,null);
}
Service 역시 가상의 데이터를 추가하여 테스트하고자 하는 Service에 넣어 실행시킵니다. 이 때 Query String이 없는 경우를 테스트할 때는 해당 파라미터를 null로 넣어 진행하면 됩니다.
@Test
public void insertUserTest() throws Exception{
// 바디에 넣을 요청값 가상으로 추가
String body = new ObjectMapper().writeValueAsString(
new InsertUserRequestDTO(
"test",
"test",
"test",
"09809"
)
);
mvc.perform(post("/user?changeTo=newname")
// Mockmvc에 바디 데이터 추가
.content(body)
// 받을 데이터 타입 설정 --> JSON으로 받기 때문에 해당 설정 ON
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk());
}
UserController에서는 post 방식으로 MockMvc 객체를 테스트합니다. content 옵션 내부에 Body 데이터를 넣고 contentType 옵션에 데이터 타입을 설정합니다. JSON 데이터를 받기 때문에 MediaType.APPLICATION_JSON 값을 설정해줍니다.
테스트 통과도 완료되었고 Postman의 결과 역시 성공적으로 잘 나타납니다.
그렇다면 트랜잭션이 성공적으로 잘 반영이 되어있는지 확인을 해보겠습니다. 트랜잭션 내의 메소드 중 Update 메소드에 오류를 발생시킵니다. 만약 트랜잭션이 정상적으로 작동하지 않는다면 이전 Insert 쿼리는 작동하여 새로운 유저가 생겨야 합니다. 위의 테스트 결과를 보시면 트랜잭션 테스트는 실패했지만 Insert 쿼리로 생성된 ID값은 나타난 것을 알 수 있습니다.
하지만 실제로 해당 아이디를 검색해보면 존재하지 않는 것을 알 수 있습니다 (현재는 별도 Validation 작업을 진행하지 않아 에러로 나타나지만 Validation 작업을 통해 해당 에러 발생을 막을 수 있습니다.) 따라서 이를 통해 성공적으로 트랜잭션이 생성된 것을 확인할 수 있습니다. 그럼 이상으로 오늘의 포스팅 마치도록 하겠습니다!
출처
https://gyoogle.dev/blog/computer-science/data-base/Transaction.html