Java와 JDBC를 활용하여 CLI 기반의 게시판을 만드는 프로젝트의 전체 뼈대와 코드를 분석한다. 초기에는 하나의 클래스에 모든 기능이 뭉쳐있었으나, 객체 지향 원칙에 따라 역할을 분리(리팩토링)하여 유지보수가 뛰어난 아키텍처를 완성했다.
아래는 본 프로젝트를 구성하는 핵심 모듈들의 전체 코드와 상세 분석이다.
과거에는 모든 로직이 Main에 있었지만, 리팩토링 후에는 단순히 프로그램을 켜는 스위치 역할만 수행한다.
package org.example;
public class Main {
public static void main(String[] args) {
// App 객체를 생성하고 run() 메서드를 호출하여 프로그램 엔진을 가동한다.
new App().run();
}
}
new App().run();: App이라는 총괄 매니저를 고용해서, 바로 업무 시작(run)을 지시한다. Main은 더 이상 비즈니스 로직(글쓰기, 수정 등)이나 DB 연결에 관여하지 않으며, 오직 시작점(Entry Point)으로서의 책임만 가진다.데이터베이스 창고에서 꺼낸 게시글 데이터를 자바 프로그램 안에서 안전하게 옮기기 위해 사용하는 '규격화된 택배 상자'다.
package org.example;
public class Article {
private int id; // 게시글 번호
private String regDate; // 작성일
private String updateDate; // 수정일
private String title; // 제목
private String body; // 내용
// 1️⃣ DB에서 꺼내올 때 사용하는 생성자 (모든 정보 포함)
public Article(int id, String regDate, String updateDate, String title, String body) {
this.id = id;
this.regDate = regDate;
this.updateDate = updateDate;
this.title = title;
this.body = body;
}
// 2️⃣ 자바에서 새로 글을 쓸 때 임시로 사용하는 생성자 (날짜 제외)
public Article(int id, String title, String body) {
this.id = id;
this.title = title;
this.body = body;
}
// (Getter, Setter 생략 - 외부에서 안전하게 데이터를 읽고 쓰기 위한 메서드들)
public int getId() { return id; }
public String getTitle() { return title; }
public String getBody() { return body; }
public String getRegDate() { return regDate; }
public String getUpdateDate() { return updateDate; }
// 3️⃣ 객체 출력 형식 재정의 (자기소개서)
@Override
public String toString() {
return "Article{" +
"id=" + id +
", regDate ='" + regDate + '\'' +
", updateDate ='" + updateDate + '\'' +
", title='" + title + '\'' +
", body='" + body + '\'' +
'}';
}
}
toString() (3️⃣): 객체를 출력할 때 메모리 주소(예: Article@1a2b3c)가 아닌, 실제 데이터 값을 예쁘게 문자열로 보여주도록 기본 기능을 덮어쓰기(Override) 했다.사용자의 입력을 직접 SQL에 더하기(+) 방식으로 붙이면 SQL Injection(해킹) 위험이 있다. 이를 방지하기 위해 물음표(?) 기반의 안전한 쿼리를 자동으로 만들어주는 도구다.
package org.example.util;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class SecSql {
private StringBuilder sqlBuilder; // 쿼리문(시험지)을 덧붙여 저장하는 곳
private List<Object> datas; // 물음표(?)에 들어갈 데이터(정답)를 저장하는 곳
public SecSql() {
sqlBuilder = new StringBuilder();
datas = new ArrayList<>();
}
// 1️⃣ 쿼리와 데이터를 한 번에 세팅하는 가변인자 메서드
public SecSql append(Object... args) {
if (args.length > 0) {
String sqlBit = (String) args[0];
sqlBuilder.append(sqlBit + " "); // 첫 번째 인자는 SQL 문장으로 추가
}
for (int i = 1; i < args.length; i++) {
datas.add(args[i]); // 두 번째 인자부터는 데이터 리스트에 추가
}
return this; // 메서드 체이닝을 위해 자기 자신을 반환
}
// 2️⃣ 완성된 PreparedStatement(안전한 주문서)를 반환
public PreparedStatement getPreparedStatement(Connection dbConn) throws SQLException {
PreparedStatement stmt = null;
// INSERT 문이면 방금 생성된 PK(id)를 가져올 수 있게 세팅
if (sqlBuilder.toString().startsWith("INSERT")) {
stmt = dbConn.prepareStatement(sqlBuilder.toString(), Statement.RETURN_GENERATED_KEYS);
} else {
stmt = dbConn.prepareStatement(sqlBuilder.toString());
}
// 모아둔 정답(datas)을 타입에 맞게 물음표(?)에 쏙쏙 끼워 넣음
for (int i = 0; i < datas.size(); i++) {
Object data = datas.get(i);
int parameterIndex = i + 1;
if (data instanceof Integer) stmt.setInt(parameterIndex, (int) data);
else if (data instanceof String) stmt.setString(parameterIndex, (String) data);
}
return stmt;
}
}
Object... args (가변인자): 들어오는 인자의 개수를 제한하지 않고 배열 형태로 모두 받아내는 마법의 문법.instanceof: 주머니에 든 데이터(Object)가 진짜 숫자인지(Integer), 글자인지(String) 타입을 검사하여 창고 관리인에게 정확한 형태로 전달한다.매번 try-catch를 작성하고 rs, pstmt를 닫아주는 반복적인 노가다를 없애기 위해 만든 핵심 유틸리티 클래스다.
package org.example.util;
import org.example.exception.SQLErrorException;
import java.sql.*;
import java.util.*;
public class DBUtil {
// 1️⃣ 조회를 위한 공용 메서드 (Map을 사용해 어떤 형태의 테이블이든 다 받아냄)
public static List<Map<String, Object>> selectRows(Connection dbConn, SecSql sql) {
List<Map<String, Object>> rows = new ArrayList<>();
PreparedStatement stmt = null;
ResultSet rs = null;
try {
stmt = sql.getPreparedStatement(dbConn);
rs = stmt.executeQuery();
// ResultSetMetaData: DB 창고의 칸(컬럼) 이름과 개수를 알아내는 똑똑한 도구
ResultSetMetaData metaData = rs.getMetaData();
int columnSize = metaData.getColumnCount();
while (rs.next()) {
Map<String, Object> row = new HashMap<>();
for (int i = 0; i < columnSize; i++) {
String columnName = metaData.getColumnName(i + 1);
Object value = rs.getObject(columnName);
row.put(columnName, value); // 라벨(컬럼명)과 내용물(데이터)을 매핑하여 저장
}
rows.add(row);
}
} catch (SQLException e) {
throw new SQLErrorException("SQL 예외 발생", e); // 🚨 커스텀 에러로 변환
} finally {
// 🧹 깔끔한 자원 해제 (반복되는 노가다를 여기서 한 번에 처리)
try { if (rs != null) rs.close(); } catch (SQLException e) {}
try { if (stmt != null) stmt.close(); } catch (SQLException e) {}
}
return rows;
}
// 2️⃣ 데이터 삽입 (INSERT) 후 생성된 ID 반환
public static int insert(Connection dbConn, SecSql sql) {
int id = -1;
PreparedStatement stmt = null;
ResultSet rs = null;
try {
stmt = sql.getPreparedStatement(dbConn);
stmt.executeUpdate();
rs = stmt.getGeneratedKeys(); // 생성된 번호표 가져오기
if (rs.next()) id = rs.getInt(1);
} catch (SQLException e) {
throw new SQLErrorException("SQL 예외 발생", e);
} finally { /* 자원 해제 생략 */ }
return id;
}
}
이 프로그램은 각 클래스들이 다음과 같은 유기적인 순서로 협력하며 작동한다.
Main): new App().run();을 통해 프로그램을 메모리에 올리고 제어권을 넘긴다.App): 사용자가 article list를 입력하면, App은 이 요청을 확인하고 DB 통신용 전화선(Connection)을 챙긴다.SecSql): App은 날것의 SQL이 아닌, SecSql 객체를 만들어 sql.append("SELECT * FROM article"); 형식으로 안전한 주문서를 작성한다.DBUtil): App은 본인이 직접 try-catch를 하지 않고, 작성한 SecSql과 Connection을 DBUtil.selectRows() 공장에 던져버린다.DBUtil ➡️ DB):DBUtil이 DB와 통신하여 ResultSet을 받는다.MetaData) Map<String, Object> 형태로 유연하게 포장한다.App ⬅️ Article):DBUtil이 넘겨준 Map 데이터를 App이 다시 받아, 우리가 규격화해둔 Article 캡슐 안에 쏙쏙 옮겨 담는다.결론: App은 오직 "무엇을 할지(명령어 분기, 데이터 출력)"만 신경 쓰고, "어떻게 안전하게 통신할지(SQL 인젝션 방어, 자원 해제)"는 SecSql과 DBUtil이 전담하는 완벽한 역할 분담(관심사의 분리)이 완성된 것이다.