이 글은 스프링 DB 1편 - 데이터 접근 핵심 원리 강의를 듣고 정리한 내용입니다.
애플리케이션을 개발할 때 중요한 데이터는 대부분 데이터베이스
에 보관한다.
클라이언트가 애플리케이션 서버를 통해 데이터를 저장하거나 조회하면, 애플리케이션 서버
는 다음 과정을 통해 데이터베이스
를 사용한다.
일반적으로, 애플리케이션 서버와 DB는 아래와 같은 순서로 진행된다.
커넥션 연결: 주로 TCP/IP를 사용해서 커넥션을 연결한다.
SQL 전달: 애플리케이션 서버는 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통해 DB에 전달한다.
결과 응답: DB는 전달된 SQL을 수행하고 그 결과를 응답한다. 애플리케이션 서버는 응답 결과를 활용한다.
하지만 각각의 데이터베이스 마다 사용법(커텍션 연결, SQL 전달, 결과 응답)이 다르다는 문제점을 가지고 있다. (참고로 관계형 데이터베이스는 수십개가 있다)
여기에는 2가지 큰 문제점이 있다.
데이터베이스를 다른 종류의 데이터베이스로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다.
개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 그리고 그 결과를 응답 받는 방법을 새로 학습해야 한다.
이러한 문제를 해결하기 위해 JDBC
라는 자바 표준이 등장하게 되었다.
JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API다. JDBC는 데이터베이스에서 자료를 쿼리하거나 업데이트하는 방법을 제공한다. - 위키백과 -
대표적으로 다음 3가지 기능을 표준 인터페이스
로 정의해서 제공한다.
java.sql.Connection
- 연결java.sql.Statement
- SQL을 담은 내용java.sql.ResultSet
- SQL 요청 응답이 JDBC 인터페이스를 각각의 DB 벤더(회사)에서 자신 의 DB에 맞도록 구현해서 라이브러리로 제공하는데, 이를 JDBC 드라이버
라 한다.
예를 들어서 MySQL DB에 접근 할 수 있는 것은 MySQL JDBC 드라이버
라 하고, Oracle DB에 접근할 수 있는 것은 Oracle JDBC 드라이버
라 한다.
MySQL 드라이버
를 사용하면 아래와 같은 그림을 볼 수 있다.
정리하면 JDBC
의 등장으로 두 가지 주요 문제가 해결되었다.
(참고로 JPA(Java Persistence API)를 사용하면 이렇게 각각의 데이터베이스마다 다른 SQL를 정의해야 하는 문제도 많은 부분을 해결할 수 있다)
JDBC
는 1997년에 출시될 정도로 오래된 기술이고, 사용하는 방법도 복잡하다.
그래서 최근에는 JDBC
를 직접 사용하기 보다는 JDBC
를 편리하게 사용하는 다양한 기술이 존재한다. 대표적으로 SQL Mapper
와 ORM
기술로 나눌 수 있다.
SQL Mapper
ORM 기술
ORM
은 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술이다. 이 기술 덕분에 개발자는 반복적인 SQL을 직접 작성하지 않고, ORM 기술이 개발자 대신에 SQL을 동적으로 만들어 실행
해준다.
추가로 각각 의 데이터베이스마다 다른 SQL을 사용하는 문제도 중간에서 해결해준다.JPA
는 자바 진영의 ORM 표준 인터페이스이고, 이것을 구현한 것으로 하이버네이트와 이클립스 링크 등의 구현 기술이 있다.여기서 중요한 점은 이런 기술들도 내부에서는 모두 JDBC를 사용한다. 따라서 JDBC를 직접 사용하지 않더라도, JDBC가 어떻게 동작하는지 기본 원리는 알아두어야 한다.
그래야 해당 기술들을 더 깊이있게 이해할 수 있고, 무엇보다 문제가 발생했을 때 근본적인 문제를 찾아서 해결할 수 있다.
JDBC
는 자바 개발자라면 꼭 알아두어야 하는 필수 기본 기술이다. (사실 이 말은 듣고, 이번 강의를 통해 확실히 알고 넘어가기로 마음 먹었다)
H2 데이터베이스는 개발이나 테스트 용도로 사용하기 좋은 가볍고 편리한 DB이다. 그리고 SQL을 실행할 수 있는 웹 화면을 제공한다.
사전에는 H2 데이터베이스를 위한 다운로드 및 설치를 해줘야 한다.
H2 데이터베이스는 스프링 부트 버전에 맞춘다. - H2 다운로드 버전
스프링 부트 2.x를 사용하면 1.4.200 버전을 다운로드 받으면 된다.
스프링 부트 3.x를 사용하면 2.1.214 버전 이상 사용해야 한다.
MAC, 리눅스 사용자 기준으로 아래와 같은 순서로 진행하면 된다.
cd bin #1. 디렉토리 이동
chmod 755 h2.sh #2. 권한 주기
./h2.sh #3. 실행
그런 다음, 데이터베이스 파일을 생성한다. (참고로, 주소 맨 앞에 localhost
가 아니라면, localhost
로 입력하고 Enter를 입력한다, 나머지 부분은 변경해선 안된다)
sa
를 입력한다.jdbc:h2:~/test
입력하고, 연결
버튼을 직접 눌러야 한다. (연결 시험
을 클릭하면 오류가 발생한다)jdbc:h2:tcp://localhost/~/test
이렇게 접속한다.H2 데이터베이스가 연결되면, 테스트를 위한 Member 테이블을 생성한다.
H2 데이터베이스 웹 콘솔에 아래와 같은 SQL 문을 입력하고, 실행한다.
drop table member if exists cascade;
create table member
(
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
insert into member(member_id, money)
values ('memberV1', 10000);
insert into member(member_id, money)
values ('memberV2', 20000);
select *
from member;
그런 다음, 쿼리를 실행해서 저장한 데이터가 잘 나오는지 결과를 확인한다.
먼저 H2 데이터베이스 서버를 실행시켜주고, 아래와 같이 데이터베이스에 접속하는데 필요한 기본 정보를 입력한다.
jdbc > connection 패키지
ConnectionConst 추상 클래스
package hello.jdbc.connection;
public abstract class ConnectionConst {
public static final String URL = "jdbc:h2:tcp://localhost/~/test";
public static final String USERNAME = "sa";
public static final String PASSWORD = "";
}
그리고 JDBC를 사용해서 실제 데이터베이스에 연결하는 코드를 작성한다.
DBConnectionUtil 클래스
@Slf4j
public class DBConnectionUtil {
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
log.info("get connection={}, class={}", connection, connection.getClass());
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
데이터베이스에 연결하려면 JDBC가 제공하는 DriverManager.getConnection(...)
를 사용하면 된다.
이렇게 하면 라이브러리에 있는 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해준다.
여기서는 H2 데이터베이스 드라이버
가 작동해서 실제 데이터베이스와 커넥션을 맺고 그 결과를 반환해준다.
DBConnectionUtilTest 클래스(테스트 코드)
@Slf4j
class DBConnectionUtilTest {
@Test
void connection() {
Connection connection = DBConnectionUtil.getConnection();
assertThat(connection).isNotNull();
}
}
실행 결과를 보면, class=class org.h2.jdbc.JdbcConnection
부분을 확인할 수 있다. 이것이 바로 H2 데이터베이스 드라이버가 제공하는 H2 전용 커넥션이다.
물론 이 커넥션은 JDBC 표준 커넥션 인터페이스인 java.sql.Connection
인터페이스를 구현하고 있다.
(만약 오류가 발생하면 H2 데이터베이스가 실행되지 않거나 설정에 오류가 있으므로, H2 데이터베이스 설정 부분을 확인한다)
JDBC는 java.sql.Connection
표준 커넥션 인터페이스를 정의한다.
H2 데이터베이스 드라이버는 JDBC Connection 인터페이스를 구현한 org.h2.jdbc.JdbcConnection
구현체를 제공한다.
JDBC가 제공하는 DriverManager
는 라이브러리에 등록된 DB 라이브러리를 관리하고, 커넥션을 획득하는 기능을 제공한다.
DriverManager.getConnection()
을 호출한다.DriverManager
는 라이브러리에 등록된 드라이버 목록을 자동으로 인식한다. 이 드라이버들에게 순서대로 다음 정보를 넘겨서 커넥션을 획득할 수 있는지 확인한다.
jdbc:h2:tcp://localhost/~/test
여기서는 H2 데이터베이스 드라이버만 라이브러리에 등록했기 때문에 H2 드라이버가 제공하는 H2 커넥션을 제공받는다.
이제 본격적으로 JDBC를 사용해서 애플리케이션을 개발해보자.
여기서는 JDBC를 사용해서 회원(Member) 데이터를 데이터베이스에 관리하는 기능을 개발해보자.
H2 데이터베이스 설정 마지막에 테이블과 샘플 데이터 만들기를 통해
member
테이블을 미리 만들어두어야 한다.
관련 커밋 - 등록
drop table member if exists cascade;
create table member
(
member_id varchar(10),
money integer not null default 0,
primary key (member_id)
);
Member 클래스
@Data
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
}
MemberRepositoryV0 클래스
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?, ?)";
Connection con = null;
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 {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (stmt != null) {
try {
stmt.close(); // Exception
} catch (SQLException e) {
log.info("error", e);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
private static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
커넥션 획득 - getConnection()
: 이전에 만들어둔 DBConnectionUtil
를 통해서 데이터베이스 커넥션을 획득한다.
save()
- SQL 전달
sql
: 데이터베이스에 전달할 SQL을 정의한다. 여기서는 데이터를 등록해야 하므로 insert sql
을 준비했다.con.prepareStatement(sql);
: 데이터베이스에 전달할 SQL과 파라미터로 전달할 데이터들을 준비한다.
pstmt.setString(1, member.getMemberId());
: SQL의 첫번째 ?
에 값을 지정한다. 문자이므 로 setString
을 사용한다.
pstmt.setInt(2, member.getMoney());
: SQL의 두번째 ?
에 값을 지정한다. Int
형 숫자이므로 setInt
를 지정한다.
pstmt.executeUpdate();
: Statement
를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달한다. 참고로 executeUpdate()
은 int
를 반환하는데 영향받은 DB row 수를 반환한다. 여기서는 하나의 row 를 등록했으므로 1을 반환한다.
close()
- 리소스 정리
쿼리를 실행하고 나면 리소스를 정리해야 한다. 여기서는 Connection
, PreparedStatement
를 사용했으므로, 리소스를 정리할 때는 항상 역순으로 정리해준다.
예외가 발생하든, 하지 않든 항상 수행되어야 하므로 finally
구문에 주의해서 작성해야 한다.
만약 이 부분을 놓치게 되면 커넥션이 끊어지지 않고 계속 유지되는 문제가 발생할 수 있다.
이런 것을 리소스 누수
라고 하는데, 결과적으로 커넥션 부족으로 장애가 발생할 수 있다.
참고:
Statement
는 sql에 직접 넣는 것이고,PrepareStatement
는 파라미터에 직접 바인딩해서 sql에 넣는 것이다.
PrepareStatement
는Statement
를 상속받아서close()
메서드를 호출할 때, 파라미터로 넘길 수 있는 것이다. 추가적으로 SQL Injection 공격을 예방하려면PreparedStatement
를 통한 파라미터 바인딩 방식을 사용해야 한다.
이제 테스트 코드를 통해 JDBC로 회원을 데이터베이스에 등록하는 코드를 작성하면 아래와 같다.
MemberRepositoryV0Test - 회원 등록
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
//save
Member member = new Member("memberV100", 10000);
repository.save(member);
}
}
실행 결과, 데이터베이스에 select * from member
쿼리를 실행하면 데이터가 저장된 것을 확인할 수 있다.
참고로, 이 테스트는 2번 실행하면 PK 중복 오류가 발생하기 때문에, 이 경우 PK 값을 바꾸거나 혹은 delete from member
쿼리로 데이터를 삭제한 다음에 다시 실행하면 된다.
아래는 PK 중복 오류에 관한 내용이다.
org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or
primary key violation: "PUBLIC.PRIMARY_KEY_8 ON PUBLIC.MEMBER(MEMBER_ID) VALUES
9"; SQL statement:
관련 커밋 - 조회
MemberRepositoryV0 클래스 - findById()
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?, ?)";
Connection con = null;
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 {
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()) {
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, null);
}
}
private static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
finById()
- 쿼리 실행
sql
: 데이터 조회를 위한 select SQL을 준비한다.
rs = pstmt.executeQuery()
데이터를 변경할 때는 executeUpdate()
를 사용하지만, 데이터를 조회 할 때는 executeQuery()
를 사용한다. executeQuery()
는 결과를 ResultSet
에 담아서 반환한다.
ResultSet
ResultSet
은 다음과 같이 생긴 데이터 구조이다. 보통 select 쿼리의 결과가 순서대로 들어간다.
예를 들어서 select member_id, money
라고 지정하면 member_id
, money
라는 이름으로 데이터 가 저장된다.
참고로 select *
을 사용하면 테이블의 모든 컬럼을 다 지정한다.
ResultSet
내부에 있는 커서( cursor
)를 이동해서 다음 데이터를 조회할 수 있다.
rs.next()
: 이것을 호출하면 커서가 다음으로 이동한다. 참고로 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next()
를 최초 한번은 호출해야 데이터를 조회할 수 있다.
rs.next()
의 결과가 true
면 커서의 이동 결과 데이터가 있다는 뜻이다.
rs.next()
의 결과가 false
면 더이상 커서가 가리키는 데이터가 없다는 뜻이다.
참고로 이 ResultSet
의 결과 예시는 회원이 2명 조회되는 경우이다.
1-1 에서 rs.next()
를 호출할 때 1-2의 결과로 cursor가 다음으로 이동하고, 이 경우 cursor가 가리키는 데이터가 있으므로 true
를 반환한다.
2-1 역시 1-1과 마찬가지로 cursor가 가리키는 데이터가 있으므로 true
를 반환한다.
3-1 에서는 3-2의 결과로 cursor가 가리키는 데이터가 없으므로 false
를 반환한다.
findById()
에서는 회원 하나를 조회하는 것이 목적이다. 그래서 조회 결과가 항상 1건이므로 if
절을 사용한 것이다.
MemberRepositoryV0Test - 회원 조회
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
//save
Member member = new Member("memberV100", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
}
}
실행 결과, member 객체의 참조 값이 아니라 실제 데이터가 보이는 이유는 lombok의 @Data
가 toString()
을 적절히 오버라이딩해서 보여줬기 때문이다.
isEqualTo()
: findMember.equals(member)
를 비교한다.
결과가 참인 이유는 롬복의 @Data
는 해당 객체의 모든 필드를 사용하도록 equals()
를 오버라이딩 하기 때문이다.
관련 커밋 - 수정, 삭제
수정과 삭제는 등록과 비슷하다. 따라서 데이터를 변경하는 쿼리는 executeUpdate()
를 작성하면 된다.
MemberRepositoryV0 클래스 - update(), delete()
package hello.jdbc.repository;
import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
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.info("db error", e);
throw e;
} 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);
pstmt.executeUpdate();
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (stmt != null) {
try {
stmt.close(); // Exception
} catch (SQLException e) {
log.info("error", e);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
private static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
executeUpdate()
는 쿼리를 실행하고 영향받은 row 수를 반환한다. 여기서 하나의 데이터만 변경하기 때문에 결과로 1이 반환된다.
만약 회원이 100명이고, 모든 회원의 데이터를 한 번에 수정하는 update sql을 실행하면 결과는 100이 되낟.
MemberRepositoryV0Test - CRUD(회원 수정, 삭제 포함)
@Slf4j
class MemberRepositoryV0Test {
MemberRepositoryV0 repository = new MemberRepositoryV0();
@Test
void crud() throws SQLException {
//save
Member member = new Member("memberV100", 10000);
repository.save(member);
//findById
Member findMember = repository.findById(member.getMemberId());
log.info("findMember={}", findMember);
assertThat(findMember).isEqualTo(member);
//update: money: 10000 -> 20000
repository.update(member.getMemberId(), 20000);
Member updateMember = repository.findById(member.getMemberId());
assertThat(updateMember.getMoney()).isEqualTo(20000);
//delete
repository.delete(member.getMemberId());
Assertions.assertThatThrownBy(() -> repository.findById(member.getMemberId()))
.isInstanceOf(NoSuchElementException.class);
}
}
회원을 삭제한 다음, findById()
를 통해서 조회한다. 회원이 없기 때문에 NoSuchElementException
이 발생한 것이다.
(참고로 assertThatThrownBy
는 해당 예외가 발생해야 검증이 성공한다)
사실 마지막에 회원을 삭제하기 때문에 테스트가 정상 수행하지만, 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없다.
트랜잭션을 활용해서 이 문제를 해결할 수 있지만, 자세한 내용은 다음 강의(트랜잭션)에서 나올 예정이다.
아래 코드는 이제까지 JDBC 라이브러리를 이용하여 CRUD 개발을 작업한 결과이다.
MemberRepositoryV0(등록, 조회, 수정, 삭제)
package hello.jdbc.repository;
import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* JDBC - DriverManager 사용
*/
@Slf4j
public class MemberRepositoryV0 {
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?, ?)";
Connection con = null;
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 {
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()) {
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, 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.info("db error", e);
throw e;
} 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);
pstmt.executeUpdate();
} catch (SQLException e) {
log.info("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
log.info("error", e);
}
}
if (stmt != null) {
try {
stmt.close(); // Exception
} catch (SQLException e) {
log.info("error", e);
}
}
if (con != null) {
try {
con.close();
} catch (SQLException e) {
log.info("error", e);
}
}
}
private static Connection getConnection() {
return DBConnectionUtil.getConnection();
}
}
JDBC에 대한 개념을 처음부터 학습하고 JDBC 라이브러리를 활용하여 CRUD(등록, 조회, 수정, 삭제) 작업을 직접 구현해보는 경험을 쌓아보았다.
주로 기초적인 부분에 중점을 두어 다루었기 때문에, 더 깊은 내용은 다른 참고서를 참조해야 할 것 같다. 그러나 이 강의를 통해 JDBC의 핵심 개념과 동작 원리를 이해할 수 있었다.
이전에도 JDBC를 이용한 CRUD 개발을 시도해봤지만, 제대로 된 복습 없이 넘어간 부분이 있었다. 따라서 이 강의를 통해 그 과정을 제대로 되짚어 볼 수 있게 되었다.
2024 Dev History
인프런 강의: 우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복
인프런 강의: 김영한의 스프링 부트와 JPA 실무 완전 정복 로드맵
인프런 강의: Kotlin
서버 개발과 관련된 책
개인 프로젝트: Hibit (version 2)
Github : 기존 version1 에 개발했던 백엔드 코드와 구조를 개선하기 위해 version2로 디벨롭시킨 프로젝트
240101 ~ 240107
[Programmers] SQL - Join 문제 - 오랜 기간 보호한 동물(1), 없어진 기록 찾기(1), 있었는데요 없었습니다, 주문량이 많은 아이스크림 조회하기
240108 ~ 240114
[Spring MVC 2편] 파일 업로드, 다운로드 강의 수강
240115 ~ 24021
240122 ~ 240128
240129 ~ 240204
240205 ~ 240211
240226 ~ 240303
240311 ~ 240317
[Kotlin] 자바 개발자를 위한 코틀린 입문 강의 - 정리
섹션1. 코틀린에서의 변수와 타입, 연산자를 다루는 방법
섹션2. 코틀린에서 코드를 제어하는 방법
섹션3. 코틀린에서의 OOP
섹션4. 코틀린에서의 FP
240318 ~ 240324
[Kotlin] 실전! 코틀린과 스프링 부트 실습 강의 - 정리
섹션1. 도서관리 애플리케이션 리팩토링 준비하기
섹션2. Java 서버를 Kotlin 서버로 리팩터링하자!
섹션3. 첫 번째 요구사항 추가하기 - 책 분야 (Enum 활용)
섹션4. 두 번째 요구사항 추가하기 - 도서 대출 현황 (N+1 문제 해결)
240325 ~ 240331
240401 ~ 240407
240406 ~ 240414
240415 ~ 240421
240422 ~ 240428
240429 ~ 240505
240621
240819 ~ 240825
240902 ~ 240908
240909 ~ 240915
240916 ~ 240922
2401021 ~ 241027
241028 ~ 241103
2023년은 20대 동안 개발
면에서 가장 활발하게 다양한 활동을 경험한 한 해이자, 휴식이 적은 한 해였다.
정말 순식간에 1년이 지나가버린 것 같다 🤔.
그동안 내가 무엇을 배웠고 어떤 성장을 했는지 하나씩 정리해보자. ✍🏻
2023년에 내가 공부하면서 배운 내용을 2023 Dev History에도 기록해두었다.
2023년에 참여한 모임
1월 ~ 5월: 신입 개발자를 위한 CS 스터디 모임
3월 ~ 11월: 히빗(팀 프로젝트)
(8월: 대학교 졸업)
4월 ~ 9월: 우리FISA '클라우드 서비스 개발자'
8월 ~ 9월, 12월: 굿프렌즈(팀 프로젝트)
11월 ~ 12월: 우아한스터디 2023 겨울시즌 '내 코드가 그렇게 이상한가요?'
12월: 글또 9기 '글 쓰는 또라이가 세상을 바꾼다! 글쓰는 개발자 모임'
2023년 1월에 시작한 신입 개발자를 위한 CS 스터디 5월 말까지 5개월 동안 지속적으로 운영해왔다. 그 당시에는 컴퓨터 기반 지식이 부족했고, 배워야 할 것이 너무 많아서 운영체제, 데이터베이스, 네트워크와 같은 컴퓨터 공학의 필수 과목을 매월 1개씩 스터디원들과 공부해나갔다.
스터디를 처음 운영해보는 경험이라 깊이 있는 학습과 철저한 기록을 목표로 삼았다.
그래서 우리 스터디의 목표
는 CS 기본 지식들을 깊이 있게 습득하고, 공부한 내용을 설명하여 죽은 지식이 아닌 살아있는 지식으로 바꾸고자 했다.
여러 시행착오를 거치면서 더 나은 개선 방향을 찾고, 이러한 경험을 Issues와 Wiki에 정리해놓았다.
Issues에는 주제와 내용을 매주 정하고, 각 스터디원이 주제를 정해 발표하는 내용을 기록했다.
Wiki에는 외부인도 쉽게 이해할 수 있도록 주제를 카테고리별로 정리하고, 주차별로 기록했다.
5개월 동안 진행한 뒤 6월부터는 다른 교육과 사이드 프로젝트로 인해 참석하지 못했지만, 남아있는 스터디원들이 적극적으로 활동하여 12월 31일 현재까지 총 591개의 Star를 받게 되었다. 이는 1-3월에 80개, 5월에 150개, 11월에 500개로 거의 매일 Star가 증가한 것을 보며 뿌듯함과 신기함을 느끼게 되었습니다.
이 스터디가 많은 Star를 받은 이유는 다른 CS 스터디 모임보다 CS 지식을 깊게 공부하고 정리한 점과 외부 사람이 이해하기 쉽도록 작성하려고 노력한 점이라고 생각한다. 현재까지도 스터디원분들끼리 매주 온라인 회의로 스터디를 진행하시는데, 이런 분들과 같이 스터디를 할 수 있어서 영광이라고 생각한다 👏🏻.
해당 스터디에서 배운 내용은 아래와 같다.
신입 개발자를 위한 CS 스터디 모임에서 진행했던 내용
- 데이터베이스 - 데이터베이스 개론, 면접을 위한 CS 전공지식 노트, 학교 전공 서적
- 운영체제 - Operating Systems: Three Easy Pieces , 운영체제와 정보기술의 원리
- 네트워크 - 면접을 위한 CS 전공지식 노트
- 자바 - 자바의 정석
Spring, JPA 부분은 김영한의 스프링 완전 정복에 있는 강의를 혼자서 들으면서, 관련 내용을 블로그에 정리했다.
메모리: 119 MB, 시간: 49.53 ms - Answer Code1
메모리: 98.7 MB, 시간: 4.72 ms - Answer Code2
import java.util.*;
class Solution {
public int solution(int[] queue1, int[] queue2) {
Queue<Integer> que1 = new LinkedList<>();
Queue<Integer> que2 = new LinkedList<>();
long sum1 = 0, sum2 = 0;
for(int i = 0; i < queue1.length; i++) {
sum1 += queue1[i];
que1.offer(queue1[i]);
}
for(int i = 0; i < queue2.length; i++) {
sum2 += queue2[i];
que2.offer(queue2[i]);
}
int count = 0;
while(sum1 != sum2) {
count++;
if(sum1 > sum2) {
int value = que1.poll();
sum1 -= value;
sum2 += value;
que2.offer(value);
} else {
int value = que2.poll();
sum1 += value;
sum2 -= value;
que1.offer(value);
}
if(count > (queue1.length + queue2.length) * 2) return -1;
}
return count;
}
}
각 큐의 합이 같을 때까지 반복문을 돌려줬다.
또한 원소의 합이 같지 않는 경우에는 return을 -1로 두는 특수 예외 조건이 존재한다.
분기 조건이 (queue1.length + queue2.length) * 2
인 이유는 양쪽 큐 길이를 전부 돌았을 횟수로 잡았기 때문이다.
즉, 각 큐에서 원소를 추출하고 집어넣는 작업이 최대로 반복될 수 있는 횟수를 고려한 것이다.
최악의 경우, 한 큐의 모든 원소를 다른 큐에 집어넣어야 할 수 있으므로, 이때 루프의 최대 횟수는 큐의 길이의 2배가 된다.
합계(sum)가 long 형으로 한 이유는 제한사항
에서 queue1, queue2 원소의 최대 범위가 10^9이므로, 합 계산 과정 중 산술 오버플로우 발생 가능성이 있어서이다.
import java.util.*;
class Solution {
public int solution(int[] queue1, int[] queue2) {
int[] totalQueue = new int[queue1.length + queue2.length];
long queue1Sum = 0;
long queue2Sum = 0;
for(int i = 0; i < queue1.length; i++) {
int val = queue1[i];
totalQueue[i] = val;
queue1Sum += val;
}
for(int i = queue1.length; i < queue1.length + queue2.length; i++) {
int val = queue2[i - queue1.length];
totalQueue[i] = val;
queue2Sum += val;
}
if((queue1Sum + queue2Sum) % 2 == 1) return -1;
int count = 0;
int left = 0;
int right = queue1.length;
long half = (queue1Sum + queue2Sum) / 2;
while(left < right && right < totalQueue.length) {
if(queue1Sum == half) {
return count;
} else if(queue1Sum > half) {
queue1Sum -= totalQueue[left++];
} else {
queue1Sum += totalQueue[right++];
}
count++;
}
return -1;
}
}
이 글은 제미니의 개발실무의 Git 형상 관리 + 작업 단위와 PR 코드 리뷰 + 협업을 잘하는 개발자 영상을 보면서 제 생각과 같이 정리한 글입니다.
(시청자) 질문. 큰 규모의 작업을 할 때 commit 해야 할 작업 task를 나누는 기준이 궁금합니다.
- commit 할 작업 단위를 먼저 생각해보고 -> 그 단위로 작업을 하면서 중간중간 commit한다.
- 그냥 한번에 기능 단위 개발을 하고 -> 이후에 작업을 쪼개면서 commit한다.
리뷰
시스템 전체를 개발한다고 가정을 해보자. 그러면 하나의 task로 담기에는 굉장히 클 수 있다.
그렇기 때문에 먼저 작업 단위 자체
를 잘 나누는게 가장 중요하다.
리뷰
라는 기능이 있다면,
리뷰 등록, 리뷰 삭제와 같은 각 세부 기능에 대해서 하나의 작업
이라 생각하고, 그러한 작업을 Issue & Branch & PR
로 가져가는게 좋다.
큰 규모의 작업 자체가 있으면, 이는 여러가지 어려움을 만드는 것 같다.
추가/수정된 파일과 커밋 갯수가 많을 수록 다른 사람이 코드 리뷰하는게 까다로울 수 있다.
제민님의 의견: 같이 일하는 동료가 내 코드를 쉽게 이해할 수 있도록 task를 작게 가져갈 것 같다.
작업 단위를 작게 나누고, 위에 질문주신 2가지를 고민할 것 같다.
작업 단위를 작게 엄청 잘 나누게 되면, 애초에 커밋에 대한 생각을 많이 안해도 되는 경우가 있는 것 같다.
흔히 많이 하는 실수가 신규 기능 개발과 리팩터링하는 것을 하나의 task
라고 생각하고 작업하는 경우가 있다.
이러한 실수를 예방하기 위해 한 가지 작업을 딱 정해서, 최대한 작게 가져가면 커밋에 대한 고민은 크게 하지 않아도 된다.
즉, 신규 기능 개발과 리팩터링을 각각의 작업 단위로 생각해야 한다. -> task1 : 신규 기능 개발 / task2 : 리팩터링
결론적으로 질문 주신 것에 대해 답변을 하면, 1번과 2번을 다 쓰긴 한다.
어떻게 하면 동료가 코드 리뷰하기 더 좋을까에 대해서 커밋과 PR를 나눈다고 보시면 될 것 같다.
개인적으로 2번을 많이 쓴다. 2번: “그냥 한번에 기능 단위 개발을 하고 -> 이후에 작업을 쪼개면서 commit한다.”
커밋하는 부분도 협업 스킬 중 하나라고 생각한다.
아래와 같이 커밋을 작성하면 리뷰하는 사람 입장에서 힘들 수 있다.
User 클래스에 대한 작업 <- 하나의 task
--- 아래부터는 커밋 내용
feat: User 클래스가 수정!
feat: User 클래스가 수정!
feat: User 클래스가 수정! (리팩토링 포함)
feat: User 클래스가 수정!
...
feat: User 클래스가 수정!
[PR]
수정 파일이 50개
커밋이 10개
만약 작업 단위가 작았다면, 위에서처럼 발생할 수 없다.
수정 파일이 많고, 같은 클래스에서 여러 번의 커밋을 작성하면, 리뷰할 사람 입장에서는 이해하기가 어려울 수 있다.
[PR 올릴 때 커밋 정리 기준 중 하나] - 한 클래스는 한 파일에서만 수정된다.
보통 커밋은 아래와 같이 진행한다.
하나의 작업에 대한 커밋을 여러번 작성한다. -> 본인을 위해
어느 정도 커밋을 작성하고 PR을 올리기 전에, 지금까지 작성한 커밋들을 정리한다.
PR을 올리면서 동료로부터 코드 리뷰를 받고, 추가적인 커밋을 한다. -> 동료를 위해
리뷰가 끝나면, 지금까지 작성한 커밋을 한번 더 정리를 한다. -> 회사 자산 관리 + 미래의 동료를 위해
리뷰
에 대한 기능을 개발한다고 가정했을 때, 작업 단위를 나눈다. -> 리뷰 등록, 리뷰 조회, 리뷰 수정, 리뷰 삭제
그런 다음, 작업에 대한 Branch를 나누고 PR도 여러개 올린다.
리뷰 등록에 대한 Branch명: review-add
리뷰 삭제에 대한 Branch명: review-remove
그리고 세부 기능에 대해서 Branch를 연계적으로 생성한다.
이전 굿프렌즈 팀 프로젝트에서는 우리만의 Git flow 전략을 만들면서, 형상 관리를 나름 신경썼다고 생각했다.
브랜치 면에서는 이 영상에서 다루는 거와 거의 비슷하게 작업을 진행했지만, 커밋 부분에 있어서는 ‘이 정도로 신경을 써야 하는구나’라는 생각이 많이 들었다.
최근에 주문
기능에 대해서 리팩터링 과정을 진행했는데, 작은 작업 단위 보다는 큰 작업으로 진행해서 아래와 같이 15개의 커밋 내용과 17개의 파일이 수정되었다.
(물론, 10월 이후부터는 팀원들을 제외한 나 혼자만의 리팩터링을 진행해서 더 신경을 안쓴것도 맞다..ㅎㅎ 😅)
다음에 팀 프로젝트를 진행한다면, 커밋
에 대해서도 신경쓰면서 작업을 진행하면 좋겠다는 생각이 들었다.
(해당 영상을 참고하시면 더 자세하게 설명해주시니 참고하면 좋을 것 같습니다!)
문제: PRODUCT
테이블과 OFFLINE_SALE
테이블에서
상품코드 별 매출액(판매가 * 판매량) 합계를 출력하는 SQL문을 작성해주세요.
결과는 매출액을 기준으로 내림차순 정렬해주시고
매출액이 같다면 상품코드를 기준으로 오름차순 정렬해주세요.
SELECT A.PRODUCT_CODE,
(A.PRICE * B.TOT_SALES_AMOUNT) AS SALES
FROM PRODUCT A
LEFT JOIN (
SELECT PRODUCT_ID
, SUM(SALES_AMOUNT) TOT_SALES_AMOUNT
FROM OFFLINE_SALE B
GROUP BY
PRODUCT_ID
) B
ON A.PRODUCT_ID = B.PRODUCT_ID
ORDER BY SALES DESC, PRODUCT_CODE ASC
문제를 풀기 위해서 해야할 작업들
첫 번째 단계는 OFFLINE_SALE 테이블에서 PRODUCT_ID 별 판매 수량을 집계한다.
이때 GROUP BY를 집계해서 각 상품별 수량을 산출한다.
SELECT PRODUCT_ID
, SUM(SALES_AMOUNT) TOT_SALES_AMOUNT
FROM OFFLINE_SALE B
GROUP BY
PRODUCT_ID
두 번째 단계는 앞에서 진행한 결과를 서브쿼리로 넣고, PRODUCT 테이블을 기준으로 LEFT JOIN을 해준다.
이 결과를 통해 각 상품별 총 판매개수와 상품별 금액을 알 수 있다.
SELECT *
FROM PRODUCT A
LEFT JOIN (
SELECT PRODUCT_ID
, SUM(SALES_AMOUNT) TOT_SALES_AMOUNT
FROM OFFLINE_SALE B
GROUP BY
PRODUCT_ID
) B
ON A.PRODUCT_ID = B.PRODUCT_ID
SELECT A.PRODUCT_CODE,
(A.PRICE * B.TOT_SALES_AMOUNT) AS SALES
FROM PRODUCT A
LEFT JOIN (
SELECT PRODUCT_ID
, SUM(SALES_AMOUNT) TOT_SALES_AMOUNT
FROM OFFLINE_SALE B
GROUP BY
PRODUCT_ID
) B
ON A.PRODUCT_ID = B.PRODUCT_ID
SELECT A.PRODUCT_CODE,
(A.PRICE * B.TOT_SALES_AMOUNT) AS SALES
FROM PRODUCT A
LEFT JOIN (
SELECT PRODUCT_ID
, SUM(SALES_AMOUNT) TOT_SALES_AMOUNT
FROM OFFLINE_SALE B
GROUP BY
PRODUCT_ID
) B
ON A.PRODUCT_ID = B.PRODUCT_ID
ORDER BY SALES DESC, PRODUCT_CODE ASC