이번엔 Spring 프로젝트로 Mybatis와 연결하려고 한다.
build.gradle
dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.3'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
application.yml
spring:
datasource:
driver-class-name: org.mariadb.jdbc.Driver
url: jdbc:mariadb://localhost:3306/menudb
username: {username}
password: {password}
위와 같은 기본 세팅으로 시작했다.
이번엔 이전과 달리 @Mapper를 활용해서 sqlSession을 쓰지 않고 해볼 것이다.
우선 설정파일
com.jehun.transactional.MybatisConfiguration
@Configuration
@MapperScan(basePackages = "com.jehun.transactional", annotationClass = Mapper.class)
public class MybatisConfiguration {
}
라이브러리를 추가해줬기 때문에
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
와 같이 import를 추가하게 된다.
mapper 부터는 특별히 언급이 없다면, 같은 패키지에 속하는 클래스 or 인터페이스들이다.
com.jehun.transactional.section01.annotation.MenuMapper
@Mapper
public interface OrderMapper {
}
OrderService
@Service
public class OrderService {
/* 설명 sqlSession.getMapper() 대신 @Mapper가 달려 하위 구현체가 관리되면 의존성 주입을 받을 수 있다.*/
private OrderMapper orderMapper;
@Autowired
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
}
이제 테이블들이 필요하다.
Menu, Order, 다대다 관계를 해결해줄 OrderMenu
Menu
public class Menu {
private int menuCode;
private String menuName;
private int menuPrice;
private int categoryCode;
private String orderableStatus;
// 기본 생성자
// 매개변수를 갖는 생성자
// getter
// toString() 오버라이딩
}
주석처리 된 부분은 Alt + Insert로 인텔리제이에서는 자동 추가 가능하다.
Order
public class Order {
private int orderCode;
private String orderDate;
private String orderTime;
private int totalOrderPrice;
// 기본 생성자
// 매개변수를 갖는 생성자
// getter
// toString() 오버라이딩
}
OrderMenu
public class OrderMenu {
private int menuCode;
private int orderCode;
private int orderAmount;
// 기본 생성자
// 매개변수를 갖는 생성자
// getter
// toString() 오버라이딩
}
OrderDTO에 담겨 컨트롤러에서 넘어온다는 가정에, 주문 한 건 발생한 경우라는 가정으로 할 것이다.
Service 계층부터 개발할 때는 사용자가 입력한 값들이 어떻게 DTO 또는 Map으로 묶여서 Controller로 부터 넘어올지 충분히 고민한 후 매개변수를 작성하고 개발한다.
현재의 경우 (주문 한 건 발생한 경우) 사용자가 고른 메뉴들 각각의 코드 번호와 고른 메뉴 개수, 그리고 서버의 현재 시간이 담긴 채로 넘어왔다는 생각을 가지고 개발하자.
테이블에 꼭 일치하지 않더라도 주문을 위해 사용자가 넘겨준 값을 담을 수 있는 DTO 클래스
DTO (Data Transfer Object) : 계층을 넘어다니며 값을 옮겨주는 클래스
즉, 컨트롤러와 서비스 계층을 왔다갔다 해주는 것이 DTO이다.
(DTO는 setter도 만들었다.)
OrderDTO
public class OrderDTO {
// 서버의 현재 시간을 2개로 나눔
private LocalDate orderDate;
private LocalTime orderTime;
private List<OrderMenuDTO> orderMenus;
// 기본 생성자
// 매개변수를 갖는 생성자
// getter
// setter
// toString() 오버라이딩
}
OrderMenuDTO
public class OrderMenuDTO {
private int menuCode; // 고른 메뉴 번호
private int orderAmount; // 고른 메뉴의 개수
// 기본 생성자
// 매개변수를 갖는 생성자
// getter
// toString() 오버라이딩
}
메뉴 + 주문한 개수를 담는 OrderMenuDTO를 여러 개 담고 있는 OrderDTO
기본적인 세팅은 끝났다.
이제부터 서비스부터 기능을 만들어보자.
주문한 메뉴 코드 추출 (DTO)
메뉴 별로 Menu Entity에 담아서 조회(Select)해 오기 (부가적인 메뉴의 정보 추출(단가 등))
이 주문건에 대한 주문 총 합계를 계산한다. (Insert 한 번으로 처리하기 위해)
OrderService 에 추가 -> (1번 과정)
public void registerNewOrder(OrderDTO orderInfo) {
/* 설명. 1. 주문한 메뉴 코드 추출 (DTO) */
List<Integer> menuCode = new ArrayList<>();
for (OrderMenuDTO orderMenuDTO : orderInfo.getOrderMenus()) {
Integer code = orderMenuDTO.getMenuCode();
menuCode.add(code);
}
/* stream 방식
List<Integer> menuCode = orderInfo.getOrderMenus()
.stream()
.map(OrderMenuDTO::getMenuCode)
.collect(Collectors.toList());
*/
}
한 번 1번 과정을 test 코드에서 해보자.
ctrl + shift + T 를 누르면 자동으로 클래스를 만들어준다.
OrderServiceTests
@SpringBootTest
class OrderServiceTests {
@Autowired
private OrderService registerOrder;
private static Stream<Arguments> getOrderInfo() {
OrderDTO orderInfo = new OrderDTO();
orderInfo.setOrderDate(LocalDate.now());
orderInfo.setOrderTime(LocalTime.now());
orderInfo.setOrderMenus(
List.of(
new OrderMenuDTO(3, 10),
new OrderMenuDTO(4, 5)
)
);
return Stream.of(
Arguments.of(orderInfo)
);
}
@DisplayName("주문 등록 테스트")
@ParameterizedTest
@MethodSource("getOrderInfo")
void testRegisterNewOrder(OrderDTO orderInfo) {
Assertions.assertDoesNotThrow(
() -> registerOrder.registerNewOrder(orderInfo)
);
}
}
잘 되고 있다.
이번엔 2번 코드를 구현해보자.
1번 과정에서 나온 List를 map으로 묶은 뒤에 할 것이다.
MenuService에 추가
Map<String, List<Integer>> map = new HashMap<>();
map.put("menuCodes", menuCode);
/* 설명. 2. 주문한 메뉴 별로 Menu Entity에 담아서 조회(Select)해 오기 (부가적인 메뉴의 정보 추출(단가 등)) */
List<Menu> menus = menuMapper.selectMenuByMenuCodes(map);
보면 menuMapper로 인해 새로운 MenuMapper 인터페이스가 필요하다.
MenuMapper
@Mapper
public interface MenuMapper {
List<Menu> selectMenuByMenuCodes(Map<String, List<Integer>> map);
}
위의 인터페이스 생성으로 인해 OrderService에는 생성자에 필드와 매개 변수가 추가된다.
OrderService 수정
private OrderMapper orderMapper;
private MenuMapper menuMapper;
@Autowired
public OrderService(OrderMapper orderMapper, MenuMapper menuMapper) {
this.orderMapper = orderMapper;
this.menuMapper = menuMapper;
}
Mapper가 생성됐으니 이제 resources 에도 xml 파일을 추가해줘야 한다.
com/jehun/transactional/section01/annotation/MenuMapper.xml
com/jehun/transactional/section01/annotation/OrderMapper.xml
둘다 추가해주자.
OrderMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jehun.transactional.section01.annotation.OrderMapper">
</mapper>
MenuMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jehun.transactional.section01.annotation.MenuMapper">
</mapper>
이제 이전에 배웠던 동적 쿼리를 사용할 수 있게 됐다. 해보자.
MenuMapper.xml 에 추가
<resultMap id="menuResultMap" type="com.jehun.transactional.section01.annotation.Menu">
<id property="menuCode" column="MENU_CODE"/>
<result property="menuName" column="MENU_NAME"/>
<result property="menuPrice" column="MENU_PRICE"/>
<result property="categoryCode" column="CATEGORY_CODE"/>
<result property="orderableStatus" column="ORDERABLE_STATUS"/>
</resultMap>
resultMap을 해뒀으니 selectMenuByMenuCodes 의 쿼리를 작성해보자.
MenuMapper.xml에 추가
<select id="selectMenuByMenuCodes" resultMap="menuResultMap" parameterType="hashmap">
SELECT
A.MENU_CODE
, A.MENU_NAME
, A.MENU_PRICE
, A.CATEGORY_CODE
, A.ORDERABLE_STATUS
FROM TBL_MENU a
WHERE A.MENU_CODE IN
<foreach collection="menuCodes" item="menuCode" open="(" close=")" separator=", ">
#{ menuCode }
</foreach>
</select>
동적 쿼리를 활용해보았고, 로그도 함께 봐보자.
참고 : 로그를 보기 위한 세팅
log4jdbc.log4j2.propertieslog4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator log4jdbc.dump.sql.maxlinelength=0log4jj2.xml
<?xml version="1.0" encoding="UTF-8"?> <configuration debug="true"> <!-- https://www.egovframe.go.kr/wiki/doku.php?id=egovframework:rte3:fdl:logging:log4j_2:%EC%84%A4%EC%A0%95_%ED%8C%8C%EC%9D%BC%EC%9D%84_%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94_%EB%B0%A9%EB%B2%95 --> <!-- Appenders --> <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <charset>UTF-8</charset> <Pattern>%d %5p [%c] %m%n %r</Pattern> </encoder> </appender> <!-- info부터 적용 table사용시 --> <logger name="jdbc.resultsettable" additivity="false"> <level value="info" /> <appender-ref ref="console" /> </logger> <!-- sql 로깅 --> <!-- SQL문이 preparedStatement일 경우 argument값으로 대체된 SQL문 출력 --> <logger name="jdbc.sqlonly" additivity="false"> <level value="info" /> <appender-ref ref="console" /> </logger> <!-- resultset제외한 모든 jdbc 호출 정보 출력 --> <logger name="jdbc.audit" additivity="false"> <level value="info" /> <appender-ref ref="console" /> </logger> <!-- info부터 적용 기본적인 resultset--> <!-- resultset을 포함한 모든 jdbc 호출 정보 로그 --> <!-- resultsettable 만 사용하기 위해서 로그레벨은 warning시킨다. resultset이 중복됨 --> <logger name="jdbc.resultset" additivity="false"> <level value="info" /> <appender-ref ref="console" /> </logger> <!-- 수행시간을 찍는다--> <logger name="jdbc.sqltiming" additivity="false"> <level value="info" /> <appender-ref ref="console" /> </logger> <!-- Root Logger --> <root level="off"> <appender-ref ref="console" /> </root> </configuration>
application.yml도 수정해줘야 한다.
spring:
datasource:
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
url: jdbc:log4jdbc:mariadb://localhost:3306/menudb
username: {username}
password: {password}
logging:
level:
# root: info
com:
jehun:
transactional:
section01:
annotation: debug

