Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch2
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch1
- 2024 정보처리기사 수제비 실기
- 스프링 db1 - 스프링과 문제 해결
- 자바의 정석 기초편 ch3
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch5
- 스프링 고급 - 스프링 aop
- @Aspect
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch9
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch7
- 2024 정보처리기사 시나공 필기
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch13
- 자바의 정석 기초편 ch12
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch6
- 스프링 입문(무료)
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch11
- 스프링 db2 - 데이터 접근 기술
- 코드로 시작하는 자바 첫걸음
- 게시글 목록 api
- 타임리프 - 기본기능
Archives
- Today
- Total
나구리의 개발공부기록
커넥션 풀과 데이터소스 이해, DataSource 예제1 - DriverManaget, DataSource 예제2 - 커넥션 풀, DataSource 적용 본문
인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 1편 - 데이터 접근 핵심 원리
커넥션 풀과 데이터소스 이해, DataSource 예제1 - DriverManaget, DataSource 예제2 - 커넥션 풀, DataSource 적용
소소한나구리 2024. 9. 11. 18:54 출처 : 인프런 - 스프링 DB 1편 데이터 접근 핵심 원리 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/dashboard
1. 커넥션 풀 이해
1) 드라이버 매니저를 사용하면 데이터 베이스 커넥션을 매번 획득함
(1) 데이터 베이스 커넥션을 획득할 때 거치는 과정
- 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회
- DB 드라이버는 DB와 TCP/IP 커넥션을 연결함(3 way handshake 같은 네트워크 동작 발생)
- TCP/IP 커넥션이 연결되면 ID, PW와 기타 부가정보를 DB에 전달
- DB는 ID, PW를 통해 내부 인증을 완료하고 내부에 DB세션을 생성
- DB는 커넥션 생성이 완료되었다는 응답을 보냄
- DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환
(2) 문제점
- 커넥션을 새로 만드는 과정은 복잡하기도하고 시간도 많이 소모되며, DB, 애플리케이션 서버에서도 TCP/IP 커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 함
- 고객이 애플리케이션을 사용할 때 SQL을 실행하는 시간 뿐만 아니라 커넥션을 새로 만드는 시간이 추가 되기 때문에 응답 속도에 영향을 줄수 밖에 없으므로 사용자 경험이 좋지 않을 수 밖에 없음
- 이러한 문제를 한변에 해결하는 아이디어가 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법(커넥션을 관리하는 장소정도로 생각하면 됨)
** 참고
- 데이터베이스마다 커넥션을 생성하는 시간은 각각 다르며 시스템 상황마다도 달라짐
- MySQL의 계열은 수ms 정도로 매우 빨리 커넥션을 확보할 수 있지만 수십 밀리초 이상 걸리는 데이터 베이스도 있음
- 그러나 수ms초라고고 하더라도 요청이 많아지면 부담이 되는건 사실
2) 커넥션 풀
(1) 커넥션 풀 초기화
- 애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관함
- 보통 얼마나 보관할지는 서비스의 특징과 서버 스펙에 따라 다르지만 기본값은 보통 10개
(2) 커넥션 풀의 연결 상태
- 커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있음
(3) 커넥션풀 사용
- 애플리케이션 로직에서 DB 드라이버를 통해서 새로운 커넥션을 획득하는 것이 아니라 커넥션 풀을 통해 이미 생성되어 있는 커넥션을 객체 참조로 그냥 가져다 쓰기만 하면됨
- 커넥션 풀에 커넥션을 요청하면 커넥션 풀은 자신이 가지고 있는 커넥션 중에 하나를 반환함
- 애플리케이션 로직은 커넥션 풀에서 받은 커넥션을 사용해서 SQL을 데이터베이스에 전달하고 그 결과를 받아서 처리
- 커넥션을 모두 사용하고 나면 이제는 커넥션을 종료하는 것이 아니라 다음에 다시 사용할 수 있도록 해당 커넥션을 커넥션 풀에 반환하는데 주의할 점이 종료된 상태가아닌 살아있는 상태로 커넥션 풀에 반환해야함
3) 커넥션풀 정리
- 적절한 커넥션 풀 숫자는 서비스 특징, 애플리케이션 서버 스펙, DB 서버 스펙에 따라 다르기 때문에 성능 테스트를 통해서 정해야 함
- 커넥션 풀은 서버당 최대 커넥션 수를 제한할 수 있어서 DB에 무한정 연결이 생성되는 것을 막아주는 보호 효과도 있음
- 커넥션 풀은 얻는 이점이 매우 크기 때문에 실무에서는 항상 기본으로 사용
- 개념적으로 단순히 직접 구현할 수도 있지만(Map에 저장에서 꺼내서쓴다거나..) 사용도 편리하고 성능이 뛰어난 오픈소스 커넥션 풀이 많기 때문에 오픈소스를 사용하면 됨
- 대표적으로 commons-dbcp2, tomcat-jdbc pool,HikariCP 등이 있는데 성능과 사용의 편리함 측면에서 최근에는 HikariCP를 주로 사용하고, 스프링 부트 2.0부터는 기본 커넥션 풀로 hikariCP를 사용하기에 고민할 것 없이 이미 성능,안정성, 편리함 등에 대해 검증이 되어있는 HikariCP를 사용하면됨
- 실무에서도 레거시 프로젝트가 아닌이상 대부분 hikariCP를 사용함
2. DataSource 이해
1) 커넥션을 얻는 다양한 방법
- 커넥션을 얻는 다양한 방법이 존재하는데, DriverManager를 통해서 커넥션을 획득하다가 커넥션 풀을 사용하는 방법으로 변경하거나, 커넥션 풀을 사용하다가 다른 커넥션풀로 넘어가려고 하면 애플리케이션 코드 수정이 불가피함
- 의존관계가 변경이 되고 사용법이 조금씩 다르기 때문
2) 커넥션을 획득하는 방법을 추상화 - DataSource 인터페이스 활용
- 이런 문제를 해결하기위해 자바은 javax.sql.Datasource라는 인터페이스를 제공해서 커넥션을 획득하는 방법을 추상화 함
- 해당 인터페이스의 핵심 기능은 커넥션 조회이며 다른 기능도 있지만 크게 중요하지 않음
DataSource 인터페이스의 핵심 기능
public interface DataSource {
Connection getConnection() throws SQLException;
}
3) DataSource 정리
- 대부분의 커넥션 풀은 DataSource 인터페이스를 이미 구현해 두었으므로 개발자는 각 라이브러리 커넥션 풀의 코드를 직접 의존하는 것이 아니라 DataSource인터페이스에만 의존하도록 애플리케이션 로직을 작성하면 됨
- 커넥션 풀 구현 기술을 변견하고 싶으면 해당 구현체로 갈아 끼우기만 하면 됨
- DriverManager는 DataSource 인터페이스를 사용하지 않기 때문에 DriverManager는 커넥션을 직접 사용해야 하는데, 사용하다가 DataSource 기반의 커넥션 풀을 사용하도록 변경하면 관련 코드를 모두 고쳐야하는 문제를 스프링은 DriverManagerDataSource라는 DataSource를 구현한 클래스를 제공하여 DriverManager를 사용하다가 커넥션 풀을 사용하도록 코드를 변경해도 애플리케이션 로직을 변경하지 않도록 해결했음
3. DataSource 예제1 - DriverManager
1) ConnectionTest - 드라이버매니저, 데이터소스 드라이버매니저 비교
- 테스트 코드를 작성 후 로그를 확인해보면 결과가 큰 차이는 없어보이지만 드라이버 매니저 데이터 소스를 사용했을 경우 해당 클래스를 생성했다는 로그를 추가적으로 볼 수 있음(아래 logback 설정 참고)
(1) 큰 차이점 존재
- 둘의 코드를 보면 DriverManager는 getConnection(URL, USERNAME, PASSWORD)처럼 호출 할 때마다 설정 정보를 파라미터로 계속 입려해줘야하는데, DriverManagerDataSource는 처음 객체 생성시 한번만 필요한 파라미터를 넘겨두고 커넥션을 획득할 때는 단순히 dataSource.getConnection()만 호출하면 됨
(2) 설정과 사용의 분리
- 설정: DataSource를 만들고 필요한 속성들을 사용해서 URL, USERNAME, PASSWORD 같은 부분을 입력하는 것을 말하며 설정과 관련된 속성들은 한 곳에 있는 것이 향후 변경에 더 유현하게 대처할 수 있음
- 사용: 설정은 신경쓰지 않고 DataSource의 getConnection()만 호출해서 사용하면 됨
(3) 설정과 사용의 분리 설명
- 이부분이 작아보이지만 큰 차이를 만들어 내는데 필요한 데이터를 DataSource가 만들어지는 시점에 미리 넣어두게 되면 DataSource를 사용하는 곳에서는 dataSource.getConnection()만 호출하면 되므로 URL, USERNAME, PASSWORD 같은 속성들에 의존하지 않아도 되고 DataSource만 주입 받아서 getConnection()만 호출하면 됨
- Repository는 DataSource만 의존하고 이러한 속성은 몰라도 되며, 애플리케이션을 개발하면 보통 설정은 한곳에서 하고 사용은 수많은 곳에서 하게 되는데 설정 부분과 사용부분을 좀 더 명확하게 분리할 수 있게 됨
- DriverManager를 사용해서 설정 정보를 전부 불러내서 사용해야 하는 환경이라고 해도 설정은 별도의 한 공간에서 커넥션을 생성하는 메서드를 만들어 해당 메서드를 호출해서 사용하는 형태로 하는 것이 좋음
package hello.jdbc.connection;
@Slf4j
public class ConnectionTest {
// 드라이버 매니저 사용사여 커넥션 획득
@Test
void driverManager() throws SQLException {
Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("connection={}, class={}", con1, con1.getClass());
log.info("connection={}, class={}", con2, con2.getClass());
}
// 데이터소스드라이버매니저 사용하여 커넥션 획득
@Test
void dataSourceDriverManager() throws SQLException {
// DriverManagerDataSource - 항상 새로운 커넥션을 획득
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
useDataSource(dataSource);
}
private void useDataSource(DataSource dataSource) throws SQLException {
Connection con1 = dataSource.getConnection();
Connection con2 = dataSource.getConnection();
log.info("connection={}, class={}", con1, con1.getClass());
log.info("connection={}, class={}", con2, con2.getClass());
}
}
** 참고
- 실행 시 스프링 부트 3.x 이상 버전에서는 기본 log level이 info여서 debug로 변경해야 DriverManager가 사용되었다는 로그를 확인할 수 있음
- ~/resources/ 경로에 logback.xml 파일로 생성하여 해당 내용을 입력하면 됨
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT" />
</root>
</configuration>
3. DataSource 예제2 - 커넥션 풀
1) ConnectionTest- 데이터소스 커넥션 풀 테스트 코드 추가
- HikariCP 커넥션 풀을 활용
- .setMaximumPoolSize(): 풀의 최대 개수를 정할 수 있음, 기본 10개
- .setPoolNama(): 풀의 이름을 지정할 수 있음
- 커넥션 풀에서 커넥션을 생성하는 작업은 애플리케이션 속도에 영향을 주지 않기 위해 별도의 쓰에드에서 작동하는데, 예제처럼 대기시간을 주어야 로그를 확인할 수 있음 (대기시간이 없으면 테스트가 먼저 종료되어 로그가 안남음)
@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
// 커넥션 풀링: Hikari 사용 - 스프링 JDBC를 쓰면 자동으로 import됨
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(10); // 풀의 개수, 디폴트가 10개
dataSource.setPoolName("MyPool"); // 풀의 이름도 지정 가능
useDataSource(dataSource);
// 풀은 별도의 쓰레드에서 작동되는데 대기시간을 주지 않으면 테스트가 먼저 끝나버려서 풀이 생성되는 로그가 안찍힘
Thread.sleep(1000);
}
3) 로그 확인
(1) HiKariConfig
- HikariCP 관련 설정들을 확인할 수 있음
(2) MyPool connection adder
- 별도의 쓰레드를 사용해서 커넥션 풀에 커넥션을 채우고 있는 것을 확인할 수 있고 지정한 최대 풀수 까지 커넥션 풀에 커넥션을 채움
- 커넥션 풀에 커넥션을 채우는 것은 상대적으로 오래 걸리는 일이기 때문에 애플리케이션을 실행할 때 커넥션 풀을 채울 때까지 대기하고 있다면 애플리케이션 실행 시간이 늦어지게 됨
- 이렇게 별도의 쓰레드를 사용해서 커넥션 풀을 채워야 애플리케이션 실행 시간에 영향을 주지 않음
(3) 커넥션 풀에서 커넥션 획득
- 커넥션 풀에서 커넥션을 획득하고 그 결과를 출력
- 10개 생성 중 커넥션2개를 획득하고 반환하지 않았기 때문에 마지막 로그를 보면 total 10 개에 active가 2개, idle이 8(풀에서 대기상태) 인 것을 확인할 수 있음
- 커넥션 풀에 커넥션이 전부 찰때까지 커넥션을 획득하지 않고 기다리다가 다 차면 그때 획득 함(기본 10개까지는 이렇게 동작함)
- 만약 생성한 개수보다 더 많이 커넥션을 할당하려고 하면 마지막 로그의 wating 에 할당받지 못하고 기다리는 커넥션의 숫자가 뜨게 되고 애플리케이션이 블락이 되며 일정 시간이 지나도 할당을 받지 못했을 경우 애플리케이션이 종료가 됨
- 이런식으로 풀이 가득 찼을 때 기다리는 시간을 조정할 수 있는데, 해당 시간을 적절하게 세팅하는 것이 중요함
- 고객은 오래 기다려 주지 않으므로.. 가급적 짧게 적용하는 것이 좋을 수 있음
- 상세한 내용은 공식사이트를 참고 https://github.com/brettwooldridge/HikariCP
4. DataSource 적용
- 기존 코드를 유지하기 위해 기존 코드를 복사해서 새로 진행
- MemverRepositoryV0 -> MemberRepositoryV1
- MemberRepositoryV0Test -> MemberRepositoryV1Test
1) MemberRepositoryV1 수정
(1) DataSource 의존 관계 주입
- 외부에서 DataSource를 주입 받아서 사용함
- 직접 만든 DBConnectionUtil을 사용하지 않아도 됨
- DataSource는 표준 인터페이스이기 때문에 DriverManagerDataSource에서 HikariDataSource로 변경되어도 해당 코드를 변경하지 않아도 됨
(2) JdbcUtils 편의 메서드
- JDBC를 편리하게 다룰 수 있는 JdbcUtils라는 편의 메서드를 제공하는데 해당 메서드를 사용하면 커넥션을 좀 더 편리하게 닫을 수 있음
package hello.jdbc.repository;
// JDBC - DataSource, JdbcUtils 사용
@Slf4j
public class MemberRepositoryV1 {
private final DataSource dataSource;
public MemberRepositoryV1(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
/*
Statement 를 상속받은 PreparedStatement 인터페이스
Statement : SQL을 그대로 입력
PreparedStatement : 파라미터를 바인해서 SQL을 입력, 기능이 더 많음
*/
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId()); // 해당 인덱스에 값 저장
pstmt.setInt(2, member.getMoney()); // 해당 인덱스에 값 저장
pstmt.executeUpdate(); // 실행
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
close(con, pstmt, null);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money); // 해당 인덱스에 값 저장
pstmt.setString(2, memberId); // 해당 인덱스에 값 저장
int resultSize = pstmt.executeUpdate(); // 실행
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
close(con, pstmt, null);
}
}
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId); // 해당 인덱스에 값 저장
int resultSize = pstmt.executeUpdate(); // 실행
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally { // 꼭 finally 연결을 닫아줘야함 -> 안그럼 연결이 계속 유지됨
close(con, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery(); // 쿼리 결과를 반환
if (rs.next()) { // next()을 최소 한번을 호출 해줘야 커서가 데이터를 가르켜서 데이터를 조회할 수 있음
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else { // 데이터가 없을때
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
/**
* 연결을 닫아주는 메서드를 별도로 추출
* JdbcUtils 를 사용해 사용한 리소스들을 close
* 직접 짜는 것보다 훨씬 안정적으로 잘 짜여져있음
*/
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
// 커넥션 연결 메서드 추출
private Connection getConnection() throws SQLException {
// dataSource를 통해 커넥션을 연결하고 얻은 커넥션을 반환
Connection con = dataSource.getConnection();
log.info("connection={}, class={}", con, con.getClass());
return con;
}
}
2) MemberRepositoryV1Test 수정
- 검증 코드는 그대로 두고 BeforeEach로 검증 코드가 돌기 전에 커넥션을 획득하는 메서드를 만들어서 DriverManagerDataSource를 사용한방법과 HikariCP 커넥션 풀을 사용하여 커넥션을 얻는 방법을 각각 테스트
- 둘중 한가지 생성을 주석 처리하여 각각 테스트를 진행해서 로그를 확인해보면 아래 이미지처럼 출력되는데 DriverManagerDataSource를 사용한 방식은 각각 conn0 ~ 5번호를 통해서 새로운 커넥션이 생성되었지만 Hikari를 사용한 경우 항상 똑같은 conn0이 재사용 된 것을 볼 수 있음
- 코드 구성시 연결을 종료시키는 close()를 정의하여 연결을 종료 시켰는데 커넥션 풀을 사용 시 커넥션을 종료시키는 것이 아니라 커넥션풀에 반환하게 되고 다시 반환된 커넥션이 사용됨
- 웹 애플리케이션에서 동시에 여러 요청이 들어오면 여러 쓰레드에서 커넥션 풀의 커넥션을 다양하게 가져가는 상황을 확인할 수 있음
- Hikari의 로그를 보면 HikariProxyConnection@주소 이렇게 되어있는데, 풀에서 반환될때 HikariProxyConnection이라는 객체를 생성해서 커넥션풀을 감싸서 반환해서 주소가 다다르게 찍혀있지만 그 안에있는 커넥션 커넥션 풀에 있으므로 동일함
package hello.jdbc.repository;
@Slf4j
class MemberRepositoryV1Test {
MemberRepositoryV1 repository;
@BeforeEach // 각 테스트가 실행되기 직전에 먼저 호출됨
void beforeEach() {
// 기본 DriverManager - 항상 새로운 커넥션을 획득
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
// 커넥션 풀링 사용 - HikariCP
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
repository = new MemberRepositoryV1(dataSource);
}
@Test
void crud() throws SQLException {
// 커넥션 생성 로그를 찍기위한 슬립
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// .. 검증 코드 동일
}
}
3) DI
- DriverManagerDataSource -> HikariDataSource로 변경해도 MemberRepositoryV1는 DataSource인터페이스에만 의존하기 때문에 MemberRepositoryV1의 코드는 전혀 변경하지 않아도 됨
- 이것이 DataSource를 사용하는 장점임(DI + OCP)
'인프런 - 스프링 완전정복 코스 로드맵 > 스프링 DB 1편 - 데이터 접근 핵심 원리' 카테고리의 다른 글
스프링과 문제 해결 - 트랜잭션, 트랜잭션 템플릿, 트랜잭션 AOP(이해/적용/정리), 스프링 부트의 자동 리소스 등록 (0) | 2024.09.13 |
---|---|
스프링과 문제 해결 - 트랜잭션, 문제점들, 트랜잭션 추상화 및 동기화, 트랜잭션 매니저 (0) | 2024.09.13 |
트랜잭션 이해, 데이터베이스 연결 구조와 DB 세션, 트랜잭션 - DB예제, DB 락, 트랜잭션 적용 (0) | 2024.09.12 |
JDBC 이해 - JDBC 개발(등록 / 조회 / 수정 / 삭제) (1) | 2024.09.11 |
JDBC 이해 - 프로젝트 생성 및 H2 데이터베이스 설정, JDBC 이해, JDBC와 최신 데이터 접근 기술, 데이터 베이스 연결 (1) | 2024.09.11 |