토비의 스프링 13편 - SQL과 DAO의 분리, 그리고 MyBatis가 나오기까지

초기의 스프링에서는 DAO를 통해 데이터 액세스 로직을 작성하고 관리를 하였습니다. 비즈니스 로직은 서비스에 작성하고 SQL은 DAO안에 숨기면서 책임을 분리했지만 여전히 가지고 있는 문제점이 SQL을 변경하게 되면 DAO를 함께 수정해야된다는 점이 문제였습니다.

SQL과 DAO를 분리하는 과정에서 어떤 트레이드 오프를 하며 발전했는지 살펴봅시다.

개별 SQL 프로퍼티 방식

가장 직관적인 방법은 SQL을 XML 설정 파일로 빼고, 각 SQL을 개별 프로퍼티로 주입하는 것입니다.

<bean id="userDao" class="com.ksb.spring.UserDaoJdbc">
    <property name="sqlAdd" value="insert into users(...) value (?,?,?,?,?,?)"/>
    <!-- 나머지 SQL 프로퍼티들 -->
</bean>
  • SQL이 자바 파일에 분리되었기 때문에 재컴파일 없이 활용할 수 있습니다. 
  • XML에서 프로퍼티 명을 인식하기 때문에 컴파일 시 런타임에서 체크 가능

여기서 가진 치명적인 문제점이 있는데 바로 보일러 플레이트 코드가 엄청 많이 작성될 수 있다는 점입니다.

테이블이 10개이고 각 테이블당 CRUD만 구현하더라도 40개 넘는 프로퍼티를 추가해야 합니다. 그리고 새로운 프로퍼티를 추가할 때마다 DAO를 수정해야되는 문제점을 가지고 있습니다. 

SQL 맵 프로퍼티 방식

SQL을 Map에 담아 프로퍼티 하나로 관리합니다.

<beans>
    <!-- 이것이 본래 빈 설정의 관심사 -->
    <bean id="userDao" class="com.ksb.spring.UserDaoJdbc">
        <property name="dataSource" ref="dataSource"/>
    </bean>

    <!-- 그런데 여기에 SQL이 섞여 들어옴 -->
    <bean id="userDao" class="com.ksb.spring.UserDaoJdbc">
        <property name="sqlMap">
            <map>
                <entry key="add" value="insert into users(...) value (?,?,?,?,?,?)"/>
                <entry key="get" value="select * from users where id = ?"/>
                <!-- SQL 수십 개... -->
            </map>
        </property>
    </bean>
</beans>

 

  • 프로퍼티가 하나로 줄어 DAO의 보일러플레이트 문제 해소
  • SQL 추가 시 DAO 코드 변경 없이 XML에 entry만 추가하면 됨

여기서 이전에 문제가 완전히 해결된 것 같지만 코드 변경없이 파일 수정만으로 확장할 수 있는 이점을 가진만큼 단점도 명확합니다. 바로 자바의 큰 장점 중 하나인 타입 안정성을 포기한 것입니다.

이로인해 생기는 단점은 스프링 빈 설정과 SQL 설정이 같은 xml안에 공존하면서 관심사가 섞여 SQL 사이에 있는 빈 설정을 찾아 헤매야 합니다. 그리고 같은 xml을 참조하고 있다면 키충돌의 위험이 있어 타입이 아닌 네임 컨벤션에 의존해야하는 문제점 또한 치명적입니다.

SqlService 인터페이스 도입

SQL 제공을 독립적인 서비스로 분리합니다.

public interface SqlService {
    String getSql(String key) throws SqlRetrievalFailException;
}

// 구현체: Map 기반으로 SQL을 제공
public class SimpleSqlService implements SqlService {
    private Map<String, String> sqlMap;

    public void setSqlMap(Map<String, String> sqlMap) {
        this.sqlMap = sqlMap;
    }

