개발/KB IT's Your Life 7기

KB IT's Your Life 7기 - JDBC와 MyBatis로 백엔드랑 친해지기

Lylica 2026. 6. 13. 21:13

우리 커리큘럼도 이제는 본격적으로 백엔드 개발에 들어가고 있다. 프론트엔드를 배울 때는 화면에 무언가를 보여주는 일이 중심이었다. HTML로 뼈대를 만들고, CSS로 살을 붙이고, JavaScript와 Vue를 이용해서 화면이 살아 움직이도록 만들었다. 버튼을 누르면 화면이 바뀌고, 데이터를 가져오면 리스트가 출력되고, 컴포넌트들이 서로 값을 주고받았다. 그때까지만 해도 데이터는 어딘가에서 “가져오면 되는 것” 이었지만... 이제는 가져다 주는걸 만드는 중이다.

그 데이터가 어디에 저장되어 있는지, 어떻게 꺼내야 하는지, 꺼낸 데이터를 Java 객체로 어떻게 담아야 하는지, 다시 화면으로 어떻게 보내야 하는지까지 생각해야 한다.

 

 

식당으로 비유하자면, 이전에는 식당에서 완성된 음식을 받아 테이블에 예쁘게 차리는 일을 배웠다면, 이제는 주방 안쪽으로 들어가 냉장고 위치부터 재료 손질, 조리 순서, 설거지 동선까지 배우는 느낌이다.

그리고 이 주방의 첫 문이 바로 JDBC 라는 것이다.

 

JDBC가 뭔데?

JDBC(Java Database Connectivity) 는 Java 애플리케이션에서 데이터베이스와 통신하기 위한 표준 API다.

쉽게 말하면, Java 코드로 SQL 쿼리를 실행하고 결과를 받아오는 다리 역할을 한다.

 

데이터베이스의 종류는 MySQL, Oracle, PostgreSQL 등 다양하게 존재하는데, Java 입장에서는 매번 데이터베이스마다 다른 방식으로 연결하는건 솔직히 귀찮은 일이다. 나는 모든 프로젝트를 재 컴파일하는 악몽을 경험하고 싶지 않다. 그래서 JDBC라는 공통 인터페이스를 만들어두고, 각 데이터베이스 벤더가 드라이버를 구현하는 방식으로 동작한다.

// 1. 드라이버 로드
Class.forName("com.mysql.cj.jdbc.Driver");

// 2. 연결 생성
Connection conn = DriverManager.getConnection(
    "jdbc:mysql://localhost:3306/world2", "root", "password"
);

// 3. SQL 실행
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM city");

// 4. 결과 처리
while (rs.next()) {
    System.out.println(rs.getString("Name"));
}

// 5. 자원 해제
rs.close();
stmt.close();
conn.close();
 

코드를 보면 알겠지만, 연결하고 → 실행하고 → 결과 받고 → 닫는 구조가 기본이다.

근데 이 과정에서 항상 자원을 반드시 닫아야 한다는 것을 잊어선 안된다.

 

`rs.close()`, `stmt.close()`, `conn.close()`를 빠뜨리면 메모리 누수로 이어진다는 점을 기억해야 한다.
그래서 실제로는 이렇게 쓰지 않고, `try-with-resources` 구문을 사용하게 된다.

 

try (
    Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("SELECT * FROM city")
) {
    while (rs.next()) {
        System.out.println(rs.getString("Name"));
    }
} catch (SQLException e) {
    e.printStackTrace();
}

 

처음 JDBC 코드를 봤을 때 느낀 점은 정말로 귀찮다는 느낌이 강했다.

 

DB에서 데이터 한 번 꺼내오는데 이렇게까지 해야 한다고?

Connection을 만들고, Statement를 만들고, ResultSet을 받고, while문을 돌면서 값을 하나하나 꺼내고, 예외 처리까지 해야 한다. 게다가 연결을 닫지 않으면 자원 누수 문제가 생길 수 있으니 try-with-resources까지 사용해야 한다.

