IT·테크/개발

MyBatis 설정과 서블릿 필터 — SQL을 XML로 분리하고 요청을 가로채기

레루루 2026. 8. 1. 00:55
반응형

이 글은 2022년 7월 기준 기록입니다.

 

MyBatis를 Spring에 붙이는 작업은 파일 하나로 끝나지 않습니다. 라이브러리를 받고, MyBatis 자체의 설정 파일을 만들고, SQL이 들어갈 매퍼를 따로 두고, 이들을 Spring이 알아볼 수 있도록 bean으로 등록하는 네 단계가 순서대로 필요합니다.

1. 라이브러리부터 내려받는다

pom.xml에 네 개의 의존성을 추가합니다. DB 드라이버, Spring의 JDBC 지원, MyBatis 본체, 그리고 MyBatis를 Spring과 이어 주는 연동 모듈입니다.

<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.29</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
    <version>${org.springframework-version}</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.10</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.mybatis/mybatis-spring -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>2.0.7</version>
</dependency>

2. MyBatis 설정 파일은 별칭과 매퍼 위치를 알린다

MyBatis 설정 내용을 작성할 파일을 만듭니다. 여기서 하는 일은 두 가지입니다. typeAliases로 긴 클래스 경로에 짧은 별칭을 붙이고, mappers로 SQL이 들어 있는 매퍼 파일의 위치를 알립니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>
    <typeAliases>
        <typeAlias alias="member" type="net.koreate.db_test.vo.MemberVO"/>
    </typeAliases>
    <mappers>
        <mapper resource="sql/memberMapper.xml" />
    </mappers>
</configuration>

 

별칭을 걸어 두면 매퍼 XML에서 resultType="member"처럼 짧게 쓸 수 있습니다.

3. SQL과 객체의 맵핑은 매퍼 XML이 담당한다

