성능 테스트
는 특정 상황에서 시스템의 구성 요소가 어떻게 수행되는지 확인하기 위해 수행되는 테스트이다.
또한 이 테스트를 통해 제품의 리소스 사용량, 확장성 및 신뢰성을 확인할 수 있다.
이 테스트는 소프트웨어 제품의 설계 및 아키텍처에서 성능 문제를 해결하는 데 중점을 둔 성능 엔지니어링의 하위 집합이다.
위의 이미지는 성능 테스트가 부하 및 스트레스 테스트 모두에 대한 상위 집합이라는 것을 명확하게 설명한다. 성능 테스트에 포함된 다른 유형의 테스트는 스파이크 테스트, 볼륨 테스트, 내구성 테스트 및 확장성 테스트가 있다. 따라서 성능 테스트는 기본적으로 광범위한 용어이다.
성능 테스트의 주요 목표
에는 시스템의 벤치마크 동작을 설정하는 것이 포함된다.
성능 테스트는 애플리케이션의 결함을 찾는 것을 목표로 하지 않고, 애플리케이션의 벤치마크 및 표준을 설정하는 중요한 작업을 처리한다.
그래서 실제 트래픽 상황에서 정상적으로 동작되는지, 애플리케이션/시스템 성능에 대한 모니터링이 성능 테스트의 주요 특성을 의미한다.
성능 테스트는 속도(speed), 응답 시간(response time), 처리량(throughput), 자원 사용량(resource usage), 안정성(stability) 등의 속성을 기준으로 애플리케이션의 벤치마크와 표준을 설정해야 하고, 이 모든 속성을 테스트한다.
성능 테스트는 다음과 같은 상황에서 주로 사용된다.
실제 트래픽 상황에서의 정상 동작
기존 시스템 대비 BenchMarking
성능 테스트는 가용성
과 서비스에서 설정한 목표치를 달성하기 위해서이다.
가용성
이란 시스템이 서비스를 정상적으로 제공할 수 있는 상태를 말한다.
성능 테스트를 확인하기 위해 대표적으로 확인해야할 지표는 처리량
(throughput), 응답 시간
(response time)이다.
초당 1000건의 요청 처리 - 처리량(throughput)
모든 조회 요청을 1초 이내로 응답 - 응답 시간(response time)
처리량
은 초당 처리하는 작업의 수를 의미한다. 일반적으로 RPS
(Request Per Second)라고 많이 부르는데, 이는 1초에 처리하는 HTTP 요청의 수를 의미한다.
처리량은 서브 시스템중 가장 처리량이 낮은 부분으로 계산한다.
예를 들어, 클라이언트 <-> Nginx 가 500rps, Nginx <-> Tomcat 이 200 rps, Tomcat <-> MySQL 이 100 rps가 있다고 가정했을 때
Tomcat <-> MySQL 이 해당한다고 보면 된다. 이는 병목 구간
으로 말한다. (병목 구간
은 하위 시스템 중 가장 낮은 처리량을 가지는 부분이다)
응답 시간
은 시스템이 요청을 받고 응답할 때까지의 시간을 의미한다.
시스템이 요청을 처리할 때 까지 대기하는 시간도 응답 시간에 포함된다.
응답 시간은 각 서브 시스템 응답 시간의 총합으로 계산한다.
예를 들어, 클라이언트 <-> Nginx 가 500rps, Nginx <-> Tomcat 이 200 rps, Tomcat <-> MySQL 이 100 rps가 있다고 가정했을 때
응답 시간은 750ms (150ms + 500ms + 100ms)으로 어떤 부분을 개선하더라도 총 시스템 응답 시간에 영향을 주게 된다.
처리량과 응답 시간을 정리하면 다음과 같다.
처리량: 초당 처리하는 작업의 수
응답 시간: 시스템의 요청 처리 시간
둘 지표간에는 상관관계를 가지고 있다.
성능 테스트에는 다양한 테스트가 있는데, 그 중에서 부하 테스트와 스트레스 테스트를 알아보고자 한다.
부하 테스트
는 성능 테스트의 하위 집합으로, 임계값에 도달할 때까지 시스템의 부하를 지속적으로 꾸준히 증가시켜 시스템을 테스트하는 것을 의미한다.
부하 테스트의 유일한 목적
은 시스템이 감당할 수 있는 가장 큰 작업을 할당하여 시스템의 내구성을 시험하고 결과를 모니터링하는 것이다.
부하 테스트에서 모니터링되는 속성에는 최대 성능, 서버 처리량, 다양한 부하 레벨에서 응답 시간, H/W 환경의 적정성, 성능에 영향을 주지 않고 처리할 수 있는 사용자 애플리케이션 수 등이 포함된다.
부하 테스트의 목표
는 애플리케이션과 데이터베이스 등의 최대 성능을 파악하는 것이다.
부하 테스트는 다음과 상황에서 사용된다.
리소스 병목 탐색, 애플리케이션
이벤트 상황과 같은 순간 트래픽 최대치, 한계치를 탐색
신규 스펙 장비에서 MYSQL 설정 최적화 탐색
스트레스 테스트
에서는 기존 자원에 초과 작업으로 과부하를 주는 다양한 활동을 수행하며 시스템을 무너졌을 때 어떤 동작을 보이는지 확인하는 테스트이다.
스트레스 테스트의 목적
은 시스템의 고장을 확인하고 시스템이 어떻게 정상적으로 복구되는지를 모니터링 하는 것이다.
따라서 스트레스 테스트는 최대 부하 및 정상 조건을 넘어서는 애플리케이션의 동작을 평가한다. 이러한 스트레스 테스트는 피로 테스트라고도 불린다.
스트레스 테스트의 목표
는 시스템이 망가진 후에 애플리케이션이 어떻게 동작을 정의하는 것이다.
가장 중요한 점은 시스템이 장애 발생 후 중요한 데이터를 손상시키지 않도록 하는 것이다.
스트레스 테스트를 통해 가장 이상적인 시스템은 극도의 부하가 발생한 이후에도 모든 구성 요소와 함께 정상 상태로 복귀하는 것이다.
스트레스 테스트는 다음과 같은 상황에서 사용된다
장기간 부하 발생에 대한 한계치를 탐색, 예외 동작 상황 확인
데이터베이스 failover 상황, 자동 복구, 예외 동작 상황 확인
외부 요인(PG사)의 예외 상황 동작 확인
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
예상 독자: Spring Boot 기반 테스트 코드를 작성하는 개발자
이 글은 히빗 프로젝트(ver.2)에 Jacoco
를 설정한 과정에 대해 공유하고자 합니다.
아래 글부터는 히빗 프로젝트(ver.2) 를 히빗2
로 줄여서 작성했습니다.
히빗 프로젝트(ver.1)에서는 테스트 코드가 거의 없어서 QA 작업때 정말 많은 시간을 할애했다. (QA 작업에 대한 내용과 테스트 코드를 도입하게된 배경은 좋은 단위 테스트란? (feat. 히빗) 글에서 확인할 수 있습니다.)
그래서 이번에 히빗2 에서는 테스트 코드를 도입하게 되면서, 테스트 커버리지도 신경써야 한다고 생각했다.
(여기서 말하는 테스트 커버리지
란 시스템 및 소프트웨어 대해 충분한 테스트가 되었는지를 나태내는 정도를 말한다.)
토스 유튜브 채널에서 토스뱅크 이응준님이 발표하신 SLASH21 - 테스트 커버리지 100% 영상을 우연히 보게 되었다.
이응준님은 “클린 코더” 책을 읽다가 ‘테스트 커러비지 100%를 강력히 요구한다’ 는 글을 보고서 실제로 적용해보셨다고 한다. 토스 홈 리뉴얼 프로젝트에 2개월 동안 9천 라인의 테스트 코드를 작성하여 달성하였고, 커버리지 100%가 아니면 배포가 안되게 설정하셨다고 한다.
이응준님이 발표하신 내용에 따르면 높은 테스트 커버리지에는 다음과 같은 이점이 존재한다고 하셨다.
자신있게 누를 수 있는 배포 버튼
거침없는 리팩터링을 할 수 있음
전체 코드의 10% 이상을 수정하는 리팩터링을 master 브랜치에 걱정없이 merge하여 괜찮은 수준의 코드 가독성을 유지할 수 있음
불필요한 프로덕션 코드가 사라짐
시간이 갈수록 점점 쉬워지는 테스트 작성 (이미 작성한 테스트를 참조해서 새 테스트를 작성할 수 있음)
테스트가 없으면 리팩터링을 할 수 없고, 리팩터링을 하지 않는 코드는 이해할 수 없게 되며, 이러한 코드는 수정할 수 없다는 확신이 없다는 말씀을 들으면서 많은 공감이 되었다.
이로 인해 테스트의 중요성을 새롭게 깨달았고, 이번 프로젝트에서는 코드 커버리지
를 적극적으로 도입하기로 결정하게 되었다.
코드 커버리지
란 소프트웨어의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표 중 하나이다. 테스트를 진행하였을 때 ‘코드 자체가 얼마나 실행되었느냐’ 는 것이고, 이는 수치를 통해 확인할 수 있다.
Jacoco 는 Java 코드의 커버리지를 체크하는 라이브러리이다. 테스트 코드를 돌리고 그 커버리지 결과를 눈으로 보기 좋도록 html이나 xml, csv와 같은 리포트로 생성한다.
그리고 테스트 결과가 내가 설정한 커버리지 기준을 만족하는지 확인하는 기능도 있다.
여기서는 Java 코드가 섞인 Gradle 기준의 히빗2에 Jacoco
를 도입하여 코드 커버리지를 분석하고, 코드 커버리지가 80% 미만일 경우에 빌드가 실패되도록 설정했다.
아래 build.gradle
파일에 jacoco 플러그인을 가져오고 버전을 설정한다.
plugins {
// ...
id 'jacoco'
}
jacoco {
// JaCoCo 버전
toolVersion = '0.8.7'
}
jacoco에 플러그인을 불러왔다면, 프로젝트의 테스트 코드를 한번 실행시켜보면 아래와 같이 test.exec
파일이 생성된 것을 확인할 수 있다.
해당 파일은 jacoco가 테스트 코드를 실행하고, 코드 커버리지를 분석하여 만들어준 보고서 파일이다.
jacoco 폴더 안에 indext.html, index.csv, index.xml 파일은 밑에서 설명할
jacocoTestReport
을 이미 실행했기 때문에 생긴 파일이다.
Jacoco Gradle 플러그인에는 jacocoTestReport
와 jacocoTestCoverageVerification
task가 있다.
jacocoTestReport
: 바이너리 커버리지 결과를 사람이 읽기 좋은 형태의 리포트로 저장한다.
html 파일로 생성해 사람이 쉽게 눈으로 확인할 수 있고, SonarQube 등으로 연동하기 위해 html, csv, xml 같은 형태로도 리포트를 생성할 수 있다.
Jacoco는 위에 생성한 바이너리 커버리지(test.exec
) 파일을 사람이 읽을 수 있는 html, csv, xml 파일로 생성하는 기능을 제공한다.
jacocoTestReport {
reports {
// 원하는 리포트를 켜고 끌 수 있습니다.
html.enabled true
xml.enabled true
csv.enabled true
// 각 리포트 타입마다 리포트 저장 경로를 설정할 수 있습니다.
html.destination file("${buildDir}/jacoco/index.html")
xml.destination file("${buildDir}/jacoco/index.xml")
csv.destination file("${buildDir}/jacoco/index.csv")
}
}
위에 있는
${buildDir}
는 디렉터리 경로를 의미한다.
위에 이미 실행되어 test.exec
파일이 생성되었다고 가정하고 아래 명령어를 통해 jacocoTestReport
태스크를 실행하면 html, csv, xml 파일이 생기게 된다.
build.gradle -> jacoco에서 index.html
폴더와 index.csv
, index.xml
파일이 생성된 것을 확인할 수 있다.
html
파일은 index.html
폴더 내부에 존재한다.
jacocoTestReport
에 대한 자세한 내용은 JacocoReport 참고하자
jacocoTestCoverageVerification
: 내가 원하는 커버리지 기준
을 만족하는지 확인해주는 task이다.
예를 들어, 브랜치 커버리지를 최소한 80% 이상으로 유지하고 싶다면, 이 task에 설정하면 된다. test
task 처럼 Gradle 빌드의 성공/실패로 결과를 보여준다.
아래는 Hibit2에서 설정한 jacocoTestCoverageVerification
이다.
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
element = 'CLASS'
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.80
}
}
rule {
// 규칙을 여러개 추가할 수 있다.
}
}
}
enabled : 규칙의 활성화 여부를 나타낸다. 기본값은 true
이다.
element : 커버리지를 체크할 단위를 설정한다. 아래와 같은 옵션이 있다. (Default값은 BUNDLE
)
BUNDLE
: 패키지 번들(프로젝트 모든 파일을 합친 것)
CLASS
: 클래스
GROUP
: 논리적 번들 그룹
METHOD
: 메서드
PACKAGE
: 패키지
SOURCEFILE
: 소스 파일
counter : 코드 커버리지를 측정할 때 사용되는 지표이다. (Default값은 INSTRUCTION
)
LINE
: 빈 줄을 제외한 실제 코드의 라인 수, 라인이 한 번이라도 실행되면 실행된 것으로 간주
BRANCH
: 조건문 등의 분기 수
CLASS
: 클래스 수, 내부 메서드가 한 번이라도 실행된다면 실행된 것으로 간주
METHOD
: 메서드 수, 메서드가 한 번이라도 실행된다면 실행된 것으로 간주
COMPLEXITY
: 복잡도
INSTRUCTION
: Java 바이트코드 명령 수
value : limit 메서드를 통해 지정할 수 있으며 측정한 커버리지를 어떠한 방식으로 보여줄 것인지를 말한다. (Default 값은 COVEREDRATIO
)
COVEREDRATIO
: 커버된 비율, 0부터 1사이의 숫자로 1이 100%
COVEREDCOUNT
: 커버된 개수
MISSEDCOUNT
: 커버되지 않은 개수
MISSEDRATIO
: 커버되지 않은 비율, 0부터 1사이의 숫자로 1이 100%
TOTALCOUNT
: 전체 개수
minimum : limit 메서드를 통해 지정할 수 있으며 counter 값을 value 에 맞게 표현했을 때 최솟값을 말한다.
이 값을 통해 jacocoTestCoverageVerification
의 성공 여부가 결정된다. (Default 값이 존재하지 않는다)
jacocoTestReport
에 대한 자세한 내용은 JacocoCoverageVerification 참고하자
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
이전에 작성한 좋은 단위 테스트란? (feat. 히빗) 글에 이어서 이번에는 통합 테스트와 슬라이드 테스트에 대한 글을 작성했다.
결론부터 말하면 나는 통합 테스트와 슬라이드 테스트를 각각의 레이어에 맞게 적용했다.
통합 테스트
@SpringBootTest
적용슬라이스 테스트
Presentation Layer(Controller) - @WebMvcTest
적용
Persistence Layer(Repository) - @DataJpaTest
적용
아래에는 Spring Boot 기반으로 통합 테스트와 슬라이스 테스트가 무엇이고, 어떻게 적용했는지 작성했다.
통합 테스트
(Integration Testing)는 시스템의 각 구성 요소가 올바르게 연동되는지 확인하는 것을 의미한다.
기능 테스트
가 사용자 입장에서 테스트하는 반면, 통합 테스트
는 소프트웨어 코드를 직접 테스트한다.
웹을 예로 들면 기능 테스트는 웹을 통해 게시글 기능을 테스트한다면, 통합 테스트는 게시글 기능에서 등록, 수정, 조회, 삭제를 직접 테스트하는 식이다.
요즘에는 기능 테스트를 인수 테스트
로 더 자주 부르는 경향이 있다. (인수 테스트
는 사용자 스토리(시나리오)에 맞춰 수행하는 테스트이다.)
일반적인 웹 애플리케이션은 프레임워크, 라이브러리, 데이터베이스, 구현한 코드가 주요 통합 테스트 대상이다. 게시글의 세부 기능에 대한 통합 테스트를 수행하면 스프링 프레임워크나 JPA 설정이 올바른지, SQL 쿼리가 맞는지, DB 트랜잭션이 잘 동작하는지 검증할 수 있다.
통합 테스트의 장점은 실제 환경에서 시스템이 어떻게 동작할지를 보다 정확하게 확인할 수 있다는 점이다. 비록 완벽하게는 아니지만, 통합 테스트는 거의 유사한 조건에서 시스템이 동작하는 것을 확인할 수 있다.
그러나 통합 테스트는 단위 테스트보다 여러 구성 요소를 포함하므로 단위 테스트보다 비용과 시간이 더 많이 소요된다. 여러 구성 요소가 함께 작동하기 때문에 문제 발생시 디버깅이 어려울 수 있다.
통합 테스트를 실행하려면 준비할 것이 많고 단위 테스트에 비해 실행 시간도 길지만, 그럼에도 불구하고 필요한 부분이라고 생각한다.
각 구성 요소가 올바르게 연동되는 것을 확인해야 하는데 이를 자동화
하기 좋은 수단이 통합 테스트 코드 이기 때문이다.
Spring Boot 에서는 클래스 상단에 @SpringBootTest
어노테이션을 붙여 통합 테스트를 수행할 수 있다.
@SpringBootTest
어노테이션을 사용하면 스프링 애플리케이션을 통해 테스트에 사용되는 ApplicationContext를 생성함으로써 작동한다.
이러한 어노테이션을 사용하면 우리 애플리케이션에서 사용하고 있는 모든 빈을 등록한 뒤 간편하게 테스트를 진행할 수 있다.
하지만 모든 빈을 등록하기 때문에 아래와 같은 단점을 가질 수 있다.
모든 빈을 등록하기 때문에 비교적 오랜 시간이 걸린다
모든 빈을 등록하기 대문에 의존성을 고려하지 않고 테스트를 진행할 수 있다. -> 테스트하고자 하는 객체의 의존성을 무시한채 테스트하게 된다.
결과적으로 웹을 실행시키지 않고 테스트 코드를 통해 빠른 피드백을 받을 수 있다는 장점이 희석된다.
이러한 @SpringBootTest
는 모든 빈을 등록한 채 테스트를 진행하는 통합 테스트에 적합한 어노테이션이다.
모든 빈을 등록하지 않으려면, classes 속성
을 통해 빈을 생성할 클래스를 지정하면 해당 클래스와 관련된 빈만 등록하게 된다.
@SpringBootTest
는 주로 Business Layer
에 사용된다.
Business Layer
(Service) 는 비즈니스 로직을 구현하는 역할로 Persistence Layer
와의 상호작용(Data를 읽고 쓰는 행위)를 통해 비즈니스 로직을 전개시키는 레이어이다.
아래는 @SpringBootTest
을 이용하여 실제 프로젝트에 작성한 테스트 중 일부를 가져왔다.
PostServiceTest
@ActiveProfiles("test")
@SpringBootTest
class PostServiceTest {
@Autowired
private PostRepository postRepository;
@Autowired
private ProfileRepository profileRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private PostService postService;
@AfterEach
void tearDown() {
postRepository.deleteAll();
profileRepository.deleteAll();
memberRepository.deleteAll();
}
@DisplayName("게시글을 등록한 페이지를 반환한다.")
@Test
void 게시글을_등록한_페이지를_반환한다() {
// given
Member 팬시 = 팬시();
memberRepository.save(팬시);
Profile 팬시_프로필 = 팬시_프로필(팬시);
profileRepository.save(팬시_프로필);
PostCreateRequest request = PostCreateRequest.builder()
.title(게시글제목1)
.content(게시글내용1)
.exhibition(전시회제목1)
.exhibitionAttendance(전시관람인원1)
.possibleTime(전시관람희망날짜1)
.openChatUrl(오픈채팅방Url1)
.togetherActivity(함께하고싶은활동1)
.imageName(게시글이미지1)
.postStatus(모집상태1)
.build();
// when
Long newPostId = postService.save(팬시_프로필.getMember().getId(), request);
Post savedPost = postRepository.findByMemberId(팬시.getId()).orElse(null);
// then
assertThat(newPostId).isNotNull();
assertEquals("프로젝트_해시테크", savedPost.getTitle());
}
}
통합 테스트 코드를 작성할 때 보통은 2가지 입장(관점)으로 작성하는 경우가 있다.
Classicist
- 진짜 객체로 테스트를 하자. -> 상태 검증을 통한 의존 객체의 구현보다는 실제 입/출력 값을 통해 검증하는 테스트이다.
Mockist
- 모든 걸 mocking 위주(가짜 객체) 로 테스트를 하자. -> 행위 검증을 통해 의존 객체의 특정 행동이 이루어졌는지를 검증하는 테스트이다.
나의 경우 Business Layer
에 대해 테스트 할 때, Repository의 실체 객체를 사용하여 통합 테스트를 진행하였다. 즉, Classicist
관점으로 작성했다.
메일 전송 혹은 AWS S3 같은 외부 시스템을 요청하거나 연결할 때는 Mocking을 사용한다. 왜냐하면 이런 외부 시스템은 우리가 개발한 게 아니기 때문이다.
Mocking 위주로 테스트를 작성한다면 과연 실제 프로덕션 코드에서 런타임 시점에 일어날 일을 정확하게 Mocking 했다고 단언할 수 있을까에 대한 의구심이 들었다.
테스트를 했다고 실제 프로덕션과 100% 재현한다고 보장은 할 수 없다. 그런 리스크를 감당할 바에는 비용을 조금 들여서 실제 객체를 가져와서 테스트를 하는게 더 좋을 것 같다는 생각이 들어서 나는 이 방식을 택했다.
슬라이드 테스트
는 스프링에서 특정 영역의 레이어를 테스트하는 방법 중 하나이다.
즉, 특정 계층은 다른 계층에 의존하지 않기 때문에 필요한 빈들만 주입받아 독립적으로 테스트를 수행할 수 있다.
이를 통해 전체 Application Context를 로드하는 것보다 더 가볍고 빠른 테스트를 작성할 수 있다.
아래는 대표적인 슬라이드 테스트 어노테이션이다.
@WebMvcTest
@DataJpaTest
@WebMvcTest
는 웹 컨트롤러 관련 빈들만 테스트하는 어노테이션으로 주로 웹 계층의 테스트에 사용된다.
@WebMvcTest
어노테이션이 사용되면 웹 계층의 테스트를 위한 빈들이 주입되는데, 주로 주입되는 빈들로는 다음과 같은 것들이 있다.
Controller
Interceptor
ViewResolver
Converter, Formatter
MessageConverter
HandlerMapping
ExceptionHandler
ResourceHandler
이 빈들을 사용하여 웹 레이어의 동작을 테스트하고, 의존성 주입 및 상호 작용을 확인할 수 있다.
@WebMvcTest
는 주로 Presentation Layer
에 사용된다.
Presentation Layer
(Controller)는 외부 세계의 요청을 가장 먼저 받는 계층 으로 파라미터에 대한 최소한의 검증을 수행한다.
아래는 @WebMvcTest
를 이용한 실제 프로젝트의 일부 테스트 코드를 가져왔다.
PostControllerTest
@WebMvcTest(controllers = PostController.class)
class PostControllerTest {
private static final String AUTHORIZATION_HEADER_NAME = "Authorization";
private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaaaaa.bbbbbbbb.cccccccc";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private PostService postService;
@MockBean
private AuthService authService;
@DisplayName("신규 게시글을 등록한다.")
@Test
void 신규_게시글을_등록한다() throws Exception {
// given
PostCreateRequest request = PostCreateRequest.builder()
.title(게시글제목1)
.content(게시글내용1)
.exhibition(전시회제목1)
.exhibitionAttendance(전시관람인원1)
.openChatUrl(오픈채팅방Url1)
.togetherActivity(함께하고싶은활동1)
.possibleTime(전시관람희망날짜1)
.openChatUrl(오픈채팅방Url1)
.togetherActivity(함께하고싶은활동1)
.imageName(게시글이미지1)
.postStatus(모집상태1)
.build();
// when & then
mockMvc.perform(post("/api/posts/new")
.header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isCreated());
}
}
사실상 비즈니스 로직 보다는 넘겨온 값이 중요하기 때문에 하위 2개의 Layer를 Mocking
(가짜 객체로 대신)하고 테스트를 진행했다.
여기서 MockMvc
이란 Mock(가짜) 객체를 이용해서 Spring MVC 동작을 재현할 수 있는 테스트 프레임워크이다.
Spring Data JPA를 사용했기 때문에, 테스트를 위해 DataJpaTest
를 활용할 수 있다.
@Entity
, JpaRepository
등 JPA 사용에 필요한 빈들을 등록하여 테스트할 때 사용된다.
@DataJpaTest
는 주로 Persisence Layer
에 사용된다.
Persistence Layer
(Repository)는 Data Access의 역할로 비즈니스 가공 로직이 포함되어서는 안된다. Data에 대한 CRUD 작업에만 집중한 레이어이다.
아래는 @DataJpaTest
를 실제 프로젝트의 일부 테스트 코드를 가져왔다.
PostRepositoryTest
@ActiveProfiles("test")
@DataJpaTest
class PostRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private ProfileRepository profileRepository;
@Autowired
private PostRepository postRepository;
private Member member;
private Profile profile;
private Post post;
@BeforeEach
void setUp() {
member = 팬시();
memberRepository.save(member);
profile = 팬시_프로필(member);
profileRepository.save(profile);
post = 프로젝트_해시테크(member);
postRepository.save(post);
}
@DisplayName("게시글과 회원 테이블이 정상적으로 매핑이 된다.")
@Test
void 게시글과_회원_테이블이_정상적으로_매핑이_된다() {
// given
Post foundPost = postRepository.getById(post.getId());
// when & then
Assertions.assertThat(foundPost.getMember().getId()).isNotNull();
}
@DisplayName("특정 회원 ID에 해당하는 게시글을 찾는다.")
@Test
void 특정_회원_ID에_해당하는_게시글을_찾는다() {
// given
Long memberId = post.getMember().getId();
// when
Optional<Post> actual = postRepository.findByMemberId(memberId);
// then
assertThat(actual).isPresent();
Post foundPost = actual.get();
assertThat(foundPost.getMember().getId()).isEqualTo(memberId);
}
}
@DataJpaTest
는 스프링 서버를 띄어서 테스트를 하는데, @SpringBootTest
보다 가볍다(속도가 빠르다).
JPA 관련된 Bean 들만 주입을 해줘서 서버를 띄어준다.
또한 @DataJpaTest
는 기본적으로 @Transactional
어노테이션이 있기 때문에 테스트가 완료되면 자동으로 롤백된다.
@DataJpaTest
내부에 들어가서 확인해보면, @Transactional
이 있다.
PostRepositoryTest
의 경우 @DataJpaTest
로 인해 트랜잭션 롤백이 자동으로 이루어지므로 테스트가 데이터베이스에 영향을 미치지 않는다.
그래서 @DataJpaTest
내부에 @Transactional
어노테이션이 있어서 PostRepositoryTest
에는 수동으로 삭제하는 tearDown() 메서드의 필요가 없어지게 된다.
통합 테스트와 슬라이드 테스트의 어노테이션을 적용함으로써 전보다 테스트 코드에 대한 이해를 높일 수 있게 되었다.
테스트 코드는 상대적으로 운영 코드에 비해 관심이 덜 받는 편이지만, 이는 실제로 안정성과 효율성, 그리고 확장성을 유지하기 위해 필수적으로 작성해야하는 부분이라고 생각한다.
테스트의 목적
과 필요한 빈
들에 대한 고민을 통해 적절한 어노테이션을 선택하는 것이 효과적인 테스트 코드 작성의 핵심이지 않을까 한다.
이를 통해 불필요한 리소스를 최소화하고, 운영 코드의 변화에 유연하게 대응할 수 있게 되었다.
테스트 코드를 관리하는 것은 운영 코드
의 안정성과 효율성을 높이는 핵심 요소임을 다시 한번 강조하고 싶다.
이 글은 실제 히빗 프로젝트(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)