이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
이전 히빗 프로젝트(ver.1)에서는 테스트 코드를 도입하지 못했다. 정확히 말하면 하지 않았다.
작년 4월부터 11월까지 진행했던 팀 프로젝트인 히빗(ver.1) 을 진행하면서, 초반에는 테스트 코드를 작성하려고 노력했다.
소셜 로그인 기능에 대한 단위 테스트와 통합 테스트를 작성하면서 얻은 경험을 정리하고 팀원들과 공유하기도 했지만, 각 팀원들이 히빗 팀 프로젝트 외에 다른 곳에도 시간을 할애해야 했기 때문에 이뤄지지 못했다.
나 또한 히빗 프로젝트 뿐만 아니라 여러 활동에 참여하고 있어서, 시간이 갈 수록 테스트 코드를 작성하는 것에 대한 시간을 투자하지 못했다.
그러나 9월부터 QA 작업을 진행하면서 정말 많은 에러들이 발생했다.
어느 부분에서 예외가 발생하였고, 어떤 현상이 발생했는지 아래와 같이 세부 기능별로 기록했다.
실제 예시: 프로필 기능 중 프로필 등록 화면
QA 작업은 10월까지 2달간 계속되었고, 에러가 많아 QA Note Ver1
과 QA Note Ver2
로 나누어 정리하게 되었다.
실제 예시: QA Note Ver2
QA 작업을 진행하면서 불편한 점은 서비스에 따라 다르겠지만, 해당 서비스 이용을 위해 필요한 단계들을 매번 반복해야 한다는 것이다.
우리 서비스에서는 소셜 로그인 -> 회원가입 -> 프로필 작성
까지의 단계가 필수적이었다. (이 과정을 정말 100번 이상 반복한 것 같다…)
이러한 반복적인 과정과 다양한 기능으로 인해 사람이 수시로 테스트하는 데 시간이 오래 걸리는 문제점이 있었다.
그래서 이러한 과정을 자동화하고자 결심하게 되었고, 이후의 히빗 (ver.2)
에서는 단위 테스트
를 적극 활용하기로 도입하게 되었다.
단위 테스트
는 작은 코드 단위를 독립적으로 검증하는 테스트이다. 여기서 작은 코드는 클래스 혹은 메서드를 의미한다.여기서 작은 코드는
클래스
혹은메서드
를 의미한다.
작년 11월부터 12월까지 Practical Testing: 실용적인 테스트 가이드 강의를 듣고 정리한 Practical Testing: 테스트 코드 작성 방법 을 바탕으로 단위 테스트를 작성하였다.
구조적으로 보면, 단위 테스트
는 서비스
와 모델
에 가깝다고 볼 수 있다.
기존 회원
에 대한 엔티티 클래스는 다음과 같다.
Member.class
@Entity
public class Member extends BaseEntity {
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-z0-9._-]+@[a-z]+[.]+[a-z]{2,3}$");
private static final int MAX_DISPLAY_NAME_LENGTH = 20;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(name = "email", nullable = false)
private String email; // 이메일
@Column(name = "display_name", nullable = false)
private String displayName; // 닉네임
@Enumerated(value = EnumType.STRING)
@Column(name = "social_type", nullable = false)
private SocialType socialType; // 소셜 로그인 유형
protected Member() {
}
@Builder
public Member(final String email, final String displayName, final SocialType socialType) {
super();
validateEmail(email);
validateDisplayName(displayName);
this.email = email;
this.displayName = displayName;
this.socialType = socialType;
}
// validate, getter 생략
}
회원
엔티티 클래스 내부에는 이메일
, 닉네임
, 소셜 로그인 유형
에 대한 필드값이 있고, Builder 어노테이션이 적용된 Member 메서드가 있다.
해당 엔티티 클래스에 대해 단위 테스트
를 작성한 코드는 아래와 같다.
MemberTest.class
class MemberTest {
@DisplayName("회원을 생성한다.")
@Test
void 회원을_생성한다() {
// given & when & then
assertDoesNotThrow(() -> new Member(팬시_이메일, 팬시_닉네임, SocialType.GOOGLE));
}
@DisplayName("회원의 email 형식이 맞지 않으면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"fancy.junyongmoon@", "fancy.junyongmoon@amail", "fancy.junyongmoon"})
void 회원의_email_형식이_맞지_않으면_예외가_발생한다(final String email) {
// given & when & then
assertThatThrownBy(() -> new Member(email, 팬시_닉네임, SocialType.GOOGLE))
.isInstanceOf(InvalidMemberException.class);
}
@DisplayName("회원의 닉네임 형식이 빈칸이거나 공백이면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"", " "})
void 회원의_닉네임_형식이_빈칸이거나_공백이면_예외가_발생한다(final String displayName) {
// given & when & then
assertThatThrownBy(() -> new Member(팬시_이메일, displayName, SocialType.GOOGLE))
.isInstanceOf(InvalidMemberException.class);
}
@DisplayName("회원의 닉네임 글자가 20자 초과하면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"일이삼사오육칠팔구십일이삼사오육칠팔구십일", "일이삼사오육칠팔구십일이삼사오육칠팔구십일이삼사오육칠팔구십"})
void 회원의_닉네임_글자가_20자_초과하면_예외가_발생한다(final String displayName) {
// given & when & then
assertThatThrownBy(() -> new Member(팬시_이메일, displayName, SocialType.GOOGLE))
.isInstanceOf(InvalidMemberException.class);
}
}
좋은 단위 테스트란 무엇인지에 대해 나는 아래와 같이 핵심 기준을 두고 작성했다.
테스트 케이스 세분화하기
DisplayName을 섬세하게
BDD 스타일로 작성하기
한 눈에 들어오는 Test Fixture 구성하기 (선택)
위 네 가지 기준에 대해서 어떻게 적용했는지 하나씩 자세히 살펴보고자 한다.
나는 해피 케이스
와 예외 케이스
, 이 두 가지 케이스를 가지고 경계값 테스트를 도출했다.
해피 케이스
- 회원을 생성한다.
예외 케이스
- 회원의 email 형식이 맞지 않으면 예외가 발생한다. / 회원의 닉네임 형식이 빈칸이거나 공백이면 예외가 발생한다. / …
class MemberTest {
@Test
void 회원을_생성한다() { // 내부는 생략
}
@Test
void 회원의_email_형식이_맞지_않으면_예외가_발생한다(final String email) {
}
@Test
void 회원의_닉네임_형식이_빈칸이거나_공백이면_예외가_발생한다(final String displayName) {
}
}
예외 케이스는 필드값이 많아질 수록 더 많아질거라고 생각한다.
Junit5에서 추가된 @DisplayName
이라는 어노테이션을 통해 테스트에 대한 설명을 한글로 작성해서 어떤 테스트를 의미하는지 쉽게 알 수 있다.
나의 경우, 메서드 명을 영어로 작성해도 되지만, 매번 생각해야 하는 번거로움이 있어서 단순하게 @DisPlayName
에 있는 설명과 동일하게 작성했다.
class MemberTest {
@DisplayName("회원을 생성한다.")
@Test
void 회원을_생성한다() {
// given & when & then
assertDoesNotThrow(() -> new Member(팬시_이메일, 팬시_닉네임, SocialType.GOOGLE));
}
@DisplayName("회원의 email 형식이 맞지 않으면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"fancy.junyongmoon@", "fancy.junyongmoon@amail", "fancy.junyongmoon"})
void 회원의_email_형식이_맞지_않으면_예외가_발생한다(final String email) {
// given & when & then
assertThatThrownBy(() -> new Member(email, 팬시_닉네임, SocialType.GOOGLE))
.isInstanceOf(InvalidMemberException.class);
}
}
BDD
(Behavior Driven Development)란
TDD에서 파생된 개발 방법으로 함수 단위의 테스트에 집중하기 보다, 시나리오에 기반한 테스트 케이스(TC) 자체에 집중하여 테스트를 한다.
개발자가 아닌 사람이 봐도 이해할 수 있을 정도의 추상화 수준(레벨)을 권장한다.
Given / When / Then
Given: 시나리오 진행에 필요한 모든 준비 과정(객체, 값, 상태)
When: 시나리오 행동 진행
Then: 시나리오 진행에 대한 결과 명시, 검증(AssertJ)
정리하면, 어떤 환경에서(Given) 어떤 행동을 진행했을 때(When), 어떤 상태 변화가 일어난다(Then)와 같이 3단계를 기반으로 작성하면 DisplayName에 문장을 명확하게 작성할 수 있다.
간혹, 단위 테스트에 대한 리팩터링을 진행하다보면 Given & When & Then을 묶어주는 경우도 있다.
회원
엔티티에 대한 단위 테스트 코드 (MemberTest.class)
class MemberTest {
@DisplayName("회원을 생성한다.")
@Test
void 회원을_생성한다() {
// given & when & then
assertDoesNotThrow(() -> new Member(팬시_이메일, 팬시_닉네임, SocialType.GOOGLE));
}
@DisplayName("회원의 email 형식이 맞지 않으면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"fancy.junyongmoon@", "fancy.junyongmoon@amail", "fancy.junyongmoon"})
void 회원의_email_형식이_맞지_않으면_예외가_발생한다(final String email) {
// given & when & then
assertThatThrownBy(() -> new Member(email, 팬시_닉네임, SocialType.GOOGLE))
.isInstanceOf(InvalidMemberException.class);
}
}
회원 조회에 대한 테스트 코드 (MemberRepositoryTest.class)
@DataJpaTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@DisplayName("이메일을 통해 회원을 찾는다.")
@Test
void 이메일을_통해_회원을_찾는다() {
// given
Member 팬시 = memberRepository.save(팬시());
// when
Member actual = memberRepository.getByEmail(팬시_이메일);
// then
assertThat(actual.getId()).isEqualTo(팬시.getId());
}
}
Given / When / Then 에 대한 과정을 나눠서 정리해보면 아래와 같다.
Given: MemberRepository
의 save
메서드를 사용하여 팬시 회원을 저장한다.
When: getByEmail
메서드를 사용하여 이전에 저장한 팬시 회원의 이메일을 조회한다.
Then: actual
객체의 ID와 이전에 저장한 팬시 회원의 ID가 일치하는지를 검증한다.
Test Fixture
란 Given절을 구성할 때, 테스트를 위해 원하는 상태로 고정시킨 일련의 객체를 의미한다.
Fixture 구성할 때 유의해야 할 점
미리 셋업하는 data.sql과 같은 파일은 되도록 지양하자!
given 절을 구성할 때 필요한 파라미터만 구성하자!
나는 위의 2가지 유의점을 지키면서 회원
엔티티에 대한 단위 테스트 코드를 작성할 때, 팬시_이메일
, 팬시_닉네임
과 같은 Test Fixture를 사용했다.
해당 값들은 해당 클래스 이외에도 여러 클래스에도 반복적으로 사용하기 때문에 MemberFixtures
클래스로부터 import해서 가져왔다.
이러한 Test Fixture를 사용하면 여러 테스트에서 동일한 데이터를 사용할 때 중복을 방지하고, 데이터 변경이 필요한 경우 한 곳에서 수정할 수 있는 이점이 있다.
또한 Test Fixture을 적용한 테스트 코드는 더 간결하고 일관성 있게 작성될 수 있어서 테스트 코드를 효율적으로 관리할 수 있다.
단위 테스트 코드를 작성하면 다음과 같은 장점을 얻을 수 있다.
단위 테스트를 통해 코드의 각 부분이 예상대로 작동하는지 시나리오를 작성하고 확인함으로써 버그를 조기에 감지할 수 있다.
또한 코드를 리팩터링할 때에도 단위 테스트가 있다면 변경된 코드가 여전히 예상대로 동작하는지 확인할 수 있어서 안전한 리팩터링을 지원해준다.
그리고 코드의 동작 방식을 문서화하는 역할을 가지고 있어 문서라고 표현할 수 있다. 이는 과거에 했던 고민의 결과물을 새로운 개발자나 유지보수를 위해 활용될 수 있다.
이처럼 단위 테스트를 통해 소프트웨어 개발 및 유지보수의 효율성을 높일 수 있으며 코드의 신뢰성과 안정성을 확보하는데 기여를 준다.
이번 글에서는 왜 테스트 코드를 도입하게 되었고, 단위 테스트는 무엇인지, 그리고 실제 프로젝트를 진행하면서 회원
엔티티에 대한 단위 테스트를 예시로 가져와서 설명했다.
나는 단위 테스트를 작성할 때 네 가지 기준을 적용했지만, 이 외에도 다양한 기법들이 존재할 거라 생각한다.
우선은 이 네 가지를 기준으로 삼아 단위 테스트를 작성해보고, 이후에 추가적인 기준이 있다면, 적용해보고 괜찮다 싶으면 해당 포스팅을 업로드할 예정이다.
‘Practical Testing: 실용적인 테스트 가이드’ 강의를 들으면서 기억에 남는 말이 있는데,
가까이 보면 느리지만, 멀리 보면 가장 빠르다
이 말은 테스트 작성은 초기에는 시간이 오래 걸리고 귀찮은 작업일 수 있지만, 시간이 흐를수록 서비스가 고도화되면서 테스트 코드가 내 미래의 시간을 절약해줄 수 있다는 것을 의미한다고 느껴졌다.
다음 글에서는 통합 테스트
에 대해 살펴보고, 실제 예시를 통해 어떻게 구성했는지 기억과 경험을 기록해보자. ✍🏻
이 글은 스프링 DB 1편 - 데이터 접근 핵심 원리 강의를 듣고 정리한 내용입니다.
Spring의 Transaction에 대한 심층적인 이해를 위해 “Spring의 Transaction 정리 (@Transactional)”이라는 주제로 글을 작성하려고 한다.
이전에는 데이터베이스 교재를 통해 트랜잭션과 트랜잭션 격리 수준을 공부하며 관련 개념을 정리했다.
그러나 팀 프로젝트에서는 @Transactional
어노테이션을 사용하면서도 실제로 어떻게 트랜잭션을 관리해야 하는지 제대로 알지 못했다..
이에 따라서 이번 기회에 Spring의 Transaction에 대해서, 그리고 @Transactional
어노테이션이 왜 도입하게 되었고, 어떻게 사용하고 있는지 알아가보자.
트랜잭션을 이름 그대로 번역하면 거래
라는 의미이다. 데이터베이스에서 트랜잭션
은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻한다.
조금 더 구체적으로 말하면, 트랜잭션
은 작업 하나를 수행하는 데 필요한 데이터베이스의 연사들을 모아놓은 것으로, 데이터베이스에서 논리적인 작업의 단위가 된다.
그리고 데이터베이스에 장애가 발생했을 때 데이터를 복구하는 작업의 단위도 된다.
Spring의 Transaction
을 알기 위한 사전 준비트랜잭션에 관한 예시와 개념, 그리고 트랙잭션의 특성에 대해 자세히 알고 싶다면, 이전에 작성한 트랜잭션을 참고하자. 트랜잭션 격리 수준에 대해서도 자세히 알고 싶다면, 이전에 작성한 트랜잭션 격리 수준을 참고하자.
추가적으로, Spring의 트랜잭션을 이해하기 위해서는 Connection pool과 DataSource에 대한 이해가 필요한데, 이에 대한 내용은 이전에 작성한 커넥션 풀과 데이터소스 이해을 참고하자.
이 글에서는 일반적으로 많이 사용하는
READ COMMITTED
(커밋된 읽기) 트랜잭션 격리 수준을 기준으로 작성했다.
트랜잭션을 더 자세히 이해하기 위해 데이터베이스 서버 연결 구조와 DB 세션에 대해 알아보자.
사용자는 웹 애플리케이션 서버
(WAS)나 DB 접근 툴
같은 클라이언트를 사용해 데이터베이스 서버에 접근할 수 있다.
이때 데이터베이스 서버
는 내부에 세션
이라는 것을 만들고 앞으로 해당 커넥션을 통한 모든 요청을 해당 세션을 통해 실행한다.
쉽게 말해, 개발자가 클라이언트를 통해 SQL를 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행한다.
세션
은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
세션을 종료하는 방법은 사용자가 커넥션을 닫거나, 또는 DBA가 세션을 강제로 종료하면 된다.
(참고로 커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다)
스프링을 이용한 트랜잭션을 처리하기 이전에, 애플리케이션 구조
에 대해 간단하게 짚고 넘어가자.
가장 단순하면서 많이 사용하는 방법은 역할에 따라 3가지 계층으로 나누는 거이다.
프리젠테이션 계층
은 UI와 관련된 처리를 담당한다. 그외 다른 역할들은 다음과 같다.
웹 요청과 응답
사용자 요청을 검증
주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC
서비스 계층
은 비즈니스 로직을 담당한다.
데이터 접근 계층
은 실제 데이터베이스에 접근하는 코드를 담당한다.
데이터를 저장하고 관리하는 기술 담당
주 사용 기술: JDBC, JPA, File, Redis, …
여기서 가장 중요한 곳은 핵심 비즈니스 로직이 들어있는 서비스 계층
이다.
시간이 흘러도 비즈니스 로직은 최대한 변경없이 유지되어야한다.
이렇게 계층을 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다.
JDBC, JPA와 같은 구체적인 데이터 접근 기술로부터 서비스 계층
을 보호해주는데, 예를 들어 JDBC에서 JPA로 변경해도 서비스 계층은 변경하지 않아도 된다.
JdbcRepository
대신 JpaRepository
인터페이스로 변경하면 되기 때문이다. (인터페이스에 의존하는 것이 좋다)그래서 서비스 계층은 특정 기술에 종속되지 않기 때문에 비즈니스 로직을 유지보수 하기도 쉽고, 테스트하기도 쉽다.
정리하면, 서비스 계층은 가급적 비즈니스 로직만 구현하고, 특정 구현 기술에 직접 의존해서는 안된다. 이렇게 하면 향후 구현 기술이 변경될 때 변경의 영향 범위를 최소화할 수 있다.
데이터베이스에는 여러 접근 기술이 존재한다.
만약 JDBC에 의존하는 코드를 작성하다가 JPA로 전환하고자 한다면, 기존 코드를 전부 고쳐야 하는 문제가 발생한다.
하지만 JDBC, JPA 모두 논리적인 로직에 대한 과정(트랜잭션 시작 -> 비즈니스 로직 수행(성공 시 커밋, 실패시 롤백) -> 트랜잭션 종료)은 같다.
이 문제를 해결하려면 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다.
스프링 트랜잭션 추상화의 핵심인 PlatformTransactionManager
인터페이스는 트랜잭션 매니저라고 부르는데 트랜잭션 시작, 종료, 커밋, 롤백에 대한 내용이 있고, 이에 대한 각 접근 기술인 구현체를 제공한다.
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
스프링이 제공하는 트랜잭션 매니저
는 크게 2가지 역할을 한다.
트랜잭션 추상화 -> 앞에서 설명했으니 생략
리소스 동기화 : 트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다.
보통 코드를 작성하면 서비스 계층에서 트랜잭션이 시작하고 로직이 끝나면 트랜잭션이 종료된다.
즉, 하나의 서비스 로직에서 리포지토리 계층(데이터 접근 계층)에 접근하는 로직이 여러 개 있다고, 여러 개의 트랜잭션을 사용하는 것이 아니라 같은 트랜잭션을 사용한다.
이를 위해 스프링은 쓰레드 로컬
(ThreadLocal)을 사용해 커넥션을 동기화해주는 트랜잭션 동기화 매니저
을 제공한다.
쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도로 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근할 수 있다.
(쓰레드 로컬에 대한 자세한 내용은 스프링 핵심 원리 - 고급편 강의을 참고하자.(정말 끝도 없구나..)
트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저
를 사용한다. 내부 동작 방식은 아래와 같다.
클라이언트의 요청으로 서비스 로직을 실행한다.
[1] 서비스 계층에서 transactionManager.getTransaction()
을 호출해서 트랜잭션을 시작한다.
[2] 트랜잭션이 시작하려면 머저 데이터베이스 커넥션이 필요하기 때문에 트랜잭션 매니저
는 내부에서 데이터소스를 사용해 커넥션을 생성한다.
[3] 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
[4] 커넥션을 트랜잭션 동기화 매니저
에 보관한다.
[5] 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. (멀티 쓰레드 환경에서는 안전하게 커넥션을 보관할 수 있다)
[6] 서비스 계층은 비즈니스 로직을 실행하면서 리포지토리 메서들을 호출한다.
[7] 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하므로 DataSourceUtils.getConnection()
을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정에서 같은 커넥션을 사용하므로 트랜잭션도 유지하게 된다.
[8] 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.
[9] 비즈니스 로직이 끝나고 트랜잭션 종료를 요청한다.
[10] 트랜잭션을 종료하려면 동기화된 커넥션이 필요한데, 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
[11] 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
[12] 전체 리소스를 정리한다.
트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용후 꼭 정리해야 한다.
con.setAutoCommit(true)
로 되돌린다. 커넥션 풀을 고려해야 한다.
con.close()
를 호출해셔 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close()
를 호출하면 커넥션 풀에 반환된다.
트랜잭션 AOP를 이해하기 전에, 위에 설명한 트랜잭션 매니저를 사용하면 작성하게 되는 코드를 살펴보자.
@Transactional 적용 전
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
// 커넥션 release는 커밋되거나 롤백되면 알아서 transactionManager가 처리한다.
}
}
코드를 확인해보면 트랜잭션을 처리하는 객체
와 비즈니스 로직을 처리하는 서비스 객체
가 섞여있는 것을 확인할 수 있다.
이는 가독성을 떨어뜨리며 유지 보수도 여려워지게 만든다.
스프링에서 프록시
를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
// 프록시 객체
public class TransactionProxy {
private MemberService target;
public void logic() {
TransactionStatus status = transactionManager.getTransaction(..); //트랜잭션 시작
try {
target.logic(); //실제 대상 호출
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백 throw new IllegalStateException(e);
}
}
}
프록시 객체
는 트랜잭션을 시작한 후에 실제 서비스 로직을 대신 호출한다.
이러한 작업으로 인해 서비스 계층
에서는 트랜잭션 관련 코드를 명시적으로 작성하지 않아도 되며, 결과적으로 서비스 계층은 순수한 비즈니스 로직에만 집중할 수 있게 된다.
트랜잭션 프록시를 활용하기 위해 스프링에서 제공하는 @Transactional
어노테이션을 사용하면 해당 기능을 자동으로 처리할 수 있다.
@Transactional
어노테이션을 적용한 코드는 아래와 같다.
@Transactional 적용 후
/**
* 트랜잭션 - @Transactional AOP
*/
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional // 트랜잭션 AOP 기능
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
// ... 생략
}
이러한 구현을 통해 트랜잭션 처리에 대한 부분을 간소화하고, 비즈니스 로직에 집중할 수 있게 된다.
@Transactional
애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다.
클래스에 붙이면 외부에서 호출 가능한 public
메서드가 AOP 적용 대상이 된다.
스프링에서는 보통 클래스
단위로 프록시 객체를 생성한다.
예를 들어 Service 클래스에 @Transactional
이 붙은 메서드가 여러 개 있더라도,
프록시 객체는 한 개만 만들어지고, 해당 프록시 객체 안에서 트랜잭션이 필요한 메서드들을 관리한다.
따라서 Service 클래스에 @Transactional
이 붙은 메서드가 2개, 일반 메서드 1개가 있을 경우에도 프록시 객체는 한 개만 생성되며, 트랜잭션이 필요한 메서드에서만 트랜잭션 관련 프록시 동작이 수행된다.
트랜잭션 AOP가 사용된 전체 흐름을 그림과 글로 다시 한번 정리해보자.
클라이언트로부터 API 요청이 들어오면 프록시
가 호출된다.
스프링 컨테이너를 통해 트랜잭션 매니저를 획득한다.
DataSourceUtils.getConnection()
을 호출해 트랜잭션을 시작한다.
데이터소스를 통해 커넥션을 생성한다.
만든 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스를 시작한다.
커넥션을 트랜잭션 동기화 매니저에 보관한다.
보관된 커넥션은 스레드 로컬에서 멀티 스레드에 안전하게 보관된다.
실제 서비스 로직을 호출한다.
리포지토리의 데이터 접근 로직에서는 트랜잭션 동기화 매니저에서 커넥션을 획득한다.
트랜잭션 처리 로직(AOP 프록시)으로 돌아와 성공이면 커밋하고, 예외(실패)시 롤백을 수행하며 트랜잭션을 종료한다.
AOP 프록시 적용 확인 - MemberServiceV3_3Test
다음 테스트 코드를 통해 MemberService
클래스에 프록시가 적용된 걸 확인할 수 있다.
2024-01-16 20:18:27.624 INFO 94861 --- [main] h.jdbc.service.MemberServiceV3_3Test : memberService class=class hello.jdbc.service.MemberServiceV3_3$$EnhancerBySpringCGLIB$$28863787
2024-01-16 20:18:27.624 INFO 94861 --- [main] h.jdbc.service.MemberServiceV3_3Test : memberRepository class=class hello.jdbc.repository.MemberRepositoryV3
선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리
선언적 트랜잭션(Declarative Transaction Management)
ex) @Transactional
과거에는 XML에 설정하기도 했다.
프로그래밍 방식에 비해 훨씬 간편하고 실용적이라 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.
프로그래밍 방식의 트랜잭션 관리(programmatic transaction management)
ex) TransactionManager, TransactionTemplate
트랜잭션 관련 코드를 직접 작성하는 것을 말한다.
스프링 트랜잭션에 대한 이해를 위해서는 트랜잭션 추상화, 트랜잭션 매니저 등 다양한 개념을 알아야 한다. (사실 여기서부터 다양한 개념들을 이해하느라 머리가 아팠다..🥲)
처음에는 김영한님의 Spring DB 1편 강의를 듣고 실습해도 완벽한 이해가 어려워, 여러 차례 복습하면서 머릿속에 개념과 구조를 조금씩 잡아나갔다.
@Transactional
이 어떻게 동작하는지와 그 용도를 대강은 이해했다고 느끼고 있지만, 아직 완벽하게 흡수되지 않았기 때문에 종종 복습하고, 프로젝트에도 적용해보면서 서서히 이해의 폭을 넓혀가자!
이 글은 스프링 DB 1편 - 데이터 접근 핵심 원리 강의를 듣고 정리한 내용입니다.
커넥션 풀을 배우기 전에, 먼저 기존 데이터베이스에서 커넥션을 획득하는 과정에 대해 알아보자.
데이터베이스 커넥션을 획득할 때는 다음과 같은 복잡한 과정을 거친다.
DB 드라이버
를 통해 커넥션을 조회한다.TCP/IP
커넥션을 연결한다. 이 과정에서 3 way handshake와 같은 TCP/IP
연결을 위한 네트워크 동작이 발생한다.
TCP/IP
커넥션이 연결되면 ID, PW와 같은 부가정보를 DB에 전달한다.이렇게 커넥션을 새로 만드는 것에 대한 과정이 복잡하고 시간도 많이 소모된다.
DB는 물론이고, 애플리케이션 서버에서도 TCP/IP
커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 한다.
이와 같이 데이터베이스 커넥션을 획득하는 과정에서 시간이 많이 소요하기 때문에, 사용자에게 좋지 않는 경험을 줄 수 있다.
그래서 이러한 문제를 해결하기 위해 커넥션을 미리 생성해두고 사용하는 커넥션 풀
이라는 방법이 나오게 되었다.
커넥션 풀
이란 이름 그대로 커넥션을 관리하는 풀(수영장 풀을 상상하면 된다)이다.애플리케이션이 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다.
(기본 값은 보통 10개지만, 서비스의 특서오가 서버 스펙에 따라 다르다)
커넥션 풀에 들어있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다.
애플리케이션 로직에서는 이제 DB 드라이버를 통해 새로운 커넥션을 획득하는 것이 아니라 커넥션 풀
을 통해 이미 생성되어 있는 커넥션을 객체 참조로 그냥 가져다 쓰면 된다.
커넥션 풀에 커넥션을 요청하면 커넥션 풀은 자신이 가지고 있는 커넥션 중 하나를 반환한다.
커넥션을 모두 사용하고 나면 커넥션을 종료하는 것이 아니라 다음에 다시 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환해야 한다.
커넥션 풀은 서버당 최대 커넥션 수를 제한할 수 있어서 DB에 무한정 연결이 생성되는 것을 막아주어 DB를 보호하는 이점이 있다. (실무에서 항상 기본으로 사용한다)
커넥션 풀은 사용도 편리하고 성능도 뛰어난 오픈소스 커넥션 풀이 많지만, 대표적으로 사용하는 오픈소스 커넥션 풀은 HikariCP
가 있다.
HikariCP
는 성능과 사용의 편리함 측면에서 사용하며, 스프링 부트 2.0 부터는 기본 커넥션 풀이 HikariCP
를 제공하고 있다.
커넥션을 얻는 방법은 앞서 학습한 JDBC DriverManager
를 직접 사용하거나, 커넥션 풀을 사용하는 등 다양한 방법이 존재한다.
하지만 앞서 JDBC로 개발한 애플리케이션처럼 DriverManager
를 통해 커넥션 획득하다가 HikariCP
같은 커넥 션 풀을 사용하도록 변경하면 커넥션을 획득하는 애플리케이션 코드도 함께 변경해야 하는 문제가 생긴다.
의존관계가 DriverManager
에서 HikariCP
로 변경되기 때문이다.
그래서 이런 문제를 해결하기 위해 DataSource
가 등장하게 되었다.
DataSource
는 커넥션을 획득하는 방법을 추상화하는 인터페이스로, 자바에서는 javax.sql.DataSource
라는 인터페이스를 제공한다.
대부분의 커넥션 풀은 DataSource
인터페이스를 이미 구현해두어서, DataSource
인터페이스에만 의존하도록 애플리케이션 로직을 작성하면 된다.
커넥션 풀 구현 기술을 변경하고 싶으면 해당 구현체로 갈아끼우면 된다.
예외적으로 DriverManager
는 DataSource
인터페이스를 사용하지 않는다.
이 문제를 해결하기 위해 스프링은 DriverManager
도 DataSource
를 통해 사용할 수 있도록 DriverManagerDataSource
라는 DataSource
를 구현한 클래스를 제공한다.
정리하면, 커넥션을 생성하고 가져오는 방식에서는 DriverManager
과 여러 가지 오픈소스 커넥션 풀이 있는데
코드 측면에서는 다를 수 있어도 논리적인, 기능적인 측면에서 보면 커넥션을 생성하고 가져오는 일을 하기 때문에
이 기능을 DataSource
로 추상화한 것이다.
따라서 코드에서 추상화한 DataSource
인터페이스에 의존하도록 작성하고 기술을 교체해야 하는 일이 생기면 구현체만 교체하면 된다.
기존에 개발했던 DriverManager와
와 DataSource
의 구현체인 DriverManagerDataSource
를 통해 커넥션을 획득해보자.
@Slf4j
public class ConnectionTest {
public static final int DEFAULT_SIZE = 10;
@Test
void driverManager() throws SQLException {
// DriverManager 사용
// 커넥션을 획득할 때마다 설정 정보를 인자에 넘겨야 한다.
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 {
// DataSource를 구현한 DriverMangerDataSource 사용 - 항상 새로운 커넥션을 획득
// 초기 세팅에만 설정값을 넘긴다.
DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);// 스프링에서 제공
useDateSource(dataSource);
}
private void useDateSource(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());
}
}
DriverManager
DriverMangerDataSource
내부적으로 DriverManger를 사용하지만, DataSource의 구현체이다.
DriverManager에서 사용하는 방식에서 설정
과 사용
을 분리했다.
설정
은 초기에 한 번만 입력하고, 이후 사용
하는 곳에서는 getConnection
만 호출한다.
설정과 사용을 분리함으로써 향후 변경에 더 유연하게 대처할 수 있다. -> 애플리케이션을 개발해보면 보통 설정
은 한 곳에서 사용하지만 사용
은 수 많은 곳에서 하게 된다.
이번에는 DataSource
의 구현체인 HikariDataSource
을 통해 커넥션 풀을 사용해서 커넥션을 획득해보자.
@Slf4j
public class ConnectionTest {
public static final int DEFAULT_SIZE = 10;
@Test
void dataSourceConnectionPool() throws SQLException, InterruptedException {
// 커넥션 풀링: HikariProxyConnection(Proxy) -> JdbcConnection(Target)
HikariDataSource dataSource = new HikariDataSource(); // 스프링에서 jdbc를 사용하면 자동으로 import 됨
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(DEFAULT_SIZE); // 기본 사이즈 10
dataSource.setPoolName("MyPool");
useDateSource(dataSource);
Thread.sleep(1000); // 1초
}
private void useDateSource(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());
}
}
// 커넥션 풀에서 커넥션 획득 2개 - conn0, conn1
// [Test worker] INFO hello.jdbc.connection.ConnectionTest - connection=HikariProxyConnection@1489193907 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection
// [Test worker] INFO hello.jdbc.connection.ConnectionTest - connection=HikariProxyConnection@1453606810 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class=class com.zaxxer.hikari.pool.HikariProxyConnection
// 마지막 로그 출력
// 총 10개, 사용중인 커넥션: 2개 / 풀에서 대기 상태인 커넥션: 8개 / 기다리는 커넥션: 0개
// 14:58:55.280 [MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - MyPool - After adding stats (total=10, active=2, idle=8, waiting=0)
커넥션 풀에서 커넥션을 생성하는 작업은 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 작동한다.
그래서 위와 같이 Thread.sleep(1000);
을 통해 대기 시간을 주어야 쓰레드 풀에 커넥션이 생성되는 로그를 확인할 수 있다.
(HikariCP 커넥션 풀에 대한 더 자세한 내용은 다음 공식 사이트를 참고하자 - HikariCP)
이 글은 스프링 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
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 부분은 김영한의 스프링 완전 정복에 있는 강의를 혼자서 들으면서, 관련 내용을 블로그에 정리했다.