Java/JDBC 와 Reflection 적용

Reflection 으로 JDBC Template 구현해보기 (행복 ver)

김관현 2024. 8. 6. 17:41

 

데브코스 백엔드 1기 첫번째 팀 프로젝트(토이 프로젝트지만..)를 진행하게 되었다

 

대충 주제는 'JDBC 를 이용해서 간단한 프로젝트 만들기!' 였다

 

결국엔 JDBC

우리가 ORM 기술을 사용하든 SQL Mapper 기술을 사용하든 결과적으로 JDBC 에 쿼리를 보내주는 것은 동일하다. 우리가 직접 보내느냐 저런 기술들을 거쳐서 보내느냐 정도의 차이이다.

 

따라서 JDBC 에 대해서 알고가는 것이 백엔드 개발자로서의 사명감이라고 생각을 한다. (물론 지금 우리가 직접 JDBC 만 이용해서 코딩하진 않지만)

 

SQL Mapper 와 ORM 기술이 탄생한 이유

이번 데브코스 첫 토이 프로젝트는 개발자들의 역사를 직접 체험해보는 시간이라 정말 감명깊었으나 JDBC 를 한번도 사용한 적이 없기에 이러한 코딩은 엄청난 불편함이 동반되었다.

 

ORM 기술 및 SQL Mapper 기술을 만든 개발자에게 존경과 감사함을 가지고 다음의 문제점들을 살펴보자

 

내가 직접 느껴본 기존 JDBC 를 이용한 코드의 문제점은 다음과 같다

 

기존 JDBC 코드의 문제점

1. DB 예외를 처리하기 귀찮다!

JDBC 가 DB 와 연결하기 위해 사용하는 Connection, PreparedStatement, ResultSet 은 끔찍한 예외 덩어리이다.

 

심지어 사용할때보다 회수할 때 이것들의 사악함이 묻어나온다.

 

자원 회수에도 예외를 발생시키는 이녀석들은 finally 구문 안에서 try-catch 를 사용하게 하는 아주 악랄한 녀석들이라고 할 수 있다. 예제는 두가지 문제를 섞어서 한 번에 보여드리겠다.

 

2. 너 ~ 무 노가다 코딩이고 중복이 난무한다!!!

예외 처리만으로도 끔찍한데 객체로 받아오거나 DB 에 Insert 할 때 더욱 끔찍해진다.

 

객체로 받아오려면 해당 객체의 필드마다 직접 값을 뽑아줘야한다.

필드가 100개라면(그럴리는 없겠지만) preparedStatement.get() 을 100번 써야할 것이다.

 

물론 Insert 에도 똑같이 필드 개수만큼 노가다 코딩을 해야한다.

 

이런 노가다를 해야하는 객체가 과연 한둘일까? 간단한 토이프로젝트를 하더라도 3개는 기본적으로 넘어갈 것이다.

당연히 이러한 노가다 과정은 실수를 만들어내게 될 것이다

 

지금까지 얘기한 문제점들을 섞은 예제를 보자

3. 1, 2번 문제가 만들어낸 괴물 

// import 는 생략

public class Original {