    @Override
    public String getSql(String key) throws SqlRetrievalFailException {
        String sql = sqlMap.get(key);
        if (sql == null)
            throw new SqlRetrievalFailException(key + "에 대한 SQL을 찾을 수 없습니다.");
        return sql;
    }
}
// DAO에서의 사용: SqlService에만 의존
public class UserDaoJdbc implements UserDao {
    private SqlService sqlService;

    public void setSqlService(SqlService sqlService) {
        this.sqlService = sqlService;
    }

    public void add(final User user) {
        this.jdbcTemplate.update(sqlService.getSql("userAdd"), ...);
    }

    public void deleteAll() {
        this.jdbcTemplate.update(sqlService.getSql("userDeleteAll"));
    }
}

DAO는 SqlService 인터페이스에만 의존하므로, SQL의 저장 방식이나 출처가 바뀌어도 DAO 코드는 변경되지 않습니다.

얻은 것

  • DAO에서 SQL에 대한 관심을 완전히 제거함. DAO는 키만 전달하고 SQL을 받을 뿐, 그것이 XML에서 오는지, DB에서 오는지, 원격 서비스에서 오는지 알 필요 없음
  • SQL 조회 실패를 런타임 예외(SqlRetrievalFailException)로 정의하여 불필요한 예외 처리 강제를 방지함. SQL을 못 찾으면 복구할 방법이 없으므로 합리적임

하지만 여전히 타입 안정성이 떨어지는 문제와 인터페이스에 xml 매칭을 강제시화 하면서 런타임 환경에서 SQL을 수정하지 못하는 문제점 또한 가지게 되었습니다.

OXM 서비스 추상화

SQL을 XML 파일에서 읽어오려면 XML↔자바 오브젝트 변환이 필요합니다. 스프링은 JAXB, Castor XML, JiBX 등 여러 OXM 기술을 Marshaller(마샬러)/Unmarshaller(언마샬러) 인터페이스로 추상화합니다.

<!-- JAXB → Castor 변경 시 빈 설정만 교체 -->
<bean id="unmarshaller" class="org.springframework.oxm.jaxb.Jaxb2Marshaller">
    <property name="contextPath" value="com.ksb.spring.jaxb"/>
</bean>
// OXM 추상화 인터페이스에만 의존하는 테스트 코드
// JAXB든 Castor든 이 코드는 변경되지 않음
@Autowired
Unmarshaller unmarshaller;  // 스프링 OXM 추상화 인터페이스

@Test
public void unmarshallSqlMap() throws Exception {
    Source xmlSource = new StreamSource(
            getClass().getResourceAsStream("sqlmap.xml"));

    // 어떤 OXM 구현체가 주입되든 동일한 코드
    Sqlmap sqlmap = (Sqlmap) this.unmarshaller.unmarshal(xmlSource);

    List<SqlType> sqlList = sqlmap.getSql();
    assertThat(sqlList.size(), is(6));
}

얻은 것

  • OXM 기술 교체가 빈 설정 변경만으로 가능해짐
  • 테스트 코드와 서비스 코드에 구체 기술이 등장하지 않으므로 기술 종속성 제거

하지만 실무에서 OXM 기술을 교체하는 일은 거의 발생하지 않습니다. JAXB를 사용하던지 Castor를 사용하던지 특정 기술 스택을 유지하는 편입니다. 그렇다보니 추상화가 주는 유연성 대비 투자 리소스가 너무 큽니다. 

그리고 추상화 계층이 하나 더 생기면서 XML 파싱 오류가 발생했을 때 언마샬러 -> 구현체 -> XML 파서까지 따라 디버깅해야 합니다. 그리고 이제 Castor는 Deplicated 되면서 OXM의 구현체가 안정적이지 않다는 것을 알게 되었습니다.

OxmSqlService - 멤버 클래스를 활용한 통합 설계

OxmSqlReader를 OxmSqlService의 내부 멤버 클래스로 구현합니다.

public class OxmSqlService implements SqlService {
    // 내부에서 직접 생성 - 외부에서 OxmSqlReader의 존재를 알 수 없음
    private final OxmSqlReader oxmSqlReader = new OxmSqlReader();
    private final BaseSqlService baseSqlService = new BaseSqlService();
    private SqlRegistry sqlRegistry = new HashMapSqlRegistry();