이쯤 되면 데이터를 가져오려고 자동차타고 프린트물을 가지러 가는 느낌이라고 해야하나...

 

물론 이 과정이 번거롭게 느껴진다고 해서 의미가 없는 것은 아니다. 오히려 이 불편함을 직접 경험해야 이후에 등장하는 패턴과 프레임워크가 왜 필요한지 이해할 수 있다고 강사님이 항상 언급하신다. 처음부터 편리한 도구만 사용하면, 그것이 무엇을 대신 해주고 있는지 알기 어렵기 때문이라고. 맞는 말이다.

 

Statement가 뭔데?

JDBC에서 SQL을 실행할 때 사용하는 대표적인 객체가 Statement다.

 

Statement는 말 그대로 SQL 문장을 데이터베이스로 보내는 역할을 한다. Connection이 데이터베이스와 연결된 통로라면, Statement는 그 통로를 통해 실제 명령문을 전달하는 전달자라고 볼 수 있다.

 

Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM city WHERE CountryCode = '" + code + "'");
 

`executeQuery()` 는 SELECT처럼 결과를 조회하는 SQL을 실행할 때 사용한다. 실행 결과로 ResultSet을 돌려받는다.

`executeUpdate()` 는 INSERT, UPDATE, DELETE처럼 데이터가 변경되는 SQL을 실행할 때 사용한다. 실행 결과로 몇 개의 행이 영향을 받았는지를 숫자로 돌려받는다.

execute()는 결과가 조회일 수도 있고 변경일 수도 있는 경우에 사용할 수 있다.

 

단순하다. SQL 문자열을 만들고 실행하면 된다.

근데 이게 꽤 큰 문제를 안고 있다.

 

만약 code 값에 ' OR '1'='1 같은 값이 들어오면?
SQL 쿼리 자체가 오염되는 SQL 인젝션 공격에 취약해진다.

그래서 실무에서는 PreparedStatement를 사용한다고 한다.

String sql = "SELECT * FROM city WHERE CountryCode = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, code);  // ? 자리에 값을 바인딩
ResultSet rs = pstmt.executeQuery();
 

PreparedStatement는 미리 SQL의 구조를 준비해두고, 나중에 값만 안전하게 채워 넣는 방식이다. SQL 문장과 데이터를 분리해 다룰 수 있기 때문에 더 안전하고, 반복 실행에도 유리하다고 한다.

 

 

VO 패턴?

 

JDBC를 사용하면 데이터베이스에서 값을 가져올 수 있다. 그런데 문제는 그 다음이다.

ResultSet에서 값을 하나하나 꺼내는 것까지는 알겠다. 그런데 그 데이터를 계속 변수 여러 개로 들고 다닐 수는 없다.

예를 들어 회원 정보를 다룬다고 해보자.

 
while (rs.next()) {
    int id = rs.getInt("ID");
    String name = rs.getString("Name");
    String countryCode = rs.getString("CountryCode");
    String district = rs.getString("District");
    int population = rs.getInt("Population");

    System.out.println(id + " - " + name + " - " + countryCode + " - " + district + " - " + population);
}
 

이렇게 출력 조금 정도면 괜찮다.

 

그런데 컬럼이 열 개, 스무 개가 된다면?

 

만약에 이 값을 다른 메서드로 넘겨야 한다면? 벌써부터 고통스러운 미래가 그려지지 않는가?

 

그쯤 되면 그냥 한컴타자연습이랑 다를 바가 없다. 오타 한 번 나면 머리가 아파지는건 덤이다.

 

그래서 사용하는 것이 VO라고 한다.

 

 

VO는 Value Object의 약자로, 값을 하나의 객체로 묶어서 표현하는 방식이다. 수업에서는 데이터베이스의 한 행을 Java 객체로 표현하는 용도로 많이 사용했다.

 

