요약 : 이미 JDBCTemplate 가 있다는 것도 알고, 요즘은 JPA가 Entity 에 모두 자동화하여 DB 내용을 넣어준다는 것도 알지만, SSAFY 에서 순수 JDBC를 활용해 DTO로 데이터를 매핑해야 할 일이 생겼습니다. 노가다하는 것이 개발자로써 너무 말이 되지 않는다고 생각하여, 직접 try~catch~finally 를 활용하고 데이터를 매핑해 돌려주는 부분을 한 방에 처리해 줄 수 있는 기능을 만들었습니다. ( dto.setAbc(rs.getString(abc)) 처리하는 부분 모두 포함합니다 )
문제 인식 1 : try~catch~finally 를 모든 메소드에 사용하는 것!
SSAFY 자바 전공반 수업을 듣는 중, Java 와 DB를 연결하는 JDBC에 대해서 배우게 되었습니다. 그런데, SSAFY 에서 가르쳐주는 JDBC 코드에는 심각하다면 심각한(?) 문제가 존재했습니다.. try~catch~finally 를 모두 활용해 데이터를 일일이 매핑해야 한다는 점이 바로 그것입니다...
아래의 코드 패턴을, DAO 코드를 만들때마다 계속 남발해야 한다니, 너무 코드가 더럽고 길어진다고 생각했습니다.
문제가 되는 코드 -
@Override
public List<AttractionInfoDto> attractionList(AttractionInfoDto attractionInfoDto) {
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
List<AttractionInfoDto> resultList = new ArrayList<>();
try {
StringBuilder sql = new StringBuilder("select * from attraction_info where content_id is not null");
if(attractionInfoDto.getSidoCode() != 0){
sql.append(" and sido_code = ").append(attractionInfoDto.getSidoCode());
}
if(attractionInfoDto.getContentTypeId() != 0){
sql.append(" and content_type_id = ").append(attractionInfoDto.getContentTypeId());
}
conn = DBUtil.getInstance().getConnection();
stmt = conn.prepareStatement(sql.toString());
rs = stmt.executeQuery();
while(rs.next()) {
AttractionInfoDto dto = new AttractionInfoDto();
dto.setContentId(rs.getInt("content_id"));
dto.setContentTypeId(rs.getInt("content_type_id"));
dto.setTitle(rs.getString("title"));
dto.setAddr1(rs.getString("addr1"));
dto.setAddr2(rs.getString("addr2"));
dto.setZipcode(rs.getString("zipcode"));
dto.setTel(rs.getString("tel"));
dto.setFirstImage(rs.getString("first_image"));
dto.setFirstImage2(rs.getString("first_image2"));
dto.setReadcount(rs.getInt("readcount"));
dto.setSidoCode(rs.getInt("sido_code"));
dto.setGugunCode(rs.getInt("gugun_code"));
dto.setLatitude(rs.getDouble("latitude"));
dto.setLongitude(rs.getDouble("longitude"));
dto.setMlevel(rs.getString("mlevel"));
resultList.add(dto);
}
}catch(Exception e){
e.printStackTrace();
}
finally {
DBUtil.getInstance().close(conn,stmt,rs);
}
return resultList;
}
문제 해결 시도 1 : Try~Catch~Finally 그만 남발하기.
이런 코드를 계속 남발하는 것이 정말 말도 되지 않는다고 생각하여, 최소한 try~catch~finally 를 사용하는 부분을 모두 DBUtil 에 집어넣으면 한결 나을 것 같다는 생각이 들었습니다.
계속 공통적으로 사용하는 코드에 대해서 먼저 살펴본 결과
1. Connection, PreparedStatement, ResultSet 을 사용함.
2. try~catch~finally 를 사용함.
이렇게 크게 3가지 공통적인 코드 사용이 존재했습니다.
모든 메소드가 맡은 기능이 각각 다르므로, SQL 문을 작성하는 것은 공통 사항으로 묶을 수 없다고 생각했고, DTO 매핑을 어떻게 처리해야 할지 생각이 나지 않았습니다. 일단, 1~2번 항목에 대해서라도 한번 기능을 묶어서 구현할 수는 없을까 생각했습니다.
그래서, DB와 상호작용하는 try~catch~finally 를 사용하는 공통 부분을 처리하는 기능을 만들어 보았습니다.
코드 설명은, 코드와 같이 보기 좋도록 주석으로 한번 달아 보았습니다.
1차적으로 위의 문제를 해결한 코드 : 공통 Try~Catch~Finally 사용 코드 분리하기 -
@FunctionalInterface
public interface DataMapper<T> {
T map(ResultSet rs) throws SQLException;
}
public static <T> List<T> selectQueryAsList(String givenSql, DataMapper<T> mapper, T objectMapper){
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
List<T> resultList = new ArrayList<>();
// 일단 select query 를 사용하는 코드에서 모두 사용하는 것을 하나로 묶는 시도를 하는 코드입니다.
// SQL 문을 사용하면, DTO 리스트가 나올 수 있을 것입니다. 그래서, 먼저 select query 를 사용해서
// 리스트를 가져오게 하는 코드를 만들었습니다.
// 그렇다면, DTO 를 담는 리스트를 만드는 것도 원래는 문제가 될 수 있습니다.
// 이 부분은, objectMapper 라는 객체를 받아옵니다. 이 객체는 단순히 DTO 가 어떤 데이터 타입을 가지고 있는지
// 그 힌트가 되는 객체입니다.
// T의 타입을 제네릭으로 받아오고, objectMapper 를 주입받으면서 어떤 타입인지도 알 수 있으니,
// 자연스럽게 DTO 리스트를 생성하는 것도 가능해집니다.
// Connection, PreparedStatement, ResultSet 은 공통적으로 사용하는 객체입니다.
try {
conn = DBUtil.getInstance().getConnection();
stmt = conn.prepareStatement(givenSql);
rs = stmt.executeQuery();
// SQL 문을 파라미터로 받아, 쿼리를 실행하는 것도 모두 공통적입니다.
while(rs.next()) {
resultList.add(mapper.map(rs));
// 이 부분이 문제입니다. 모든 부분에서 DTO를 매핑하는 코드는 각자 다를 것입니다.
// 그렇다면, 매핑하는 로직을 밖으로 빼어, functional interface 를 활용해 외부에서 로직을 구현한 뒤
// 이 method 에 넣어준다면, 그 관심사 로직만 밖에서 구현한 뒤 주입해 줄 수 있을 것입니다.
}
}catch(SQLException e){
e.printStackTrace();
}
finally {
DBUtil.getInstance().close(conn,stmt,rs);
}
return resultList;
}
1차적으로 위의 문제를 해결한 코드 : 위의 코드 활용하기
public List<AttractionInfoDto> attractionList(AttractionInfoDto attractionInfoDto) {
StringBuilder sql = new StringBuilder("select * from attraction_info where content_id is not null");
if(attractionInfoDto.getSidoCode() != 0){
sql.append(" and sido_code = ").append(attractionInfoDto.getSidoCode());
}
if(attractionInfoDto.getContentTypeId() != 0){
sql.append(" and content_type_id = ").append(attractionInfoDto.getContentTypeId());
}
return DBUtil.selectQueryAsList(sql.toString(), new AttractionInfoDtoMapper<>(), new AttractionInfoDto());
}
@Override
public List<AttractionInfoDto> searchByTitle(String title, int sidoCode) {
StringBuilder sql = new StringBuilder("select * from attraction_info");
sql.append("where title = ").append(title);
return DBUtil.selectQueryAsList(sql.toString(), new AttractionInfoDtoMapper<>(), new AttractionInfoDto());
}
static class AttractionInfoDtoMapper<T> implements DataMapper<T>{
@Override
public T map(ResultSet rs) throws SQLException {
AttractionInfoDto dto = new AttractionInfoDto();
dto.setContentId(rs.getInt("content_id"));
dto.setContentTypeId(rs.getInt("content_type_id"));
dto.setTitle(rs.getString("title"));
dto.setAddr1(rs.getString("addr1"));
dto.setAddr2(rs.getString("addr2"));
dto.setZipcode(rs.getString("zipcode"));
dto.setTel(rs.getString("tel"));
dto.setFirstImage(rs.getString("first_image"));
dto.setFirstImage2(rs.getString("first_image2"));
dto.setReadcount(rs.getInt("readcount"));
dto.setSidoCode(rs.getInt("sido_code"));
dto.setGugunCode(rs.getInt("gugun_code"));
dto.setLatitude(rs.getDouble("latitude"));
dto.setLongitude(rs.getDouble("longitude"));
dto.setMlevel(rs.getString("mlevel"));
return (T) dto;
}
}
일단, attractionList(), searchByTitle() 두 메소드에서 try~catch~finally 를 사용하는 부분을 모두 걷어 내 보았습니다. 단순히 SQL 문을 작성해 주고, DBUtil 의 selectQueryAsList() 메소드에 sql 문, resultSet 을 주입받아 dto 를 생성하는 로직, 그리고 DTO 객체를 입력해 주면, List<DTO> 객체를 출력할 수 있게 된 것입니다.
try~catch~finally 지옥을 빠져나왔다고 할 수 있을 것입니다.
문제 인식 2 : DTO 세팅 코드가 너무 반복적이고, 수동으로 매핑해 줘야 하는 문제가 존재함.
처음에는, "이 정도로 try~catch~finally 코드를 하나로 묶어서 참 좋다!" 라고 스스로 뿌듯해 했습니다만, DTO 매핑하는 이슈도 상당히 반복적인 노가다라고 생각했습니다. 아래의 코드를 한번만 이렇게 매핑하면 괜찮을 수 있습니다만, 맨날 저 짓(?) 을 하기에는 실수가 많이 나올 수 있고, 이름과 타입을 보고 저렇게 매일 반복해 주는 것도 의미 없다고 생각했습니다.
그리고, 결정적으로 제가 DTO를 분석하고 아래의 코드를 만드는데, 현자 타임이 너무 왔습니다. 그리고 의미 없이 시간을 보낸다고 생각한 것도 큽니다.
그렇다면, 위의 코드를 한번 분석해서 해결해 보도록 하겠습니다.
문제 해결 시도 2 : DTO Mapping 자동화하기
먼저, 원하는 메소드를 상황에 따라 실행시키려면, 최소한 이름만 가지고도 메소드를 원하는 메소드를 불러 올 수 있어야 합니다. 그리고 언젠가, java.lang.class 에서 원하는 메소드를 가져올 수 있는 기능이 있다는 것을 본 적이 있었습니다. 이를 적극적으로 활용해 보려고 합니다.
일단, 이름만 가지고 원하는 객체의 원하는 메소드를 모두 불러올 수 있다고 한다면, 우리는 메소드의 이름을 세팅할 수 있다면 그 메소드를 사용할 수 있을 것입니다.
그렇다면, 위의 "노가다 코드" 를 한번 분석해 봅시다.
1. dto의 setter method 를 가져옵니다.
2. dto field 가 어떤 타입을 들고 있는가에 따라, ResultSet 에서 getXXX 의 이름이 달라집니다.
3. 필드의 이름을 snake_case 로 만들어 ResultSet의 getter method 에 String type 으로 집어 넣어 줍니다.
4. 모든 DTO Field 에 대해 위의 작업을 반복합니다.
일단, 이 모든 것 이전에, "어떤 필드가 존재하는지?" 정보부터 받아와야 합니다.
필드가 어떤 것이 있는지 알 수 있다면, 필드마다 for문을 돌면서 위의 1~4번 작업을 처리할 수 있을 것입니다. 그리고, 어떻게 메소드를 불러오는지도 모르고 있습니다.
그렇다면 필드 정보부터 받아와 봅니다.
0. 필드 정보 받아오기 & 메소드 불러오기
public static void callSetterMethod(Object object) {
Field[] fields = object.getClass().getDeclaredFields();
for(Field field : fields){
// field 마다의 로직이 처리될 for 문입니다.
}
}
먼저, 어떤 DTO가 올지 모르기 때문에, Object 타입으로 DTO 매개변수를 받아옵니다.
그런 다음, object.getClass().getDeclaredFields() 메소드를 사용한다면, 필드 정보를 불러올 수 있다고 합니다.
그리고, 어떻게 method 를 가져올 수 있는지도 잘 모르기 때문에, ChatGPT 에게 물어서 어떤 방식으로 method 를 가져와서 불러 실행할 수 있는지 먼저 파악해 보았습니다.
ChatGPT 가 준 코드는 아래와 같습니다.
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Exception {
// 클래스와 메소드 이름 지정
Class<?> myClass = MyClass.class;
String methodName = "myMethod";
// 메소드 객체 얻기
Method method = myClass.getMethod(methodName, String.class, int.class);
// 객체 생성
MyClass instance = new MyClass();
// 메소드 호출
Object result = method.invoke(instance, "Hello, Reflection!", 42);
// 결과 출력
System.out.println("Result: " + result);
}
}
class MyClass {
public String myMethod(String message, int number) {
return message + " " + number;
}
}
이 코드가 가능하다고 한다면(물론, 실행해 본 결과 가능했습니다), 로직이 실행되는 순서는 아래와 같습니다.
1. class 정보 받아오기 (with MyClass.class)
2. 메소드 이름 지정하기
3. myclass.getMethod(메소드명, 메소드 내 매개변수 타입 가변인자) 사용하기
4. 사용할 객체 생성하기
5. method.invoke(메소드를 사용할 객체, 메소드 인자(가변인자)) 사용하기.
이렇게 메소드를 불러와서 실행하는 것까지 알았으니, 본격적으로 문제를 해결해 보도록 합니다.
1. DTO의 setter method 가져오기
이를 위해 필요한 작업은 아래와 같습니다.
1.1 Field 이름을 보고, 필드 이름 첫글자를 대문자로 바꾼 후 앞에 "set" 붙이기
1.2 메소드 불러오기
public static void callSetterMethod(Object object) {
Field[] fields = object.getClass().getDeclaredFields();
for(Field field : fields){
String fieldName = field.getName();
// dto.setContentId(rs.getInt("content_id")) 과 같은 코드의 dto.setContentId() 가져오기
Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
}
}
일단, 필드 이름은 Field 클래스의 getName() 을 통해서 알수 있다고 합니다. 당연히 바로 사용해 줍니다.
그리고, setter 는 위의 ChatGPT 가 준 코드를 참고해서 만들어 보았습니다. getMethod() 메소드를 활용했으며, 카멜 케이스에서 파스칼 케이스로 이름을 변동해 준 뒤 앞에 "set" 을 붙여 주면 완성입니다. camelCase 에서 PascalCase 로 고치는 부분은 중요한 부분이 아니므로, 일단 나중에 한번에 보여드리도록 하겠습니다.
그리고, 필드의 타입을 매개변수로 받아 주어야 하기 때문에, Field.getType() 메소드를 함께 넣어 주었습니다.
2. DTO Field 타입을 참고해 ResultSet getter method 가져오기
일단, 어떤 타입인지 안다면, pascal case 로 바꿔 준 뒤 앞에 "get" 을 붙여 주면 될 것입니다.
field.getType() 에서 타입 이름을 불러오는 메소드는 총 4가지가 있었습니다.
field.getType().getName();
field.getType().getSimpleName();
field.getType().getTypeName();
field.getType().getCanonicalName();
이 중, getSimpleName() 메소드에 대해서 알아보았고, 결과는 아래와 같습니다.
getSimpleName() 메소드:
- getSimpleName() 메소드는 클래스나 인터페이스의 이름을 반환합니다.
- 반환되는 문자열은 패키지 이름을 포함하지 않고, 클래스/인터페이스의 간결한 이름만을 제공합니다.
- 예를 들어, List와 같은 클래스 이름만 반환합니다.
그렇다면, field.getType() 에서 getSimpleName() 을 받아와 PascalCase 로 바꾼 뒤, 앞에 "get" 을 붙여 메소드를 가져올 수 있을 것입니다. 그 결과 아래와 같이 코드를 만들어 볼 수 있었습니다.
public static void callSetterMethod(Object object, ResultSet resultSet) {
Field[] fields = object.getClass().getDeclaredFields();
for(Field field : fields){
String fieldName = field.getName();
Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
String typeName = field.getType().getSimpleName();
Method getterFromResultSet = resultSet.getClass().getMethod("get" + camelCaseToPascalCase(typeName), String.class);
}
}
3. 필드의 이름을 snake_case 로 만들고 ResultSet getter method 에 집어넣기 & 실행하기
이제, 필드의 이름을 snake_case 로 만들고, 받아온 method 를 순차적으로 실행시킬 일만 남았습니다.
스네이크 케이스를 만드는 일은, 그냥 문자열을 고치는 일입니다. 단순히 fieldName 을 charArray 로 만들고, 대문자라면 앞에 '_' 을 붙이고 그 대문자를 소문자로 만들어 주기만 하면 됩니다.
그리고, resultSet 객체를 집어 넣어 주고, resultSet에서 추출해 온 Getter Method 를 invoke() 해 줍니다.
객체를 먼저 집어넣고, 매개변수를 넣는 식이라고 합니다.
이후, 그 결과를 다시 setter 에 집어넣어 주면 됩니다.
결과 코드는 아래와 같습니다.
public static void callSetterMethod(Object object, ResultSet resultSet) {
Field[] fields = object.getClass().getDeclaredFields();
for(Field field : fields){
String fieldName = field.getName();
Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
String typeName = field.getType().getSimpleName();
Method getterFromResultSet = resultSet.getClass().getMethod("get" + camelCaseToPascalCase(typeName), String.class);
String snakeCase = camelCaseToSnakeCase(fieldName);
Object objectFromDB = getterFromResultSet.invoke(resultSet, snakeCase);
setter.invoke(object, objectFromDB);
}
}
추가적으로, 위의 Method 를 받아와 invoke() 하는데 Checked Exception 이 너무 많아서, try~catch 문을 활용해 감싸 주었습니다. 그 결과 코드 전문은 아래와 같습니다.
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.sql.ResultSet;
/**
* @author WhalesBob
* @since 2023-09-16
*/
public class ResultSetter {
private static final StringBuilder builder = new StringBuilder();
public static void callSetterMethod(Object object, ResultSet resultSet) {
try{
Field[] fields = object.getClass().getDeclaredFields();
for(Field field : fields){
String fieldName = field.getName();
Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
String snakeCase = camelCaseToSnakeCase(fieldName);
String typeName = field.getType().getSimpleName();
Method getterFromResultSet = resultSet.getClass().getMethod("get" + camelCaseToPascalCase(typeName), String.class);
Object objectFromDB = getterFromResultSet.invoke(resultSet, snakeCase);
setter.invoke(object, objectFromDB);
}
}catch(Exception e){
e.printStackTrace();
}
}
private static String camelCaseToSnakeCase(String input){
if (input == null || input.equals("")) {
return input;
}
builder.delete(0, builder.length());
char previousChar = input.charAt(0);
builder.append(Character.toLowerCase(previousChar));
for (int i = 1; i < input.length(); i++) {
char currentChar = input.charAt(i);
if (Character.isUpperCase(currentChar)) {
builder.append('_').append(Character.toLowerCase(currentChar));
} else {
builder.append(currentChar);
}
}
return builder.toString();
}
private static String camelCaseToPascalCase(String input){
builder.delete(0, builder.length());
builder.append(Character.toUpperCase(input.charAt(0)));
builder.append(input.substring(1));
return builder.toString();
}
}
추가적으로 할 일 - selectQueryAsList() 에서 DataMapper interface 걷어내기
이제 드디어 ResultSet 에서 정보를 받아와 DTO에 매핑하는 로직을 다 구현했습니다. 이제 더 이상 개발자가 수동으로 DTO 매핑 코드를 만들지 않아도 되는 것입니다.
그러면, 위에서 만들어본 DB 상호 작용 코드에서도 쓸모 없어진 코드를 걷어 내 봅니다.
그러기 위해서 아래에서 해야 하는 일은 다음과 같습니다.
1. objectMapper 의 정보를 활용해, DTO 객체를 새로 생성하기
2. 위의 ResultSetter 의 callSetterMethod() 를 호출해, DTO 객체에다 정보를 매핑하기
objectMapper.getClass().getConstructor() 를 호출해 생성자를 불러 주고, DTO 객체를 새로 만들어 준 뒤 코드를 아래와 같이 고칠 수 있었습니다.
public static <T> List<T> selectQueryAsList(String givenSql, T objectMapper){
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
List<T> resultList = new ArrayList<>();
try {
conn = DBUtil.getInstance().getConnection();
stmt = conn.prepareStatement(givenSql);
rs = stmt.executeQuery();
while(rs.next()) {
// 이 부분에서 ResultSet 을 활용해 DTO로 매핑하는 코드를 만들어야 합니다.
Constructor<?> constructor = objectMapper.getClass().getConstructor();
T instance = (T) constructor.newInstance();
ResultSetter.callSetterMethod(instance, rs);
resultList.add(instance);
}
}catch(Exception e){
e.printStackTrace();
}
finally {
DBUtil.getInstance().close(conn,stmt,rs);
}
return resultList;
}
그리고, 이제 이 코드를 활용만 한다면, SQL 문을 집어넣고 DTO 객체를 집어넣어 주면 List<DTO> 가 반환되는 결과를 얻을 것입니다.
결과적으로 DAO 에서는 아래와 같이 SQL 만 신경쓰면 되는 코드가 나오게 됩니다.
실행해 본 결과, 성공적으로 코드가 동작함을 볼 수 있었습니다.
public class AttractionDaoImpl implements AttractionDao {
@Override
public List<AttractionInfoDto> attractionList(AttractionInfoDto attractionInfoDto) {
StringBuilder sql = new StringBuilder("select * from attraction_info where content_id is not null");
if(attractionInfoDto.getSidoCode() != 0){
sql.append(" and sido_code = ").append(attractionInfoDto.getSidoCode());
}
if(attractionInfoDto.getContentTypeId() != 0){
sql.append(" and content_type_id = ").append(attractionInfoDto.getContentTypeId());
}
return DBUtil.selectQueryAsList(sql.toString(), new AttractionInfoDto());
}
@Override
public List<AttractionInfoDto> searchByTitle(String title, int sidoCode) {
StringBuilder sql = new StringBuilder("select * from attraction_info");
sql.append("where title = ").append(title);
return DBUtil.selectQueryAsList(sql.toString(), new AttractionInfoDto());
}
}
소감
솔직히, 이미 JPA 에서 이런 기능이 존재한다는 것을 알고 있었지만, SSAFY 에서 JDBC 를 활용한 프로젝트를 진행하라고 해서 한 번 자동화 구현을 해 보았습니다. 그래도, 최초의 try~catch~finally 범벅 구문에서 위와 같이 "핵심 로직에만 신경쓰는" 코드를 만들어 볼 수 있어서 참 속이 후련했던 것 같습니다. 그리고, 프레임워크를 만드는 개발자는 이런 작업을 거쳐 코드를 간결하게 유지할 것이라고 생각하니 더 대단해 보이는 것 같습니다.
긴 글 읽어주셔서 감사합니다 ^^
'Java & IntelliJ' 카테고리의 다른 글
[Java] WebClient + Reflection 기능 사용해 공공데이터 API 로 정보 받는 공통 모듈 만들기 (2) | 2024.02.26 |
---|---|
IntelliJ(Java IDE) Debugging Tools 입문 - 2 (0) | 2023.07.11 |
IntelliJ(Java IDE) Debugging Tools 입문 - 1 (0) | 2023.07.11 |