스프링 DI·@Transactional·MyBatis: 프론트 개발자가 자바 코드를 읽기 시작할 때 (프레임워크편)

스프링 의존성 주입으로 new를 안 쓰는 이유, @Transactional 동작, @Valid 빠뜨리면 검증이 안 되는 것, MyBatis가 인터페이스만으로 도는 원리, Mockito verify와 never 사용법.

조회
목차

문법편에 이어서. 문법이 좀 읽히기 시작하면 그다음 벽은 프레임워크다.

new를 안 쓰는데 객체가 어디서 오나

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final UserRepository userRepository;

    public OrderService(OrderRepository orderRepository, UserRepository userRepository) {
        this.orderRepository = orderRepository;
        this.userRepository = userRepository;
    }
}

어디에도 new OrderRepository()가 없다. Spring이 시작할 때 @Service, @Mapper 붙은 것들을 전부 만들어두고, 생성자가 요구하는 걸 찾아서 넣어준다.

왜 이렇게 하냐면 — 테스트에서 가짜를 넣을 수 있어서다.

// 진짜 실행: Spring 이 진짜 Repository 를 넣는다
// 테스트:    내가 가짜 Repository 를 넣는다
var service = new OrderService(mockOrderRepo, mockUserRepo);

코드 안에서 new로 직접 만들면 이게 불가능하다. DB 없이는 테스트를 못 돌린다.

@Transactional — 전부 성공 아니면 전부 취소

@Transactional
public void saveResult(int orderId, Status status) {
    repository.updateStatus(...);        // ①
    repository.updateHistory(...);       // ②
    if (status == CONFIRMED) {
        userRepository.updateAddress(...);  // ③
    }
}

③에서 예외가 나면 ①②도 없던 일이 된다. 절반만 반영된 상태가 안 생긴다.

이게 왜 중요하냐면, 이걸 별도 API로 쪼개면 "①②는 됐는데 ③은 실패" 상태가 실제로 생기기 때문이다. 특히 외부에서 오는 콜백 처리는 예외를 삼키는 구조가 많아서 그 실패가 조용히 지나간다.

한 덩어리로 처리돼야 하는 건 한 메서드 안에 둔다.

요청 검증 — @Valid를 빼먹으면 아무 일도 안 한다

public record RegisterRequest(
    @NotBlank(message = "아이디는 필수입니다")
    @Size(min = 5, max = 20)
    String userId,

    @NotBlank @Email
    String email
) {}
@PostMapping("/register")
public void register(@Valid @RequestBody RegisterRequest request) { ... }
//                   ^^^^^^ 이게 있어야 위의 규칙이 실제로 검사된다

MyBatis — 구현 클래스가 없는데 어떻게 도나

처음 보면 이게 제일 이상하다.

@Mapper
public interface OrderRepository {
    Optional<OrderEntity> findById(int seq);
    void updateStatus(int seq, String status);
}

인터페이스만 있고 구현 클래스가 없다. 근데 호출하면 동작한다.

짝이 되는 XML이 따로 있기 때문이다.

<mapper namespace="com.example.domain.order.OrderRepository">

  <select id="findById" resultType="com.example.domain.order.OrderEntity">
    SELECT seq, order_no, status
    FROM orders
    WHERE seq = #{seq}
  </select>

</mapper>

연결 규칙은 두 개.

  • namespace = 인터페이스의 전체 이름
  • id = 메서드 이름

MyBatis가 실행할 때 이 둘을 맞춰서 구현체를 자동으로 만든다.

그래서 메서드 이름을 바꾸면 XML의 id도 같이 바꿔야 한다. 안 바꾸면 컴파일은 통과하고 실행할 때 터진다.

그리고 #{seq}는 파라미터 자리인데 ${seq}가 아니다. #{}는 값으로 안전하게 넣고, ${}는 문자열을 그대로 갖다 붙여서 SQL 인젝션 위험이 있다.

테스트 — 세 가지만 알면 읽힌다