    public ActorDTO getActor() {
        Connection con = null;
        PreparedStatement ps = null;
        ResultSet rs = null;
        ActorDTO actorDTO = new ActorDTO();
        
        try {
            String query = "select name, age, rate, hire_date from actor";
            con = DriverManager.getConnection(DB_URL, DB_ID, DB_PASSWORD);
            ps = con.prepareStatement(query);
            rs = ps.executeQuery();

            while (rs.next()) {
                actorDTO.setName(rs.getString("name"));
                actorDTO.setAge(rs.getInt("age"));
                actorDTO.setRate(rs.getDouble("rate"));
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                if (rs != null) {
                    rs.close();
                }
                if (ps != null) {
                    ps.close();
                }
                if (con != null) {
                    con.close();
                }
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
        
        return actorDTO;
    }
}

 

 

무려 ActorDTO 하나 뽑아오는데 필요한 코드이다. 물론 줄이려면 더 줄일 수 있다. 근데 그래도 불지옥이 지옥으로 바뀐 수준밖에 되지 않을 것이다.

 

그런데 한번 생각해보자.

 

`어차피 행위는 반복된다`

 

그리고 반복되는 행위는 자동화 할 수 있을 확률이 매우 높다

 

그런 생각으로 JDBC Template 을 직접 구현하는 토이토이 프로젝트를 시작하였다 

 

필요한 기능 정리

첫번째로 정한 필요한 기능은 예외 처리이다!

DB 에 연결할 때 마다 저 끔찍한 예외를 처리하고 싶지 않았고 구현도 간편할 것이라 생각하였다.

 

따로 utility 패키지에 DBUtil 클래스를 만들어 기능을 정의하려고 하였다.

 

하지만 강사님이 먼저 그런 클래스를 만드셔서 그 방법을 따라갔다.

 

코드는 내가 직접 작성한 것이 아니라 따로 서술하지 않겠다.

대충 Connection 연결만 예외처리 해주는 getConnection() 스태틱메서드,

파라미터로 넘긴 값들을 전부 자원회수 해주고 예외처리 해주는 close() 스태틱메서드가 있었다.

(AutoClosable 사용)

 

그러면 이제 다음 기능으로 넘어가보자

 

두번째로 필요한 기능은 sql 맵핑이다!!!

이 기능이 내가 정말 포스팅하려고 하는 주된 내용이다.

 

도메인마다, 구현하는 기능마다 일일이 노가다를 해줘야하는 끔찍한 지옥에서 벗어나는 방법을 만들고 싶었다.

 

첫번째로 조회하였을때, 원하는 객체로 반환한다!!

두번째로 삽입하고자 할 때, 쿼리랑 객체만 넣는다!! 

... 가 내가 정말 만들고자 하는 기능이다

 

 

첫번째 아이디어 (제네릭)

위에서 말했듯이 어차피 행위는 반복된다 

 

조회 쿼리로 DB에서 가져온 ResultSet 에 담긴 값을 원하는 객체로 동적으로 반환해주어야 한다

 

그렇다면 ResultSet 에 원하는 값을 어떻게 뽑아낼 것인가?

그리고 원하는 객체를 어떻게 생성하여 그 객체에 어떻게 값을 담을 것인가?

 

일단 원하는 객체의 클래스를 가져오는 방법으로 제네릭을 선택했다.

파라미터로 제네릭 타입을 받고 반환도 해당 제네릭 타입을 이용하는 것이다.

 

두번째 아이디어 (리플렉션)

이제 남은 문제는 해당 제네릭 타입이 무엇인지, 어떻게 해당 객체를 생성하여 그 객체에 값을 넣을 것인지이다.

 

이때 사용했던 방법이 리플렉션이다. 

사실 이때까지 리플렉션이 뭔지도 몰랐는데 이 기능을 구현하면서 공부하게 되었다.

리플렉션이란, 구체적인 클래스 타입을 알지 못해도 그 클래스의 정보에 접근할 수 있게 해주는 자바 API 이다.

 

스프링에도 이 기술이 적용되어있다고 알고있다

 

일단 다시 설명으로 돌아가자면, 

제네릭 타입으로 받은 객체에 .getClass() 를 적용하여 어떤 클래스인지 뽑아낸다.

 

그렇게 뽑아낸 클래스에 getDeclaredFields() 로 해당 클래스가 가진 필드들을 전부 뽑아내고 그 필드에 set 으로 값을 넣어준다.

 

일단 코드로 보자

 

    public <E> E getDataByDTO(ResultSet resultSet, E form) throws SQLException {
        Class<?> clazz = form.getClass();
        Field[] fields = clazz.getDeclaredFields();

        while (resultSet.next()) {
            try {
                mapToDTO(resultSet, form, fields);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                throw new CustomReflectionException();
            }
        }

        return form;
    }
    
    
    private <E> void mapToDTO(ResultSet resultSet, E form, Field[] fields)
            throws SQLException, IllegalAccessException {
        for (int i = 0; i < fields.length; i++) {
            Object value = resultSet.getObject(fields[i].getName()); 
            fields[i].setAccessible(true);
            fields[i].set(form, value); // Object 값으로 value 한개씩 뽑아
        }
    }

 

이러한 방식으로 구현하였다. 

 

 

반환을 List<> 로 받아야 할 때에는 이 메서드를 사용하였다.

    public <E> List<E> getDataByList(ResultSet resultSet, E form)
            throws SQLException {
        Class<?> clazz = form.getClass();
        Field[] fields = clazz.getDeclaredFields();

        List<E> forms = new ArrayList<>();

        while (resultSet.next()) {
            try {
                mapToDTO(resultSet, form, fields); // 객체 한 건 맵핑
                forms.add(form);
                form = (E) clazz.getDeclaredConstructor().newInstance();
            }  catch (InvocationTargetException | IllegalAccessException | InstantiationException |
                      NoSuchMethodException e) {
                e.printStackTrace();
                throw new CustomReflectionException();
            }
        }
        return forms;
    }

 

 

데이터를 DB 에 Insert 하는 것도 이와 별반 다르지 않다.

    public <E> void setData(PreparedStatement ps, E form) throws IllegalAccessException, SQLException {
        Class<?> clazz = form.getClass();
        Field[] fields = clazz.getDeclaredFields();

        for (int i = 1; i <= fields.length; i++) {
            fields[i - 1].setAccessible(true);
            ps.setObject(i, fields[i - 1].get(form));
        }
        ps.executeUpdate();
    }

 

 

물론 이 메서드들만 모아서 Converter 라는 클래스를 만든 뒤 Converter 클래스에서 던지는 예외들을 Utility 클래스에서 try-catch 로 처리해주었다.

 

따라서 내가 만든 기능을 실제로 사용하는 모습은

String query = "select name, age, rate from actor where name = '김관현'";
ActorDTO form = Utility.readData(query, new ActorDTO);

 

이게 끝이다!!! 아까 위에서 보았던 그 끔찍한 양의 코드가 이렇게 줄어들어버렸다.

동적 쿼리(ex. 이름으로 검색) 의 경우에는 파라미터로 '이름' 을 받고 where name = '" + 이름 + "' ; 처리를 해주어야한다.

( ' 을 양쪽에 둘러줘야함!!!) 

 

 

리스트로 반환받고 싶다면

String query = "select name, age, rate from actor";
List<ActorDTO> list = Utility.readListData(query, new ActorDTO);

 

이게 끝이다!! 정말 간편해졌고 우리가 평소에 익숙하게 느꼈던 코드로 돌아왔다.

 

Insert 도 한 번 보자

String query = " INSERT INTO ACTOR(NAME,AGE,RATE) VALUES(?,?,?) ";
Utility.writeData(query, form); 

// 이미 값을 가지고 있는 form 객체여야함

 

역시 너무나도 간편해졌다.

 

이러한 기능을 며칠동안 하루종일 시간을 쏟아부어가며 개발하였고 나는 테스트코드가 성공했다는 것을 보자마자 너무 기뻤다. 결국 팀 토이 프로젝트에서 이 기술을 팀원들에게 공유하였고, 내가 직접 만든 기능을 적용하게 되었다.

 

하지만 '행복' 부분은 딱 여기까지였다.

 

다음 포스팅은 '절망' 부분으로 다루겠다.