MyBatis에서 객체와의 맵핑 정보를 분리해서 작성합니다. 아래는 member용 매퍼입니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="net.koreate.mapper.MemberMapper">

    <insert id="insertMember" parameterType="net.koreate.db_test.vo.MemberVO">
        INSERT INTO tbl_member VALUES(#{userid}, #{userpw}, #{username}, now(), now())
    </insert>

    <select id="readMember" parameterType="String" resultType="member">
        SELECT * FROM tbl_member WHERE userid=#{userid}
    </select>

    <select id="readMemberWithPass" parameterType="Map" resultType="member">
        SELECT * FROM tbl_member WHERE userid=#{userid} AND userpw=#{userpw}
    </select>

    <select id="memberList" resultType="member">
        SELECT * FROM tbl_member ORDER BY regdate DESC
    </select>

</mapper>

 

namespace가 이 매퍼의 식별자가 되고, 각 구문의 id와 합쳐진 namespace + "." + id 문자열로 자바 코드에서 SQL을 호출하게 됩니다.

 

파라미터가 하나일 때는 parameterType에 그 타입을 그대로 적지만, readMemberWithPass처럼 아이디와 비밀번호 두 개를 넘겨야 하면 Map으로 묶어 전달합니다.

4. 세 개의 bean이 DataSource에서 SqlSession까지 잇는다

root-context.xml에 bean을 등록합니다. 셋은 서로 물려 있습니다. DB 접속 정보를 가진 DriverManagerDataSource, 그 DataSource와 MyBatis 설정 파일을 받아 세션을 만드는 SqlSessionFactoryBean, 그리고 실제 코드가 주입받아 쓰게 될 SqlSessionTemplate입니다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd">

    <!-- Root Context: defines shared resources visible to all other web components -->
    <bean id="ds" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" />
        <property name="url" value="jdbc:mysql://localhost:3306/spring_data"></property>
        <property name="username" value="spring" />
        <property name="password" value="1234" />
    </bean>

    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="ds"></property>
        <property name="configLocation" value="classpath:/MybatisConfig.xml" />
    </bean>

    <bean id="sqlSession" class="org.mybatis.spring.SqlSessionTemplate">
        <constructor-arg name="sqlSessionFactory" ref="sqlSessionFactory"/>
    </bean>

    <context:component-scan base-package="net.koreate.db_test.dao"></context:component-scan>
</beans>

 

마지막 줄의 component-scan은 DAO 패키지를 훑어 @Repository가 붙은 클래스를 bean으로 등록하기 위한 것입니다.

VO와 DAO는 매퍼의 SQL을 자바 쪽에서 부른다

먼저 테이블 한 행에 대응하는 VO를 만듭니다. lombok의 어노테이션으로 getter·setter·toString을 대신합니다.

package net.koreate.db_test.vo;

import java.util.Date;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class MemberVO {

    private String userid;
    private String userpw;
    private String username;
    private Date regdate;
    private Date updatedate;

}

 

DAO는 인터페이스와 구현체로 나눕니다. 인터페이스는 어떤 조회·삽입이 가능한지만 선언합니다.

package net.koreate.db_test.dao;

import java.util.List;

import net.koreate.db_test.vo.MemberVO;

public interface MemberDAO {
    void insertMember(MemberVO memberVO);

    MemberVO readMember(String userid);

    MemberVO readMemberWithPass(String userid, String userpw);

    List<MemberVO> readMemberList();
}

 

구현체에서는 SqlSession을 주입받아, 앞서 정한 namespace + "." + id 문자열로 매퍼의 SQL을 호출합니다. readMemberWithPass처럼 인자가 둘인 메서드는 여기서 Map으로 묶어 넘깁니다.

package net.koreate.db_test.dao;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.inject.Inject;

import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;

import net.koreate.db_test.vo.MemberVO;

@Repository
public class MemberDAOImpl implements MemberDAO {

    @Inject
    private SqlSession session;

    private static String namespace = "net.koreate.mapper.MemberMapper";

    @Override
    public void insertMember(MemberVO memberVO) {
        session.insert(namespace + ".insertMember", memberVO);
    }

    @Override
    public MemberVO readMember(String userid) {
        MemberVO vo = session.selectOne(namespace + ".readMember", userid);
        return vo;
    }

    @Override
    public MemberVO readMemberWithPass(String userid, String userpw) {
        Map<String, String> map = new HashMap<>();

        map.put("userid", userid);
        map.put("userpw", userpw);

        MemberVO vo = session.selectOne(namespace + ".readMemberWithPass", map);
        return vo;
    }

    @Override
    public List<MemberVO> readMemberList() {
        return session.selectList(namespace + ".memberList");
    }

}

테스트는 서버를 띄우지 않고 컨텍스트만 읽는다

여기까지의 설정이 맞는지 확인하려면 연결을 실제로 열어 봐야 합니다. 그런데 문제가 하나 있습니다. context 파일에 등록된 설정 정보는 web.xml이 동작할 때 읽어오게 되는데, 그러려면 서버가 시작되어야 합니다.

 

테스트 환경에서 서버를 실행하지 않고 그 내용을 읽어오기 위해 @RunWith(SpringJUnit4ClassRunner.class)@ContextConfiguration을 사용합니다.

package net.koreate.db_test;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;

import javax.inject.Inject;
import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import net.koreate.db_test.dao.MemberDAO;
import net.koreate.db_test.vo.MemberVO;

// context 파일에 등록된 설정 정보는 web.xml이 동작할 때 읽어오게 되는데, 그러려면 서버가 시작되어야 함
// 테스트 환경에서 서버를 실행하지 않고 해당 내용을 읽어오기 위해 사용하는 어노테이션
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "file:src/main/webapp/WEB-INF/spring/root-context.xml" })
public class DataSourceTest {

    @Inject
    DataSource ds;