물론 VO도 귀찮다. 필드 만들고, 생성자 만들고, getter/setter 만들고, toString() 만들고...

그래서 선배들이 만들어놓은 Lombok 같은 라이브러리를 사용하게 된다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class City {
    private int id;
    private String name;
    private String countryCode;
    private String district;
    private int population;
}
 

이제 데이터베이스에서 가져온 값을 하나의 객체로 담을 수 있다.

 

이렇게 객체로 묶고 나면 코드를 치는게 그나마 괜찮아진다. 단순히 컬럼을 여러 개 들고 다니는 것이 아니라, Member 라는 의미 있는 단위로 데이터를 다루게 되기 때문이다.

 

그리고 이제 이렇게 만들어주면 `ResultSet` 에서 꺼낸 값들을 곧바로 객체에 담아줄 수 있다.

 

City city = City.builder()
    .id(rs.getInt("ID"))
    .name(rs.getString("Name"))
    .countryCode(rs.getString("CountryCode"))
    .district(rs.getString("District"))
    .population(rs.getInt("Population"))
    .build();

 

이제부터는 각 컬럼 값을 따로따로 들고 다니는 것이 아니라, City 객체 하나로 회원 데이터를 묶어서 다룰 수 있게 된다.

Lombok의 `@Builder`를 사용하면 생성자를 직접 작성하지 않아도 된다.

어노테이션 몇 개로 getter, setter, 생성자, builder 등을 만들 수 있으니 편하긴 하다.

 

DAO, DTO 패턴?

 

VO를 배우고 나자 자연스럽게 DAO와 DTO 내용을 설명해주신다.

 

처음에는 이 이름들이 굉장히 헷갈린다. VO, DAO, DTO. 일단 오브젝트는 알겠다. 나머지는 뭘까.

 

하나씩 나눠보면 그렇게 어렵지는 않다.

 

DAO는 Data Access Object. 데이터베이스에 접근하는 코드를 따로 분리한 객체다.

DTO는 Data Transfer Object. 계층 사이에서 데이터를 전달하기 위해 사용하는 객체다.

VO는 값 자체를 표현하는 객체다. 수업에서는 주로 데이터베이스의 한 행을 표현하는 Domain 객체처럼 사용했다.

 

물론 실무나 팀마다 VO와 DTO를 구분하는 방식은 조금씩 다를 수 있다고... 어떤 곳에서는 VO를 불변 객체에 가깝게 사용하고, 어떤 곳에서는 Entity나 Domain과 비슷하게 사용하기도 한다고 말씀하신다.

 

중요한 것은 이름 자체보다 역할을 분리한다는 관점이다.

처음 JDBC를 배울 때는 한 클래스 안에 모든 코드를 넣어도 동작은 한다.

 
public class CityApp {
    public static void main(String[] args) {
        // DB 연결
        // SQL 작성
        // SQL 실행
        // ResultSet 처리
        // 화면 출력
    }
}
 

동작은 하지만 좋은 구조라고 보기는 어렵다.

화면 출력도 여기 있고, DB 연결도 여기 있고, SQL도 여기 있고, 예외 처리도 여기 있다. 즉, 이 클래스는 너무 많은 일을 하고 있다. 혼자서 접수, 조리, 서빙, 계산, 설거지를 모두 하는 식당 사장님 같은 상태다. 처음에는 가능할지 몰라도, 손님이 늘어나면 그걸 혼자 담당한다는건 솔직히 무리잖아.

 