    // === 간접 DI: 외부에서 받은 프로퍼티를 내부 OxmSqlReader에 전달 ===
    public void setUnmarshaller(Unmarshaller unmarshaller) {
        oxmSqlReader.setUnmarshaller(unmarshaller);
    }

    public void setSqlmapFile(Resource sqlmap) {
        oxmSqlReader.setSqlmap(sqlmap);
    }

    // === 위임: BaseSqlService에 실제 로직을 맡김 ===
    @PostConstruct
    public void loadSql() {
        this.baseSqlService.setSqlReader(this.oxmSqlReader);
        this.baseSqlService.setSqlRegistry(this.sqlRegistry);
        this.baseSqlService.loadSql();  // 위임
    }

    @Override
    public String getSql(String key) throws SqlRetrievalFailException {
        return this.baseSqlService.getSql(key);  // 위임
    }

    // === 내부 멤버 클래스: 빈으로 등록되지 않음 ===
    private class OxmSqlReader implements SqlReader {
        private Unmarshaller unmarshaller;
        private Resource sqlmap = new ClassPathResource("/sqlmap.xml", UserDao.class);

        public void setUnmarshaller(Unmarshaller unmarshaller) {
            this.unmarshaller = unmarshaller;
        }

        public void setSqlmap(Resource sqlmap) {
            this.sqlmap = sqlmap;
        }

        @Override
        public void read(SqlRegistry sqlRegistry) {
            try {
                Source source = new StreamSource(sqlmap.getInputStream());
                Sqlmap sqlmap = (Sqlmap) this.unmarshaller.unmarshal(source);
                for (SqlType sql : sqlmap.getSql())
                    sqlRegistry.registrySql(sql.getKey(), sql.getValue());
            } catch (IOException e) {
                throw new IllegalArgumentException(
                    this.sqlmap.getFilename() + "을 가져올 수 없습니다." + e);
            }
        }
    }
}
<!-- 빈 등록은 OxmSqlService 하나만 하면 됨 -->
<bean id="sqlService" class="com.ksb.spring.OxmSqlService">
    <property name="unmarshaller" ref="unmarshaller"/>
    <property name="sqlmapFile" value="classpath:/sqlmap.xml"/>
</bean>

<bean id="unmarshaller" class="org.springframework.oxm.jaxb.Jaxb2Marshaller">
    <property name="contextPath" value="com.ksb.spring.jaxb"/>
</bean>

OxmSqlReader는 빈이 아니므로 OxmSqlService가 Unmarshaller와 sqlmapFile을 대신 받아 전달하는 간접 DI 구조를 사용합니다. 또한 BaseSqlService와 중복되는 loadSql(), getSql()은 상속 대신 위임 패턴으로 재사용합니다.

얻은 것

  • SQL 읽기 방식을 OXM으로 고정하여 설정이 단순해짐. 빈 등록 개수도 줄어듦
  • 위임 패턴으로 상속의 결합도 문제를 피하면서 코드를 재사용함

트레이드오프

sqlReader를 서비스 빈 내부에 고정함으로써 다른 방식(DB 조회, 원격 서비스)으로 SQL을 읽으려면 OxmSqlService 자체를 버려야합니다. 간접 DI에서는 Oxm이 존재하는지 알 수 없기 때문에 단위 테스트가 어렵고 위임할 메소드가 늘어나면 단순한 포워딩 코드(값을 전달하는 코드)가 늘어나게 됩니다.

 

리소스 추상화

자바는 리소스 위치(클래스패스, 파일시스템, 웹)마다 다른 API를 사용해야 합니다. 스프링은 Resource 타입과 접두어 방식으로 이를 통합합니다.

classpath: classpath:file.txt 클래스패스
file: file:/C:/temp/file.txt 파일 시스템
http: http://example.com/data.xml 웹 리소스
<property name="sqlmap" value="classpath:/sqlmap.xml"/>