테스트도 로그도 잘 된다.
다음은 3번 총 합계 금액 계산을 기능 구현을 해보자.
OrderService의 registerNewOrder() 에 추가
/* 설명. 3. 이 주문건에 대한 주문 총 합계를 계산 (Insert 한 번으로 처리하기 위해) */
int totalOrderPrice = calculateTotalOrderPrice(orderInfo.getOrderMenus(), menus);
calculateTotalOrderPrice 메소드 : 주문 건에 대한 총 합계 금액 계산 메소드
(orderMenus : 사용자의 주문 내용, menus : 조회된 메뉴 전체 내용)
이것도 추가해보자.
OrderService의 calculateTotalOrderPrice() 메소드
private int calculateTotalOrderPrice(List<OrderMenuDTO> orderMenus, List<Menu> menus) {
int totalOrderPrice = 0;
int orderMenuSize = orderMenus.size();
for (int i = 0; i < orderMenuSize; i++) {
OrderMenuDTO orderMenu = orderMenus.get(i);
Menu menu = menus.get(i);
totalOrderPrice += menu.getMenuPrice() * orderMenu.getOrderAmount();
}
return totalOrderPrice;
}
System.out.println("totalOrderPrice = " + totalOrderPrice); 로 출력해보자.

테스트에서 로 10개, 5개, 20개를 받았는데 가격 계산을 해보면 알맞다.
new OrderMenuDTO(3, 10), // 300원 10개 3000원
new OrderMenuDTO(4, 5), // 7000원 5개 35000원
new OrderMenuDTO(10, 20) // 7000원 20개 140000원
이제 tbl_order 테이블에 insert가 가능하다.
OrderService 에 추가
List<OrderMenu> orderMenus = new ArrayList<>(
orderInfo.getOrderMenus().stream()
.map(dto -> {
return new OrderMenu(dto.getMenuCode(), dto.getOrderAmount());
}).collect(Collectors.toList())
);
for-each로 나타내는 방법
List<OrderMenu> orderMenus = new ArrayList<>(); List<OrderMenuDTO> orderMenuDTOs = orderInfo.getOrderMenus(); for (OrderMenuDTO orderMenuDTO : orderMenuDTOs) { orderMenus.add(new OrderMenu(orderMenuDTO.getMenuCode(), orderMenuDTO.getOrderAmount())); }
여기서 이제 menuCode, orderAmount 로 된 생성자는 OrderMenu에 없기 때문에
OrderMenu에도 추가
public OrderMenu(int orderAmount, int menuCode) {
this.orderAmount = orderAmount;
this.menuCode = menuCode;
}
OrderDTO를 Order로 바꿔주자.
OrderService에 추가
Order order = new Order(orderInfo.getOrderDate(), orderInfo.getOrderTime(), totalOrderPrice);
여기서 Order도 생성자가 저 3가지 매개변수로 이뤄지지 않은데..
String으로 해뒀기 때문에 변환 작업이 필요하다.
Order에 생성자 추가
public Order(LocalDate orderDate, LocalTime orderTime, int totalOrderPrice) {
/* 설명. LocalDate 또는 LocalTime 형을 DB에 맞춰서 저장하기 위한 변환작업 */
this.orderDate = orderDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
this.orderTime = orderTime.format(DateTimeFormatter.ofPattern("HH:mm:ss"));
this.totalOrderPrice = totalOrderPrice;
}
이제 tbl_order 테이블에 insert 하기 위해서 Mapper를 사용해야 한다.
OrderService에 추가
orderMapper.registerOrder(order);
OrderMapper에도 추가
public interface OrderMapper {
void registerOrder(Order order);
}
이제 sql문을 추가하자. OrderMapper.xml
<insert id="registerOrder">
INSERT
INTO TBL_ORDER
(
ORDER_DATE
, ORDER_TIME
, TOTAL_ORDER_PRICE
)
VALUES
(
#{ orderDate }
, #{ orderTime }
, #{ totalOrderPrice }
)
<!-- insert 가 끝나면 int 값이 들어가는 것이 당연하지만, 추가로 조회 결과 (현재는 insert 당시 pk 값) 를 가지고 돌아갈 수 있게 해주는 selectKey-->
<selectKey keyProperty="orderCode" order="AFTER" resultType="_int">
SELECT MAX(ORDER_CODE) FROM TBL_ORDER
</selectKey>
</insert>
이제 4번까지 완료했다.
OrderService 에 System.out.println("order insert 후 order에 담긴 pk값 확인 = " + order); 을 추가해서 pk값이 조회되는지 확인해보자.