그래서 DB 접근 로직을 DAO로 분리한다.

 
public class CityDao {
    public List<City> selectList(int count) {
        List<City> cityList = new ArrayList<>();

        String sql = """
                SELECT ID, Name, CountryCode, District, Population
                FROM city
                ORDER BY Population DESC
                LIMIT ?
                """;

        try (
            Connection conn = DBUtil.getConnection();
            PreparedStatement pstmt = conn.prepareStatement(sql)
        ) {
            pstmt.setInt(1, count);

            try (ResultSet rs = pstmt.executeQuery()) {
                while (rs.next()) {
                    City city = City.builder()
                            .id(rs.getInt("ID"))
                            .name(rs.getString("Name"))
                            .countryCode(rs.getString("CountryCode"))
                            .district(rs.getString("District"))
                            .population(rs.getInt("Population"))
                            .build();

                    cityList.add(city);
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }

        return cityList;
    }
}
 

이제 도시 목록을 가져오는 코드는 CityDao가 책임진다.

다른 곳에서는 이렇게 사용하면 된다.

CityDao dao = new CityDao();
List<City> cityList = dao.selectList(5);



이것만으로도 코드의 책임이 조금 나뉜다. 데이터를 조회하는 코드는 DAO가 맡고, 그 데이터를 어떻게 보여줄지는 다른 계층에서 맡을 수 있다.

DTO는 VO와 다른가?

 

VO와 DTO가 헷갈리는 사람이 많다. 나도 처음엔 같은 거 아닌가 싶었다.

 

VO(Value Object) 는 데이터베이스 테이블과 매핑되는 불변의 객체를 의미하는 경우가 많고, DTO(Data Transfer Object) 는 계층 간 데이터를 전달할 때 사용하는 운반용 객체다.

 

City.java가 VO이기도 하고, DAO에서 Main으로 데이터를 전달할 때 DTO처럼 사용되기도 한다.

중요한 건, 데이터를 객체로 담아서 전달한다는 개념 자체를 이해하는 것이다.

 

MyBatis?

순수 JDBC를 사용하다보면 뭐 괜찮기는 한데, `ResultSet` 에서 값을 하나하나 꺼내서 객체에 담는 과정이 좀 번거로운 면이 있다. Connection을 매번 만들고 닫는 일도 생기고, SQL 쿼리가 통째로 코드 안에 들어가있는 것도 불편하다면 불편한 점이다.

그래서 이 불편한 점을 해결하기 위해서 MyBatis를 사용하게 되었다고 한다.

 

 

이름만 들으면 뭔가 거창해 보이지만, 개인적으로는 “JDBC의 반복 작업을 줄여주고, SQL과 Java 코드를 조금 더 깔끔하게 나눠주는 도구” 정도로 이해했다.

아쉬운 점은 MyBatis가 SQL을 없애주는 도구가 아니라는 것이다. SQL은 여전히 개발자가 직접 작성한다. 다만 그 SQL을 Java 코드 안에 문자열로 주렁주렁 달아놓는 대신, Mapper XML 같은 별도 파일로 분리해 관리할 수 있게 된다. Java 코드에서는 SQL 자체보다 사용하는 로직에 좀 더 집중하게 된다.

예를 들면 Mapper XML에는 이런 식으로 SQL을 작성한다.

<!-- CityMapper.xml -->
<?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="com.example.mapper.CityMapper">

    <!-- 전체 목록 조회 -->
    <select id="selectList" parameterType="int" resultType="City">
        SELECT * FROM city
        ORDER BY Population DESC
        LIMIT #{limit}
    </select>

    <!-- 단건 조회 -->
    <select id="selectOne" parameterType="int" resultType="City">
        SELECT * FROM city
        WHERE ID = #{id}
    </select>

    <!-- 삽입 -->
    <insert id="insert" parameterType="City">
        INSERT INTO city (Name, CountryCode, District, Population)
        VALUES (#{name}, #{countryCode}, #{district}, #{population})
    </insert>

    <!-- 수정 -->
    <update id="update" parameterType="City">
        UPDATE city
        SET Name = #{name},
            Population = #{population}
        WHERE ID = #{id}
    </update>

    <!-- 삭제 -->
    <delete id="delete" parameterType="int">
        DELETE FROM city WHERE ID = #{id}
    </delete>

</mapper>

 

SQL이 Java 코드 밖으로 완전히 빠져나왔다.
그리고 `#{변수명}` 형태로 파라미터를 바인딩하는데, 이 방식이 PreparedStatement처럼 사용하는 방식이라고 한다.

 

대략적으로 설명을 하자면,

  • Mapper XML의 namespace는 DAO 인터페이스와 연결되는 이름표다.

  • 각 SQL 태그의 id는 실행할 SQL의 이름이다.

  • Java 코드에서는 namespace + id를 이용해 어떤 SQL을 실행할지 지정한다.

  • resultType은 조회 결과를 어떤 Java 객체로 매핑할지 알려준다.

  • parameterType은 SQL에 전달되는 값이 어떤 타입인지 알려준다. 


이제 자바에서 SQL을 쓰기 위해 이런 DAO 객체와 메서드까지 전부 구현하면 된다...

public class CityMyBatisDao {
    private static final String NAMESPACE = "mapper.CityMapper.";

    public List<City> selectList(int count) {
        try (SqlSession session = MyBatisConfig.getSqlSession()) {
            return session.selectList(NAMESPACE + "selectList", count);
        }
    }

    public City selectOne(int id) {
        try (SqlSession session = MyBatisConfig.getSqlSession()) {
            return session.selectOne(NAMESPACE + "selectOne", id);
        }
    }

    public int insert(City city) {
        try (SqlSession session = MyBatisConfig.getSqlSession()) {
            int result = session.insert(NAMESPACE + "insert", city);
            session.commit();
            return result;
        }
    }

    public int update(City city) {
        try (SqlSession session = MyBatisConfig.getSqlSession()) {
            int result = session.update(NAMESPACE + "update", city);
            session.commit();
            return result;
        }
    }

    public int delete(int id) {
        try (SqlSession session = MyBatisConfig.getSqlSession()) {
            int result = session.delete(NAMESPACE + "delete", id);
            session.commit();
            return result;
        }
    }
}

 

 

결국 MyBatis는 Java 코드와 SQL 사이에 놓인 중간 계층 역할을 한다.

Java DAO가 도시 목록을 요구하면, MyBatis는 Mapper XML에서 해당 SQL을 찾아 실행하고, 결과를 City 같은 객체로 담아 돌려준다.

이걸 직접 JDBC로 작성하려면 Connection, PreparedStatement, ResultSet, while, rs.getString() 같은 코드가 줄줄이 나왔을 것이다.

 

왜 이렇게 복잡해요???


JDBC만 사용할 때는 DAO 안에 SQL 문자열이 직접 있었다. 그래서 DAO를 열면 어떤 SQL이 실행되는지 바로 볼 수 있었다. 그래서 꽤나 직관적이었다.

 

하지만 MyBatis에서는 SQL이 Mapper XML로 빠져나간다. DAO는 SQL을 직접 작성하지 않고, Mapper에 정의된 SQL을 호출한다. SQL 보려면 XML 파일로 가야 하고, Java 메서드 보려면 DAO로 가야 하고, VO 필드명도 확인해야 한다. 실습 시간에 강사님의 코드를 따라 칠 때에도 강사님이 파일을 왔다 갔다 하니까 정신을 차리기가 힘들다. 그냥 이 구조는 오히려 복잡도를 증가시키는게 아닌가??? 하는 의구심이 들기도 한다.

 

하지만 강사님이 항상 말씀하시는 것처럼 우리는 프로젝트 단위로 사고를 변경해볼 필요가 있다. 나 혼자 몇백줄짜리 프로그램을 만들 때는 그냥 다 main에 때려 넣어도 된다. 반면에 팀이 협업하고, 기능이 수백 개가 되고, 유지보수를 해야 하는 순간이 오면 이야기가 달라진다.

 

역할을 분리한다는 것은 결국 변경의 영향 범위를 줄이는 일이다.


SQL은 SQL끼리 모여 관리할 수 있고, Java 코드는 Java 코드대로 관리할 수 있다. 데이터베이스 쿼리를 수정해야 할 때 Java 로직 전체를 건드리지 않아도 된다. 반대로 Java 코드의 흐름을 볼 때 SQL 문자열이 길게 끼어들지 않으니 로직을 파악하기도 쉬워진다는 이야기다.

DAO는 데이터 접근 메서드를 제공하고, Mapper XML은 실제 SQL을 정의한다. 
VO는 조회 결과를 담는 객체이며, SqlSession은 실행자가 된다.

어떤 객체가 어떤 일을 담당해야 하는지, 어떤 코드는 어디에 있어야 자연스러운지, 변경이 생겼을 때 어디를 수정하면 되는지 예상 가능하게 만드는 것. 그게 결국 패턴을 배우는 이유가 아닐까 싶다.

 

Servlet

그리고 최근에는 Servlet을 배우고 있다. Servlet은 Java 웹 애플리케이션에서 HTTP 요청을 처리하고 응답을 만들어내는 Java 클래스다. 사용자가 브라우저에서 어떤 URL로 요청을 보내면, Tomcat 같은 Servlet Container가 그 요청을 받아 적절한 Servlet으로 전달한다.

그러면 Servlet은 요청 값을 꺼내고, 필요한 비즈니스 로직을 수행하거나 DAO를 호출하고, 결과를 응답으로 돌려준다.

@WebServlet("/city/list")
public class CityListServlet extends HttpServlet {
    private final CityDao cityDao = new CityDao();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        int count = 5;
        List<City> cityList = cityDao.selectList(count);

        request.setAttribute("cityList", cityList);
        request.getRequestDispatcher("/WEB-INF/views/city/list.jsp")
               .forward(request, response);
    }
}

 


여기서 `@WebServlet("/city/list")`는 이 Servlet이 어떤 URL 요청을 처리할지 정하는 매핑이다.

사용자가 /city/list로 요청을 보내면 CityListServlet이 실행된다.

doGet()은 GET 요청을 처리하고, DAO를 통해 도시 목록을 조회하고, 그 결과를 request에 담는다.

마지막으로 JSP로 forward해서 화면을 보여준다.


이것도 결국 흐름의 문제일 뿐이다. 프론트엔드에서 사용자가 버튼을 누르는 순간부터, 백엔드의 Servlet이 요청을 받고, DAO가 DB를 조회하고, MyBatis가 SQL을 실행하고, 다시 결과가 화면으로 돌아오는 흐름. 지금 배우는 내용들은 전부 그 하나의 길을 만들기 위한 패턴이었다.

 



이번 백엔드 과정을 배우면서 흥미로운 점은, 수업의 흐름 자체가 기술의 발전 과정처럼 느껴진다는 것이다.
그리고 앞으로는 Spring을 배운다고 한다. 그때 가면 또 새로운 설정 파일도 생기고 문법도 늘어나서 머리가 아파올게 뻔하지만... 이제는 조금 알 것 같다. 새로운 기술이 나오는 이유는 대개 이전 방식의 불편함 때문이다. 또 Servlet의 무언가가 불편해서 Spring을 사용하게 되는게 아닐까. 그런 의미에서 지금의 과정은 꽤 중요하다고 봐도 되겠지.

 

부트캠프 중반을 넘어가는 시점에서, 강의의 속도가 점점 빨라지고 있다는 게 느껴진다.

JDBC 내용을 하루만에 배우고, 단 며칠만에 DAO를 만들고 MyBatis를 배우면서 Mapper를 작성하다가, Servlet과 JSP를 배우고있다. 그리고 곧바로 Spring으로 넘어간다고 하는데... 정리해야 할 것들이 산더미다.

 

다음 포스팅에서는 Servlet에 관한 내용과 Spring 과정에 들어가면서 배운 것들을 정리해볼 생각이다.