JDBC 성능 최적화 - 8

목차

    1. PreparedStatement 사용의 장점

    PreparedStatement는 SQL 쿼리를 미리 컴파일하고, 동일한 쿼리를 반복해서 실행할 때 효율적으로 사용할 수 있는 인터페이스이다. 이는 성능과 보안 측면에서 많은 장점을 제공한다.

    • 성능 향상:
      • 쿼리 컴파일 : SQL 쿼리를 미리 컴파일하여, 쿼리를 여러 번 실행할 때 컴파일 시간을 절약할 수 있다.
      • 쿼리 계획 재사용 : 동일한 쿼리를 반복적으로 실행할 때, 쿼리 계획을 재사용하여 실행 시간을 단축할 수 있다.
    • 보안 향상:
      • SQL 인젝션 방지 : 쿼리와 데이터가 분리되어 있어 SQL 인젝션 공격을 방지할 수 있다.

    예시 코드

    String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
    try (Connection connection = DBConnectionManager.getConnection();
         PreparedStatement preparedStatement = connection.prepareStatement(sql)) {
        preparedStatement.setString(1, "고길동");
        preparedStatement.setString(2, "a@example.com");
        preparedStatement.executeUpdate();
    } catch (SQLException e) {
        e.printStackTrace();
    }

     

    SQLInjectionExample

    package ch01;
    
    public class SQLInjectionExample {
    
    	public static void main(String[] args) {
    		
    		try (Connection conn = DBConnectionManger.getConnection();
    				Scanner scanner = new Scanner(System.in)){
    			
    			System.out.print("사용자 이름을 입력하세요 : ");
    			String username = scanner.nextLine();
    			
    			// 취약한 SQL 쿼리 작성해보기(SQL 인젝션이 가능)
    			String query = " SELECT * FROM users WHERE name = '" + username + "'";
    			
    			try (Statement stmt = conn.createStatement()){
    				ResultSet resultSet = stmt.executeQuery(query);
    				
    				while (resultSet.next()) {
    					System.out.println("사용자 ID : " + resultSet.getInt("id"));
    					System.out.println("사용자 이름 : " + resultSet.getString("name"));
    					System.out.println("사용자 이메일 : " + resultSet.getString("email"));
    				}
    			} catch (Exception e) {
    				e.printStackTrace();
    			}
    			
    		} catch (Exception e) {
    			e.printStackTrace();
    		}
    		
    	} // end of main
    
    } // end of class
    사용자 이름을 입력하세요 : ' or 1 = 1 or '
    사용자 ID : 1
    사용자 이름 : 유저1
    사용자 이메일 : 유저1@nate.com
    사용자 ID : 2
    사용자 이름 : 유저2
    사용자 이메일 : 유저2@nate.com
    사용자 ID : 3
    사용자 이름 : 유저3
    사용자 이메일 : 유저3@nate.com
    사용자 ID : 4
    사용자 이름 : admin
    사용자 이메일 : admin@naver.com

     

    SQL 인젝션 예방 코드

    package ch01;
    
    public class SQLInjectionExample {
    
    	public static void main(String[] args) {
    
    		try (Connection conn = DBConnectionManger.getConnection(); Scanner scanner = new Scanner(System.in)) {
    
    			System.out.print("사용자 이름을 입력하세요 : ");
    			String username = scanner.nextLine();
    
    			// 취약한 SQL 쿼리 작성해보기(SQL 인젝션이 가능)
    			String query = " SELECT * FROM users WHERE name = '" + username + "'";
    			
    			String query2 = " SELECT * FROM users WHERE name = ? ";
    
    			try (Statement stmt = conn.createStatement();
    				// Statemnet 사용과 인젝션 공격 확인
    				// ResultSet resultSet = stmt.executeQuery(query);
    				
    				// SQL 인젝션 방지를 위한 PreparedStatement의 사용
    				PreparedStatement pstmt = conn.prepareStatement(query2)) 
    			{
    				pstmt.setString(1, username);
    				ResultSet resultSet = pstmt.executeQuery();
    				while (resultSet.next()) {
    					System.out.println("사용자 ID : " + resultSet.getInt("id"));
    					System.out.println("사용자 이름 : " + resultSet.getString("name"));
    					System.out.println("사용자 이메일 : " + resultSet.getString("email"));
    				}
    			} catch (Exception e) {
    				e.printStackTrace();
    			}
    
    		} catch (Exception e) {
    			e.printStackTrace();
    		}
    
    	} // end of main
    
    } // end of class
    사용자 이름을 입력하세요 : ' or 1 = 1 or '
    
    아무일 없음

    2. 연결 풀 (Connection Pool)과 데이터 소스 (Data Source)

    데이터베이스 연결의 생애주기

    1. 애플리케이션 로직에서 데이터베이스 연결 요청
    2. 데이터베이스 드라이버 초기화 및 연결 설정
    3. TCP/IP 연결 설정
    4. 사용자 인증 및 세션 생성
    5. 커넥션 생성 완료
    6. 데이터베이스 연결 사용 및 종료

    일반적으로 데이터베이스 연결은 비용이 큰 작업이다. 데이터베이스 서버와의 연결을 맺고 끊는 과정은 시간이 많이 소요되며, 데이터베이스 서버의 리소스도 소비된다. 그렇기 때문에 매번 데이터베이스 연결을 필요로 하는 요청이 발생할 때마다 새로운 연결을 생성하는 것은 효율적이지 않다.

    성능 최적화의 여러 방법중 하나로 아래와 같은 기술들을 적용할 수 있다.

    커넥션 풀(Connection Pool)데이터 소스 (Data Source)는 데이터베이스 연결 관리를 효율적으로 하기 위해 사용하는 개념이다.

     

    연결 풀 (Connection Pool)

    • 정의: 데이터베이스 연결 풀은 일정 수의 데이터베이스 연결을 미리 생성해두고, 애플리케이션에서 필요할 때마다 이 연결을 재사용하는 기술.
    • 목적: 데이터베이스 연결을 효율적으로 관리하여 성능을 향상시키고, 데이터베이스 서버의 부하를 줄인다.
    • 작동 원리: 애플리케이션이 데이터베이스 연결을 요청하면, 연결 풀에서 사용 가능한 연결을 반환합니다. 사용이 끝난 연결은 폐기되지 않고 다시 연결 풀에 반환되어 재사용된다.

    핵심 정리

    • DB 드라이버가 아닌 커넥션 풀에서 커넥션을 가져온다.
    • 커넥션 사용 후 재사용할 수 있도록 커넥션 풀에살아 있는 상태로 반환 한다.

    커넥션 생성에 필요한 6가지 단계들이 모두 처리된 상태이므로, 언제든지 즉시 SQL 문을 데이터베이스로 전달할 수 있어서 사용자 응답 속도가 빨라진다.

     

    서버당 최대 커넥션 개수를 제한할 수 있어 DB에 무한정 연결이 생성되는 것을 막아 데이터베이스를 보호하는 효과도 존재한다.

    데이터 소스 (Data Source)

    • 정의: 데이터 소스는 데이터베이스 연결 정보를 캡슐화한 객체로, 애플리케이션이 데이터베이스와 상호작용할 때 사용된다. 일반적으로 데이터 소스는 연결 풀을 포함하여 데이터베이스 연결을 관리한다.
    • 목적: 데이터 소스는 데이터베이스 연결 설정을 단순화하고, 연결 풀을 통해 효율적인 연결 관리를 제공한다.
    • 사용 방법: 데이터 소스를 설정하고, 애플리케이션에서 데이터 소스를 통해 데이터베이스 연결을 요청한다.

    핵심 정리 

    데이터 소스 (Data Source)는 추상화된 개념으로 설계되어 있으며, 데이터베이스 연결을 관리하고 사용할 수 있는 수준의 인터페이스를 제공한다. 이 추상화된 개념을 구현한 다양한 라이브러리를 사용하여, 프로젝트의 성격이나 정책에 맞게 선택하고 쉽게 사용할 수 있다

    연결 풀과 데이터 소스의 관계

    • 통합 관리: 데이터 소스는 연결 풀을 사용하여 데이터베이스 연결을 관리한다. 데이터 소스를 통해 데이터베이스 연결을 요청하면, 내부적으로 연결 풀에서 관리하는 연결이 반환된다.
    • 연결 추상화: 데이터 소스는 연결 풀을 포함한 다양한 연결 관리 방식을 추상화하여 제공한다. 이를 통해 애플리케이션 개발자는 데이터 소스를 통해 쉽게 데이터베이스 연결을 사용할 수 있다.

    라이브러리를 프로젝트에 추가 방법

    • Maven을 사용하는 경우 또는 Gradle 빌드 툴을 사용할 수 있다.
    • 라이브러리를 직접 추가하는 방법

    https://github.com/brettwooldridge/HikariCP

     

    GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.

    光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP

    github.com

    https://mvnrepository.com/artifact/com.zaxxer/HikariCP

     

    HikariCP 라이브러리를 사용하기 위해서 다른 추가적인 라이브러리 설정이 필요하다.

    https://mvnrepository.com/artifact/mysql/mysql-connector-java/8.0.21

    https://mvnrepository.com/artifact/org.slf4j/slf4j-api/2.0.0-alpha5

    https://mvnrepository.com/artifact/org.slf4j/slf4j-simple/2.0.0

    package com.tenco.quiz.ver3;
    
    /**
     * 커넥션 풀을 활용하는 예제로 수정해보자.<br>
     * HikariCP-5.1.0.jar lib 설정 
     */
    public class DBConnectionManger {
    	
    	private static HikariDataSource dataSource;
    	
    	private static final String URL = "jdbc:mysql://localhost:3306/quizdb?serverTimezone=Asia/Seoul";
    	private static final String USER = "root";
    	private static final String PASSWORD = "asd123";
    	
    	// static {} 블록 - 정적 초기화 블록
    	// 클래스가 처음 로드될 때 한 번 실행 됩니다.
    	// 정적 변수의 초기화나 복잡한 초기화 작업을 수행할 때 사용
    	// static {} 블록안에 예외를 던질 수도 있다.
    	static {
    		// HikariCP 를 사용하기 위한 설정이 필요하다.
    		// HikariConfig --> 제공해줘서 이 클래스를 활용해서 설정을 상세히 할 수 있다.
    		HikariConfig config = new HikariConfig();
    		config.setJdbcUrl(URL);
    		config.setUsername(USER);
    		config.setPassword(PASSWORD);
    		
    		config.setMaximumPoolSize(10); // 최대 연결 수 설정 10
    		dataSource = new HikariDataSource(config);
    	}
    
    	public static Connection getConnection() throws SQLException {
    		return dataSource.getConnection();
    	}
    	
    	// 테스트 코드 확인
    	public static void main(String[] args) {
    		try {
    			Connection conn = DBConnectionManger.getConnection();
    			
    		} catch (SQLException e) {
    			e.printStackTrace();
    		}
    	} // end of main
    	
    // 	기본 JDBC 드라이버 사용 버전
    //	public static Connection getConnection() throws SQLException{
    //		return DriverManager.getConnection(URL, USER, PASSWORD);
    //	}
    	
    } // end of class
    package com.tenco.quiz.ver3;
    
    public class QuizRespositoryImpl implements QuizRespository{
    
    	public static final String SELCELT_ALL = " select * from quiz ";
    	public static final String SELCELT_RANDOM = " select * from quiz order by rand() limit 1 ";
    	public static final String INSERT_QUESTION = " insert into quiz(question, answer) values (?, ?) ";
    	
    	@Override
    	public int addQuizQuestion(String question, String answer) throws SQLException {
    		
    		int resultRowCount = 0;
    		
    		try (Connection conn = DBConnectionManger.getConnection()){
    			PreparedStatement pstmt = conn.prepareStatement(INSERT_QUESTION);
    			// 트랜잭션 처리 가능 - 수동모드 커밋 사용 가능
    			pstmt.setString(1, question);
    			pstmt.setString(2, answer);
    			resultRowCount = pstmt.executeUpdate();
    		} 
    		return resultRowCount;
    	}
    
    	@Override
    	public List<QuizDTO> viewQuizQuestion() throws SQLException {
    		List<QuizDTO> list = new ArrayList<>();
    		try (Connection conn = DBConnectionManger.getConnection()){
    			PreparedStatement pstmt = conn.prepareStatement(SELCELT_ALL);
    			ResultSet rs = pstmt.executeQuery();
    			while(rs.next()) {
    				list.add(new QuizDTO(rs.getInt("id"), rs.getString("question"), rs.getString("answer")));
    			}
    		} 
    		
    		return list;
    	}
    
    	@Override
    	public QuizDTO playQuizGame() throws SQLException {
    		QuizDTO quizDTO = null;
    		try (Connection conn = DBConnectionManger.getConnection()){
    			PreparedStatement pstmt = conn.prepareStatement(SELCELT_RANDOM);
    			ResultSet rs = pstmt.executeQuery();
    			if (rs.next()) {
    				quizDTO = new QuizDTO(rs.getInt("id"), rs.getString("question"), rs.getString("answer"));
    			}
    		}
    		return quizDTO;
    	}
    
    }
    package com.tenco.quiz.ver3;
    
    public class QuizRepositoryTest1 {
    	static final int ADD_QUESTION = 1;
    	static final int VIEW_QUESTION = 2;
    	static final int PLAY_GAME = 3;
    	static final int END = 0;
    	static int size = 0;
    	static Set<Integer> correct = new HashSet<>();
    	static boolean check = false;
    	
    	public static void main(String[] args) {
    		
    		
    		// 실행의 흐름은 직접 만들기
    		try (Scanner sc = new Scanner(System.in)){
    			QuizRespositoryImpl quizImpl = new QuizRespositoryImpl();
    			while (true) {
    				System.out.println();
    				printMenu();
    				int choice = sc.nextInt();
    				sc.nextLine();
    				switch (choice){
    				case ADD_QUESTION :
    					System.out.print("문제를 입력하세요. : ");
    					String question = sc.nextLine();
    					System.out.print("정답을 입력하세요. : ");
    					String answer = sc.nextLine();
    					quizImpl.addQuizQuestion(question, answer);
    					break;
    				case VIEW_QUESTION :
    					printQuiz(quizImpl.viewQuizQuestion());
    					check = true;
    					break;
    				case PLAY_GAME : 
    					if (size == 0) {
    						System.out.println("문제 조회를 먼저 해야합니다.");
    						break;
    					} else if (size == correct.size()) {
    						System.out.println("모든 문제를 풀었습니다.");
    						break;
    					}
    					QuizDTO quizDTO;
    					do {
    						quizDTO = quizImpl.playQuizGame();
    					} while (correct.contains(quizDTO.getId()));
    					playQuiz(quizDTO, sc);
    					break;
    				case END :
    					return;
    				}
    			}
    		} catch (Exception e){
    			e.printStackTrace();
    		}
    	}
    	private static void printMenu() {
    		System.out.println("---------------------------------------------");
    		System.out.println("1. 퀴즈 문제 추가");
    		System.out.println("2. 퀴즈 문제 조회");
    		System.out.println("3. 퀴즈 게임 시작");
    		System.out.println("0. 종료");
    		System.out.print("옵션을 선택하세요 : ");
    	}
    	private static void printQuiz(List<QuizDTO> list) {
    		size = list.size();
    		for (QuizDTO quizDTO : list) {
    			System.out.println(quizDTO);
    		}
    	}
    	private static void playQuiz(QuizDTO quiz, Scanner sc) {
    		System.out.println("문제 : " + quiz.getQuestion());
    		System.out.print("정답을 입력하세요 : ");
    		String answer = sc.nextLine();
    		if (quiz.getAnswer().equalsIgnoreCase(answer)) {
    			System.out.println("정답입니다.");
    			correct.add(quiz.getId());
    		} else {
    			System.out.println("오답입니다.");
    		}
    	}
    
    }

    자료 구조(Data Structure) - 4으로 돌아가기