스프링 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())로 "하지 않아야 할 일"을 고정한다.
공부 순서
- 문법편 훑기 — 반나절
- 직접 고쳐본 Service 파일 한 개를 한 줄씩 정독 ← 여기가 제일 크다
- 그 파일의 테스트 파일 정독
- 이 글(프레임워크편)
- 막히는 것만 그때그때
더 봐야 할 것
@Transactional이 안 걸리는 경우 — 같은 클래스 안에서 부르면 무시된다는 얘기- MyBatis 동적 SQL — 검색 조건 조립할 때 나온다는데
- Spring이 객체를 만드는 시점과 순서
- 예외를 어디서 잡고 어디서 던지는지