Spring - MyBatis 와 연결해 활용하면서 @Transactional 알아보기

제훈·2024년 8월 20일

Spring

목록 보기
13/18

활용

기본 세팅

이번엔 Spring 프로젝트로 Mybatis와 연결하려고 한다.

  • Spring 버전 3.3.2
  • JDK 17

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

기본적인 세팅은 끝났다.


이제부터 서비스부터 기능을 만들어보자.

기능

  1. 주문한 메뉴 코드 추출 (DTO)

  2. 메뉴 별로 Menu Entity에 담아서 조회(Select)해 오기 (부가적인 메뉴의 정보 추출(단가 등))

  3. 이 주문건에 대한 주문 총 합계를 계산한다. (Insert 한 번으로 처리하기 위해)

  • 위 과정을 하면 tbl_order 테이블에 추가(Insert)가 가능하다.
    • tbl_order_menu 에도 주문한 메뉴 개수만큼 주문한 메뉴를 추가(Insert)할 수 있다.

1번

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번

이번엔 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.properties

log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.dump.sql.maxlinelength=0

log4jj2.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번

다음은 3번 총 합계 금액 계산을 기능 구현을 해보자.

OrderServiceregisterNewOrder() 에 추가

/* 설명. 3. 이 주문건에 대한 주문 총 합계를 계산 (Insert 한 번으로 처리하기 위해) */
        int totalOrderPrice = calculateTotalOrderPrice(orderInfo.getOrderMenus(), menus);

calculateTotalOrderPrice 메소드 : 주문 건에 대한 총 합계 금액 계산 메소드
(orderMenus : 사용자의 주문 내용, menus : 조회된 메뉴 전체 내용)

이것도 추가해보자.

OrderServicecalculateTotalOrderPrice() 메소드

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원

4번

이제 tbl_order 테이블에 insert가 가능하다.

  1. insert를 하기 위해 테이블과 매칭된느 Entity 클래스(Order)로 옮겨 담는다. (DTO -> Entity)

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번까지 완료했다.

OrderServiceSystem.out.println("order insert 후 order에 담긴 pk값 확인 = " + order); 을 추가해서 pk값이 조회되는지 확인해보자.

파랑 상자를 보면 insert 후 selectKey로 조회된 pk값이 담겨 돌아오는 것을 알 수 있다.


5번

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번에서 OrderselectKey를 활용했기 때문에 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>

@Transactional 사용

그대로 테스트 코드에서 실행하면 db에 적용이 된다.

근데 실패를 해도 됐던 곳까지는 계속 적용이 되는데, 그것을 방지하기 위해

OrderService -> registerNewOrder 메소드에 @Transactional 추가를 해주면 된다.

참고) 원래 테스트 코드에서 하는 작업이 DB에 적용이 되면 안 된다.
그래서 활용에서는 적용이 됐지만 주의해야 한다.
해결방법 : Test 코드 클래스에 @Transactional 추가하기

@SpringBootTest
@Transactional
class OrderServiceTests {
	... (중략)
  • 테스트코드에서의 @Transactional
    • DML(insert, update, delete) 작업 테스트 시 실제 DB 적용을 안 하기 위해 사용한다.

전파행위 옵션

A 트랜잭션, B 트랜잭션이 있다고 보자.

A 트랜잭션을 진행하는 도중 다른 트랜잭션(B)을 진행하기도 한다.

그 때의 A와 B는 다른 트랜잭션이라고 봐야 할까 같은 트랜잭션으로 봐야 할까? 에 대해 다루는 것이 전파행위 옵션이다.

즉, 트랜잭션 전파 : 현재 트랜잭션에서 다른 트랜잭션으로 이동할 때를 이야기한다.

  • REQUIRED : 진행 중인 트랜잭션이 있으면 현재 메소드를 그 트랜잭션에서 실행하되 그렇지 않은 경우 (진행 중인 것이 없는 경우) 새 트랜잭션을 시작해서 실행한다.

  • REQUIRED_NEW : 항상 새 트랜잭션을 시작해 메소드를 실행하고 진행중인 트랜잭션이 있으면 잠시 중단시킨다.

  • SUPPORTS : 진행 중인 트랜잭션이 있으면 현재 메소드를 그 트랜잭션 내에서 실행하되, 그렇지 않을 경우 (진행 중인 트랜잭션이 없으면) 트랜잭션 없이 실행한다.

  • NOT_SUPPORTED : 트랜잭션 없이 현재 메소드를 실행하고 진행 중인 트랜잭션이 있으면 잠시 중단한다

  • MANDATORY : 반드시 트랜잭션을 걸고 현재 메소드를 실행하되 진행 중인 트랜잭션이 있으면 예외를 던진다.

  • NEVER : 반드시 트랜잭션 없이 현재 메소드를 실행하되 진행 중인 트랜잭션이 있으면 예외를 던진다.

  • NESTED : 진행중인 트랜잭션이 있으면 현재 메소드를 이 트랜잭션의 중첩 트랜잭션 내에서 실행한다.

    • 진행 중인 트랜잭션이 없으면 새 트랜잭션을 실행한다.
    • 배치 실행 도중 처리 할 업무가 100 만개라고 하면 10만개씩 끊어서 커밋하는 경우, 중간에 잘못 되어도 중첩 트랜잭션을 롤백하면 전체가 아닌 10만개만 롤백된다.
    • 세이브포인트를 이용하는 방식이다. 따라서 세이브포인트를 지원하지 않는 경우 사용 불가능하다.

격리 수준

mysql의-격리-수준

여기서 같이 보면 괜찮을 것이다.


오염된 값

오염된 값 : 하나의 트랜젝션이 데이터를 변경 후 잠시 기다리는 동안 다른 트랜젝션이 데이터를 읽게 되면, 격리레벨이 READ_UNCOMMITTED인 경우 아직 변경 후 커밋하지 않은 재고값을 그대로 읽게 된다

그러나 처음 트랜젝션이 데이터를 롤백하게 되면 다른 트랜젝션이 읽은 값은 더 이상 유효하지 않은 일시적인 값이 된다.

이것을 오염된 값라고 한다.


재현 불가능한 값 읽기

재현 불가능한 값 읽기 : 처음 트랜젝션이 데이터를 수정하면 수정이 되고 아직 커밋되지 않은 로우에 수정 잠금을 걸어둔 상태에다.

결국 다른 트랜젝션은 이 트랜젝션이 커밋 혹은 롤백 되고 수정잠금이 풀릴 때까지 기다렸다가 읽을 수 밖에 없게 된다.

하지만 다른 로우에 대해서는 또 다른 트랜젝션이 데이터를 수정하고 커밋을 하게 되면 가장 처음 동작한 트랜젝션이 데이터를 커밋하고 다시 조회를 한 경우 처음 읽은 그 값이 아니게 된다.

이것이 재현 불가능한 값이라고 한다.


허상 읽기

허상 읽기 : 처음 트랜젝션이 테이블에서 여러 로우를 읽은 후 이후 트랜젝션이 같은 테이블의 로우를 추가하는 경우 처음 트랜젝션이 같은 테이블을 다시 읽으면 자신이 처음 읽었을 때와 달리 새로 추가 된 로우가 있을 것이다.

이것을 허상 읽기라고 한다. (재현 불가능한 값 읽기와 유사하지만 허상 읽기는 여러 로우가 추가되는 경우를 말한다.)

profile
백엔드 개발자 꿈나무

0개의 댓글