파랑 상자를 보면 insert 후 selectKey로 조회된 pk값이 담겨 돌아오는 것을 알 수 있다.
tbl_order_menu 에도 주문한 메뉴 개수만큼 주문한 메뉴를 추가(Insert)할 수 있다.
OrderService에 추가
int orderMenuSize = orderMenus.size();
/* 설명. 주문한 메뉴들의 orderCode 들이 비어 있었으니 주문 insert 후 알게 된 pk를 채워넣는다. */
for (int i = 0; i < orderMenuSize; i++) {
OrderMenu orderMenu = orderMenus.get(i);
orderMenu.setOrderCode(order.getOrderCode()); // Order에 setter 하나 추가
orderMapper.registerOrderMenu(orderMenu);
}

OrderMenu를 보면 4번에서 Order에 selectKey를 활용했기 때문에 orderCode도 받아와진다.
마지막으로 registerOrderMenu 의 이름으로 된 쿼리를 만들어주면 끝난다.
OrderMapper에 추가
void registerOrderMenu(OrderMenu orderMenu);
OrderMapper.xml에 추가
<insert id="registerOrderMenu" parameterType="com.jehun.transactional.section01.annotation.OrderMenu">
INSERT
INTO TBL_ORDER_MENU
(
ORDER_CODE
, MENU_CODE
, ORDER_AMOUNT
)
VALUES
(
#{ orderCode }
, #{ menuCode }
, #{ orderAmount }
)
</insert>
그대로 테스트 코드에서 실행하면 db에 적용이 된다.
근데 실패를 해도 됐던 곳까지는 계속 적용이 되는데, 그것을 방지하기 위해
OrderService -> registerNewOrder 메소드에 @Transactional 추가를 해주면 된다.
참고) 원래 테스트 코드에서 하는 작업이 DB에 적용이 되면 안 된다.
그래서 활용에서는 적용이 됐지만 주의해야 한다.
해결방법 : Test 코드 클래스에@Transactional추가하기
@SpringBootTest
@Transactional
class OrderServiceTests {
... (중략)
@TransactionalA 트랜잭션, B 트랜잭션이 있다고 보자.
A 트랜잭션을 진행하는 도중 다른 트랜잭션(B)을 진행하기도 한다.
그 때의 A와 B는 다른 트랜잭션이라고 봐야 할까 같은 트랜잭션으로 봐야 할까? 에 대해 다루는 것이 전파행위 옵션이다.
즉, 트랜잭션 전파 : 현재 트랜잭션에서 다른 트랜잭션으로 이동할 때를 이야기한다.
REQUIRED : 진행 중인 트랜잭션이 있으면 현재 메소드를 그 트랜잭션에서 실행하되 그렇지 않은 경우 (진행 중인 것이 없는 경우) 새 트랜잭션을 시작해서 실행한다.
REQUIRED_NEW : 항상 새 트랜잭션을 시작해 메소드를 실행하고 진행중인 트랜잭션이 있으면 잠시 중단시킨다.
SUPPORTS : 진행 중인 트랜잭션이 있으면 현재 메소드를 그 트랜잭션 내에서 실행하되, 그렇지 않을 경우 (진행 중인 트랜잭션이 없으면) 트랜잭션 없이 실행한다.
NOT_SUPPORTED : 트랜잭션 없이 현재 메소드를 실행하고 진행 중인 트랜잭션이 있으면 잠시 중단한다
MANDATORY : 반드시 트랜잭션을 걸고 현재 메소드를 실행하되 진행 중인 트랜잭션이 있으면 예외를 던진다.
NEVER : 반드시 트랜잭션 없이 현재 메소드를 실행하되 진행 중인 트랜잭션이 있으면 예외를 던진다.
NESTED : 진행중인 트랜잭션이 있으면 현재 메소드를 이 트랜잭션의 중첩 트랜잭션 내에서 실행한다.
여기서 같이 보면 괜찮을 것이다.
오염된 값 : 하나의 트랜젝션이 데이터를 변경 후 잠시 기다리는 동안 다른 트랜젝션이 데이터를 읽게 되면, 격리레벨이 READ_UNCOMMITTED인 경우 아직 변경 후 커밋하지 않은 재고값을 그대로 읽게 된다
그러나 처음 트랜젝션이 데이터를 롤백하게 되면 다른 트랜젝션이 읽은 값은 더 이상 유효하지 않은 일시적인 값이 된다.
이것을 오염된 값라고 한다.
재현 불가능한 값 읽기 : 처음 트랜젝션이 데이터를 수정하면 수정이 되고 아직 커밋되지 않은 로우에 수정 잠금을 걸어둔 상태에다.
결국 다른 트랜젝션은 이 트랜젝션이 커밋 혹은 롤백 되고 수정잠금이 풀릴 때까지 기다렸다가 읽을 수 밖에 없게 된다.
하지만 다른 로우에 대해서는 또 다른 트랜젝션이 데이터를 수정하고 커밋을 하게 되면 가장 처음 동작한 트랜젝션이 데이터를 커밋하고 다시 조회를 한 경우 처음 읽은 그 값이 아니게 된다.
이것이 재현 불가능한 값이라고 한다.
허상 읽기 : 처음 트랜젝션이 테이블에서 여러 로우를 읽은 후 이후 트랜젝션이 같은 테이블의 로우를 추가하는 경우 처음 트랜젝션이 같은 테이블을 다시 읽으면 자신이 처음 읽었을 때와 달리 새로 추가 된 로우가 있을 것이다.
이것을 허상 읽기라고 한다. (재현 불가능한 값 읽기와 유사하지만 허상 읽기는 여러 로우가 추가되는 경우를 말한다.)