이 글은 제가 혼자서 개발한 히빗 (version 2) 에 대한 회고 글입니다. 이전 version 1과 비교하여 어떤 성장을 이루었고, 그 과정에서 어떤 깨달음을 얻었는지 작성했습니다.
히빗 프로젝트 version1에 참가하게된 계기와 회고는 이전에 작성했던 ‘[2023년 회고] 다양한 활동으로 가득한 특별한 한 해’ 글에 있기 때문에, 생략했다.
이번 글은 히빗 프로젝트를 version2로 다시 진행하면서 개선했던 들을 하나씩 알아보고, 느낀점(아쉬운 점)을 작성해보고자 한다.
과거 version1 에 참가했을 때의 내 실력은 아래와 같았다.
2023, 과거의 ‘나’
자바 - 기초적인 개념은 어느 정도 알고 있으나 일부 개념에 대해서는 제대로 대답하지 못하는 수준. 람다 & 스트림을 알지만, 프로젝트에 적용할 수 없었음
스프링 - 김영한님 강의에서 Spring 핵심 원리, Spring MVC 1편, JPA 활용1,2 까지 들었고 복습한 수준
좋은 코드가 무엇이고, 좋은 코드를 만들기 위해 어떻게 작성해야 할지 판별을 못하는 수준
스프링 & JPA 기반 프로젝트에서의 테스트코드를 어떻게 작성해야 해야 하는지 모름
일단 기능 동작만 구현할 수 있을 정도의 실력
히빗 프로젝트(version 1)를 진행하면서, 그리고 끝난 이후에도 과거의 ‘나’의 실력을 성장시키기 위해 아래와 같은 공부를 했다.
2024, 현재의 ‘나’
자바 - ‘자바의 정석’ 책을 통해 빠르게 복습하면서 기본기 튼튼히 잡음. 람다 & 스트림 개념을 공부하면서 프로젝트에 적용할 수 있음
스프링 - 김영한님 강의에서 JPA 기본, Spring MVC 2편, Spring DB 1편를 수강했고, 기본기는 꾸준히 복습했음 -> JPA 정리, Spring 정리
좋은 코드가 무엇인지 알기 위해 우아한 스터디 겨울시즌, “내 코드가 그렇게 이상한가요?” 주제에 참가해서 2달간 공부 및 정리했음
테스트코드가 무엇이고, 스프링 & JPA 기반 프로젝트에서의 테스트코드를 작성하는 방법을 “Practical Testing” 강의를 통해 학습하고 정리했음
간단히 말해, 이전에는 프로젝트에서 개발할 줄 알았지만 깊이있게 학습하지 않았다. 그래서 그러한 약점을 보완하기 위해 꾸준히 공부하면서 부족한 부분을 채워나갔다.
기존의 프로젝트를 version2를 하게된 이유는 내가 제대로 성장했는지 확인하고 싶었고, 기존 version1에 있는 백엔드 코드와 구조를 개선하는 것이였다.
그래서 version1에서 개선하고 싶은 점들을 리스트로 정리한 결과 아래와 같았다.
무분별한 setter 지양하고 하나의 메서드당 하나의 역할만 하도록 리팩터링하기
테스트코드를 도입하여 QA 작업을 자동화하고 Jacoco를 도입하여 코드 커버리지 80% 유지하기
데이터베이스 레플리케이션을 통한 쿼리 성능 개선하기
동시성으로 인한 데이터베이스 정합성이 맞지 않는 문제 해결하기
검색 기능의 개선과 보안 강화하기
게시글 조회에 대한 어뷰징을 막기 위해 조회수를 Cookie에 저장하여 관리하기
기존 API 명세서인 Swagger에서 Rest Docs로 전환하기
version1 -> version2로 개발을 진행하면서 개선한 사항 중 하나는 setter를 사용하지 않고, 단일 책임 원칙을 더욱 철저히 준수하는 것이였다.
초기 버전(ver.1) - PostService
@RequiredArgsConstructor
@Service
public class PostService {
// ...
@Transactional
public Post save(PostSaveDto postSaveDto, Long idx) {
Member member = memberRepository.getById(idx);
postSaveDto.setMember(member);
Post post = postSaveDto.toEntity();
postRepository.save(post);
postHistory postHistory = new postHistory();
postHistory.setPost(post);
postHistory.setOkUsers(new ArrayList<>());
postHistory.setRealUsers(new ArrayList<>());
postHistoryRepository.save(postHistory);
return post;
}
}
개선된 버전(ver.2) - PostService
@Service
@Transactional(readOnly = true)
public class PostService {
// ...
@Transactional
public Long save(final Long memberId, final PostCreateRequest request) {
validateMember(memberId);
Member foundMember = memberRepository.getById(memberId);
Post post = createPost(request, foundMember);
Post savedPost = postRepository.save(post);
return savedPost.getId();
}
}
기존 version1과 version2는 요구 사항이 다를 수 있어 코드상의 차이가 있을 수 있다.
그러나 version1에서는 엔티티 클래스와 비즈니스 로직에서 무분별하게 setter를 사용하고 있다.
왜 setter를 지양해야 할까?
누군가 이렇게 물어본다면, 나는 다음과 같이 2가지 이유를 들 수 있을 것 같다.
JPA에서는 setter를 통해 트랜잭션 안에서 엔티티의 변경 사항을 감지하여 update 쿼리를 수행한다.
즉, setter 메서드는 update
기능을 수행한다.
setter 없이 데이터를 수정하는 방법은 사용한 의도와 의미가 명확한 메서드명을 사용하는 것이 좋다.
예를 들어, 게시글 정보를 변경할 경우 updatePost
라는 메서드를 생성하여 사용하면 setter 메서드 보다 행위의 의도를 더 명확히 할 수 있다.
따라서 setter를 public으로 열어두고 사용하는 것보다는, 변경이라는 의미가 담긴 메서드를 통해 update 처리하는 것이 객체지향적인 접근 방식이라고 본다.
그리고 version2에서는 단일 책임 원칙
을 잘 지키기 위해, 하나의 메서드가 한 가지 일만 하도록 구현했다.
이를 통해 각 메서드의 목적이 명확해지고 코드의 유지보수가 용이해졌다.
자세한 내용은 해당 포스팅에 정리했습니다.
우선 테스트코드에 대한 개념부터 실전까지의 경험을 쌓기위해 “Practical Testing: 실용적인 테스트 가이드” 강의를 들으면서 블로그에 정리해갔다.
해당 강의를 들으면서 동시에 히빗 ver2 프로젝트에도 적용하였고, BDD 기반으로 코드를 작성했다.
(단위 테스트 코드에 대한 자세한 내용은 이전에 작성한 좋은 단위 테스트란? 글에 작성했다)
단위 테스트코드를 작성하면서 동시에 통합 테스트코드를 작성했는데, @MockBean
이라는 어노테이션을 사용했다.
주로 Presentation Layer에서 Business Layer 하위로 Mocking 처리할 때 사용했다.
그리고 토스 유튜브 채널에서 토스뱅크 이응준님이 발표하신 SLASH21 - 테스트 커버리지 100% 영상을 보면서 테스트 커버리지의 여러 이점들을 알게되었고, 하신 말씀 중에 가장 기억에 남는 문구가 아래와 같았다.
테스트가 없으면 리팩터링을 할 수 없고, 리팩터링을 하지 않는 코드는 이해할 수 없게 되며, 이러한 코드는 수정할 수 없다는 확신이 없다. - 토스뱅크 이응준 -
이로 인해 테스트의 중요성을 새롭게 깨달았고, 프로덕션 코드의 품질과 신뢰를 높이기 위해 히빗 ver2 프로젝트에는 코드 커버리지를 80%를 유지하는 목표를 세우고 실천에 옮겼다.
그 결과, 아래와 같이 80% 유지를 할 수 있었다.
해당 내용과 관련된 PR 입니다.
기존 히빗 ver1 프로젝트는 데이터베이스가 1개였다. 그래서 등록, 조회, 수정, 삭제가 모두 하나의 데이터베이스에서 관리되기 때문에 성능적인 면에서 부족한 부분이 있다.
특히 히빗 서비스에서는 주로 등록, 수정, 삭제보다 ‘조회’하는 경우가 많았기 때문에, 조회에 대한 성능을 높이고자 레플리케이션을 도입하기로 했다.
아래와 같이 현재 RDS에 있는 데이터베이스(Source)를 기준으로 하위에 Replica 2개를 추가로 두었다.
그리고 스프링 부트에도 쓰기와 읽기를 분리하기 위해 아래와 같이 DataSource를 구분지었다.
@Configuration
@Profile("prod")
public class DataSourceConfiguration {
@Bean
@Primary
public DataSource dataSource() {
DataSource determinedDataSource = routingDataSource(sourceDataSource(), replica1DataSource(), replica2DataSource());
return new LazyConnectionDataSourceProxy(determinedDataSource);
}
@Bean
@Qualifier(SOURCE_NAME)
@ConfigurationProperties(prefix = "spring.datasource.source")
public DataSource sourceDataSource() {
return DataSourceBuilder.create()
.build();
}
@Bean
@Qualifier(REPLICA_1_NAME)
@ConfigurationProperties(prefix = "spring.datasource.replica1")
public DataSource replica1DataSource() {
return DataSourceBuilder.create()
.build();
}
@Bean
@Qualifier(REPLICA_2_NAME)
@ConfigurationProperties(prefix = "spring.datasource.replica2")
public DataSource replica2DataSource() {
return DataSourceBuilder.create()
.build();
}
}
이후에 성능 테스트로 게시글 조회에 대한 테스트를 진행해본 결과, 2,000명을 기준으로 40TPS 에서 177.8 TPS로 4.4배 증가하였다.
레플리케이션 도입 전
레플리케이션 도입 전
자세한 내용은 해당 포스팅에 정리했습니다.
기존 히빗 ver1 에서 특정 게시글에 대한 조회수 증가에 대해 성능 테스트로 해본 결과, 정합성이 맞지 않는 문제가 생겼다.
서로 다른 사용자 1000명이 해당 게시글을 조회하면, 당연히 1000회가 증가해야 했는데, 아래와 같이 117회의 조회수만 정상적으로 증가된 것을 확인할 수 있다.
이러한 부분을 해결하기 위해 여러가지 방법 중 쿼리(DB atomic operation)을 적용했다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdForUpdate(@Param("id") final Long id);
// ...
@Transactional
@Modifying
@Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId")
void updateViewCount(@Param("postId") Long postId);
}
for update
를 통해 조회하지 않고 이렇게 자기 자신의 값을 이용하여 계산한다면, 배타락 덕분에 조회수 개수에 대한 데이터 정합성을 보장할 수 있다.
위 그림에서 보는 것처럼 먼저 실행된 트랙잭션이 update 쿼리를 통해 마치고 커밋 또는 롤백할 때까지 락 획득을 위해 대기하고 있는 방식이다.
해당 내용과 관련된 PR 입니다.
기존 히빗 ver1 에서는 사용자 입력을 그대로 쿼리에 사용할 경우 SQL 인젝션 공격의 위험이 있었고, 특정 키워드가 없는 경우에 대한 처리가 전혀 없었다.
따라서 안전하게 사용자 입력을 처리하여 SQL 인젝션 공격을 방지하고, 빈 입력값에 대해서도 적절히 처리할 수 있는 검색 기능을 구현하는 것을 목표로 두었다.
그리고 특정 키워드로 게시물을 검색하는 기능은 있지만, Page를 사용해서 구현했다.
하지만 총 페이지 수에 대한 값이 필요하지 않다면 Page
대신 Slice
를 적용하는게 성능 개선에 있어서 더 나은 방법을 알게되었다.
해결1. SQL 인젝션 공격과 빈 입력값에 대한 처리를 위해 SearchQuery
클래스 생성
SearchQuery
클래스를 통해 특수 문자 및 SQL 예약어 제거 로직을 구현하였고, 이를 통해 안전한 검색 쿼리 생성이 가능해졌다.```java
public class SearchQuery {
private static final Pattern SPECIAL_CHARS = Pattern.compile(“\[‘”-#@;=*/+]”);
private static final Set
스케일 아웃 / 스케일 업 ⇒ 할 수도 있지만, 10배를 대비해봤자 그 이상이 나오면 장애가 날 거임
왜 Redis 를 선택했을까?
어떤 요구사항인가?
레디스 사용시 고려사항
프로모션 스케줄러를 통해서 뒤로 흘리는 TPS를 조절
하나의 문제점 → 큐 시스템
을 중간에 어떻게 끼워넣지?
주문라우터
를 이용주문라우터
에 있는 해당 Rule을 이용한다.⇒ 이벤트에 영향없이 일반주문은 가능하도록 장애 격리
모니터링 - CloudWatch
10만 이상의 트래픽 경험을 쌓기 위해 해당 영상을 참고하게 되었다.
해당 방식은 2019년 우아한형제들에서 발표한 내용으로 현재(2024) 사용하는 방식과 다소 차이가 있을 수 있다.
또한 기존 Mysql 외에 Redis를 사용하면 비용과 관리도 그 만큼 증가하기 때문에, 상황에 맞게 적절히 사용하는게 엔지니어링의 역할이자 책임인 것 같다.
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
해당 이슈에서 고민했던 주제입니다.
아래 부터는 ‘히빗 프로젝트(ver.2)’를 히빗2
라고 줄여서 작성했습니다.
일반적으로 조회수의 의미와 중요성에 대해 고민과 여러 검색을 통해 알아본 결과, 몇 가지 핵심적인 질문들을 정리해보았다.
한 사용자가 게시글을 여러 번 접속할 때, 그때마다 조회수를 증가시켜야 할까요?
같은 사용자가 여러 번 접속해도 조회수는 단 한 번만 카운트해야 할까요?
사용자가 하루에 한 번만 조회수를 증가시킬 수 있어야 할까요?
비회원이 게시글을 열람할 경우에도 조회수를 카운트해야 할까요?
회원만이 게시글을 열람할 수 있도록 하고, 그 후에만 조회수를 카운트해야 할까요?
한 사용자가 다양한 장소(다른 IP)에서 접속했을 때, 조회수를 어떻게 처리해야 할까요?
조회수가 높다는 것은 게시글의 가치나 정보의 중요성을 나타낼 수 있기 때문에, 특히 커뮤니티 서비스에서는 이 주제에 대해 심도 있게 고민해볼 필요가 있다. 예를 들어, YouTube 같은 서비스에서는 조회수가 수익 창출과 밀접한 관련이 있어 조회수 관리가 매우 중요하다.
히빗2 서비스의 이전 버전(ver.1)에서는 조회수와 관련된 API를 아래와 같이 구현했다.
적용전: PostController
@RestController
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// ...
@GetMapping("api/posts/{id}")
@Operation(summary = "/api/posts/{id}", description = "게시글에 대한 상세 페이지를 조회한다.")
public ResponseEntity<PostDetailResponse> findPost(@PathVariable(name = "id") Long postId) {
PostDetailResponse response = postService.findPost(postId);
return ResponseEntity.ok(response);
}
}
이 구현 방식은 조회수 중복 방지에 대해 고려하지 않았기 때문에 다음과 같은 문제가 있었다.
비회원도 조회할 때마다 조회수가 증가, 하루에 여러 번 조회 가능
한 사용자가 여러 장소(다른 IP)에서 접속해도 조회수가 접속한 수만큼 증가
조회수가 증가할 때마다 데이터베이스에 업데이트, 이는 많은 사용자들이 특정 게시글을 자주 조회할 경우 데이터베이스에 큰 부하를 유발할 수 있음
이러한 문제를 인식하고, 조회수의 어뷰징을 방지하기 위한 새로운 접근 방식이 필요했다.
어뷰징(Abusing)
이란 의도적인 조작을 통해 조회수나 클릭수를 높이기 위한 일련의 행위
이에 따라 히빗2 서비스에서는 조회수 증가에 대한 새로운 기준을 다음과 같이 설정했다.
조회수 증가는 로그인한 회원에게만 허용된다.
한 회원은 하루에 한 번만 조회수를 증가시킬 수 있다.
조회수 증가에 대한 어뷰징을 막는 방법을 스스로도 고민해보고, 구글링을 해보면서 대략 4가지 방법으로 추리게 되었다.
전체 게시글 조회 페이지에서 클릭
쿠키 사용
IP 또는 Mac Address 사용
DB 이용
결론적으로 히빗2
서비스에서는 쿠키를 사용하기로 했다.
HTTP 요청마다 쿠키를 보내주는데, 만약 쿠키값이 커질 경우 네트워크 트래픽에 부담을 줄 수 있다.
하지만 쿠키 표준안인 RFC 2109에 따르면 쿠키는 300개까지 만들 수 있으며, 최대 크기는 4,096바이트(4KB)이고, 하나의 호스트나 도메인에서 최대 20개까지 만들 수 있다고 한다. 대부분의 브라우저는 표준안보다 더 적은 개수의 쿠키만을 지원한다.
악의적인 사용자가 쿠키를 삭제하여 어뷰징을 할 수 있지만, 히빗2에서는 조회수가 수익 창출이나 주요 비즈니스에 큰 영향을 미치지 않기 때문에 이 방법을 선택했다. 또한, 이 프로젝트가 학습 목적이기 때문에 쿠키를 사용하는 경험도 유용하다고 생각했다.
히빗2 서비스에서는 조회수 관리의 정확성을 높이기 위해 ViewCountManager
라는 새로운 클래스를 도입했다.
이 클래스는 쿠키에 저장된 게시글 조회 로그를 파싱하는 역할이다.
쿠키에는 한 도메인당 최대 20개의 항목만 저장할 수 있는 제한이 있다.
이를 극복하기 위해, ViewCountManager
는 하나의 쿠키에 여러 게시글의 ID를 기록하는 방식으로 조회수 관리 로직을 구현했다.
조회수 관리 :
ViewCountManager
@Component
public class ViewCountManager {
private static final String DATE_LOG_DELIMITER = "&";
private static final String DATE_AND_ID_DELIMITER = ":";
private static final String ID_DELIMITER = "/";
private static final int DATE_INDEX = 0;
private static final int LOG_INDEX = 1;
// <DATE>:1/2/3&
public boolean isFirstAccess(String logs, Long postId) {
Map<Integer, String> dateLogs = extractDateLogs(logs);
int today = LocalDateTime.now().getDayOfMonth();
String todayLog = dateLogs.get(today);
if (Objects.isNull(todayLog)) {
return true;
}
return isLogNonExist(todayLog, postId);
}
private Map<Integer, String> extractDateLogs(String logs) {
Map<Integer, String> dateLogs = new HashMap<>();
String[] logsPerDate = logs.split(DATE_LOG_DELIMITER);
for (String logPerDate : logsPerDate) {
dateLogs.putAll(divideDateAndLog(logPerDate));
}
return dateLogs;
}
private boolean isLogNonExist(String log, Long postId) {
List<Long> loggedPostIds = Arrays.stream(log.split(ID_DELIMITER))
.map(Long::parseLong)
.collect(Collectors.toList());
return !loggedPostIds.contains(postId);
}
private Map<Integer, String> divideDateAndLog(String logPerDate) {
if (logPerDate.isEmpty()) {
return Collections.emptyMap();
}
String[] dateAndLog = logPerDate.split(DATE_AND_ID_DELIMITER);
int date = Integer.parseInt(dateAndLog[DATE_INDEX]);
String log = dateAndLog[LOG_INDEX];
return Map.of(date, log);
}
public String getUpdatedLog(String logs, Long postId) {
if (!isFirstAccess(logs, postId)) {
return logs;
}
Map<Integer, String> dateLogs = extractDateLogs(logs);
int today = LocalDateTime.now().getDayOfMonth();
String updatedLog = appendLog(dateLogs.get(today), postId);
return today + DATE_AND_ID_DELIMITER + updatedLog;
}
private String appendLog(String log, Long postId) {
if (Objects.isNull(log) || log.isEmpty()) {
return Long.toString(postId);
}
return log + ID_DELIMITER + postId;
}
}
이 방식을 사용하면, 로그 문자열은 다음과 같은 형태를 갖는다.
<날짜>:<PostID>/<PostID>/<PostID>&<날짜>:<PostID>/<PostID>&...
이를 통해 날짜에 어떤 포스트가 조회되었는지 추적할 수 있다. 예를 들어, 쿠키에 “27:1/2/3&28:4/5”라는 값이 저장되어 있다면, 이는 27일에 게시글 1, 2, 3이 조회되었고, 28일에는 게시글 4, 5가 조회되었음을 나타낸다.
ViewCountManager
에 대한 테스트 코드를 작성한 결과, 아래와 같이 방문 여부에 따른 값과 다른 게시글을 방문할 때마다 log를 업데이트한 것을 확인할 수 있다.
PostService는 이전에 해당 게시글을 조회하지 않았던 경우에만 조회수를 데이터베이스에 업데이트하는 로직을 구현했다.
적용후:
PostService
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
private final ViewCountManager viewCountManager;
// ...
@Transactional
public PostDetailResponse findPost(final Long postId, final LoginMember loginMember, final String cookieValue) {
if (viewCountManager.isFirstAccess(cookieValue, postId)) { // 해당 게시글에 처음 방문했다면
postRepository.updateViewCount(postId);
}
Post foundPost = findPostObject(postId);
return PostDetailResponse.of(foundPost, loginMember);
}
public String updatePostLog(final Long postId, final String cookieValue) { // 다른 게시글ID를 포함한 로그를 쿠키에 저장
return viewCountManager.getUpdatedLog(cookieValue, postId);
}
private Post findPostObject(final Long postId) {
List<Post> posts = postRepository.findPostById(postId);
if (posts.isEmpty()) {
throw new NotFoundPostException();
}
return posts.get(0);
}
}
PostService
에 대한 테스트 코드로 검증한 결과 정상적으로 나오는 걸 확인할 수 있다.
PostController
에서는 PostService
를 통해 게시글 상세 정보를 가져오고, 쿠키를 업데이트하여 조회 로그를 관리한다.
적용후:
PostController
@Tag(name = "posts", description = "매칭 게시글")
@RestController
public class PostController {
private final PostService postService;
@GetMapping("api/posts/{id}")
@Operation(summary = "/api/posts/{id}", description = "게시글에 대한 상세 페이지를 조회한다.")
public ResponseEntity<PostDetailResponse> findPost(@PathVariable(name = "id") final Long postId,
@AuthenticationPrincipal final LoginMember loginMember,
@CookieValue(value = "viewedPost", required = false, defaultValue = "") final String postLog) {
PostDetailResponse response = postService.findPost(postId, loginMember, postLog);
String updatedLog = postService.updatePostLog(postId, postLog);
ResponseCookie responseCookie = ResponseCookie.from("viewedPost", updatedLog).maxAge(86400L).build(); // 86400L: 1일(24시간)
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, responseCookie.toString()).body(response);
}
}
추가적으로 쿠키에 대한 maxAge를 설정하지 않으면 브라우저가 종료될 때 쿠키가 사라지는 문제가 생긴다. 따라서 이를 방지하기 위해 쿠키의 maxAge를 1일(24시간)으로 설정했다.
특정 게시글에 대한 조회 API를 Postman
으로 확인한 결과 아래와 같이 viewedPost
값이 제대로 나오는 것을 확인할 수 있다.
어뷰징 방지를 위해 조회수 증가 기준을 설정하고, 이를 쿠키를 통해 구현하는 과정을 진행했다. 악의적인 사용자가 쿠키를 삭제하여 어뷰징을 할 수 있으나, 현재 히빗2 서비스에서는 조회수가 수익이나 비즈니스에 큰 영향을 미치지 않아 문제가 되지 않는다. 또한, 쿠키에 저장된 정보는 날짜와 게시글 ID에 불과하므로, 정보가 탈취되어도 큰 문제가 없다.
쿠키의 표준에 따라 하나의 도메인에 최대 20개의 쿠키만 저장할 수 있으므로, 대안으로 하나의 쿠키에 "<DATE>/1/2/3/"
같은 형식으로 여러 게시글 ID를 문자열로 저장하고 분할하는 방식을 선택했다.
하지만, 사용자 수가 증가하면 쿠키의 한계에 부딪힐 수 있다.
이를 해결하기 위해 인메모리 데이터 저장소인 Redis를 활용하여 IP나 MAC 주소와 게시글을 key-value 구조로 저장하는 방법을 고려할 수 있으나, 조회수가 현재 서비스에 큰 영향을 주지 않아 이 방법은 채택하지 않았다.
이후에 조회수가 현재보다 더 중요한 가치로 생긴다면, 그때가서 다시 한번 고민해보고 더 나은 개선 방안을 생각해보자.
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
@EntityGraph
가 무엇이고 어떻게 적용해야할지 궁금한 개발자현재, 특정 게시글 조회를 위한 API에 대한 성능 테스트를 진행하면서 다음 가정을 설정했다.
아래와 같이 조회수는 정상적으로 1000
이 나오는 걸 볼 수 있다.
{
"id": 1,
"writerId": 1,
"writerName": "mjy",
"title": "디뮤지엄 전시 보러가요",
"content": "오스린티 전시회 보러가실 분 있나요?",
"exhibition": "오스틴리 전시",
"exhibitionAttendanceAndTogetherActivity": [
"4인 관람",
"맛집 가기"
],
"possibleTime": "2024-01-05 12:00",
"openChatUrl": "https://kakao",
"postStatus": "HOLDING",
"imageName": "exhibition.png",
"viewCount": 1000
}
JMeter
를 통한 성능 테스트에서 요약 보고서를 확인했을 때, 아래와 같이 평균 처리량(TPS)이 27.9/sec
나오는 걸 확인할 수 있다.
TPS가 27.9
라는 것은 Traffic Per Second(초당 처리 건수)가 27.9건이라는 의미이다.
이는 1초 동안 시스템이 27.9번의 요청을 처리할 수 있다는 것을 나타낸다.
TPS에 대한 그래프를 확인했을 때, 최대 30
이 나왔다.
현재 게시글(Post)와 게시글을 작성한 회원(Member)의 관계는 다대일 단방향 매핑 으로 이루어져 있고, 지연 로딩을 사용했다.
여기서 지연 로딩
을 사용한 이유는 Post
엔티티와 연관된 엔티티(Member
)를 실제로 사용할 때 엔티티를 조회할 때 프록시를 통해 가져오기 때문이다.
즉, Post 엔티티를 로드할 때 Member 엔티티의 정보는 필요한 시점에만 로드되고, 이때 프록시(Proxy)를 통해 지연 로딩이 이루어진다.
그 후 해당 프록시 객체를 호출할 때마다 그때그때 select 쿼리가 실행된다. (자세한 내용은 이전에 작성한 [JPA] 프록시와 연관관계 관리 글을 참고하자)
Post(게시글) 엔티티
@Table(name = "posts")
@Entity
public class Post extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "members_id")
private Member member;
// ...
}
Member(회원) 엔티티
@Table(name = "members")
@Entity
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
// ...
}
그리고 하나의 게시글을 조회할 때 JPA에서 제공해주는 findById()
를 사용했다.
그래서 PostRepository 인터페이스에는 아무런 코드를 입력하지 않았다.
PostService 클래스
@Service
@Transactional(readOnly = true)
public class PostService {
// ...
@Transactional
public PostDetailResponse findPost(final Long postId, final LoginMember loginMember, final String cookieValue) {
if (viewCountManager.isFirstAccess(cookieValue, postId)) {
postRepository.updateViewCount(postId);
}
Post foundPost = findPostObject(postId);
return PostDetailResponse.of(foundPost, loginMember);
}
// ...
private Post findPostObject(final Long postId) {
return postRepository.findById(postId)
.orElseThrow(NotFoundPostException::new);
}
}
그 결과, 특정 게시글에 대한 조회 api에 대한 쿼리를 확인했을 때 아래와 같이 나왔다.
Hibernate:
select
post0_.id as id1_2_0_,
post0_.created_date_time as created_2_2_0_,
post0_.updated_date_time as updated_3_2_0_,
post0_.content as content4_2_0_,
post0_.exhibition as exhibiti5_2_0_,
post0_.exhibition_attendance as exhibiti6_2_0_,
post0_.image_name as image_na7_2_0_,
post0_.members_id as members14_2_0_,
post0_.open_chat_url as open_cha8_2_0_,
post0_.possible_time as possible9_2_0_,
post0_.post_status as post_st10_2_0_,
post0_.title as title11_2_0_,
post0_.together_activity as togethe12_2_0_,
post0_.view_count as view_co13_2_0_
from
posts post0_
where
post0_.id=?
// ...
Hibernate:
select
member0_.id as id1_0_0_,
member0_.created_date_time as created_2_0_0_,
member0_.updated_date_time as updated_3_0_0_,
member0_.display_name as display_4_0_0_,
member0_.email as email5_0_0_,
member0_.is_profile as is_profi6_0_0_,
member0_.main_image as main_ima7_0_0_,
member0_.social_type as social_t8_0_0_
from
members member0_
where
member0_.id=?
게시글을 조회할 때, 연관된 회원도 같이 조회가 된다. 근데, 여기서 select 절이 2번 조회되는 걸 볼 수 있다.
매번 특정 게시글을 조회할 때마다 게시글안에 회원 정보도 필요하기 때문에, 한 번에 select 하는게 더 낫지 안을까?
라는 생각이 들었다.
기본적으로 Post
엔티티는 Member
엔티티를 로딩할 때 지연 로딩으로 사용되는데, 위와 같이 특정 상황에서는 즉시 로딩
전략이 필요 할 수 있다.
이때 사용하는 방법이 바로 @EntityGraph
어노테이션이다.
@EntityGraph
는 Data JPA에서 fect 조인을 어노테이션으로 사용할 수 있도록 만들어 준 기능이다.
@EntityGraph
를 사용하면 특정 엔티티나 연관된 엔티티들을 로딩할 때 그래프를 명시적으로 정의할 수 있다.
PostService 클래스, PostRepository 인터페이스
// PostService
@Service
@Transactional(readOnly = true)
public class PostService {
// ...
@Transactional
public PostDetailResponse findPost(final Long postId, final LoginMember loginMember, final String cookieValue) {
if (viewCountManager.isFirstAccess(cookieValue, postId)) {
postRepository.updateViewCount(postId);
}
Post foundPost = findPostObject(postId);
return PostDetailResponse.of(foundPost, loginMember);
}
// ...
private Post findPostObject(final Long postId) {
return postRepository.findPostById(postId)
.orElseThrow(NotFoundPostException::new);
}
}
// PostRepository
public interface PostRepository extends JpaRepository<Post, Long> {
// ...
@EntityGraph(attributePaths = "member")
@Query("SELECT p FROM Post p")
Optional<Post> findPostById(Long id);
}
위와 같이 특정 게시글(Post
)을 조회할 때 해당 게시물에 대한 회원(Member
) 정보를 함께 조회하게 된다.
이렇게 함으로써 지연 로딩을 사용하더라도 특정 게시글을 가져올 때마다 작성자 정보를 필요한 경우에만 가져오게 되어 효율적인 쿼리를 수행할 수 있다.
그 결과 쿼리는 아래와 같이 나오게 된다.
Hibernate:
select
post0_.id as id1_2_0_,
member1_.id as id1_0_1_,
post0_.created_date_time as created_2_2_0_,
post0_.updated_date_time as updated_3_2_0_,
post0_.content as content4_2_0_,
post0_.exhibition as exhibiti5_2_0_,
post0_.exhibition_attendance as exhibiti6_2_0_,
post0_.image_name as image_na7_2_0_,
post0_.members_id as members14_2_0_,
post0_.open_chat_url as open_cha8_2_0_,
post0_.possible_time as possible9_2_0_,
post0_.post_status as post_st10_2_0_,
post0_.title as title11_2_0_,
post0_.together_activity as togethe12_2_0_,
post0_.view_count as view_co13_2_0_,
member1_.created_date_time as created_2_0_1_,
member1_.updated_date_time as updated_3_0_1_,
member1_.display_name as display_4_0_1_,
member1_.email as email5_0_1_,
member1_.is_profile as is_profi6_0_1_,
member1_.main_image as main_ima7_0_1_,
member1_.social_type as social_t8_0_1_
from
posts post0_
left outer join
members member1_
on post0_.members_id=member1_.id
where
post0_.id=?
위의 쿼리를 통해 알 수 있듯이, @EntityGraph
어노테이션을 적용하면 left outer join
을 사용하는 걸 확인할 수 있다.
그리고 처음과 같이, 1초 동안 서로 다른 100명의 사용자가 동시에 특정 게시글을 10번 반복해서 조회하는 성능 테스트를 실행했다.
결과를 확인해보니, 평균 TPS 값이 39.1
로 이전 평균 TPS(27.9) 보다 40.14% 개선된 것을 확인할 수 있다.
TPS에 대한 그래프에서는 최대 TPS 값이 42
로 이전 최대 TPS(30)보다 40% 개선된 것을 확인할 수 있다.
이처럼 쿼리를 명시적으로 작성하지 않아도 fetch join
과 같은 효과를 나타낼 수 있으나, 약간의 차이점이 있다.
@EntityGraph
의 경우 fetchType을 eager로 변환하는 방식으로 outer left join
을 수행하여 데이터를 가져오지만,
fetch join
의 경우 따로 outer join으로 명시하지 않는 경우 inner join
을 수행한다는 점이다.
이 때문에 1:N(일대다) 연관 관계에서 fetch join
을 사용할 때, 일반적으로 1개의 컬렉션만 함께 조회할 수 있다.
그러나 이로 인해 distinct가 필요한 상황에서도 @EntityGraph
를 사용하면 이러한 단점을 극복할 수 있다.
@EntityGraph
은 다음과 같이 2가지 유형이 있다. 기본 값은 FETCH
이다.
FETCH
: entity graph에 명시한 attribute는 EAGER
로 패치하고, 나머지 attribute는 LAZY
로 패치
LOAD
: entity graph에 명시한 attribute는 EAGER
로 패치하고, 나머지 attribute는 entity에 명시한 fetch type
이나 디폴트 FetchType으로 패치
(@OneToMany는 LAZY
, @ManyToOne은 EAGER
등이 기본 값이다)
위에서 소개한 @EntityGraph
를 이용한 성능 개선은 현재 프로젝트에 적용하기에는 아직 간단한 수준이다.
현재 개발 중인 히빗 프로젝트(ver.2)
는 EC2 인스턴스 서버 2개를 활용하고 있다. (NGINX 서버 - 백엔드 서버 인스턴스 유형: t2.micro
)
데이터베이스 레플리케이션을 활용한 쿼리 성능 개선은 고려사항 중 하나이지만, 혼자 개발 중인 프로젝트의 비용 부담도 고려해야 한다.
따라서 추가로 비용을 발생시키지 않는 방법을 고민하며 성능을 향상시킬 수 있는 다양한 접근 방법을 모색해보자.
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
예상 독자: 동시성 처리로 인한 데이터 정합성 문제를 해결하는 것에 대해 궁금한 개발자
히빗 (ver.1)에서 발생한 데이터 정합성 문제를 히빗2 (ver.2)를 통해 해결한 과정에 대해 정리했습니다.
서버 개발을 하다 보면 여러 요청에서 동시에 공유 자원을 활용하는 경우 동시성 이슈가 발생할 수 있다.
특히 java를 활용한 웹 애플리케이션의 경우 멀티 쓰레드로 동작하기 때문에 데이터 정합성을 위해서는 공유 자원에 대한 관리가 필요하다.
동시성 이슈 해결을 위해서는 다양한 방법이 존재한다. 요청 쓰레드가 순차적으로 처리할 수 있도록 제한하거나 낙관적 락/비관적 락을 활용하거나 쿼리를 통해 해결하는 등 다양한 방법을 통해 막을 수 있다.
이번 글에서는 각각의 해결 방법을 사용해보고 고민해서 가장 적합한 해결 방법을 히빗2에 적용하는 과정을 정리하였다.
기존 히빗 서비스(ver.1)
에서는 ‘게시글 전체보기’ 에서 사용자가 하나의 게시글을 클릭할 때, 조회수 1이 증가되도록 구현했다.
게시글에 대한 엔티티 클래스인 Post
는 제목, 내용 등 여러 필드가 있고, 그 중에서 조회수를 나타내는 viewCount
를 가지고 있다.
```java @Entity public class Post extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = “post_id”) private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@Column(name = "title", nullable = false)
@Embedded
private Title title;
@Column(name = "content", nullable = false)
@Embedded
private Content content;
이 글은 실제 히빗 프로젝트(ver.2)를 혼자서 개발하면서 경험한 내용을 정리한 글입니다.
예상 독자: 성능 테스트에 대한 도구와 설치 과정에 대해 궁금한 개발자
아래 글부터는 히빗 프로젝트(ver.2) 를 히빗2
로 줄여서 작성했습니다.
히빗2 서비스에서 특정 게시글에 대한 조회를 할 때마다 조회수 1이 증가되도록 했다.
사용자 1명이 특정 게시글에 대한 조회를 할 때 1이 증가하는 것을 확인하기 위해 테스트 코드를 작성했다.
class PostServiceTest extends IntegrationTestSupport {
@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("특정 게시글을 조회하면 조회수를 1 증가시킨다.")
@Test
void 특정_게시글을_조회하면_조회수를_1_증가시킨다() {
// given
Member 팬시 = 팬시();
memberRepository.save(팬시);
Member member = memberRepository.getById(팬시.getId());
Profile 팬시_프로필 = 팬시_프로필(member);
Profile profile = profileRepository.save(팬시_프로필);
memberRepository.save(팬시);
Post post = 프로젝트_해시테크(profile.getMember());
postRepository.save(post);
// when
int viewCount = post.getViewCount();
postService.findPost(post.getId());
int updatedViewCount = postRepository.findById(post.getId()).get().getViewCount();
// then
Assertions.assertThat(viewCount + 1).isEqualTo(updatedViewCount);
}
}
이런 경우에는 문제 없이 잘 동작하는 것을 확인했다.
여기서 만약 동시 사용자가 100명이 10초동안 5번 반복했을 때, 오류 없이 조회수 증가가 제대로 처리되고 있는지 확인하고 싶었다.
그래서 성능 테스트를 도입하게 되었고, 이번 글에서는 성능 테스트를 위한 서버 환경을 구축하고 재연하는 과정을 시도했다.
성능 테스드에 대한 개념을 학습한 과정은 이전에 성능 테스트, 부하 테스트, 스트레스 테스트란에 정리했다.
Jmeter 는 아파치 소프트웨어 재단에서 개발한 성능 테스트를 위한 도구로 순수 100% Java로 개발한 웹 애플리케이션이다.
웹 애플리케이션 서버 성능 테스트를 위해 개발했지만, 현재는 데이터베이스, 파일 시스템, FTP, TCP 등 다양한 애플리케이션/서버/프로토콜 유형의 성능을 테스트할 수 있게 발전되었다.
Jmeter
은 최근 2주전까지 릴리즈하는 만큼 많은 릴리즈와 개선 사항을 통해 고도로 유지 및 관리하고 있다.
그리고 스프링과 통합하기 위한 여러가지 다양한 플러그인을 제공한다.
하지만, 애플리케이션 자체가 오래되어서 복잡한 스크립트와 문서가 가독성이 좋지 않다.
또한 Jmeter
는 단계별 스레드 할당 방식이라고 해서 스레드가 하나 생성될 때마다 리소스를 새로 할당해야 한다.
그래서 하나의 Worker에서 1000개 이상의 스레드를 할당하게 되면 조금 무리가 있을 수 있다. 즉, 한 대의 Wokrer에서 사용할 수 있는 사용자 수가 매우 한정적이라는 말이다.
그리고 실제로 Jmeter
를 설치해서 사용해봤지만, 위에 나온 그림처럼 기본적으로 제공되는 그래프가 나한테는 그렇게 이쁘지 않는 것 같다는 생각이 들었다.
nGrinder 는 서버 성능 테스트를 위해 Naver가 ‘The Grinder’를 기반으로 개발한 오픈소스 성능 테스트 도구이며 국내 개발자들이 많이 사용한다.
성능 측정 도구로 nGrinder
는 groovy
스크립트로 테스트 시나리오를 작성할 수 있다는 것이다.
그래서 기존에 Junit 테스트에 관심이 있는 개발자라면 거부감 없게 쓸 수 있을 정도로 비슷하다는 평을 받고 있다.
한국 기업인 네이버가 개발하고 유지보수하는 만큼 도구 자체에서 한국어를 지원한다. (물론, 나는 찾지 못했지만) 한국어로 작성된 문서는 못본 것 같다.
또한 완성도 높은 뼈대 코드를 지원해준다. (스크립트 이름 + 도메인 주소 + 요청을 보낼 API) 실제로 스크립트 이름과 도메인 주소, API 주소만 입력하면 완성도 높은 뼈대를 지원해준다.
nGrinder
는 Controller, Agent, Target으로 구성된다.
Controller
는 WAS 기반으로 웹 브라우저에 접속하여 테스트를 위한 웹 인터페이스를 제공하며, Agent
에게 신호를 보내 테스트 스크립트를 실행 및 nGrinder
에 대한 전반적인 기능 관리를 제공한다.
Agent
는 직접 부하를 발생시키는 머신이고, Controller
로부터 신호를 받아 동작한다.
Target
은 부하가 발생할 대상 서버를 의미한다.
WAS 기반으로 동작하기 때문에 젠킨스 같이 개발자 각각의 계정을 가질 수 있고, 계정별 부하 테스트 히스토리를 관리할 수 있는 장점이 있다.
반면, nGrinder
자체가 프로세스가 분리되어 있어서 Controller
와 Agent
를 각각 따로 실행해줘야 한다는 단점이 있다.
그리고 groovy
라는 문법이 흔하지 않아서 IDE를 지원해주지 않는 경우가 많다. 그래서 nGrinder
내부에 직접 작성해야 한다는 불편함이 있다.
두 도구를 비교하여 초기에는 nGrinder
를 먼저 사용해보았으나, 설치하는 과정이 생각보다 번거로웠고, 참고할 레퍼런스 자료가 많지 않았다.
또한, Jmeter
는 최근까지(2주전) 릴리즈가 이루어져 활발하게 업데이트가 이루어지고 있었지만, nGrinder
는 마지막 릴리즈 날짜가 2022년 12월 30일 정도로 1년 가까이 최신화가 잘되어 있지 않았다.
nGrinder
를 활용하려면 Controller, Agent 각각 별도의 서버를 구축하고 실행해야 하는데, 현재 히빗2는 백엔드 서버 EC2 서버 한개만 있었고, 추가 서버를 두는 것이 번거로웠고 비용적인 측면에서도 조금은 부담이 되었다.
반면에 Jmeter
는 기본적으로 GUI로 제공되고 있고, 학습 비용이 낮고 빠르게 테스트 시나리오를 작성할 수 있다.
만약 히빗2 프로젝트가 개인 프로젝트가 아닌 팀 프로젝트이고 실제 서비스를 해야하는 상황이라면 nGrinder
를 사용할텐데, 지금은 게시글 조회에 대한 성능 테스트만 하는게 주 목적이였다.
또한 Jmeter
는 자체적으로 Plugin Manager를 내장하고 있어서 별도의 설정 없이 플러그인을 설치할 수 있다.
이러한 이유로 히빗2에서는 높은 점유율과 확장성, 풍부한 레퍼런스, 낮은 초기 학습 비용 등을 고려하여 Jmeter
를 사용하게 되었다.
Apache JMeter 공식 웹 사이트에서 Binaries 부분 아래에 apache.jmeter-5.6.3.zip
를 다운받는다.
해당 파일을 다운로드 받은 뒤 압축을 풀고, bin
디렉터리에서 아래와 같은 명령어를 입력한다.
$ ./jmeter.sh
그러면 Jmeter
가 실행되면서 위와 같은 창이 나오게 된다.
[1] Jmeter = Thread Group 생성
테스트 계획 우클릭 -> 추가 -> 쓰레드들 -> 쓰레드 그룹 선택
쓰레드들의 수(사용자 수, Number of Threads) : 모든 Thread 개수(가상의 사용자 수)
Ramp-up 시간(단위: 초, Ramp-Up Period) : 쓰레드 수들을 얼마 시간 동안 테스트할지에 대한 설정
Loop Count: 한 Thread당 모두 몇 번의 테스트를 수행하는 지 정의
ex. 쓰레드들의 수
가 1000이고, Ramp-up 시간
이 10이라면 동시의 사용자 1000명이 10초 안에 실행한다는 걸 의미한다.
만약 `쓰레드들의 수`가 1000, `Loop Count`이 5이라면, 1000명이 5번 요청해서 총 10,000번의 요청이 전송되는 걸 의미한다.
그래서 `쓰레드들의 수`가 1000이고, `Ramp-up 시간`이 10, `Loop Count`이 5이면 동시의 사용자 1000명이 10초 안에 실행하는 것을 5번 반복한다는 걸 말한다. (총 5,000번의 요청이 전송됨)
나는 100, 10, 5 순으로 입력했다. -> 동시 사용자 100명이 10초동안 5번 반복하도록 실행한다. (500번)
[2] Jmeter - HTTP Request 생성
쓰레드 그룹 우클릭 -> 추가 -> 표본추출기 -> HTTP 요청(Request) 선택
[+] HTTP 헤더 관리자 추가
HTTP 요청(Request) 우클릭 -> 추가 -> 설정 엘리먼트 -> HTTP 헤더 관리자
필자는 히빗2 서비스에 구글 소셜 로그인을 이용했기 때문에 Header 이름에 Authorization
을 넣고, 값에는 액세스 토큰/리프레시 토큰 값을 넣었다.
[3] Jmeter - 요약 보고서
, 결과 그래프
, 결과들의 트리 보기
추가
쓰레드 그룹(히빗2 테스트) -> 추가 -> 리스너 -> 원하는 형태의 결과창 선택
[4] 실행 후 결과 확인
요약 보고서를 통해 결과를 확인해보니, 다행히 오류없이 정상적으로 처리가 되었다.
여기서 처리량은 TPS
(초당 처리한 요청 수)를 의미한다.
TPS
는 Transaction Per Seconds의 약자로 요청 단위로 초당 얼마나 많은 요청을 처리할 수 있는지에 대한 수치이다. (= 초당 처리된 트랜잭션 수)
TPS
가 높을수록 시스템이 더 많은 요청을 처리할 수 있음을 의미하기 때문에 성능 테스트에 있어 중요한 지표로 여겨진다.
JMeter
도구에서 측정하는 TPS
는 우리가 설정한 테스트 구성을 가지고 결과를 측정하기 때문에 설정값을 가지고 해당 테스트 상에서 최대 TPS 값을 구할 수 있다.
앞으로 이 TPS라는 지표를 활용하여 서비스의 성능이 개선되었음을 판단할 예정이다.
24.01.24 추가) JMeter Plugins 설치 - Transactions Per Second
Custom Plugins for Apache JMeter 사이트에서 Download Version
아래 2.0 부분 클릭해서 다운로드 후 압축 해제한다.
압축 해제한 이후에 jar 파일들을 jmeter 폴더(apache-jmeter-5.6.3/lib/ext/
)안에 넣는다.
그리고 jmeter을 재실행후 왼쪽 메뉴 영역에서 히빗2
우클릭으로 jp@gc 관련 플러그인이 잘 포홤되었는지 확인한다
그러면 아래와 같이 플러그인 옵션이 생기는 걸 확인할 수 있다.
https://www.youtube.com/watch?v=YGYQ-f01TjE