가짜 객체를 만들어서 "이 상황이면 이렇게 동작하나"를 확인한다.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository orderRepository;    // 가짜
    @Mock UserRepository userRepository;      // 가짜

    OrderService service;

    @BeforeEach
    void setUp() {
        service = new OrderService(orderRepository, userRepository);
    }

    @Test
    @DisplayName("확정이면 주소를 저장한다")
    void 확정시_저장() {
        // given — 가짜가 이렇게 답하도록 지정
        when(orderRepository.findById(100)).thenReturn(Optional.of(order));

        // when — 실제 호출
        service.saveResult(100, Status.CONFIRMED);

        // then — 이 메서드가 이 인자로 불렸는지 확인
        verify(userRepository).updateAddress(100, "서울시...");
    }

    @Test
    @DisplayName("취소면 주소를 건드리지 않는다")
    void 취소시_미반영() {
        when(orderRepository.findById(100)).thenReturn(Optional.of(order));

        service.saveResult(100, Status.CANCELED);

        verify(userRepository, never()).updateAddress(anyInt(), anyString());
        //                     ^^^^^^^ 안 불렸는지 확인
    }
}
문법
when(A).thenReturn(B)A를 물으면 B라고 답해라 (상황 설정)
verify(x).method(...)이 메서드가 이 인자로 불렸는지 확인
verify(x, never()).method(...)안 불렸는지 확인

never()가 특히 중요하다. "하지 않아야 할 일"은 이걸로만 고정할 수 있다. 이게 없으면 실수로 추가한 저장 로직이 아무 테스트도 안 깬다.

테스트 이름을 한글로 쓰는 건, 테스트 목록이 그대로 동작 명세서가 되게 하려는 거다. 나중에 이 코드를 처음 보는 사람이 테스트 이름만 읽어도 규칙을 안다.

자주 보이는 팀 규칙과 그 이유

문법은 아닌데 코드 모양을 정하는 규칙들. 팀마다 다르지만 이유는 비슷하다.

Service 인터페이스를 안 만든다

구현체가 하나뿐인데 인터페이스를 만들면 파일만 두 배가 된다. 옛날엔 테스트 때문에 필요했는데 지금은 테스트 도구가 클래스도 가짜로 만들 수 있어서 이유가 없어졌다.

API 주소에 DB 순번을 노출하지 않는다

GET /api/user/12345          ← 안 한다. 순번이 보이면 남의 데이터도 짐작된다
GET /api/user/{userCode}     ← 이렇게

모든 테이블에 기록용 컬럼을 둔다

등록일시·등록자·수정일시·수정자·삭제여부. 그래서 서비스 메서드가 "누가 했는지"를 인자로 받고 다니는 거다.

삭제여부는 진짜로 안 지우고 표시만 하는 것. 지우면 안 되는 데이터가 있으니까.

남길 것

  • new가 없는 건 테스트 때문이다. 생성자로 받으면 가짜를 넣을 수 있다.
  • @Transactional은 "전부 아니면 전무"다. 한 덩어리로 처리돼야 하는 건 한 메서드에 둔다.
  • @Valid를 빼먹으면 검증 규칙을 다 써놔도 검사를 안 한다.
  • MyBatis는 인터페이스 이름 + 메서드 이름으로 XML과 연결된다. 메서드명을 바꾸면 실행할 때 터진다.
  • verify(x, never())로 "하지 않아야 할 일"을 고정한다.

공부 순서

  1. 문법편 훑기 — 반나절
  2. 직접 고쳐본 Service 파일 한 개를 한 줄씩 정독 ← 여기가 제일 크다
  3. 그 파일의 테스트 파일 정독
  4. 이 글(프레임워크편)
  5. 막히는 것만 그때그때

더 봐야 할 것

  • @Transactional이 안 걸리는 경우 — 같은 클래스 안에서 부르면 무시된다는 얘기
  • MyBatis 동적 SQL — 검색 조건 조립할 때 나온다는데
  • Spring이 객체를 만드는 시점과 순서
  • 예외를 어디서 잡고 어디서 던지는지

사이트 내 검색

제목·태그·설명·본문으로 발행 글을 찾습니다