들어가기에 앞서
Naive한 MyBatis 구조라는 표현은, 기능 구현에는 성공했지만 아키텍처적인 고민이나 안전장치 없이 가장 단순하고 일차원적인 방식으로 MyBatis를 사용한 형태를 의미한다.
소프트웨어 공학에서 Naive하다는 것은 "순진한", "경험이 부족한"이라는 뜻으로, 보통 돌아가긴 하는데 확장성과 유지보수성을 고려하지 않은 초기 상태를 가리킬 때 쓴다.
이번에는 일반적인 초급 프로젝트에서 볼 수 있는 Naive한 구조의 2가지 특징을 알아보는 시간이 되겠다.
JPA로 시작한 나에게 MyBatis란
일단 나의 Dash 프로젝트가 어쩌다 Naive하게 되었는지에 대해 언급해야 할 듯하다. 관통 프로젝트를 시작하며 전달받은 일종의 요구사항(?)이 있는데, 그게 바로 MyBatis와 Spring을 연동하여 표준 REST API를 설계/구현하라는 것이었다.
application.properties
# MyBatis Configuration
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.ssafy.dash.user.domain
mybatis.configuration.map-underscore-to-camel-case=true
실습했던대로 application.properties를 통한 자동 설정을 사용했다. xml 위치는 classpath:mapper/*.xml을 스캔하도록 설정하여 SQL과 자바 코드를 분리한다. Bean 주입의 경우, @Mapper 어노테이션이 붙은 인터페이스를 MyBatis가 런타임에 프록시 객체로 생성하여 스프링 빈으로 등록하는 방식이다.
내 엔티티는 만능이야
1) 보안에 취약한 엔티티
아래는 Dash 프로젝트 초기 User 관련 코드들이다.
현재 Service나 Mapper Interface는 단순히 데이터를 토스하는 역할이기 때문에 생략한다.
User.java
public class User {
private Long id;
private String username;
private String email; // PII (개인정보)
private String role; // 권한 (ADMIN, USER)
// ... 기본 생성자, Getters & Setters 생략 ...
}
UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {
// ... 일부 생략 ...
@PutMapping("/{id}")
public ResponseEntity<User> update(@PathVariable Long id, @RequestBody User user) {
User updated = userService.update(id, user); // 클라이언트가 보낸 JSON이 엔티티로 변환
return ResponseEntity.ok(updated);
}
// ... 일부 생략 ...
}
<mapper namespace="com.ssafy.dash.user.mapper.UserMapper">
<update id="updateUser" parameterType="com.ssafy.dash.user.User">
UPDATE users
SET
username = #{username},
email = #{email},
role = #{role}}
WHERE id = #{id}
</update>
</mapper>
코드를 보면 알 수 있듯이 엔티티가 국밥 그 자체다. DB 테이블과 1대1로 매핑되는 클래스 하나를 컨트롤러부터 DB까지 모든 계층에서 돌려막기 하고 있다.
DTO 없이 엔티티를 직접 노출하면 다음과 같은 문제가 발생할 수 있다.
- 개인정보 노출: listAll() 같은 조회 API에서 의도치 않게 타인의 이메일이나 전화번호 같은 민감정보가 그대로 내려간다.
- 권한 탈취: {"role": "ADMIN"}이라는 필드를 섞어서 보내면, 컨트롤러는 User 객체에 그대로 매핑하고, 매퍼는 DB의 role 컬럼을 ADMIN으로 업데이트한다.
명심하자. 엔티티는 데이터베이스 테이블 그 자체다. 이를 외부에 그대로 노출하면, 보여주지 말아야 할 정보가 나타나거나 수정하면 안 되는 정보가 수정될 수 있다. (세터를 닫아 일시적으로 해결할 수 있지만 이 또한 좋은 방법은 아니다.)
2) 웹 계층에 오염된 엔티티
우리 서비스는 사용자가 알고리즘 코드를 파일로 업로드하면 내용을 읽어 저장하는 기능이 있다. 그런데 화면에서 MultipartFile을 받아야 하는데, 받을 객체가 엔티티밖에 없다 보니 DB 테이블과 매핑되는 엔티티에 파일 객체를 필드로 넣는 상황이 발생한다.
AlgorithmRecord.java
public class AlgorithmRecord {
private Long id;
private Long userId;
private String code;
private MultipartFile file; // 오직 '요청'을 받기 위한 필드가 침투
// ... 기본 생성자, Getters & Setters 생략 ...
}
'DB'의 스키마를 표현해야 할 엔티티 클래스가 '웹 계층'의 요청을 처리하기 위해 오염되었고, 모든 계층이 하나의 엔티티를 바라보다 보니 서비스간의 경계도 모호해지기 쉽다.
예를 들어, 회원 서비스에서도 User를 수정하고, 게시판 서비스에서도 작성자 정보를 위해 User를 조회해서 수정하려 든다. 이렇게 되니 서비스끼리 서로를 참조(순환 참조)하게 되고, 이를 해결하기 위해 생성자 주입으로 바꾸고 구조를 틀어봐도 근본적인 '의존성'이 해결되지 않으니 밑 빠진 독에 물 붓기다. → 순환 참조 문제에 대해서는 다음 게시글에서 다룰 예정이다.
엔티티는 "테이블 설계를 클래스로 옮긴 것",
DTO는 "화면에서 넘어오는 데이터",
도메인과 뷰 사이 명확한 구분이 필요하다.
개선) 도메인을 순수하게 유지하자
웹 요청은 Request DTO가 담당한다. 컨트롤러는 Request DTO를 통해 MultipartFile을 받고, 이를 비즈니스 로직에 적합한 Command 객체로 변환하여 서비스에 넘긴다.
AlgorithmRecordCreateRequest.java
public class AlgorithmRecordCreateRequest {
private String title;
private MultipartFile file; // 파일로 받기
public AlgorithmRecordCreateCommand toCommand(Long userId) throws IOException {
String code = new String(file.getBytes(), StandardCharsets.UTF_8);
return new AlgorithmRecordCreateCommand(userId, title, code); // 변환하기
}
}
덕분에 도메인은 순수하게 유지할 수 있다. 더 이상 웹 기술을 알 필요가 없어졌고, DB 테이블 구조를 명확하게 반영할 수 있게 되었다. (파일 업로드 예시가 아니더라도, 회원가입 시 Full Name을 분리해 입력받도록 변경되는 경우 등 여러 시나리오가 존재할 수 있다.)
AlgorithmRecord.java
public class AlgorithmRecord {
private Long id;
private String title;
private String code; // 순수한 문자열 데이터
// MultipartFile 웹 의존성 제거
}
내 서비스는 MyBatis를 알아
1) 테스트가 불가능한 서비스
DTO 문제만큼이나 심각했던 것이 서비스 계층이 MyBatis Mapper 인터페이스에 직접 의존하는 구조였다.
UserService.java
@Service
public class UserService {
@Autowired
private UserMapper userMapper; // MyBatis 인터페이스 직접 주입
public UserResult create( ... ) {
// ... 비즈니스 로직 ...
userMapper.insert(user); // SQL 실행
}
}
UserMapper는 인터페이스지만, 실제로는 MyBatis가 DB와 통신하는 프록시 객체다. 즉, 서비스 로직을 테스트하려면 반드시 DB가 연결되어 있어야 한다. 간단한 로직 하나 검증하려고 무거운 통합 테스트를 돌려야 하는 상황이 벌어진다.
서비스 코드가 "데이터를 저장한다"는 행위가 아니라, "MyBatis를 써서 저장한다"는 구체적인 기술에 묶여있기 때문이다. 만약 나중에 JPA로 기술 스택을 바꾸게 된다면? 서비스 코드 전체를 뜯어고쳐야 할 것이다.
개선) 리포지토리 패턴으로 DIP를 실현하자
기술 의존성을 제거하기 위해 계층을 하나 더 추가한다. 서비스는 구체적인 기술(Mapper)를 모르게 하고, 오직 인터페이스와 대화하게 만들면 된다. 서비스는 인터페이스만 볼 뿐이다. MyBatis를 쓰든, JPA를 쓰든, 아니면 메모리에 저장하는 서비스든 전혀 알 바 아니다.
UserRepository.java
public interface UserRepository {
void save(User user);
Optional<User> findById(Long id);
}
UserRepositoryImpl.java
@Repository
public class UserRepositoryImpl implements UserRepository {
private final UserMapper userMapper; // 실제 MyBatis 매퍼 위치
public UserRepositoryImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public void save(User user) {
userMapper.insert(user);
}
}
UserService.java
@Service
public class UserService {
private final UserRepository userRepository; // 인터페이스 의존
// 테스트 시에는 가짜(Mock) Repository 추가
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
덕분에 서비스 테스트는 DB 연결 없이도 가능해졌다. UserMapper 대신 MockRepository만 갈아 끼우면 되기 때문이다. 이것이 바로 DIP(의존성 역전 원칙)의 힘이다.
마치며
오늘은 DTO의 부재 상황과 기술 의존적인 서비스 사례를 통해 Naive한 MyBatis에 대해 알아보았다. 다음 시간에는 도메인이 '웹 기술'이나 '데이터 접근 기술'에 오염되지 않게 할 뿐 아니라, 순환 참조 문제까지 예방하는 방법으로 4계층 아키텍처와 테스트 방법론을 소개할 예정이다.
읽어주셔서 감사합니다!