실습3 : 도서 대출 및 반납 트랜잭션 적용하기
[조건1] AppStart , BookController , BookService , BookMapper (DTO 선택)
[조건2]
도서대출 :
1. 대출 요청 시 해당 책의 재고를 1 감소한다. 재고가 0이면 예외발생
2. 재고 감소 후 대출기록을 (등록) 처리한다. 대출기록 처리가 실패하면 예외발생
3. 예외가 발생하면 전체 SQL 실행은 rollback한다.
도서반납 :
1. 반납 요청 시 해당 책의 재고를 1 증가한다. 만약 없는 책이면 예외발생
2. 재고 증가 후 대출기록을 (업데이트) 처리한다. 만약에 대출기록이 없거나 이미 반납한 책이면 예외발생
3. 예외가 발생하면 전체 SQL 실행은 rollback한다.
[ 조건3 ] 트랜잭션 적용 :
· BookService의 rentBook() / returnBook() 메소드에 @Transactional 어노테이션을 적용
· 하나의 기능 내에서 SQL 실행 중 하나라도 실패하면 전체가 취소되어야 한다.
[ 조건4 ] 샘플 SQL
-- 1. 책 테이블
CREATE TABLE books (
id INT NOT NULL,
title VARCHAR(255) NOT NULL,
stock INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
-- 2. 대출 기록 테이블
CREATE TABLE rentals (
id INT NOT NULL,
book_id INT NOT NULL,
member VARCHAR(100) NOT NULL,
rent_date DATETIME DEFAULT NOW(),
return_date DATETIME NULL,
PRIMARY KEY (id),
FOREIGN KEY (book_id) REFERENCES books(id)
);
-- 3. 샘플 데이터 (책 목록)
INSERT INTO books (id, title, stock) VALUES (1, '자바의 정석', 3);
INSERT INTO books (id, title, stock) VALUES (2, '스프링 인 액션', 2);
INSERT INTO books (id, title, stock) VALUES (3, '토비의 스프링', 1);
INSERT INTO books (id, title, stock) VALUES (4, '리액트 교과서', 5);
-- 4. 샘플 데이터 (대출 기록)
INSERT INTO rentals (id, book_id, member) VALUES (1, 1, '홍길동');
-- 5. 확인용 조회 쿼리
SELECT * FROM books;
SELECT * FROM rentals;
[ 제출방법 ] 코드가 작성된 파일이 위치한 GitHub 상세 주소를 제출하시오.
상기 문제를 풀어야한다.
처음에 SQL을 대충 읽고 넘어가서, rentals 테이블과 books 테이블의 id가 다른 데이터라는 걸 나중에 알았다.
package example.실습.실습3;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AppStart {
public static void main(String[] args) {
SpringApplication.run(AppStart.class);
}
}
먼저 AppStart를 만들어준다.
package example.실습.실습3;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BookMapper {
// [1] 대출 요청 (재고가 0보다 커야 발동. DB에서 unsigned로 이중 유효성검사)
@Update("update books set stock = (stock - 1) where id = #{bookId} and stock > 0 ")
int checkoutBook(int bookId);
// [2] 대출 기록 등록
@Insert("insert into rentals (book_id , member) values (#{bookId} , #{member}) ")
int checkoutRecord(int bookId, String member);
// [3] 반납 요청
@Update("update books set stock = (stock + 1) where id = #{bookId} ")
int turnbackBook(int bookId);
// [4] 대출 기록 변경 ( return_date가 null이어야 반응하도록 유효성 검사)
@Update("update rentals set return_date = now() where id = #{rentId} and return_date is null ")
int turnbackRecord(int rentId);
}
다음으로는 인터페이스로 Mapper를 만들어줘서 SQL문을 실행한다.
books 테이블의 id는 bookId로, rentals 테이블의 id는 rentId로 치환한다.
package example.실습.실습3;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Transactional // 트랜젝셔널로 예외 통과 못하면 DB에 변화 없음
public class BookService {
// DI
private final BookMapper bookMapper;
// [1] 대출 메소드
public int checkout(Map<String, Object> check) {
// Map으로 받은 값 변수에 넣기
int bookId = (Integer) check.get("bookId");
String member = (String) check.get("member");
// 2) 대출 기록 등록 (stock을 unsigned로 제약했을 때, 트랜잭션이 제대로 적용되어 대출기록이 재발생하지 않는가?)
int checkoutRecord = bookMapper.checkoutRecord(bookId, member);
// *) 강제 예외 발생
if (checkoutRecord == 0) {
throw new RuntimeException("대출 기록 등록 실패 : 재고가 없거나 존재하지 않습니다");
}
// 1) 대출 요청
int checkoutBook = bookMapper.checkoutBook(bookId);
// *) 강제 예외 발생
if (checkoutBook == 0) {
throw new RuntimeException("대출 요청 실패");
}
return 1;
} // method end
// [2] 반납 메소드
public int turnback(Map<String, Integer> turn) {
// Map으로 받은 값 변수에 넣기
int rentId = turn.get("rentId");
int bookId = turn.get("bookId");
// 1) 반납 요청 (반납 기록이 없을 때, 트랜잭션이 발동하여 책 재고가 계속 늘어나지 않는가?)
int turnbackBook = bookMapper.turnbackBook(bookId);
// *) 강제 예외 발생
if (turnbackBook == 0) {
throw new RuntimeException("반납 요청 실패 : 존재하지 않는 책");
}
// 2) 대출 기록 변경
int turnbackRecord = bookMapper.turnbackRecord(rentId);
// *) 강제 예외 발생
if (turnbackRecord == 0) {
throw new RuntimeException("반납 기록 등록 실패 : 대출 기록이 없거나 이미 반납처리됨");
}
return 1;
} // method end
} // class end
제일 중요한 서비스 파트. 트랜잭션이 클래스에 적용되어 있다. 예외가 발생할 경우, 둘 다 실행이 되지 않도록 처리하는 어노테이션이다.
package example.실습.실습3;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/book")
@RequiredArgsConstructor
public class BookController {
// DI
private final BookService bookService;
// [1] 대출 메소드
// URL : http://localhost:8080/book/rent
// BODY : { "bookId" : 1 , "member" : "염현수" }
@PostMapping("/rent")
public int checkout(@RequestBody Map<String, Object> check){
return bookService.checkout(check);
}
// [2] 반납 메소드
// URL : http://localhost:8080/book/return
// BODY : { "bookId" : 1 , "rentId" : 2 }
@PostMapping("/return")
public int turnback(@RequestBody Map<String , Integer> turn){
return bookService.turnback(turn);
}
} // class end
PostMapping으로 컨트롤러에서 처리해준다.
문제를 푸는 중간에 테스트를 하다가, 반납 메소드에서 렌탈_로그는 변경되었는데 책 수량이 증가하지 않는 버그가 발생했다.
순서를 바꿨더니 고쳐졌다. 왜 그런지는 모름.
하단은 중간에 문제 해결받는데 도움이 된 MD파일.
AOP 트랜잭션 실습 문제 해결 가이드
안녕하세요! AOP 트랜잭션 실습 중 발생한 문제에 대해 분석하고 해결 방안을 안내해 드립니다.
현재 코드에서 트랜잭션 설정 자체(@Transactional)는 올바르게 되어 있습니다. API 테스트가 실패하고 예외가 발생하는 원인은 트랜잭션 설정 문제가 아니라, 서비스 로직 내부의 버그와 조건 처리가 미흡하기 때문입니다.
@Transactional이 붙은 메소드 내에서 RuntimeException이 발생하면, 스프링은 해당 트랜잭션을 자동으로 롤백합니다. 즉, 현재 예외가 발생하면서 DB 변경 사항이 롤백되는 것은 트랜잭션이 정상적으로 동작하고 있다는 신호입니다.
아래에 문제점과 수정된 코드를 안내해 드립니다.
1. 문제점 분석
1.1. BookService.java - 대출(checkout) 로직 오류
- 원인:
BookController에서 API 요청 시 Body에{ "bookId": 1, ... }와 같이bookId를 사용하지만,BookService에서는check.get("book_id")로 값을 찾고 있습니다. key 이름이 일치하지 않아null을 반환받게 되고,Integer.parseInt()과정에서NumberFormatException이 발생하여 트랜잭션이 롤백됩니다. - 해결:
book_id를bookId로 수정해야 합니다.
1.2. BookMapper.java - 재고 및 반납 조건 처리 미흡
- 원인:
- 대출 시 재고 확인: 현재 대출 쿼리는 재고가 0이어도
stock을 1 감소시켜 음수로 만듭니다. 요구사항인 "재고가 0이면 예외발생"을 정확히 만족시키려면, 재고가 0보다 클 때만 업데이트하도록 SQL을 수정해야 합니다. - 반납 시 중복 반납 확인: 현재 반납 기록 업데이트 쿼리는 이미 반납된
rental에 대해서도return_date를 다시 업데이트할 수 있습니다. 요구사항인 "이미 반납한 책이면 예외발생"을 만족시키려면,return_date가NULL인 경우에만 업데이트하도록 SQL을 수정해야 합니다.
- 대출 시 재고 확인: 현재 대출 쿼리는 재고가 0이어도
- 해결:
UPDATE쿼리에WHERE조건을 추가하여 이러한 예외 케이스를 처리합니다.
1.3. BookService.java - 불필요한 형변환
- 원인: Controller에서
@RequestBody로 받은Map의 값(Value)은 이미 적절한 타입(Integer, String)으로 변환되어 있습니다.String.valueOf()와Integer.parseInt()를 중복해서 사용할 필요가 없습니다. - 해결: 더 간결하고 안전한 타입 캐스팅 또는 직접 할당을 사용합니다.
2. 수정 코드 제안
아래 내용을 참고하여 BookService.java와 BookMapper.java 파일을 수정해 주세요.
2.1. BookService.java 수정
package example.실습.실습3;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Transactional // 클래스 레벨에 트랜잭션 적용
public class BookService {
// DI
private final BookMapper bookMapper;
// [1] 대출 메소드
public int checkout(Map<String, Object> check) {
// Map으로 받은 값 변수에 넣기 (key 이름 수정 및 타입 캐스팅)
int bookId = (Integer) check.get("bookId");
String member = (String) check.get("member");
// 1) 대출 요청 (재고가 0이면 이 단계에서 0을 반환)
int checkoutBook = bookMapper.checkoutBook(bookId);
// *) 재고가 없거나 책이 없으면 예외 발생
if (checkoutBook == 0){
throw new RuntimeException("대출 요청 실패: 재고가 부족하거나 존재하지 않는 책입니다.");
}
// 2) 대출 기록 등록
int checkoutRecord = bookMapper.checkoutRecord(bookId, member);
// *) 기록 등록 실패 시 예외 발생
if (checkoutRecord == 0){
throw new RuntimeException("대출 기록 등록에 실패했습니다.");
}
return 1; // 성공
} // method end
// [2] 반납 메소드
public int turnback(Map<String, Integer> turn) {
// Map으로 받은 값 변수에 넣기 (불필요한 형변환 제거)
int rentId = turn.get("rentId");
int bookId = turn.get("bookId");
// 1) 반납 요청 (책 재고 증가)
int turnbackBook = bookMapper.turnbackBook(bookId);
// *) 존재하지 않는 책이면 예외 발생
if (turnbackBook == 0) {
throw new RuntimeException("반납 요청 실패: 존재하지 않는 책입니다.");
}
// 2) 대출 기록 변경 (반납 처리)
int turnbackRecord = bookMapper.turnbackRecord(rentId);
// *) 대출 기록이 없거나 이미 반납된 경우 예외 발생
if (turnbackRecord == 0) {
throw new RuntimeException("반납 기록 업데이트 실패: 대출 기록이 없거나 이미 반납 처리된 상태입니다.");
}
return 1; // 성공
} // method end
} // class end
2.2. BookMapper.java 수정
package example.실습.실습3;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface BookMapper {
// [1] 대출 요청 (재고 > 0 조건 추가)
@Update("update books set stock = (stock - 1) where id = #{bookId} and stock > 0")
int checkoutBook(int bookId);
// [2] 대출 기록 등록
// value -> values 로 수정 (SQL 문법 오류)
@Insert("insert into rentals (book_id , member) values (#{bookId} , #{member}) ")
int checkoutRecord(int bookId, String member);
// [3] 반납 요청
@Update("update books set stock = (stock + 1) where id = #{bookId} ")
int turnbackBook(int bookId);
// [4] 대출 기록 변경 (return_date is null 조건 추가)
@Update("update rentals set return_date = now() where id = #{rentId} and return_date is null")
int turnbackRecord(int rentId);
}
2.3. BookMapper.java 추가 발견사항
checkoutRecord메소드의@Insert어노테이션 SQL 쿼리에서value를values로 수정해야 합니다. 이는 표준 SQL 문법 오류입니다.
3. 결론
위와 같이 코드를 수정하면 API가 정상적으로 동작하고, 예외 상황(재고 부족, 중복 반납 등)에서는 RuntimeException이 발생하여 트랜잭션이 의도대로 롤백될 것입니다.
Talend API 테스트 시에는 아래 사항을 다시 한번 확인해 주세요.
- Request URL:
http://localhost:8080/book/rent또는/book/return - HTTP Method:
POST - Headers:
Content-Type: application/json - Request Body:
- 대출:
{ "bookId": 1, "member": "홍길동" } - 반납:
{ "bookId": 1, "rentId": 2 }(실제 존재하는bookId와rentId사용)
- 대출:
이제 코드를 수정하고 다시 테스트해 보세요.
'백엔드 > 스프링' 카테고리의 다른 글
| [Spring] JWT 토큰과 Security 인증 인가 로직 복습 (0) | 2025.10.22 |
|---|---|
| [Spring] 암호화(BCrypt) 및 쿠키(Cookie) 복습 자료 (0) | 2025.10.21 |
| [Spring] Redis 개념 및 활용법 with Spring Boot (0) | 2025.10.21 |
| [Spring] MyBatis XML 연동 개요 (0) | 2025.10.13 |
| [Spring] 13주차+: 마이크로서비스 아키텍처(MSA) 입문 (0) | 2025.09.22 |
| [Spring] 12주차: 설정 분리 및 비동기 처리 (0) | 2025.09.22 |
| [Spring] 11주차: 예외 처리 및 유효성 검사 (0) | 2025.09.22 |
| [Spring] 10주차: JWT를 이용한 API 인증 (0) | 2025.09.22 |