얻은 것

  • 접두어만 변경하면 리소스 출처를 자유롭게 전환 가능
  • String → Resource 타입 변경으로 리소스 처리 로직이 표준화됨

트레이드오프

접두어 기반이기 때문에 설정 오류가 런타임에 발견됩니다. casspath:로 오타 내더 라도 컴파일 시점에 잡히지 않습니다. http 리소스를 사용할 경우 네트워크 장애 및 타임 아웃 등의 외부 의존 에러가 발생하는데 이는 개발자가 핸들링 해야합니다.

MyBatis가 나오게 된 이유

6단계에 걸친 개선에도 불구하고 근본적으로 해결되지 않은 문제들이 있습니다.

  • 문자열 키 의존: 1단계에서 6단계까지 한 번도 해결되지 않음. 키 오타는 항상 런타임에 발견되는 문제
  • 파라미터/결과 매핑의 수동 처리: SQL에 파라미터를 바인딩하고 ResultSet을 오브젝트로 변환하는 코드는 여전히 DAO에 존재
  • 설정의 복잡도: SqlService, Unmarshaller, SqlRegistry 등 여러 빈을 조합해야 하는 설정 비용

MyBatis는 이 문제들을 프레임워크 레벨에서 해결합니다.

미해결 문제 MyBatis의 해결
문자열 키 의존 인터페이스 메소드명과 XML의 SQL ID 자동 매핑. 메소드가 없으면 컴파일 에러
파라미터/결과 매핑 수동 처리 #{} 바인딩, resultType/resultMap으로 자동 매핑
설정 복잡도 SqlSessionFactory 하나로 통합. 스프링 부트에서는 자동 설정까지 제공
SQL 변경 시 재기동 개발 모드에서 매퍼 XML 핫 리로드 지원
<!-- UserMapper.xml - SQL만 관리하는 별도 파일 -->
<mapper namespace="com.ksb.spring.UserMapper">
    <insert id="add">
        INSERT INTO users(id, name, password, level, login, recommend)
        VALUES (#{id}, #{name}, #{password}, #{level}, #{login}, #{recommend})
    </insert>

    <select id="get" resultType="User">
        SELECT * FROM users WHERE id = #{id}
    </select>

    <select id="getAll" resultType="User">
        SELECT * FROM users ORDER BY id
    </select>

    <delete id="deleteAll">
        DELETE FROM users
    </delete>

    <select id="getCount" resultType="int">
        SELECT COUNT(*) FROM users
    </select>

    <update id="update">
        UPDATE users SET name=#{name}, password=#{password},
        level=#{level}, login=#{login}, recommend=#{recommend}
        WHERE id=#{id}
    </update>
</mapper>
// 인터페이스만 정의 - 구현체 작성 불필요
public interface UserMapper {
    void add(User user);
    User get(String id);
    List<User> getAll();
    void deleteAll();
    int getCount();
    void update(User user);
}

다만 MyBatis 역시 트레이드오프가 있습니다. XML 매퍼 파일이 늘어나면 관리 비용이 증가하고, 동적 SQL(<if>, <choose>, <foreach>)이 복잡해지면 XML 자체가 또 다른 프로그래밍 언어처럼 변합니다. 이런 한계가 다시 QueryDSL이나 jOOQ 같은 타입 세이프 SQL 빌더의 등장으로 이어지게 됩니다.


마무리

SQL과 DAO를 분리하는 여정은 관심사의 분리라는 원칙을 데이터 액세스 계층에 적용하는 과정이었습니다. 그러나 각 단계의 개선은 항상 새로운 트레이드오프를 동반했습니다. 보일러플레이트를 줄이면 타입 안전성을 잃고, 추상화를 높이면 디버깅이 어려워지고, 사용성을 높이면 유연성을 포기해야 했습니다.

완벽한 해결책은 없으며, 각 단계가 이전 단계의 어떤 문제를 해결하려 했고, 그 대가로 무엇을 감수했는지를 이해하는 것이 설계 판단의 핵심입니다.