    @Test
    public void dataSourceTest() {
        Connection conn = null;

        try {
            conn = ds.getConnection();
            System.out.println(conn + " : 연결완료");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    @Inject
    SqlSessionFactory sqlSessionFactory;

    @Test
    public void sqlSessionFactoryTest() {
        try (SqlSession session = sqlSessionFactory.openSession()) {
            System.out.println("연결 정보 객체 생성 완료");
            System.out.println(session);
            System.out.println(session.getConnection());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Autowired
    MemberDAO dao;

//    @Test
    public void insertMemberTest() {
        MemberVO vo = new MemberVO();

        vo.setUserid("id004");
        vo.setUserpw("pw004");
        vo.setUsername("기시우");

        dao.insertMember(vo);

    }

    @Test
    public void readMemberTest() {
        String userid = "id001";
        String userpw = "pw001";

        MemberVO vo = dao.readMember(userid);
        Assert.assertEquals(userid, vo.getUserid());

        vo = dao.readMemberWithPass(userid, userpw);

        Assert.assertEquals("김신우", vo.getUsername());

        List<MemberVO> memberList = dao.readMemberList();
        for (MemberVO m : memberList) {
            System.out.println(m);
        }
    }
}

 

DataSource 연결, SqlSessionFactory 생성, DAO를 통한 조회까지 단계별로 나누어 확인하는 구성입니다. insertMemberTest는 매번 데이터가 쌓이지 않도록 @Test를 주석 처리해 두었습니다.

필터는 컨트롤러가 손대기 전에 요청을 잡는다

Spring Filter는 요청이 컨트롤러에 도달하기 전에 개입할 수 있는 지점입니다. 여기서는 인코딩과 로그 두 가지 용도로 씁니다.

인코딩 필터가 필요한 이유는 순서 때문이다

한글 처리를 위해서는 request의 characterEncoding을 UTF-8로 설정해야 합니다. Controller에서 매개변수로 request를 받아 직접 설정할 수도 있습니다.

 

그런데 그렇게 하면 문제가 생깁니다. 나머지 데이터들을 받기 전에 인코딩이 설정되어야 하는데, 컨트롤러 메서드에 들어온 시점은 이미 늦습니다. 매번 컨트롤러마다 같은 코드를 넣는 것도 스프링의 장점을 살리지 못하는 방식입니다.

 

그래서 인코딩은 필터에서 설정합니다. Spring이 제공하는 CharacterEncodingFilterweb.xml에 등록하면 됩니다.

<filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>utf-8</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>encodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

 

url-pattern/*로 두었으므로 모든 요청이 이 필터를 먼저 거칩니다.

로그 필터를 만들려면 서버 설정을 먼저 바꾼다

직접 필터를 만들어 요청 로그를 파일로 남겨 보겠습니다. 로그 파일을 프로젝트 폴더에 쓰려면 걸림돌이 하나 있습니다. 이클립스에서 서버 실행 시 임시 경로를 사용하므로, getRealPath()가 프로젝트 경로가 아닌 임시 디렉터리를 가리킵니다.

 

실제 경로를 사용하려면 서버 설정을 변경합니다. Servers 뷰에서 톰캣 서버를 더블클릭해 Overview를 연 뒤, [Server Options]의 [Serve modules without publishing]을 체크합니다.

 

그 다음 필터를 작성합니다. @WebFilterinitParams로 로그를 쓸 디렉터리와 파일명을 넘기고, init()에서 그 경로를 실제 경로로 변환해 PrintWriter를 준비합니다. 폴더가 없으면 mkdirs()로 만듭니다.

 

doFilter()가 요청마다 호출되는 지점입니다. 요청 시간·IP·URL·전송 방식을 기록한 뒤, chain.doFilter()로 다음 차례에 넘겨야 요청이 컨트롤러까지 도달합니다.

package net.koreate.filter;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.Date;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.http.HttpServletRequest;

@WebFilter(urlPatterns = { "/*" }, initParams = { @WebInitParam(name = "filterName", value = "printFilter"),
        @WebInitParam(name = "printDir", value = "/log"), @WebInitParam(name = "printFile", value = "filterLog.log") })
public class PrintFilter implements Filter {

    String printDir;
    String printFile;

    PrintWriter pw;

    public void init(FilterConfig fConfig) throws ServletException {
        printDir = fConfig.getInitParameter("printDir");
        printFile = fConfig.getInitParameter("printFile");

        ServletContext application = fConfig.getServletContext();
        String path = application.getRealPath(printDir);

        File file = new File(path);
        if (!file.exists()) {
            file.mkdirs();
        }

        if (pw == null) {
            file = new File(path, printFile);
            try {
                pw = new PrintWriter(new FileWriter(file, true), true);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd E HH:mm:ss");
        pw.println("=======================================================");
        pw.printf("로그 시간 : %s %n", sdf.format(new Date()));
        pw.printf("요청 IP : %s %n", req.getRemoteAddr());
        pw.printf("요청 URL : %s %n", req.getRequestURI());
        pw.printf("전송 방식 : %s %n", req.getMethod());
        pw.println("=======================================================");
        chain.doFilter(request, response);
    }

}

FileWriter의 두 번째 인자를 true로 두었으므로 서버를 재시작해도 기존 로그에 이어서 기록됩니다.

반응형