이 글은 인프런에 있는 김영한님의 "스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술" 강의를 듣고 정리한 필기입니다.
⛅️ 순수 JDBC 세팅하기
이전에는 회원 정보를 메모리에 저장해서 사용을 했었다.
이번에는 어플리케이션과 DB를 연동해서, DB에 query를 날리고, DB에 정보를 넣고 빼는 과정을 JDBC를 이용하여 구현해보자.
순수 JDBC는 예전에 했던 방식이므로 참고만 하도록 하자.
우선 build.gradle에 밑의 코드를 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
그 다음으로는 DB에 정보를 넣으려면 접속 정보를 넣어야 하는데, src - resources - application.properties에 밑의 코드를 적는다.
spring.datasource.url = jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name = org.h2.Driver
spring.datasource.username=sa
이렇게 하면 jdbc를 사용할 수 있는 환경이 조성된 것이다.
⛅️ JDBC API를 이용하여 DB 연결하기
이전에는 MemoryMemberRepository라는 구현체를 만들고, MemberRepository 인터페이스를 상속받아서 사용했다.
이번에는 jdbc를 이용한 Repository를 사용할 것이므로, JdbcMemberRepository 클래스를 생성한다.
DB에 연결해서 사용하려면 data source라는 것이 필요하다.
그러므로 DataSource Type의 변수를 final로 선언하고, 생성자를 통해 주입받는다.
spring boot가 Data Source를 만들어놓기 때문에, spring을 통해 주입받을 수 있다.
public class JdbcMemberRepository implements MemberRepository{
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
//dataSource.getConnection();
}
...
이후 dataSource.getConnection()을 통해 connection을 받을 수 있고, 여기에 sql문을 전달하여 DB에 정보를 넣고 뺄 수 있다
⛅️ save, findById, findByName, findAll 구현하기
주의) 이렇게 JDBC API로 직접 코딩하는 것은 20년 전 이야기이므로 정신 건강을 위해 참고만하고 넘어가자.
save()
@Override
public Member save(Member member) {
String sql = "insert into member(name) values(?)";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql,Statement.RETURN_GENERATED_KEYS);
pstmt.setString(1, member.getName());
pstmt.executeUpdate();
rs = pstmt.getGeneratedKeys();
if (rs.next()) {
member.setId(rs.getLong(1));
} else {
throw new SQLException("id 조회 실패");
}
return member;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
findById()
@Override
public Optional<Member> findById(Long id) {
String sql = "select * from member where id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, id);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
findByName()
@Override
public Optional<Member> findByName(String name) {
String sql = "select * from member where name = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, name);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
}
return Optional.empty();
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
findAll()
@Override
public List<Member> findAll() {
String sql = "select * from member";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
rs = pstmt.executeQuery();
List<Member> members = new ArrayList<>();
while(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
members.add(member);
}
return members;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
getConnection()과 close()
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection conn, PreparedStatement pstmt, ResultSet rs){
try {
if (rs != null) {
rs.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (pstmt != null) {
pstmt.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (conn != null) {
close(conn);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, dataSource);
}
⛅️ MemoryMemberRepository에서 JdbcMemberRepository로 변경하기
이렇게 각 기능(메서드)들을 jdbc를 통해서 구현했으니, MemoryMemberRepository에서 JdbcMemberRepository로 바꿔야 한다.
이전에는 밑 코드처럼 SpringConfig 클래스에서 @Bean을 통해 spring container에 MemoryMemberRepository를 올려주었다.
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
return new MemoryMemberRepository();
}
}
여기서 중요한데, memberRepository()에서 return new MemoryMemberRepository()를
return new JdbcMemberRepository()로 바꾸면 된다는 것이다!
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
// Jdbc Repository로 변경
return new JdbcMemberRepository();
}
}
jdbc에 필요한 DataSource는 spring에서 제공을 하기 때문에 injection을 통해 받으면 된다.
@Configuration도 spring bean으로 관리가 되기 때문에 spring boot가 dataSource를 bean으로 생성하고 관리한다.
@Configuration
public class SpringConfig {
private DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
return new MemoryMemberRepository();
}
}
이후 H2 데이터베이스를 실행하고 테스트를 돌리면 모든 테스트를 통과하는 것을 확인할 수 있다.
위 코드들을 보면 String 변수를 통해 sql문을 직접 작성하고, connection, PreparedStatement, ResultSet 변수를 또 선언하여 많은 코드를 작성하였다.
심지어 수 많은 try-catch문을 사용하여 코드 가독성까지 안 좋은 것을 알 수 있다.
다음 시간부터는 순수 jdbc에서 조금씩 발전하여 어떤 식으로 코드의 가독성과 유지보수성을 올릴 수 있는지 알아 볼 것이다.
⛅️ spring을 사용하는 이유
하지만 이번 시간을 통해 알게 된 사실도 있다.
바로 우리가 spring을 사용하면 좋은 이유들이다.
우리가 MemoryMemberRepository에서 JdbcMemberRepository로 바꾸는 과정에서 SpringConfig에서만 코드를 수정했다. MemberService나 MemberController에서는 전혀 수정하지 않고 SpringConfig에서만 코드를 수정하여 Repository를 바꿀 수 있었다.
spring은 다형성을 사용할 수 있도록 spring container에서 dependency injection(DI)와 같은 것을 지원한다.
따라서 객체지향적인 설계가 가능하고 다형성을 활용할 수 있다.
이를 통해 이번 상황처럼 DB의 구현체를 Memory에서 Jdbc로 변경할 때에 코드를 많이 수정하지 않고, SpringConfig에서만 수정하고 구현체를 변경할 수 있다.
위 그림은 구현 클래스를 추가한 이미지이다. MemberService는 MemberRepository에 의존하고 있다. 즉 MemberRepository의 기능을 사용하고 있다.
그리고 MemberRepository 인터페이스는 이제 MemoryMemberRepository와 JdbcMemberRepository 구현체를 가지게 된다.
위 그림은 우리가 SpringConfig에서 바꾼 내용에 대해 설명한 그림이다.
기존에는 MemoryMemberRepository를 spring bean으로 등록하고 사용하고 있었다.
이번 시간에는 MemoryMemberRepository를 빼고, jdbc 버전의 MemberRepository를 연결했다. 그리고 다른 코드는 변경하지 않았다.
하지만 다형성의 성질을 이용하여 JdbcMemberRepository를 생성할 수 있고 이를 사용하게 된다.
이를 개방-폐쇄 원칙(OCP, Open-Closed Principle)이라고 한다.
확장에는 열려있고, 수정&변경에는 닫혀있다는 뜻이다.
인터페이스 기반의 다형성. 즉, 객체 지향에서의 다형성의 개념을 활용하면 기능을 완전히 변경한다 하더라도 application 전체의 코드를 수정하지 않아도 된다는 것이다.
이번 시간에는 spring DI를 활용하면 "기존 코드에는 손대지 않고, 설정만으로도 구현 클래스를 변경할 수 있다"라는 사실을 배울 수 있었다.