From 317b85b05c3aed611648584f2211fc66dd62beea Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Thu, 7 Nov 2024 23:04:12 +0900
Subject: [PATCH 01/45] =?UTF-8?q?refactor:=20ncp=EB=A1=9C=20=EC=9D=B4?=
=?UTF-8?q?=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95=ED=8C=8C=EC=9D=BC=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/backend-prod-cd.yml | 35 ++++++++++-------------
docker-compose.yml | 38 +++++++++++++++++++++++++
src/main/resources/application-prod.yml | 6 ++--
src/main/resources/shema.sql | 1 +
4 files changed, 57 insertions(+), 23 deletions(-)
create mode 100644 docker-compose.yml
diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml
index fd690e7..81b10d6 100644
--- a/.github/workflows/backend-prod-cd.yml
+++ b/.github/workflows/backend-prod-cd.yml
@@ -7,6 +7,7 @@ on:
push:
branches:
- main
+ - develop
paths:
- "src/**"
jobs:
@@ -33,26 +34,20 @@ jobs:
push: true
# 이미지를 레지스트리에 푸시
tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}
- platforms: linux/arm64
+ platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
- backend-docker-pull-and-run:
- needs: [ backend-docker-build-and-push ]
- if: ${{ needs.backend-docker-build-and-push.result == 'success' }}
- runs-on: [ self-hosted, ec2-runner ]
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
- - name: Setup SSH Key
- run: |
- echo "${{ secrets.PEM_KEY }}" > pem_key
- chmod 600 pem_key
- name: WAS인스턴스 접속 및 애플리케이션 실행
- run: |
- ssh -i pem_key -o StrictHostKeyChecking=no ${{ secrets.WAS_USERNAME }}@${{ secrets.WAS_PUBLIC_IP }} << EOF
- docker rm -f agilehub-backend || true
- docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}
- docker run -v /var/log/backend:/app/logs \
- --env-file .env \
- -d -p 8080:8080 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}
- EOF
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.NCP_HOST }}
+ username: ${{ secrets.NCP_USERNAME }}
+ password: ${{ secrets.NCP_PASSWORD }}
+ key: ${{ secrets.NCP_PEM_KEY }}
+ port: 22
+ script: |
+ docker rm -f agilehub-backend || true
+ docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}
+ docker run -v /var/log/backend:/app/logs \
+ --env-file .env \
+ -d -p 8080:8080 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a89b647
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+# 로컬 DB 구성
+version: '3'
+
+services:
+ mysql:
+ image: mysql:8.0
+ container_name: mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: rootpassword
+ MYSQL_DATABASE: agilehubdb
+ MYSQL_USER: myuser
+ MYSQL_PASSWORD: mypassword
+ ports:
+ - "0.0.0.0:3306:3306"
+ volumes:
+ - ./mysql/data:/var/lib/mysql
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
+ networks:
+ - app-network
+ restart: always
+
+ redis:
+ image: redis:7.0
+ container_name: redis
+ ports:
+ - "0.0.0.0:6379:6379"
+ volumes:
+ - ./redis/data:/data
+ command: redis-server --appendonly yes
+ networks:
+ - app-network
+ restart: always
+
+networks:
+ app-network:
+ driver: bridge
\ No newline at end of file
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 35a7515..012d6b8 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -4,9 +4,9 @@ spring:
activate:
on-profile: prod
datasource:
- url: ${AWS_DB_URL}
- username: ${AWS_DB_USERNAME}
- password: ${AWS_DB_PASSWORD}
+ url: ${MYSQL_URL}
+ username: ${MYSQL_USERNAME}
+ password: ${MYSQL_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
sql:
diff --git a/src/main/resources/shema.sql b/src/main/resources/shema.sql
index 2ae6602..1e5509e 100644
--- a/src/main/resources/shema.sql
+++ b/src/main/resources/shema.sql
@@ -30,6 +30,7 @@ create table issue (
sprint_id bigint,
issue_type varchar(31) not null,
content varchar(255),
+ version bigint,
title varchar(255),
status enum ('DO','PROGRESS','DONE'),
label enum ('NONE', 'PLAN', 'DESIGN', 'DEVELOP', 'TEST', 'FEEDBACK'),
From 74409d5d0965bdb1fcb3af29c9673b653856948f Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Thu, 7 Nov 2024 23:19:56 +0900
Subject: [PATCH 02/45] =?UTF-8?q?refactor:=20docker=20file=20amd=EB=A1=9C?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 5e04649..2a95e43 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,26 +1,25 @@
# Dockerfile
# gradle && jdk17 이미지 빌드 - build 별명
-FROM --platform=linux/arm64 gradle:8.3-jdk17-jammy AS build
+FROM --platform=linux/amd64 gradle:jdk17-jammy AS build
# 작업 디렉토리 /app 생성
WORKDIR /app
# 빌드하는데 필요한 build.gradle, settings.gradle 파일 현재 디렉토리로 복사
COPY build.gradle settings.gradle /app/
+COPY gradle /app/gradle
+
# 그래들 파일이 변경되었을 때만 새롭게 의존패키지 다운로드 받게함.
-RUN gradle build -x test --parallel --continue > /dev/null 2>&1 || true
+# 의존성 다운로드
+RUN gradle dependencies --no-daemon
# 소스코드파일 /app 작업 디렉토리로 복사
COPY . /app
# Gradle 빌드를 실행하여 JAR 파일 생성
-ENV JWT_SECRET 12341234
-ENV REDIS_HOST redis
-ENV MAIL_USERNAME mail
-ENV MAIL_PASSWORD 12341234
RUN gradle build -x test --parallel
-FROM --platform=linux/arm64/v8 eclipse-temurin:17.0.10_7-jre
+FROM --platform=linux/amd64 eclipse-temurin:21.0.4_7-jre-jammy
WORKDIR /app
From b791f52987ba458e9be0a2417e6dfe2feda98482 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Thu, 7 Nov 2024 23:21:12 +0900
Subject: [PATCH 03/45] =?UTF-8?q?refactor:=20=EC=9D=B4=EC=8A=88=ED=8E=B8?=
=?UTF-8?q?=EC=A7=91=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=88=84?=
=?UTF-8?q?=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20=EB=82=99=EA=B4=80=EC=A0=81?=
=?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=ED=95=B4=EA=B2=B0=20(#131)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/exception/GeneralException.java | 7 +-
.../global/header/status/ErrorStatus.java | 22 +--
.../issue/IssueUpdateLoggingAspect.java | 41 +++++
.../issue/controller/IssueController.java | 15 +-
.../agilehub/issue/domain/Issue.java | 11 +-
.../issue/service/command/IssueService.java | 12 +-
.../agilehub/project/domain/Project.java | 2 +
.../project/service/ProjectQueryService.java | 7 +-
src/main/resources/logback-spring.xml | 5 +-
.../service/IssueUpdateConcurrencyTest.java | 158 ++++++++++++++++++
10 files changed, 254 insertions(+), 26 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java
create mode 100644 src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
diff --git a/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java b/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java
index b13faea..90112c5 100644
--- a/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java
+++ b/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java
@@ -2,14 +2,17 @@
import dynamicquad.agilehub.global.header.ReasonDto;
import dynamicquad.agilehub.global.header.status.BaseStatus;
-import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
-@AllArgsConstructor
public class GeneralException extends RuntimeException {
private final BaseStatus status;
+ public GeneralException(BaseStatus status) {
+ super(status.getReason().getMessage());
+ this.status = status;
+ }
+
public ReasonDto getErrorReason() {
return this.status.getReason();
}
diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
index e9a49dc..c7c6280 100644
--- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
+++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
@@ -60,7 +60,9 @@ public enum ErrorStatus implements BaseStatus {
// Email Error
EMAIL_NOT_SENT(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_5001", "이메일이 정상적으로 송신되지 않았습니다."),
INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."),
- ;
+
+ // Optimistic Lock Exception
+ OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요.");
private final HttpStatus httpStatus;
private final String code;
@@ -69,19 +71,19 @@ public enum ErrorStatus implements BaseStatus {
@Override
public ReasonDto getReason() {
return ReasonDto.builder()
- .message(message)
- .code(code)
- .isSuccess(false)
- .build();
+ .message(message)
+ .code(code)
+ .isSuccess(false)
+ .build();
}
@Override
public ReasonDto getReasonHttpStatus() {
return ReasonDto.builder()
- .status(httpStatus)
- .message(message)
- .code(code)
- .isSuccess(false)
- .build();
+ .status(httpStatus)
+ .message(message)
+ .code(code)
+ .isSuccess(false)
+ .build();
}
}
diff --git a/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java b/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java
new file mode 100644
index 0000000..eed54d0
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java
@@ -0,0 +1,41 @@
+package dynamicquad.agilehub.issue;
+
+import dynamicquad.agilehub.issue.dto.IssueRequestDto;
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Aspect
+@Component
+public class IssueUpdateLoggingAspect {
+
+ private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
+
+ @Around("execution(* dynamicquad.agilehub.issue.service.command.IssueService.updateIssue(..)) && args(key, issueId, request, authMember)")
+ public Object logIssueUpdate(ProceedingJoinPoint joinPoint,
+ String key, Long issueId, IssueRequestDto.EditIssue request, AuthMember authMember)
+ throws Throwable {
+
+ String threadName = Thread.currentThread().getName();
+ String requestContent = request.getContent();
+ String currentTime = LocalDateTime.now().format(formatter);
+
+ log.warn("[동시성 추적 - {}] Thread: {}, 이슈: {}, 사용자: {}, 수정 요청 내용: '{}'",
+ currentTime,
+ threadName,
+ issueId,
+ authMember.getId(),
+ requestContent);
+ Object result;
+
+ result = joinPoint.proceed();
+
+ return result;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java b/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java
index 9f70a5b..df6ec4d 100644
--- a/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java
+++ b/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java
@@ -2,6 +2,7 @@
import dynamicquad.agilehub.global.auth.model.Auth;
import dynamicquad.agilehub.global.header.CommonResponse;
+import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.global.header.status.SuccessStatus;
import dynamicquad.agilehub.issue.dto.IssueRequestDto;
import dynamicquad.agilehub.issue.dto.IssueResponseDto.IssueAndSubIssueDetail;
@@ -17,8 +18,10 @@
import java.net.URI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -71,8 +74,16 @@ public ResponseEntity> editProjectIssue(@Valid @ModelAttribute IssueRequestDto
@PathVariable("key") String key, @PathVariable("issueId") Long issueId,
@Auth AuthMember authMember) {
- issueService.updateIssue(key, issueId, request, authMember);
- return ResponseEntity.noContent().build();
+ try {
+ issueService.updateIssue(key, issueId, request, authMember);
+ return ResponseEntity.ok().build();
+ } catch (ObjectOptimisticLockingFailureException e) {
+ String viewIssueUrl = "/projects/" + key + "/issues/" + issueId;
+
+ return ResponseEntity.status(HttpStatus.CONFLICT)
+ .body(CommonResponse.of(ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION, viewIssueUrl));
+ }
+
}
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
index e616275..e785e96 100644
--- a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
@@ -1,6 +1,7 @@
package dynamicquad.agilehub.issue.domain;
import dynamicquad.agilehub.comment.domain.Comment;
+import dynamicquad.agilehub.global.domain.BaseEntity;
import dynamicquad.agilehub.issue.dto.IssueRequestDto;
import dynamicquad.agilehub.member.domain.Member;
import dynamicquad.agilehub.project.domain.Project;
@@ -21,6 +22,7 @@
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
+import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
@@ -36,7 +38,7 @@
@DiscriminatorColumn(name = "issue_type")
@Table(name = "issue")
@Entity
-public abstract class Issue {
+public abstract class Issue extends BaseEntity {
@Id
@Include
@@ -44,6 +46,9 @@ public abstract class Issue {
@Column(name = "issue_id")
private Long id;
+ @Version
+ private Long version;
+
private String title;
private String content;
@@ -98,6 +103,10 @@ protected void updateIssue(IssueRequestDto.EditIssue request, Member assignee) {
this.assignee = assignee;
}
+ public void updateContent(String content) {
+ this.content = content;
+ }
+
public void updateStatus(IssueStatus updateStatus) {
this.status = updateStatus;
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
index 515edbb..31583a4 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
@@ -19,12 +19,12 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
-@Transactional(readOnly = true)
public class IssueService {
private final ProjectQueryService projectQueryService;
@@ -44,11 +44,9 @@ public Long createIssue(String key, IssueRequestDto.CreateIssue request, AuthMem
}
- @Transactional
+ @Transactional(isolation = Isolation.READ_COMMITTED)
public void updateIssue(String key, Long issueId, IssueRequestDto.EditIssue request, AuthMember authMember) {
-
Project project = validateMemberInProject(key, authMember);
-
Issue issue = issueValidator.findIssue(issueId);
issueValidator.validateIssueInProject(project.getId(), issueId);
issueValidator.validateEqualsIssueType(issue, request.getType());
@@ -89,10 +87,12 @@ public void updateIssuePeriod(String key, Long issueId, AuthMember authMember, E
if (issue instanceof Epic epic) {
epic.updatePeriod(request.getStartDate(), request.getEndDate());
return;
- } else if (issue instanceof Story story) {
+ }
+ else if (issue instanceof Story story) {
story.updatePeriod(request.getStartDate(), request.getEndDate());
return;
- } else if (issue instanceof Task task) {
+ }
+ else if (issue instanceof Task task) {
task.updatePeriod(request.getStartDate(), request.getEndDate());
return;
}
diff --git a/src/main/java/dynamicquad/agilehub/project/domain/Project.java b/src/main/java/dynamicquad/agilehub/project/domain/Project.java
index 6907cd9..ce4158b 100644
--- a/src/main/java/dynamicquad/agilehub/project/domain/Project.java
+++ b/src/main/java/dynamicquad/agilehub/project/domain/Project.java
@@ -14,12 +14,14 @@
import lombok.EqualsAndHashCode.Include;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.ToString;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
@Table(name = "project")
@Entity
+@ToString
public class Project extends BaseEntity {
@Id
@Include
diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java
index 709144b..ff41af7 100644
--- a/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java
+++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java
@@ -31,18 +31,19 @@ public List getProjects(Long memberId) {
}
public Project findProject(String originKey) {
+ log.info("findProject: {}", originKey);
return projectRepository.findByKey(originKey)
- .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
+ .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
}
public Long findProjectId(String originKey) {
return projectRepository.findIdByKey(originKey)
- .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
+ .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
}
public Project findProjectById(long projectId) {
return projectRepository.findById(projectId)
- .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
+ .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND));
}
}
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index ed1de47..5162435 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -3,8 +3,9 @@
+
+ value="%d{yyyy-MM-dd HH:mm:ss.SSS} [${APP_NAME}] [%thread] %highlight(%5level) %cyan(%logger) - %msg%n"/>
@@ -27,7 +28,7 @@
-
+
diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
new file mode 100644
index 0000000..a4ede46
--- /dev/null
+++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
@@ -0,0 +1,158 @@
+package dynamicquad.agilehub.issue.service;
+
+import dynamicquad.agilehub.issue.IssueType;
+import dynamicquad.agilehub.issue.domain.Epic;
+import dynamicquad.agilehub.issue.domain.Issue;
+import dynamicquad.agilehub.issue.domain.IssueLabel;
+import dynamicquad.agilehub.issue.domain.IssueStatus;
+import dynamicquad.agilehub.issue.dto.IssueRequestDto;
+import dynamicquad.agilehub.issue.repository.IssueRepository;
+import dynamicquad.agilehub.issue.service.command.IssueService;
+import dynamicquad.agilehub.member.domain.Member;
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import dynamicquad.agilehub.member.repository.MemberRepository;
+import dynamicquad.agilehub.project.domain.MemberProject;
+import dynamicquad.agilehub.project.domain.MemberProjectRepository;
+import dynamicquad.agilehub.project.domain.MemberProjectRole;
+import dynamicquad.agilehub.project.domain.Project;
+import dynamicquad.agilehub.project.domain.ProjectRepository;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.concurrent.CompletableFuture;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest
+@ActiveProfiles("test")
+public class IssueUpdateConcurrencyTest {
+
+ @Autowired
+ private IssueService issueService;
+
+ @Autowired
+ private ProjectRepository projectRepository;
+ @Autowired
+ private MemberProjectRepository memberProjectRepository;
+ @Autowired
+ private MemberRepository memberRepository;
+
+ @Autowired
+ private IssueRepository issueRepository;
+
+ Project testProject;
+ ArrayList authMembers;
+
+ @BeforeEach
+ void init() {
+ testProject = createTestProject();
+ projectRepository.save(testProject);
+ System.out.println(
+ "잘 저장 = " + projectRepository.findByKey(testProject.getKey()).orElseThrow());
+ authMembers = new ArrayList<>();
+ }
+
+ @Test
+ @DisplayName("동시에 2명의 사용자가 하나의 이슈에 편집을 했을 때")
+ void concurrentUpdateTest() {
+ // given
+ createMembers(2);
+ Issue testIssue = createTestIssue();
+
+ // when
+ CompletableFuture userA = CompletableFuture.runAsync(() -> {
+ issueService.updateIssue(testProject.getKey(), testIssue.getId(),
+ createEditIssueByEditContent("사용자 A가 수정한 내용"), authMembers.get(0));
+ }).exceptionally(e -> {
+ Throwable cause = e.getCause();
+ System.out.println("발생한 예외: " + cause.getClass().getName());
+ System.out.println("예외 메시지: " + cause.getMessage());
+ //e.printStackTrace();
+ System.out.println("USER A 에러 발생");
+ return null;
+ });
+ CompletableFuture userB = CompletableFuture.runAsync(() -> {
+
+ issueService.updateIssue(testProject.getKey(), testIssue.getId(),
+ createEditIssueByEditContent("사용자 B가 수정한 내용"), authMembers.get(1));
+
+ }).exceptionally(e -> {
+ Throwable cause = e.getCause();
+ System.out.println("발생한 예외: " + cause.getClass().getName());
+ System.out.println("예외 메시지: " + cause.getMessage());
+ System.out.println("USER B 에러 발생");
+
+ return null;
+ });
+
+ // 두 작업이 모두 완료될 때까지 대기
+ CompletableFuture.allOf(userA, userB).join();
+
+ // Then
+ Issue updatedIssue = issueRepository.findById(testIssue.getId()).get();
+ System.out.println("최종 내용: " + updatedIssue.getContent());
+ }
+
+ private void createMembers(int memberCount) {
+ for (int j = 0; j < memberCount; j++) {
+ Member member = Member.builder()
+ .name("사용자" + j)
+ .build();
+ memberRepository.save(member);
+ memberProjectRepository.save(MemberProject.builder()
+ .member(member)
+ .project(testProject)
+ .role(MemberProjectRole.ADMIN)
+ .build());
+ authMembers.add(AuthMember.builder()
+ .id(member.getId())
+ .name(member.getName())
+ .build());
+ }
+ }
+
+ private Project createTestProject() {
+ return Project.builder()
+ .name("테스트 프로젝트")
+ .key("TEST")
+ .build();
+ }
+
+
+ private Issue createTestIssue() {
+ Issue testIssue = Epic.builder()
+ .title("테스트용 이슈")
+ .content(
+ """
+ 테스트용 이슈
+ 이슈 내용입니다.
+ """
+ )
+ .number(1)
+ .status(IssueStatus.DO)
+ .assignee(null)
+ .label(IssueLabel.TEST)
+ .project(testProject)
+ .startDate(LocalDate.of(2024, 11, 6))
+ .endDate(LocalDate.of(2024, 11, 9))
+ .build();
+
+ issueRepository.save(testIssue);
+ return testIssue;
+ }
+
+ private IssueRequestDto.EditIssue createEditIssueByEditContent(String editContent) {
+ return IssueRequestDto.EditIssue.builder()
+ .title("테스트용 이슈")
+ .content(editContent)
+ .type(IssueType.EPIC)
+ .status(IssueStatus.DO)
+ .label(IssueLabel.TEST)
+ .startDate(LocalDate.of(2024, 11, 6))
+ .endDate(LocalDate.of(2024, 11, 9))
+ .build();
+ }
+}
From 9f367c9cdbec93d954bf9bc3798cd0a6149a856b Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Thu, 7 Nov 2024 23:45:54 +0900
Subject: [PATCH 04/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=20=EC=9A=A9=EC=9D=B4=ED=95=98=EA=B2=8C=20=ED=95=98=EA=B8=B0=20?=
=?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0?=
=?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20JWT=20=EB=B0=8F=20OAuth=20?=
=?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/auth/MockSecurityContext.java | 46 ++++++
.../global/auth/filter/JwtAuthFilter.java | 9 +-
.../auth/util/MemberArgumentResolver.java | 31 +++--
.../global/config/SpringSecurityConfig.java | 131 +++++++++++-------
4 files changed, 152 insertions(+), 65 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
diff --git a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
new file mode 100644
index 0000000..1086dc3
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
@@ -0,0 +1,46 @@
+package dynamicquad.agilehub.global.auth;
+
+import dynamicquad.agilehub.global.auth.model.SecurityMember;
+import dynamicquad.agilehub.member.domain.Member;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import java.util.Collections;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MockSecurityContext {
+ @PostConstruct
+ public void init() {
+ // 테스트용 Member 객체 생성
+ Member mockMember = Member.builder()
+ .name("테스트사용자")
+ .profileImageUrl("http://example.com/test.jpg")
+ .build();
+
+ // SecurityMember 생성
+ SecurityMember securityMember = new SecurityMember(mockMember);
+
+ // Authentication 객체 생성
+ Authentication authentication = new UsernamePasswordAuthenticationToken(
+ securityMember,
+ null,
+ Collections.emptyList() // 필요한 경우 권한 추가
+ );
+
+ // SecurityContext에 Authentication 설정
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(authentication);
+ SecurityContextHolder.setContext(context);
+ }
+
+ // 테스트 종료 시 정리
+ @PreDestroy
+ public void cleanup() {
+ SecurityContextHolder.clearContext();
+ }
+
+}
diff --git a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java
index 3d31354..775c24a 100644
--- a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java
+++ b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java
@@ -18,11 +18,10 @@
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
-@Component
+//@Component -> 필터비활성화
@RequiredArgsConstructor
@Slf4j
public class JwtAuthFilter extends OncePerRequestFilter {
@@ -48,12 +47,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
String accessToken = token.get();
if (jwtUtil.verifyToken(accessToken)) {
saveAuthentication(accessToken);
- } else {
+ }
+ else {
String reissuedAccessToken = reissueAccessToken(accessToken);
if (StringUtils.hasText(reissuedAccessToken)) {
saveAuthentication(reissuedAccessToken);
response.setHeader(jwtUtil.getAccessHeader(), reissuedAccessToken);
- } else {
+ }
+ else {
throw new JwtException("Refresh Token is expired");
}
}
diff --git a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java
index d871c78..50f7281 100644
--- a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java
+++ b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java
@@ -1,12 +1,8 @@
package dynamicquad.agilehub.global.auth.util;
import dynamicquad.agilehub.global.auth.model.Auth;
-import dynamicquad.agilehub.global.auth.model.SecurityMember;
-import dynamicquad.agilehub.member.domain.Member;
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
import org.springframework.core.MethodParameter;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
@@ -19,18 +15,31 @@ public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType().equals(AuthMember.class);
}
+// @Override
+// public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
+// NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
+// Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+//
+// SecurityMember principal = (SecurityMember) authentication.getPrincipal();
+// Member member = principal.getMember();
+//
+// return AuthMember.builder()
+// .id(member.getId())
+// .name(member.getName())
+// .profileImageUrl(member.getProfileImageUrl())
+// .build();
+// }
+
+
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
-
- SecurityMember principal = (SecurityMember) authentication.getPrincipal();
- Member member = principal.getMember();
+ // 테스트용 유저1 -> 모든 API에 해당 유저만 진입됨 -> API 진입 유저 1 이 여러번 생성과 수정과 조회 하는 것도 의미있다고 봅니다
return AuthMember.builder()
- .id(member.getId())
- .name(member.getName())
- .profileImageUrl(member.getProfileImageUrl())
+ .id(1L)
+ .name("User1")
+ .profileImageUrl("adada")
.build();
}
}
diff --git a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java
index 5a59299..71f1f79 100644
--- a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java
+++ b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java
@@ -1,79 +1,110 @@
package dynamicquad.agilehub.global.config;
-import dynamicquad.agilehub.global.auth.filter.JwtAuthFilter;
-import dynamicquad.agilehub.global.auth.filter.JwtExceptionFilter;
-import dynamicquad.agilehub.global.auth.filter.OAuth2SuccessHandler;
-import dynamicquad.agilehub.global.auth.repository.CustomAuthorizationRequestRepository;
-import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService;
-import java.util.Collections;
-import java.util.List;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
-import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.filter.OncePerRequestFilter;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class SpringSecurityConfig {
- private final OAuth2SuccessHandler oAuth2SuccessHandler;
- private final JwtAuthFilter jwtAuthFilter;
-
- private final CustomOAuth2UserService customOAuth2UserService;
+// private final OAuth2SuccessHandler oAuth2SuccessHandler;
+// private final JwtAuthFilter jwtAuthFilter;
+//
+// private final CustomOAuth2UserService customOAuth2UserService;
+//
+// @Bean
+// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+//
+// // csrf disable 처리 : 추후 설정 변경 필요
+// http
+// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+// .cors(cors -> cors.configurationSource(request -> {
+// CorsConfiguration config = new CorsConfiguration();
+// config.setAllowedOriginPatterns(Collections.singletonList("*"));
+// config.setAllowedMethods(Collections.singletonList("*"));
+// config.setAllowedHeaders(Collections.singletonList("*"));
+// config.setAllowCredentials(true);
+// config.setExposedHeaders(List.of("Authorization"));
+// config.setMaxAge(3600L);
+// return config;
+// }))
+// .csrf(AbstractHttpConfigurer::disable)
+// .formLogin(AbstractHttpConfigurer::disable)
+// .httpBasic(AbstractHttpConfigurer::disable)
+// .logout(AbstractHttpConfigurer::disable)
+// // requestMatchers 설정
+// .authorizeHttpRequests(requests -> requests
+// .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**",
+// "/actuator/**",
+// "/favicon.ico")
+// .permitAll()
+// .anyRequest().authenticated()
+// )
+//
+// // oauth2 설정
+// .oauth2Login(customizer -> customizer
+// .authorizationEndpoint(authorization -> authorization
+// .authorizationRequestRepository(new CustomAuthorizationRequestRepository()))
+// .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
+// .successHandler(oAuth2SuccessHandler)
+// )
+//
+// // jwt 설정
+// .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
+// .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class);
+//
+// return http.build();
+// }
@Bean
- public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
-
- // csrf disable 처리 : 추후 설정 변경 필요
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ // 기존의 모든 필터 비활성화
http
- .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .cors(cors -> cors.configurationSource(request -> {
- CorsConfiguration config = new CorsConfiguration();
- config.setAllowedOriginPatterns(Collections.singletonList("*"));
- config.setAllowedMethods(Collections.singletonList("*"));
- config.setAllowedHeaders(Collections.singletonList("*"));
- config.setAllowCredentials(true);
- config.setExposedHeaders(List.of("Authorization"));
- config.setMaxAge(3600L);
- return config;
- }))
- .csrf(AbstractHttpConfigurer::disable)
- .formLogin(AbstractHttpConfigurer::disable)
- .httpBasic(AbstractHttpConfigurer::disable)
- .logout(AbstractHttpConfigurer::disable)
- // requestMatchers 설정
- .authorizeHttpRequests(requests -> requests
- .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**",
- "/actuator/**",
- "/favicon.ico")
- .permitAll()
- .anyRequest().authenticated()
- )
-
- // oauth2 설정
- .oauth2Login(customizer -> customizer
- .authorizationEndpoint(authorization -> authorization
- .authorizationRequestRepository(new CustomAuthorizationRequestRepository()))
- .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
- .successHandler(oAuth2SuccessHandler)
- )
+ .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/**").permitAll() // 모든 요청 허용
+ )
+ .oauth2Login(oauth2 -> oauth2.disable()) // OAuth2 로그인 비활성화
+ .formLogin(form -> form.disable()) // 폼 로그인 비활성화
+ .httpBasic(basic -> basic.disable()) // HTTP Basic 인증 비활성화
+ .logout(logout -> logout.disable()); // 로그아웃 기능 비활성화
- // jwt 설정
- .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
- .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class);
+ // JWT 필터 제거 (만약 커스텀 필터가 있다면)
+ http.addFilterBefore(new OncePerRequestFilter() {
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain)
+ throws ServletException, IOException {
+ filterChain.doFilter(request, response);
+ }
+ }, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
+ @Bean
+ public WebSecurityCustomizer webSecurityCustomizer() {
+ return (web) -> web.ignoring()
+ .requestMatchers("/**"); // 모든 경로에 대해 시큐리티 무시
+ }
+
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
From a268afec9d38d9ff39aa0d21b63ef621c07f8549 Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Thu, 7 Nov 2024 23:46:44 +0900
Subject: [PATCH 05/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=20=EC=9A=A9=EC=9D=B4=ED=95=98=EA=B2=8C=20=ED=95=98=EA=B8=B0=20?=
=?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0?=
=?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20JWT=20=EB=B0=8F=20OAuth=20?=
=?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/auth/MockSecurityContext.java | 46 -------------------
1 file changed, 46 deletions(-)
delete mode 100644 src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
diff --git a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
deleted file mode 100644
index 1086dc3..0000000
--- a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package dynamicquad.agilehub.global.auth;
-
-import dynamicquad.agilehub.global.auth.model.SecurityMember;
-import dynamicquad.agilehub.member.domain.Member;
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.PreDestroy;
-import java.util.Collections;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContext;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.stereotype.Component;
-
-@Component
-public class MockSecurityContext {
- @PostConstruct
- public void init() {
- // 테스트용 Member 객체 생성
- Member mockMember = Member.builder()
- .name("테스트사용자")
- .profileImageUrl("http://example.com/test.jpg")
- .build();
-
- // SecurityMember 생성
- SecurityMember securityMember = new SecurityMember(mockMember);
-
- // Authentication 객체 생성
- Authentication authentication = new UsernamePasswordAuthenticationToken(
- securityMember,
- null,
- Collections.emptyList() // 필요한 경우 권한 추가
- );
-
- // SecurityContext에 Authentication 설정
- SecurityContext context = SecurityContextHolder.createEmptyContext();
- context.setAuthentication(authentication);
- SecurityContextHolder.setContext(context);
- }
-
- // 테스트 종료 시 정리
- @PreDestroy
- public void cleanup() {
- SecurityContextHolder.clearContext();
- }
-
-}
From 666b31c26d52e1d0ace797e690f9d5175bc3ed75 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sat, 9 Nov 2024 00:06:52 +0900
Subject: [PATCH 06/45] =?UTF-8?q?refactor:=20=EB=B9=84=EA=B4=80=EC=A0=81?=
=?UTF-8?q?=20=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EC=8A=88=EB=B2=88?=
=?UTF-8?q?=ED=98=B8=20=EC=83=9D=EC=84=B1=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20?=
=?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#132)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agilehub/dummy/DummyDataLoader.java | 8 +-
.../global/header/status/ErrorStatus.java | 3 +-
.../agilehub/issue/aspect/Retry.java | 11 ++
.../agilehub/issue/aspect/RetryAspect.java | 41 +++++
.../agilehub/issue/domain/Epic.java | 2 +-
.../agilehub/issue/domain/Issue.java | 9 +-
.../issue/domain/ProjectIssueSequence.java | 43 +++++
.../agilehub/issue/domain/Story.java | 2 +-
.../agilehub/issue/domain/Task.java | 2 +-
.../agilehub/issue/dto/IssueResponseDto.java | 12 +-
.../issue/dto/backlog/EpicResponseDto.java | 2 +-
.../issue/dto/backlog/StoryResponseDto.java | 2 +-
.../issue/dto/backlog/TaskResponseDto.java | 2 +-
.../issue/repository/IssueRepository.java | 3 -
.../ProjectIssueSequenceRepository.java | 14 ++
.../service/command/IssueNumberGenerator.java | 34 ++++
.../issue/service/command/IssueService.java | 2 +
.../issue/service/factory/EpicFactory.java | 16 +-
.../issue/service/factory/StoryFactory.java | 10 +-
.../issue/service/factory/TaskFactory.java | 9 +-
.../project/service/ProjectService.java | 7 +
src/main/resources/shema.sql | 11 +-
.../agilehub/issue/IssueConcurrencyTest.java | 164 ++++++++++++++++++
.../agilehub/issue/domain/EpicTest.java | 2 +-
.../issue/domain/IssueRepositoryTest.java | 43 -----
.../agilehub/issue/domain/StoryTest.java | 4 +-
.../issue/service/IssueServiceTest.java | 2 +-
.../service/IssueUpdateConcurrencyTest.java | 2 +-
.../service/factory/EpicFactoryTest.java | 9 +-
.../service/factory/TaskFactoryTest.java | 10 +-
.../sprint/SprintQueryServiceTest.java | 6 +-
31 files changed, 393 insertions(+), 94 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
create mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
create mode 100644 src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java
create mode 100644 src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java
create mode 100644 src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
create mode 100644 src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java
diff --git a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java
index 42f2e92..3a867a4 100644
--- a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java
+++ b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java
@@ -4,9 +4,9 @@
import dynamicquad.agilehub.dummy.bulkRepository.MemberBulkRepository;
import dynamicquad.agilehub.dummy.bulkRepository.MemberProjectBulkRepository;
import dynamicquad.agilehub.dummy.bulkRepository.ProjectBulkRepository;
+import dynamicquad.agilehub.issue.domain.Epic;
import dynamicquad.agilehub.issue.domain.Issue;
import dynamicquad.agilehub.issue.domain.IssueStatus;
-import dynamicquad.agilehub.issue.domain.Epic;
import dynamicquad.agilehub.issue.domain.Story;
import dynamicquad.agilehub.issue.domain.Task;
import dynamicquad.agilehub.member.domain.Member;
@@ -98,7 +98,7 @@ private void epicBulk() {
epics.add(Epic.builder()
.title("에픽" + i)
.content("에픽 내용" + i)
- .number((int) i)
+ .number("")
.status(IssueStatus.DO)
.startDate(LocalDate.of(2021, 1, 1))
.endDate(LocalDate.of(2021, 10, 23))
@@ -126,7 +126,7 @@ private void storyBulk() {
stories.add(Story.builder()
.title("스토리" + i)
.content("스토리 내용" + i)
- .number((int) i)
+ .number("")
.status(IssueStatus.DO)
.startDate(LocalDate.of(2021, 1, 1))
.endDate(LocalDate.of(2021, 10, 23))
@@ -156,7 +156,7 @@ private void taskBulk() {
tasks.add(Task.builder()
.title("태스크" + i)
.content("태스크 내용" + i)
- .number((int) i)
+ .number("")
.status(IssueStatus.DO)
.build());
}
diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
index c7c6280..62ffcd4 100644
--- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
+++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
@@ -62,7 +62,8 @@ public enum ErrorStatus implements BaseStatus {
INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."),
// Optimistic Lock Exception
- OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요.");
+ OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."),
+ OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER(HttpStatus.LOCKED, "LOCK_4001", "이슈 번호 생성 실패. 다시 시도해주세요.");
private final HttpStatus httpStatus;
private final String code;
diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
new file mode 100644
index 0000000..1adbd20
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
@@ -0,0 +1,11 @@
+package dynamicquad.agilehub.issue.aspect;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Retry {
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
new file mode 100644
index 0000000..6006ae9
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
@@ -0,0 +1,41 @@
+package dynamicquad.agilehub.issue.aspect;
+
+import static dynamicquad.agilehub.global.header.status.ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER;
+
+import dynamicquad.agilehub.global.exception.GeneralException;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션보다 더 낮은 순서로 실행
+@Component
+@Slf4j
+public class RetryAspect {
+ private static final int MAX_RETRIES = 5;
+
+ @Around("@annotation(dynamicquad.agilehub.issue.aspect.Retry)")
+ public Object retry(ProceedingJoinPoint joinPoint) throws Throwable {
+ int attempts = 0;
+ while (attempts < MAX_RETRIES) {
+ try {
+ log.info("시작 시도: {}", attempts + 1);
+ return joinPoint.proceed();
+ } catch (ObjectOptimisticLockingFailureException e) {
+ attempts++;
+ if (attempts == MAX_RETRIES) {
+ log.error("Failed after {} attempts", MAX_RETRIES, e);
+ throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER);
+ }
+ log.warn("Retry attempt {} of {}", attempts, MAX_RETRIES);
+ Thread.sleep(100); // 재시도 전 잠시 대기
+ }
+ }
+ throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER);
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java b/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java
index 2a6c346..47e0b2a 100644
--- a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java
@@ -30,7 +30,7 @@ public class Epic extends Issue {
private List stories = new ArrayList<>();
@Builder
- private Epic(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee,
+ private Epic(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee,
Project project, LocalDate startDate, LocalDate endDate) {
super(title, content, number, status, label, assignee, project);
this.startDate = startDate;
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
index e785e96..a30143f 100644
--- a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java
@@ -22,7 +22,6 @@
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
-import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
@@ -46,14 +45,14 @@ public abstract class Issue extends BaseEntity {
@Column(name = "issue_id")
private Long id;
- @Version
- private Long version;
+// @Version
+// private Long version;
private String title;
private String content;
- private int number;
+ private String number;
@Enumerated(EnumType.STRING)
private IssueStatus status;
@@ -84,7 +83,7 @@ public void setSprint(Sprint newSprint) {
}
- protected Issue(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee,
+ protected Issue(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee,
Project project) {
this.title = title;
this.content = content;
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java b/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java
new file mode 100644
index 0000000..23d3280
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java
@@ -0,0 +1,43 @@
+package dynamicquad.agilehub.issue.domain;
+
+import dynamicquad.agilehub.global.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "project_issue_sequence")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ProjectIssueSequence extends BaseEntity {
+ @Id
+ @Column(name = "project_key")
+ private String projectKey;
+
+ @Column(name = "last_number")
+ private int lastNumber;
+
+// @Version
+// private Long version;
+
+ public ProjectIssueSequence(String projectKey) {
+ this.projectKey = projectKey;
+ this.lastNumber = 0;
+ }
+
+ public void updateLastNumber(int lastNumber) {
+ this.lastNumber = lastNumber;
+ }
+
+ public int getNextNumber() {
+ return ++lastNumber;
+ }
+
+ public int decrement() {
+ return lastNumber--;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java b/src/main/java/dynamicquad/agilehub/issue/domain/Story.java
index d426d47..262dd83 100644
--- a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/Story.java
@@ -38,7 +38,7 @@ public class Story extends Issue {
private List tasks = new ArrayList<>();
@Builder
- private Story(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee,
+ private Story(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee,
Project project,
int storyPoint, LocalDate startDate, LocalDate endDate, Epic epic) {
super(title, content, number, status, label, assignee, project);
diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java b/src/main/java/dynamicquad/agilehub/issue/domain/Task.java
index 5e96891..b92a2a8 100644
--- a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java
+++ b/src/main/java/dynamicquad/agilehub/issue/domain/Task.java
@@ -31,7 +31,7 @@ public class Task extends Issue {
private Story story;
@Builder
- private Task(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee,
+ private Task(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee,
Project project, LocalDate startDate, LocalDate endDate, Story story) {
super(title, content, number, status, label, assignee, project);
this.story = story;
diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java
index e739723..fd17fdc 100644
--- a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java
+++ b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java
@@ -57,7 +57,7 @@ public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto a
IssueType issueType) {
IssueDetail issueDetail = IssueDetail.builder()
.issueId(issue.getId())
- .key(issue.getProject().getKey() + "-" + issue.getNumber())
+ .key(issue.getNumber())
.title(issue.getTitle())
.type(issueType.toString())
.status(String.valueOf(issue.getStatus()))
@@ -71,12 +71,14 @@ public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto a
Epic epic = Epic.extractFromIssue(issue);
issueDetail.startDate = epic.getStartDate() == null ? "" : epic.getStartDate().toString();
issueDetail.endDate = epic.getEndDate() == null ? "" : epic.getEndDate().toString();
- } else if (IssueType.STORY.equals(issueType)) {
+ }
+ else if (IssueType.STORY.equals(issueType)) {
Story story = Story.extractFromIssue(issue);
issueDetail.startDate = story.getStartDate() == null ? "" : story.getStartDate().toString();
issueDetail.endDate = story.getEndDate() == null ? "" : story.getEndDate().toString();
- } else if (IssueType.TASK.equals(issueType)) {
+ }
+ else if (IssueType.TASK.equals(issueType)) {
Task task = Task.extractFromIssue(issue);
issueDetail.startDate = task.getStartDate() == null ? "" : task.getStartDate().toString();
@@ -133,7 +135,7 @@ public SubIssueDetail() {
public static SubIssueDetail from(Issue issue, IssueType issueType, AssigneeDto assigneeDto) {
return SubIssueDetail.builder()
.issueId(issue.getId())
- .key(issue.getProject().getKey() + "-" + issue.getNumber())
+ .key(issue.getNumber())
.status(String.valueOf(issue.getStatus()))
.label(String.valueOf(issue.getLabel()))
.type(issueType.toString())
@@ -165,7 +167,7 @@ public static IssueResponseDto.ReadSimpleIssue from(Issue issue, String projectK
.status(String.valueOf(issue.getStatus()))
.label(String.valueOf(issue.getLabel()))
.assignee(assignee)
- .key(projectKey + "-" + issue.getNumber())
+ .key(issue.getNumber())
.build();
}
diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java
index 15d7faf..eb3b4d0 100644
--- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java
+++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java
@@ -29,7 +29,7 @@ public static EpicDetailForBacklog from(Epic epic, String key, AssigneeDto assig
return EpicDetailForBacklog.builder()
.id(epic.getId())
.title(epic.getTitle())
- .key(key + "-" + epic.getNumber())
+ .key(epic.getNumber())
.status(epic.getStatus().toString())
.label(String.valueOf(epic.getLabel()))
.type(IssueType.EPIC.toString())
diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java
index 9a3d887..4d45df3 100644
--- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java
+++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java
@@ -32,7 +32,7 @@ public static StoryDetailForBacklog from(Story story, String projectKey, Long pa
return StoryDetailForBacklog.builder()
.id(story.getId())
.title(story.getTitle())
- .key(projectKey + "-" + story.getNumber())
+ .key(story.getNumber())
.status(String.valueOf(story.getStatus()))
.label(String.valueOf(story.getLabel()))
.type(IssueType.STORY.toString())
diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java
index 564a829..7852c56 100644
--- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java
+++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java
@@ -32,7 +32,7 @@ public static TaskDetailForBacklog from(Task task, String projectKey, Long paren
return TaskDetailForBacklog.builder()
.id(task.getId())
.title(task.getTitle())
- .key(projectKey + "-" + task.getNumber())
+ .key(task.getNumber())
.status(String.valueOf(task.getStatus()))
.label(String.valueOf(task.getLabel()))
.type(IssueType.TASK.toString())
diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java
index 34c77e8..85f097b 100644
--- a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java
+++ b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java
@@ -14,9 +14,6 @@
@Repository
public interface IssueRepository extends JpaRepository {
- @Query("select count(*) from Issue i join i.project p where p.key = :key")
- Long countByProjectKey(String key);
-
@Query(value = "select issue_type from issue i where i.issue_id = :id", nativeQuery = true)
Optional findIssueTypeById(@Param("id") Long id);
diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java
new file mode 100644
index 0000000..59afa6f
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java
@@ -0,0 +1,14 @@
+package dynamicquad.agilehub.issue.repository;
+
+import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
+import jakarta.persistence.LockModeType;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+
+public interface ProjectIssueSequenceRepository extends JpaRepository {
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ Optional findByProjectKey(String projectKey);
+
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
new file mode 100644
index 0000000..ce2b80e
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -0,0 +1,34 @@
+package dynamicquad.agilehub.issue.service.command;
+
+import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
+import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class IssueNumberGenerator {
+
+ private final ProjectIssueSequenceRepository issueSequenceRepository;
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ //@Retry
+ public String generate(String projectKey) {
+ ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
+ .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
+
+ sequence.updateLastNumber(sequence.getNextNumber());
+
+ return projectKey + "-" + sequence.getLastNumber();
+ }
+
+ @Transactional
+ public void decrement(String projectKey) {
+ ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
+ .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
+
+ sequence.decrement();
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
index 31583a4..d085949 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
@@ -31,6 +31,7 @@ public class IssueService {
private final IssueFactoryProvider issueFactoryProvider;
private final IssueValidator issueValidator;
private final MemberProjectService memberProjectService;
+ private final IssueNumberGenerator issueNumberGenerator;
private final IssueRepository issueRepository;
@@ -63,6 +64,7 @@ public void deleteIssue(String key, Long issueId, AuthMember authMember) {
Issue issue = issueValidator.findIssue(issueId);
issueRepository.delete(issue);
+ issueNumberGenerator.decrement(key);
}
@Transactional
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
index 4886694..0d5d5e2 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
@@ -10,6 +10,7 @@
import dynamicquad.agilehub.issue.repository.IssueRepository;
import dynamicquad.agilehub.issue.repository.StoryRepository;
import dynamicquad.agilehub.issue.service.command.ImageService;
+import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator;
import dynamicquad.agilehub.member.domain.Member;
import dynamicquad.agilehub.member.dto.AssigneeDto;
import dynamicquad.agilehub.member.service.MemberService;
@@ -30,6 +31,7 @@ public class EpicFactory implements IssueFactory {
private final IssueRepository issueRepository;
private final StoryRepository storyRepository;
private final ImageService imageService;
+ private final IssueNumberGenerator issueNumberGenerator;
private final MemberService memberService;
@@ -39,14 +41,20 @@ public class EpicFactory implements IssueFactory {
@Transactional
@Override
public Long createIssue(IssueRequestDto.CreateIssue request, Project project) {
- // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ]
- // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ]
- int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1);
+ // 이슈 번호 생성
+ // 리트라이 3번
+
+ String issueNumber = issueNumberGenerator.generate(project.getKey());
+
+ // 멤버를 찾기
Member assignee = memberService.findMember(request.getAssigneeId(), project.getId());
Epic epic = toEntity(request, project, issueNumber, assignee);
+ // 이슈 저장
issueRepository.save(epic);
+
+ // S3에 이미지 저장
if (request.getFiles() != null && !request.getFiles().isEmpty()) {
log.info("uploading images");
imageService.saveImages(epic, request.getFiles(), WORKING_DIRECTORY);
@@ -104,7 +112,7 @@ private SubIssueDetail getStoryToSubIssue(Story story) {
}
- private Epic toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee) {
+ private Epic toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee) {
return Epic.builder()
.title(request.getTitle())
.content(request.getContent())
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java
index 5b91dc5..a137720 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java
@@ -13,6 +13,7 @@
import dynamicquad.agilehub.issue.repository.IssueRepository;
import dynamicquad.agilehub.issue.repository.TaskRepository;
import dynamicquad.agilehub.issue.service.command.ImageService;
+import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator;
import dynamicquad.agilehub.member.domain.Member;
import dynamicquad.agilehub.member.dto.AssigneeDto;
import dynamicquad.agilehub.member.service.MemberService;
@@ -34,6 +35,7 @@ public class StoryFactory implements IssueFactory {
private final IssueRepository issueRepository;
private final TaskRepository taskRepository;
private final ImageService imageService;
+ private final IssueNumberGenerator issueNumberGenerator;
private final MemberService memberService;
@@ -43,9 +45,9 @@ public class StoryFactory implements IssueFactory {
@Transactional
@Override
public Long createIssue(IssueRequestDto.CreateIssue request, Project project) {
- // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ]
- // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ]
- int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1);
+
+ // 이슈 번호 생성
+ String issueNumber = issueNumberGenerator.generate(project.getKey());
Member assignee = memberService.findMember(request.getAssigneeId(), project.getId());
Epic upEpic = retrieveEpicFromParentIssue(request.getParentId());
@@ -138,7 +140,7 @@ private void validateParentIssue(Long parentId) {
}
}
- private Story toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee,
+ private Story toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee,
Epic upEpic) {
return Story.builder()
.title(request.getTitle())
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java
index 994daaf..d28a830 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java
@@ -10,6 +10,7 @@
import dynamicquad.agilehub.issue.dto.IssueResponseDto;
import dynamicquad.agilehub.issue.repository.IssueRepository;
import dynamicquad.agilehub.issue.service.command.ImageService;
+import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator;
import dynamicquad.agilehub.member.domain.Member;
import dynamicquad.agilehub.member.dto.AssigneeDto;
import dynamicquad.agilehub.member.service.MemberService;
@@ -30,6 +31,7 @@ public class TaskFactory implements IssueFactory {
private final IssueRepository issueRepository;
private final ImageService imageService;
private final MemberService memberService;
+ private final IssueNumberGenerator issueNumberGenerator;
@Value("${aws.s3.workingDirectory.issue}")
private String WORKING_DIRECTORY;
@@ -37,9 +39,8 @@ public class TaskFactory implements IssueFactory {
@Transactional
@Override
public Long createIssue(IssueRequestDto.CreateIssue request, Project project) {
- // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ]
- // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ]
- int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1);
+
+ String issueNumber = issueNumberGenerator.generate(project.getKey());
Member assignee = memberService.findMember(request.getAssigneeId(), project.getId());
Story upStory = retrieveStoryFromParentIssue(request.getParentId());
@@ -117,7 +118,7 @@ private void validateParentIssue(Long parentId) {
}
- private Task toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee,
+ private Task toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee,
Story upStory) {
return Task.builder()
.title(request.getTitle())
diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java
index 56af4fe..82c231b 100644
--- a/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java
+++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java
@@ -2,6 +2,8 @@
import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
+import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
+import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
import dynamicquad.agilehub.project.controller.request.ProjectRequest.ProjectCreateRequest;
import dynamicquad.agilehub.project.controller.request.ProjectRequest.ProjectUpdateRequest;
@@ -23,6 +25,8 @@ public class ProjectService {
private final ProjectRepository projectRepository;
private final MemberProjectService memberProjectService;
+ private final ProjectIssueSequenceRepository issueSequenceRepository;
+
@Transactional
public String createProject(ProjectCreateRequest request, AuthMember authMember) {
validateKeyUniqueness(request.getKey());
@@ -30,6 +34,9 @@ public String createProject(ProjectCreateRequest request, AuthMember authMember)
// 프로젝트 생성자는 프로젝트에 대한 모든 권한을 가짐(ADMIN)
memberProjectService.createMemberProject(authMember, project, MemberProjectRole.ADMIN);
+ // 이슈 시퀀스 생성
+ issueSequenceRepository.save(new ProjectIssueSequence(project.getKey()));
+
return project.getKey();
}
diff --git a/src/main/resources/shema.sql b/src/main/resources/shema.sql
index 1e5509e..1a41303 100644
--- a/src/main/resources/shema.sql
+++ b/src/main/resources/shema.sql
@@ -23,7 +23,7 @@ create table image (
primary key (image_id)
) engine=InnoDB;
create table issue (
- number integer not null,
+ number varchar(255),
issue_id bigint not null auto_increment,
member_id bigint,
project_id bigint,
@@ -36,6 +36,15 @@ create table issue (
label enum ('NONE', 'PLAN', 'DESIGN', 'DEVELOP', 'TEST', 'FEEDBACK'),
primary key (issue_id)
) engine=InnoDB;
+
+CREATE TABLE project_issue_sequence (
+ project_key VARCHAR(255) PRIMARY KEY,
+ last_number INT NOT NULL DEFAULT 0,
+ version BIGINT DEFAULT 0,
+ created_at timestamp(6) not null,
+ updated_at timestamp(6) not null
+) ENGINE=InnoDB;
+
create table member (
created_at timestamp(6) not null,
member_id bigint not null auto_increment,
diff --git a/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java
new file mode 100644
index 0000000..866a858
--- /dev/null
+++ b/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java
@@ -0,0 +1,164 @@
+package dynamicquad.agilehub.issue;
+
+
+import static java.util.stream.Collectors.toSet;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dynamicquad.agilehub.issue.domain.Issue;
+import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
+import dynamicquad.agilehub.issue.dto.IssueRequestDto;
+import dynamicquad.agilehub.issue.repository.IssueRepository;
+import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
+import dynamicquad.agilehub.issue.service.command.IssueService;
+import dynamicquad.agilehub.member.domain.Member;
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import dynamicquad.agilehub.member.repository.MemberRepository;
+import dynamicquad.agilehub.project.domain.MemberProject;
+import dynamicquad.agilehub.project.domain.MemberProjectRepository;
+import dynamicquad.agilehub.project.domain.MemberProjectRole;
+import dynamicquad.agilehub.project.domain.Project;
+import dynamicquad.agilehub.project.domain.ProjectRepository;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest
+@ActiveProfiles("test")
+public class IssueConcurrencyTest {
+
+ @Autowired
+ private IssueService issueService;
+
+ @Autowired
+ private ProjectRepository projectRepository;
+
+ @Autowired
+ private ProjectIssueSequenceRepository projectIssueSequenceRepository;
+
+ @Autowired
+ private IssueRepository issueRepository;
+
+ @Autowired
+ MemberRepository memberRepository;
+
+ @Autowired
+ MemberProjectRepository memberProjectRepository;
+
+ Project testProject;
+ ArrayList authMembers;
+
+ @BeforeEach
+ void init() {
+ testProject = createTestProject();
+ projectRepository.save(testProject);
+ projectIssueSequenceRepository.save(new ProjectIssueSequence(testProject.getKey()));
+ authMembers = new ArrayList<>();
+ }
+
+ @Test
+ @DisplayName("동시에 10명의 사용자가 이슈를 생성할 때")
+ void concurrentCreateTest() throws InterruptedException {
+ //given
+
+ int numberOfUsers = 10;
+ createMembers(numberOfUsers);
+
+ CountDownLatch latch = new CountDownLatch(numberOfUsers);
+ List> futures = new ArrayList<>();
+
+ // when
+
+ for (int i = 0; i < numberOfUsers; i++) {
+ Integer finalI = i;
+ CompletableFuture future = CompletableFuture.supplyAsync(() -> {
+ try {
+ IssueRequestDto.CreateIssue request = createIssueRequest();
+ return issueService.createIssue(testProject.getKey(), request, authMembers.get(finalI));
+ } finally {
+ latch.countDown();
+ }
+ }).exceptionally(e -> {
+ Throwable cause = e.getCause();
+ System.out.println("발생한 예외: " + cause.getClass().getName());
+ System.out.println("예외 메시지: " + cause.getMessage());
+ System.out.println("USER 에러 발생");
+ return null;
+ });
+ futures.add(future);
+ }
+
+ // then
+ latch.await(10, TimeUnit.SECONDS); // 최대 10초 대기
+
+ // 모든 Future 완료 대기
+ List issueIds = futures.stream()
+ .map(CompletableFuture::join)
+ .toList();
+
+ // 생성된 이슈 검증
+ List issues = issueRepository.findAllById(issueIds);
+
+ // 이슈 번호 중복 검사
+ Set issueNumbers = issues.stream()
+ .map(Issue::getNumber)
+ .collect(toSet());
+
+ assertThat(issueNumbers).hasSize(numberOfUsers);
+ }
+
+ private IssueRequestDto.CreateIssue createIssueRequest() {
+ return IssueRequestDto.CreateIssue.builder()
+ .title("이슈 제목")
+ .content("이슈 내용")
+ .type(IssueType.EPIC)
+ .build();
+ }
+
+
+ private void createMembers(int memberCount) {
+ for (int j = 0; j < memberCount; j++) {
+ Member member = Member.builder()
+ .name("사용자" + j)
+ .build();
+ memberRepository.save(member);
+ memberProjectRepository.save(MemberProject.builder()
+ .member(member)
+ .project(testProject)
+ .role(MemberProjectRole.ADMIN)
+ .build());
+ authMembers.add(AuthMember.builder()
+ .id(member.getId())
+ .name(member.getName())
+ .build());
+ }
+ }
+
+ private Project createTestProject() {
+ return Project.builder()
+ .name("테스트 프로젝트")
+ .key("TEST")
+ .build();
+ }
+
+ @AfterEach
+ void cleanUp() {
+ issueRepository.deleteAll();
+ memberProjectRepository.deleteAll();
+ projectRepository.deleteAll();
+ memberRepository.deleteAll();
+ projectIssueSequenceRepository.deleteAll();
+
+
+ }
+
+}
diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java
index eb7dabb..d8d9eb3 100644
--- a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java
@@ -75,7 +75,7 @@ void setUp() {
Epic epic = Epic.builder()
.title("에픽1")
.content("에픽1 내용")
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.startDate(LocalDate.of(2024, 1, 1))
.endDate(LocalDate.of(2024, 1, 10))
diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java
index b2f4aeb..1b22231 100644
--- a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java
@@ -24,49 +24,6 @@ class IssueRepositoryTest {
@Autowired
private IssueRepository issueRepository;
- @Test
- @Transactional
- void 특정_프로젝트키를_가진_이슈들의_총_개수를_구한다() {
- // given
- Project project1 = createProject("프로젝트1", "project1");
- em.persist(project1);
-
- Project project2 = createProject("프로젝트2", "project2");
- em.persist(project2);
-
- Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1);
- em.persist(epic1P1);
-
- Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1);
- em.persist(epic2P1);
-
- Story story1P1 = createStory("스토리1", "스토리1 내용", project1);
- em.persist(story1P1);
-
- Story storyP2 = createStory("스토리2", "스토리2 내용", project2);
- em.persist(storyP2);
-
- Task taskP2 = createTask("태스크1", "태스크1 내용", project2);
- em.persist(taskP2);
-
- // when
- // then
- assertThat(issueRepository.countByProjectKey("project1")).isEqualTo(3);
- assertThat(issueRepository.countByProjectKey("project2")).isEqualTo(2);
-
- }
-
-
- @Test
- @Transactional
- void 특정_프로젝트키를_가진_이슈가_없을때_0을_반환한다() {
- // given
- Project project1 = createProject("프로젝트1", "project1141");
- em.persist(project1);
- // when
- // then
- assertThat(issueRepository.countByProjectKey("project1")).isEqualTo(0);
- }
@Test
@Transactional
diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java
index 3d59251..c18c442 100644
--- a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java
@@ -74,7 +74,7 @@ void setUp() {
Epic epic = Epic.builder()
.title("에픽1")
.content("에픽1 내용")
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.assignee(member1)
.project(project)
@@ -87,7 +87,7 @@ void setUp() {
Story story = Story.builder()
.title("스토리1")
.content("스토리1 내용")
- .number(1)
+ .number("")
.status(IssueStatus.PROGRESS)
.assignee(member1)
.project(project)
diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java
index ae14733..4f0a92c 100644
--- a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java
@@ -30,13 +30,13 @@ class IssueServiceTest {
@PersistenceContext
EntityManager em;
-
@Transactional
@Test
void 에픽이_지워지면_스토리_테스크_모두_지워진다() {
// given
Project project1 = createProject("프로젝트1", "project12311");
em.persist(project1);
+
Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1);
em.persist(epic1P1);
Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1);
diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
index a4ede46..b6fb4c5 100644
--- a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java
@@ -131,7 +131,7 @@ private Issue createTestIssue() {
이슈 내용입니다.
"""
)
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.assignee(null)
.label(IssueLabel.TEST)
diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java
index 6928351..6cf7fff 100644
--- a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java
@@ -6,9 +6,9 @@
import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.issue.IssueType;
-import dynamicquad.agilehub.issue.domain.IssueStatus;
import dynamicquad.agilehub.issue.domain.Epic;
import dynamicquad.agilehub.issue.domain.Image;
+import dynamicquad.agilehub.issue.domain.IssueStatus;
import dynamicquad.agilehub.issue.dto.IssueRequestDto;
import dynamicquad.agilehub.issue.dto.IssueResponseDto;
import dynamicquad.agilehub.issue.dto.IssueResponseDto.ContentDto;
@@ -83,7 +83,6 @@ class EpicFactoryTest {
//then
assertThat(epic.getTitle()).isEqualTo("이슈 제목");
assertThat(epic.getContent()).isEqualTo("content 내용");
- assertThat(epic.getNumber()).isEqualTo(1);
assertThat(epic.getStatus()).isEqualTo(IssueStatus.DO);
assertThat(epic.getStartDate()).isEqualTo(LocalDate.of(2024, 2, 19));
assertThat(epic.getEndDate()).isEqualTo(LocalDate.of(2024, 2, 23));
@@ -168,7 +167,7 @@ class EpicFactoryTest {
Epic epic = Epic.builder()
.title("이슈 제목")
.content("content 내용")
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.assignee(null)
.project(project1)
@@ -207,7 +206,7 @@ class EpicFactoryTest {
Epic epic = Epic.builder()
.title("이슈 제목")
.content("content 내용")
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.assignee(null)
.project(project1)
@@ -255,7 +254,7 @@ class EpicFactoryTest {
Epic epic = Epic.builder()
.title("이슈 제목")
.content("content 내용")
- .number(1)
+ .number("")
.status(IssueStatus.DO)
.assignee(member1)
.project(project1)
diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java
index 66b4d37..e5f74fd 100644
--- a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java
+++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java
@@ -6,11 +6,13 @@
import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.issue.IssueType;
-import dynamicquad.agilehub.issue.domain.IssueStatus;
import dynamicquad.agilehub.issue.domain.Epic;
+import dynamicquad.agilehub.issue.domain.IssueStatus;
+import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
import dynamicquad.agilehub.issue.domain.Story;
import dynamicquad.agilehub.issue.domain.Task;
import dynamicquad.agilehub.issue.dto.IssueRequestDto;
+import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import dynamicquad.agilehub.project.domain.Project;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@@ -30,6 +32,9 @@ class TaskFactoryTest {
@Autowired
private TaskFactory taskFactory;
+ @Autowired
+ private ProjectIssueSequenceRepository projectIssueSequenceRepository;
+
@Test
@Transactional
void 부모이슈가_에픽이거나_테스크일때_예외처리() {
@@ -37,6 +42,8 @@ class TaskFactoryTest {
Project project = createProject("프로젝트1", "project124151");
em.persist(project);
+ projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey()));
+
Epic epic = Epic.builder()
.title("에픽제목")
.status(IssueStatus.DONE)
@@ -85,6 +92,7 @@ class TaskFactoryTest {
// given
Project project = createProject("프로젝트1", "proje123ct1");
em.persist(project);
+ projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey()));
Story story = Story.builder()
.title("story제목")
diff --git a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java
index 4819542..600bd64 100644
--- a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java
+++ b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java
@@ -79,7 +79,7 @@ private Epic createEpic(String title, String content, Project project) {
return Epic.builder()
.title(title)
.content(content)
- .number(1)
+ .number("")
.project(project)
.build();
}
@@ -89,7 +89,7 @@ private Story createStory(String title, String content, Project project) {
.title(title)
.content(content)
.project(project)
- .number(2)
+ .number("")
.build();
}
@@ -98,7 +98,7 @@ private Task createTask(String title, String content, Project project) {
.title(title)
.content(content)
.project(project)
- .number(3)
+ .number("")
.build();
}
From 04ec1c6748fb9222e452a242edc23a9b03a374fb Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Sat, 9 Nov 2024 16:31:02 +0900
Subject: [PATCH 07/45] =?UTF-8?q?refactor:=20hikari=20pool=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?=
=?UTF-8?q?=EC=A0=84=ED=8C=8C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agilehub/issue/service/command/ImageService.java | 2 +-
.../issue/service/command/IssueNumberGenerator.java | 4 +---
.../agilehub/issue/service/command/IssueService.java | 2 +-
.../agilehub/issue/service/factory/EpicFactory.java | 2 --
.../agilehub/member/service/MemberService.java | 1 +
src/main/resources/application-prod.yml | 10 ++++++++++
src/main/resources/application.yml | 4 ++++
7 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java
index f8d5b28..1882c0c 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java
@@ -17,7 +17,7 @@ public class ImageService {
private final ImageRepository imageRepository;
private final PhotoS3Manager photoS3Manager;
-
+
public void saveImages(Issue issue, List files, String workingDirectory) {
List imagePath = photoS3Manager.uploadPhotos(files, workingDirectory);
if (imagePath.isEmpty()) {
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index ce2b80e..1afc9fb 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -4,7 +4,6 @@
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -13,8 +12,7 @@ public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
- @Transactional(propagation = Propagation.REQUIRES_NEW)
- //@Retry
+ @Transactional
public String generate(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
index d085949..8dd7529 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java
@@ -35,7 +35,7 @@ public class IssueService {
private final IssueRepository issueRepository;
- @Transactional
+
public Long createIssue(String key, IssueRequestDto.CreateIssue request, AuthMember authMember) {
Project project = validateMemberInProject(key, authMember);
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
index 0d5d5e2..d176c8e 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java
@@ -43,8 +43,6 @@ public class EpicFactory implements IssueFactory {
public Long createIssue(IssueRequestDto.CreateIssue request, Project project) {
// 이슈 번호 생성
- // 리트라이 3번
-
String issueNumber = issueNumberGenerator.generate(project.getKey());
// 멤버를 찾기
diff --git a/src/main/java/dynamicquad/agilehub/member/service/MemberService.java b/src/main/java/dynamicquad/agilehub/member/service/MemberService.java
index e9fdebb..c75f849 100644
--- a/src/main/java/dynamicquad/agilehub/member/service/MemberService.java
+++ b/src/main/java/dynamicquad/agilehub/member/service/MemberService.java
@@ -21,6 +21,7 @@ public Member save(Member member) {
return memberRepository.save(member);
}
+ @Transactional(readOnly = true)
public Member findMember(Long assigneeId, Long projectId) {
if (assigneeId == null) {
return null;
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 012d6b8..019efbd 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -9,6 +9,16 @@ spring:
password: ${MYSQL_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+ hikari:
+ # 풀 사이즈 설정
+ maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10
+ minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게
+
+ # 시간 관련 설정
+ max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
+ connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API
+ validation-timeout: 1000 # 1초, connection-timeout보다 작게
+
sql:
init:
mode: never
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 97c7b2a..86407e5 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -18,6 +18,10 @@ springdoc:
aws:
+ credentials:
+ accessKey: ${AWS_ACCESS_KEY}
+ secretKey: ${AWS_SECRET_KEY}
+ region: ap-northeast-2
s3:
rootURL: https://image.agilehub.store
bucket: agilehub
From 06333f53f0325415dfa658f883abd15345774174 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sun, 17 Nov 2024 20:55:21 +0900
Subject: [PATCH 08/45] =?UTF-8?q?refactor:=20hikari=20pool=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95=20=EC=A7=80=EC=9B=80=20(=EB=AA=A8=EB=8B=88=ED=84=B0?=
=?UTF-8?q?=EB=A7=81=20=EB=95=8C=EB=AC=B8=EC=97=90)=20(#133)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application-prod.yml | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 019efbd..012d6b8 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -9,16 +9,6 @@ spring:
password: ${MYSQL_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
- hikari:
- # 풀 사이즈 설정
- maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10
- minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게
-
- # 시간 관련 설정
- max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
- connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API
- validation-timeout: 1000 # 1초, connection-timeout보다 작게
-
sql:
init:
mode: never
From 99ff4b5dcc561f96920c7c72756cba9d747352ad Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sun, 17 Nov 2024 21:07:03 +0900
Subject: [PATCH 09/45] Refactor/monitor deadlock (#134)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: hikari pool 설정 지움 (모니터링 때문에)
* refactor: 트랜잭션 2개로 복귀 (모니터링 때문에)
---
.../agilehub/issue/service/command/IssueNumberGenerator.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index 1afc9fb..a30e3c2 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -4,6 +4,7 @@
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -12,7 +13,7 @@ public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
- @Transactional
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
public String generate(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
From 6a5fa0e3badac7cb5014c09b998119b63975ecac Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sun, 17 Nov 2024 21:42:24 +0900
Subject: [PATCH 10/45] Refactor/monitor deadlock (#135)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: hikari pool 설정 지움 (모니터링 때문에)
* refactor: 트랜잭션 2개로 복귀 (모니터링 때문에)
* refactor: hikaripool 로그
---
src/main/resources/application-prod.yml | 10 +++++++++-
src/main/resources/logback-spring.xml | 5 +++++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 012d6b8..796d6e9 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -30,4 +30,12 @@ management:
prometheus:
enabled: true
logfile:
- external-file: ./logs/agilehub-prod.log
\ No newline at end of file
+ external-file: ./logs/agilehub-prod.log
+
+logging:
+ level:
+ com.zaxxer.hikari.HikariConfig: debug
+ com.zaxxer.hikari: TRACE
+ org.springframework.transaction: TRACE
+ file:
+ name: hikari-pool.log
\ No newline at end of file
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index 5162435..ad98b16 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -37,6 +37,11 @@
+
+
+
+
+
From 112050eed00c9c7ad25b9c647ebb7b173c8e036e Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sun, 17 Nov 2024 21:52:18 +0900
Subject: [PATCH 11/45] Refactor/monitor deadlock (#136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: hikari pool 설정 지움 (모니터링 때문에)
* refactor: 트랜잭션 2개로 복귀 (모니터링 때문에)
* refactor: hikaripool 로그
* refactor: hikaripool 로그
From 0be9b79fd2d5a8bfed0edc9087a852cf399b3dc5 Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Sun, 17 Nov 2024 23:59:38 +0900
Subject: [PATCH 12/45] =?UTF-8?q?refactor:=20hikaripool=20=ED=92=80=20?=
=?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20(20->50)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application-prod.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 019efbd..96d443f 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -11,7 +11,7 @@ spring:
hikari:
# 풀 사이즈 설정
- maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10
+ maximum-pool-size: 50 # VUser 20 기준, 톰캣 스레드(200)의 1/10
minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게
# 시간 관련 설정
From 6547538942bb37091ebecd6162568c8b20698b8e Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Mon, 18 Nov 2024 00:12:19 +0900
Subject: [PATCH 13/45] =?UTF-8?q?refactor:=20=ED=9E=88=EC=B9=B4=EB=A6=AC?=
=?UTF-8?q?=ED=92=80=20=EA=B4=80=EB=A0=A8=20=EB=A1=A4=EB=B0=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agilehub/issue/service/command/IssueNumberGenerator.java | 3 +--
src/main/resources/logback-spring.xml | 5 -----
2 files changed, 1 insertion(+), 7 deletions(-)
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index a30e3c2..1afc9fb 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -4,7 +4,6 @@
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -13,7 +12,7 @@ public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
- @Transactional(propagation = Propagation.REQUIRES_NEW)
+ @Transactional
public String generate(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index ad98b16..5162435 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -37,11 +37,6 @@
-
-
-
-
-
From 4b73aeb47fc8c1889b675a836e4bb9bd0faa0949 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 19 Nov 2024 01:04:03 +0900
Subject: [PATCH 14/45] =?UTF-8?q?refactor:=20tomcat=20thread=2050=20?=
=?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B3=A0=20hikari=20pool=2075=20(#137)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../issue/service/command/IssueNumberGenerator.java | 3 ++-
src/main/resources/application-local.yml | 4 ++++
src/main/resources/application-prod.yml | 11 -----------
src/main/resources/application.yml | 13 +++++++++++++
4 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index 1afc9fb..a30e3c2 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -4,6 +4,7 @@
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -12,7 +13,7 @@ public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
- @Transactional
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
public String generate(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml
index aacc7a2..233f222 100644
--- a/src/main/resources/application-local.yml
+++ b/src/main/resources/application-local.yml
@@ -27,3 +27,7 @@ logging:
org.hibernate.SQL: debug
root: info
org.hibernate.orm.jdbc.bind: trace
+ com.zaxxer.hikari: debug
+ com.zaxxer.hikari.HikariConfig: debug
+ com.zaxxer.hikari.HikariPool: debug
+ com.zaxxer.hikari.pool.PoolBase: DEBUG
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 09f069d..012d6b8 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -9,17 +9,6 @@ spring:
password: ${MYSQL_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
-
- hikari:
- # 풀 사이즈 설정
- maximum-pool-size: 50 # VUser 20 기준, 톰캣 스레드(200)의 1/10
- minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게
-
- # 시간 관련 설정
- max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
- connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API
- validation-timeout: 1000 # 1초, connection-timeout보다 작게
-
sql:
init:
mode: never
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 86407e5..163e9a9 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -8,6 +8,19 @@ spring:
max-request-size: 100MB
file-size-threshold: 0B
enabled: true
+ server:
+ tomcat:
+ max-threads: 50
+ min-spare-threads: 25
+ hikari:
+ # 풀 사이즈 설정
+ maximum-pool-size: 75
+ minimum-idle: 75
+
+ # 시간 관련 설정
+ max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
+ connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API
+ validation-timeout: 1000 # 1초, connection-timeout보다 작게
springdoc:
swagger-ui:
From 699fad2950cb5cccf10e90c9f305077bffa179ff Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 19 Nov 2024 08:13:24 +0900
Subject: [PATCH 15/45] Refactor/tomcat and hikaripool (#138)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: tomcat thread 50 그리고 hikari pool 75
* refactor: tomcat thread 최소개수도 50
---
src/main/resources/application.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 163e9a9..89217dc 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -11,7 +11,8 @@ spring:
server:
tomcat:
max-threads: 50
- min-spare-threads: 25
+ min-spare-threads: 50
+
hikari:
# 풀 사이즈 설정
maximum-pool-size: 75
From 3083bd7a71faf46bba500d8be8c966dbe086c3b3 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 19 Nov 2024 08:21:54 +0900
Subject: [PATCH 16/45] Refactor/tomcat and hikaripool (#139)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: tomcat thread 50 그리고 hikari pool 75
* refactor: tomcat thread 최소개수도 50
* refactor: tomcat thread 최소개수도 100
* refactor: tomcat thread 최소개수도 100
---
src/main/resources/application.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 89217dc..a1ac75f 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -10,9 +10,9 @@ spring:
enabled: true
server:
tomcat:
- max-threads: 50
- min-spare-threads: 50
-
+ max-threads: 100
+ min-spare-threads: 100
+
hikari:
# 풀 사이즈 설정
maximum-pool-size: 75
From 7795fe023d3e5be6bdc01baf6ee22cf7376bda6b Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 19 Nov 2024 08:33:35 +0900
Subject: [PATCH 17/45] Refactor/tomcat and hikaripool (#140)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: tomcat thread 50 그리고 hikari pool 75
* refactor: tomcat thread 최소개수도 50
* refactor: tomcat thread 최소개수도 100
* refactor: tomcat thread 최소개수도 100
* refactor: tomcat thread 200
---
src/main/resources/application.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a1ac75f..8b236b6 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -10,8 +10,8 @@ spring:
enabled: true
server:
tomcat:
- max-threads: 100
- min-spare-threads: 100
+ max-threads: 200
+ min-spare-threads: 200
hikari:
# 풀 사이즈 설정
From e700f43ac31daaf64579336e29044e09ae883bab Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 19 Nov 2024 08:42:47 +0900
Subject: [PATCH 18/45] Refactor/tomcat and hikaripool (#141)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: tomcat thread 50 그리고 hikari pool 75
* refactor: tomcat thread 최소개수도 50
* refactor: tomcat thread 최소개수도 100
* refactor: tomcat thread 최소개수도 100
* refactor: tomcat thread 200
* refactor: tomcat thread
---
src/main/resources/application.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 8b236b6..fb6afa8 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -10,8 +10,8 @@ spring:
enabled: true
server:
tomcat:
- max-threads: 200
- min-spare-threads: 200
+ max-threads: 75
+ min-spare-threads: 75
hikari:
# 풀 사이즈 설정
@@ -20,7 +20,7 @@ spring:
# 시간 관련 설정
max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
- connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API
+ connection-timeout: 5000 # 3초, 빠른 응답이 필요한 API
validation-timeout: 1000 # 1초, connection-timeout보다 작게
springdoc:
From 8fb0fe62989272e97ebd072e7521b320afb3d12f Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 08:47:58 +0900
Subject: [PATCH 19/45] refactor: tomcat thread remove
---
src/main/resources/application.yml | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index fb6afa8..07d5b95 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -8,10 +8,6 @@ spring:
max-request-size: 100MB
file-size-threshold: 0B
enabled: true
- server:
- tomcat:
- max-threads: 75
- min-spare-threads: 75
hikari:
# 풀 사이즈 설정
@@ -20,7 +16,7 @@ spring:
# 시간 관련 설정
max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
- connection-timeout: 5000 # 3초, 빠른 응답이 필요한 API
+ connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API
validation-timeout: 1000 # 1초, connection-timeout보다 작게
springdoc:
From ea0bd98d7b8e4d3bfdabde20cb35aba05254b0e0 Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 08:51:26 +0900
Subject: [PATCH 20/45] =?UTF-8?q?refactor:=20=EC=A0=84=ED=8C=8C=20?=
=?UTF-8?q?=EC=97=86=EC=95=A0=EA=B8=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agilehub/issue/service/command/IssueNumberGenerator.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index a30e3c2..1afc9fb 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -4,7 +4,6 @@
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -13,7 +12,7 @@ public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
- @Transactional(propagation = Propagation.REQUIRES_NEW)
+ @Transactional
public String generate(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
From 65e0a6424a1ceaae45f5df2f3c93d1a134d3e2fb Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 21:09:28 +0900
Subject: [PATCH 21/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?=
=?UTF-8?q?i=202=EA=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application.yml | 32 +++++++++++++++++++++---------
1 file changed, 23 insertions(+), 9 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 07d5b95..b1592f7 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -9,15 +9,29 @@ spring:
file-size-threshold: 0B
enabled: true
- hikari:
- # 풀 사이즈 설정
- maximum-pool-size: 75
- minimum-idle: 75
-
- # 시간 관련 설정
- max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
- connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API
- validation-timeout: 1000 # 1초, connection-timeout보다 작게
+ datasource:
+ hikari:
+ # 풀 사이즈 설정
+ maximum-pool-size: 2
+ minimum-idle: 2
+
+ # 시간 관련 설정
+ max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
+ connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API
+ validation-timeout: 1000 # 1초, connection-timeout보다 작게
+
+# 톰켓 설정
+server:
+ tomcat:
+ accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정
+ max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음
+ threads:
+ max: 10 # 최대 스레드 수
+ min-spare: 10 # 초기 스레드 풀 사이즈
+
+
+
+
springdoc:
swagger-ui:
From 59cc140a1015f63a9ed4e4eefd2e5d5aab24e081 Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 21:38:39 +0900
Subject: [PATCH 22/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?=
=?UTF-8?q?i=205=EA=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index b1592f7..781a895 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -12,8 +12,8 @@ spring:
datasource:
hikari:
# 풀 사이즈 설정
- maximum-pool-size: 2
- minimum-idle: 2
+ maximum-pool-size: 5
+ minimum-idle: 5
# 시간 관련 설정
max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
From 24ae197db232e319cf0c97e4d2541a717274cfd7 Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 21:44:48 +0900
Subject: [PATCH 23/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?=
=?UTF-8?q?i=208=EA=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 781a895..bf9bcc0 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -12,8 +12,8 @@ spring:
datasource:
hikari:
# 풀 사이즈 설정
- maximum-pool-size: 5
- minimum-idle: 5
+ maximum-pool-size: 8
+ minimum-idle: 8
# 시간 관련 설정
max-lifetime: 50000 # 50초, DB wait_timeout보다 작게
From d23805c1cbb75091a00f9f6a762b90252e7c80fb Mon Sep 17 00:00:00 2001
From: minsang-alt
Date: Tue, 19 Nov 2024 21:51:35 +0900
Subject: [PATCH 24/45] =?UTF-8?q?refactor:=20thread=2016=EA=B0=9C,=20hikar?=
=?UTF-8?q?i=208=EA=B0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index bf9bcc0..75f4ef7 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -26,8 +26,8 @@ server:
accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정
max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음
threads:
- max: 10 # 최대 스레드 수
- min-spare: 10 # 초기 스레드 풀 사이즈
+ max: 16 # 최대 스레드 수
+ min-spare: 16 # 초기 스레드 풀 사이즈
From a4d8f2efd5d1f7027648e03f07515e0b7a6044af Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Wed, 20 Nov 2024 01:09:38 +0900
Subject: [PATCH 25/45] =?UTF-8?q?feat:=20redis=20issuenumber=20=EC=A6=9D?=
=?UTF-8?q?=EA=B0=80=20(#142)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agilehub/global/config/RedisConfig.java | 25 ++++-----
.../service/command/IssueNumberGenerator.java | 56 +++++++++++++++++--
src/main/resources/application-redis.yml | 5 ++
src/main/resources/application.yml | 4 +-
4 files changed, 68 insertions(+), 22 deletions(-)
diff --git a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
index 9a99b93..dd9f253 100644
--- a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
+++ b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
@@ -7,11 +7,10 @@
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
-@EnableRedisRepositories
+//@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
@@ -21,20 +20,18 @@ public class RedisConfig {
private int port;
@Bean
- public RedisConnectionFactory redisConnectionFactory() {
- RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
- redisStandaloneConfiguration.setHostName(host);
- redisStandaloneConfiguration.setPort(port);
-// redisStandaloneConfiguration.setPassword("password");
-
- return new LettuceConnectionFactory(redisStandaloneConfiguration);
+ public RedisTemplate redisTemplate() {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new StringRedisSerializer());
+ template.setConnectionFactory(redisConnectionFactory());
+ return template;
}
@Bean
- public RedisTemplate redisTemplate() {
- StringRedisTemplate redisTemplate = new StringRedisTemplate();
- redisTemplate.setConnectionFactory(redisConnectionFactory());
- return redisTemplate;
+ public RedisConnectionFactory redisConnectionFactory() {
+ RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
+ return new LettuceConnectionFactory(config);
}
}
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index 1afc9fb..6b5dcf4 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -2,26 +2,56 @@
import dynamicquad.agilehub.issue.domain.ProjectIssueSequence;
import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository;
+import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
+@Slf4j
public class IssueNumberGenerator {
private final ProjectIssueSequenceRepository issueSequenceRepository;
+ private final RedisTemplate redisTemplate;
- @Transactional
- public String generate(String projectKey) {
- ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
- .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
+ @Value("${redis.issue.number.prefix}")
+ private String REDIS_ISSUE_PREFIX;
+
+// @Transactional
+// public String generate(String projectKey) {
+// ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
+// .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
+//
+// sequence.updateLastNumber(sequence.getNextNumber());
+//
+// return projectKey + "-" + sequence.getLastNumber();
+// }
- sequence.updateLastNumber(sequence.getNextNumber());
+ public String generate(String projectKey) {
+ String redisKey = REDIS_ISSUE_PREFIX + projectKey;
+ Long nextNumber = redisTemplate.opsForValue().increment(redisKey);
+ return projectKey + "-" + nextNumber;
+ }
- return projectKey + "-" + sequence.getLastNumber();
+ @Scheduled(fixedDelay = 1000L * 18L)
+ @Transactional
+ public void syncWithDatabase() {
+ log.info("찍히나?");
+ issueSequenceRepository.findAll().forEach(sequence -> {
+ String redisKey = REDIS_ISSUE_PREFIX + sequence.getProjectKey();
+ String currentValue = redisTemplate.opsForValue().get(redisKey);
+ if (currentValue != null) {
+ sequence.updateLastNumber(Integer.parseInt(currentValue));
+ }
+ });
}
+
@Transactional
public void decrement(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
@@ -29,4 +59,18 @@ public void decrement(String projectKey) {
sequence.decrement();
}
+
+ @PostConstruct
+ public void initializeRedis() {
+ issueSequenceRepository.findAll().forEach(seq -> {
+ String redisKey = REDIS_ISSUE_PREFIX + seq.getProjectKey();
+ redisTemplate.opsForValue().set(redisKey, String.valueOf(seq.getLastNumber()));
+ });
+ }
+
+ // 내부 트랜잭션 호출이라 안됨
+// @PreDestroy
+// public void saveToDatabase() {
+// syncWithDatabase(); // 종료 전 마지막 동기화
+// }
}
diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml
index 36ca51a..ac62e83 100644
--- a/src/main/resources/application-redis.yml
+++ b/src/main/resources/application-redis.yml
@@ -4,3 +4,8 @@ spring:
host: ${REDIS_HOST}
port: 6379
# password:
+
+redis:
+ issue:
+ number:
+ prefix: ISSUE
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 75f4ef7..bf9bcc0 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -26,8 +26,8 @@ server:
accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정
max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음
threads:
- max: 16 # 최대 스레드 수
- min-spare: 16 # 초기 스레드 풀 사이즈
+ max: 10 # 최대 스레드 수
+ min-spare: 10 # 초기 스레드 풀 사이즈
From d3b4b0cf356e96fe09c92d4519961eb9ed246d26 Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Wed, 20 Nov 2024 18:04:38 +0900
Subject: [PATCH 26/45] Refactor/redis issue generate (#143)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: redis issuenumber 증가
* feat: redis write-back 전략을 위해 스케줄러 추가
---
.../global/config/SchedulingConfig.java | 29 +++++++++++++++++++
.../service/command/IssueNumberGenerator.java | 26 ++++++++++-------
src/main/resources/application-redis.yml | 3 +-
3 files changed, 47 insertions(+), 11 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java
diff --git a/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java b/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java
new file mode 100644
index 0000000..22e15fd
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java
@@ -0,0 +1,29 @@
+package dynamicquad.agilehub.global.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+@Configuration
+@EnableScheduling
+@Slf4j
+public class SchedulingConfig implements SchedulingConfigurer {
+
+ @Override
+ public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(10); // 풀 크기 설정
+ scheduler.setThreadNamePrefix("my-scheduled-task-");
+ scheduler.setAwaitTerminationSeconds(60);
+ scheduler.setWaitForTasksToCompleteOnShutdown(true); // 종료 시 실행 중인 작업 완료 대기
+ scheduler.setErrorHandler(throwable ->
+ log.error("Scheduled task error", throwable)); // 에러 핸들링
+
+ scheduler.initialize();
+ taskRegistrar.setTaskScheduler(scheduler);
+ }
+
+}
diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
index 6b5dcf4..8f6acfa 100644
--- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
+++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java
@@ -38,21 +38,32 @@ public String generate(String projectKey) {
return projectKey + "-" + nextNumber;
}
- @Scheduled(fixedDelay = 1000L * 18L)
+ @Scheduled(fixedDelay = 1000L * 30L)
@Transactional
public void syncWithDatabase() {
- log.info("찍히나?");
issueSequenceRepository.findAll().forEach(sequence -> {
String redisKey = REDIS_ISSUE_PREFIX + sequence.getProjectKey();
String currentValue = redisTemplate.opsForValue().get(redisKey);
+
if (currentValue != null) {
- sequence.updateLastNumber(Integer.parseInt(currentValue));
+ try {
+ int redisValue = Integer.parseInt(currentValue);
+
+ // 현재 DB 값보다 큰 경우에만 업데이트
+ if (redisValue > sequence.getLastNumber()) {
+ sequence.updateLastNumber(redisValue);
+ log.debug("Synced sequence for project {}: {}",
+ sequence.getProjectKey(), redisValue);
+ }
+
+ } catch (NumberFormatException e) {
+ log.error("Invalid number format in Redis for key: {}", redisKey, e);
+ redisTemplate.delete(redisKey);
+ }
}
});
}
-
- @Transactional
public void decrement(String projectKey) {
ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey)
.orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found"));
@@ -68,9 +79,4 @@ public void initializeRedis() {
});
}
- // 내부 트랜잭션 호출이라 안됨
-// @PreDestroy
-// public void saveToDatabase() {
-// syncWithDatabase(); // 종료 전 마지막 동기화
-// }
}
diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml
index ac62e83..c319c3b 100644
--- a/src/main/resources/application-redis.yml
+++ b/src/main/resources/application-redis.yml
@@ -8,4 +8,5 @@ spring:
redis:
issue:
number:
- prefix: ISSUE
\ No newline at end of file
+ prefix: 'ISSUE::'
+
From 1087f94d51137bb0b5f061773a2d774c7915ac8c Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Sat, 7 Dec 2024 17:35:13 +0900
Subject: [PATCH 27/45] =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=B4=88=EB=8C=80?=
=?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?=
=?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#145)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: redis replication 구성
* refactor: 이메일 비동기 처리 서비스로 변경
---
docker-compose.yml | 31 ++++++++++--
.../agilehub/global/config/AsyncConfig.java | 29 +++++++++++
.../agilehub/global/config/RedisConfig.java | 5 ++
.../global/mail/service/EmailService.java | 50 +++++++++++++------
.../agilehub/issue/aspect/Retry.java | 5 ++
.../agilehub/issue/aspect/RetryAspect.java | 43 ++++++++++------
.../project/service/ProjectInviteService.java | 14 ++++--
src/main/resources/application-redis.yml | 1 +
8 files changed, 142 insertions(+), 36 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
diff --git a/docker-compose.yml b/docker-compose.yml
index a89b647..abe88a0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,3 @@
-# 로컬 DB 구성
version: '3'
services:
@@ -21,18 +20,42 @@ services:
- app-network
restart: always
- redis:
+ redis-master:
image: redis:7.0
- container_name: redis
+ container_name: redis-master
ports:
- "0.0.0.0:6379:6379"
volumes:
- ./redis/data:/data
- command: redis-server --appendonly yes
+ command: redis-server --requirepass ready!action --appendonly yes
networks:
- app-network
restart: always
+ redis-slave1:
+ image: redis:7.0
+ container_name: redis-slave1
+ ports:
+ - "0.0.0.0:6380:6379"
+ command: redis-server --requirepass yourstrong!password --masterauth ready!action --replicaof redis-master 6379 --appendonly yes
+ networks:
+ - app-network
+ restart: always
+ depends_on:
+ - redis-master
+
+ redis-slave2:
+ image: redis:7.0
+ container_name: redis-slave2
+ ports:
+ - "0.0.0.0:6381:6379"
+ command: redis-server --requirepass yourstrong!password --masterauth ready!action --replicaof redis-master 6379 --appendonly yes
+ networks:
+ - app-network
+ restart: always
+ depends_on:
+ - redis-master
+
networks:
app-network:
driver: bridge
\ No newline at end of file
diff --git a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
new file mode 100644
index 0000000..932352c
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
@@ -0,0 +1,29 @@
+package dynamicquad.agilehub.global.config;
+
+import java.util.concurrent.Executor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ private static final boolean WAIT_TASK_COMPLETE = true;
+ private static final int AWAIT_TERMINATION_SECONDS = 30;
+
+ @Bean(name = "emailExecutor")
+ public Executor emailExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(5);
+ executor.setMaxPoolSize(30);
+ executor.setQueueCapacity(50);
+ executor.setThreadNamePrefix("email-");
+ executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE);
+ executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS);
+ executor.initialize();
+
+ return executor;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
index dd9f253..1317720 100644
--- a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
+++ b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java
@@ -19,6 +19,9 @@ public class RedisConfig {
@Value("${spring.data.redis.port}")
private int port;
+ @Value("${spring.data.redis.password}")
+ private String password;
+
@Bean
public RedisTemplate redisTemplate() {
RedisTemplate template = new RedisTemplate<>();
@@ -31,6 +34,8 @@ public RedisTemplate redisTemplate() {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
+ config.setPassword(password);
+
return new LettuceConnectionFactory(config);
}
diff --git a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java
index 4214f70..4f89671 100644
--- a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java
+++ b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java
@@ -3,13 +3,19 @@
import dynamicquad.agilehub.global.exception.GeneralException;
import dynamicquad.agilehub.global.header.status.ErrorStatus;
import dynamicquad.agilehub.global.mail.model.EmailInfo;
+import dynamicquad.agilehub.issue.aspect.Retry;
import dynamicquad.agilehub.project.model.InviteEmailInfo;
import dynamicquad.agilehub.report.SummaryEmailInfo;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.springframework.mail.MailSendException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.thymeleaf.context.Context;
@@ -22,21 +28,37 @@ public class EmailService {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
- public void sendMail(EmailInfo emailInfo, String type) {
- MimeMessage mimeMailMessage = mailSender.createMimeMessage();
- try {
- MimeMessageHelper helper = new MimeMessageHelper(mimeMailMessage, false, "UTF-8");
- helper.setTo(emailInfo.getTo());
- helper.setSubject(emailInfo.getSubject());
- if (StringUtils.hasText(type)) {
- helper.setText(setContext(emailInfo, type), true);
- } else {
- helper.setText(emailInfo.getMessage());
+ @Async("emailExecutor")
+ @Retry(maxRetries = 3, // 최대 3번 시도
+ retryFor = { MessagingException.class, MailSendException.class }, // 이메일 관련 예외만 재시도
+ delay = 1000 // 1초 대기 후 재시도
+ )
+ public CompletableFuture sendMail(EmailInfo emailInfo, String type) {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ MimeMessage message = createMimeMessage(emailInfo, type);
+ mailSender.send(message);
+ } catch (MessagingException me) {
+ throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT);
}
- mailSender.send(mimeMailMessage);
- } catch (MessagingException me) {
- throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT);
- }
+ });
+ }
+
+ private MimeMessage createMimeMessage(EmailInfo emailInfo, String type) throws MessagingException {
+ MimeMessage mimeMessage = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
+
+ helper.setTo(emailInfo.getTo());
+ helper.setSubject(emailInfo.getSubject());
+ helper.setText(getEmailContent(emailInfo, type), true);
+
+ return mimeMessage;
+ }
+
+ private String getEmailContent(EmailInfo emailInfo, String type) {
+ return StringUtils.hasText(type)
+ ? setContext(emailInfo, type)
+ : emailInfo.getMessage();
}
private String setContext(EmailInfo emailInfo, String type) {
diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
index 1adbd20..322dcfb 100644
--- a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
+++ b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java
@@ -8,4 +8,9 @@
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
+ int maxRetries() default 5;
+
+ long delay() default 100;
+
+ Class extends Throwable>[] retryFor() default Exception.class;
}
diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
index 6006ae9..c7d2c1b 100644
--- a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
+++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java
@@ -1,41 +1,54 @@
package dynamicquad.agilehub.issue.aspect;
-import static dynamicquad.agilehub.global.header.status.ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER;
-
-import dynamicquad.agilehub.global.exception.GeneralException;
+import java.util.Arrays;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
-import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
@Aspect
-@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션보다 더 낮은 순서로 실행
+@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
@Slf4j
public class RetryAspect {
- private static final int MAX_RETRIES = 5;
@Around("@annotation(dynamicquad.agilehub.issue.aspect.Retry)")
public Object retry(ProceedingJoinPoint joinPoint) throws Throwable {
+ // Retry 어노테이션 정보 가져오기
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Retry retry = signature.getMethod().getAnnotation(Retry.class);
+
int attempts = 0;
- while (attempts < MAX_RETRIES) {
+ int maxAttempts = retry.maxRetries();
+
+ while (attempts < maxAttempts) {
try {
- log.info("시작 시도: {}", attempts + 1);
+ log.info("시도 #{}", attempts + 1);
return joinPoint.proceed();
- } catch (ObjectOptimisticLockingFailureException e) {
+ } catch (Exception e) {
attempts++;
- if (attempts == MAX_RETRIES) {
- log.error("Failed after {} attempts", MAX_RETRIES, e);
- throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER);
+
+ // 설정된 예외 타입들 중 하나와 일치하는지 확인
+ boolean shouldRetry = Arrays.stream(retry.retryFor())
+ .anyMatch(exceptionType -> exceptionType.isInstance(e));
+
+ if (!shouldRetry) {
+ throw e; // 재시도 대상이 아닌 예외는 즉시 던짐
+ }
+
+ if (attempts == maxAttempts) {
+ log.error("{}회 시도 후 실패", maxAttempts, e);
+ throw e;
}
- log.warn("Retry attempt {} of {}", attempts, MAX_RETRIES);
- Thread.sleep(100); // 재시도 전 잠시 대기
+
+ log.warn("재시도 {}/{}", attempts, maxAttempts);
+ Thread.sleep(retry.delay());
}
}
- throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER);
+ throw new RuntimeException("예기치 않은 재시도 실패");
}
}
diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java
index 0dfe143..6a9e7d5 100644
--- a/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java
+++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java
@@ -9,12 +9,15 @@
import dynamicquad.agilehub.project.domain.Project;
import dynamicquad.agilehub.project.model.InviteEmailInfo;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
+@Slf4j
public class ProjectInviteService {
private static final String INVITE_SUBJECT = "[AgileHub] 초대코드";
@@ -25,7 +28,7 @@ public class ProjectInviteService {
private final InviteRedisService inviteRedisService;
public void sendInviteEmail(MemberRequestDto.AuthMember authMember,
- ProjectInviteRequestDto.SendInviteMail sendInviteMail) {
+ ProjectInviteRequestDto.SendInviteMail sendInviteMail) {
memberProjectService.validateMemberInProject(authMember.getId(), sendInviteMail.getProjectId());
memberProjectService.validateMemberRole(authMember, sendInviteMail.getProjectId());
@@ -40,12 +43,17 @@ public void sendInviteEmail(MemberRequestDto.AuthMember authMember,
.inviteCode(inviteCode)
.build();
- emailService.sendMail(emailInfo, "invite");
+ emailService.sendMail(emailInfo, "invite")
+ .thenRun(() -> log.info("이메일 전송 완료"))
+ .exceptionally(e -> {
+ log.error("이메일 전송 실패", e);
+ return null;
+ });
}
@Transactional
public Project receiveInviteEmail(MemberRequestDto.AuthMember authMember,
- String inviteCode) {
+ String inviteCode) {
InviteRedisEntity inviteRedisEntity = inviteRedisService.findByInviteCode(inviteCode);
Project project = Project.createPojoProject(Long.parseLong(inviteRedisEntity.getProjectId()));
diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml
index c319c3b..bbbe1d6 100644
--- a/src/main/resources/application-redis.yml
+++ b/src/main/resources/application-redis.yml
@@ -3,6 +3,7 @@ spring:
redis:
host: ${REDIS_HOST}
port: 6379
+ password: ready!action
# password:
redis:
From 7fab1bf8335c076f6748ce7106f4d398e58b7e2e Mon Sep 17 00:00:00 2001
From: Min Sang
Date: Tue, 24 Dec 2024 19:10:09 +0900
Subject: [PATCH 28/45] =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=B4=88=EB=8C=80?=
=?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EC=84=9C?=
=?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=84=A4=EA=B3=84=20=20=EB=B0=8F=20?=
=?UTF-8?q?=EA=B3=A0=EB=A0=A4=20=20(#146)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 멤버초대 컨트롤러, 서비스 틀 구축
* feat: UUID 초대토큰 생성
* feat: ses 서비스 도입
* feat: 이메일 초대 토큰 키-값 레디스에 저장
* feat: 이메일 초대 토큰 키-값 레디스에 저장
* feat: 비동기 적용 및 외부서비스 장애 발생 시 처리 로직 구현
* feat: 타임아웃 설정
* refactor: 이메일 스레드 개수
* refactor: 딜레이 1초는 너무 길기때문에 500ms로 줄임
---
build.gradle | 2 +
.../agilehub/email/AmazonSESService.java | 67 +++++++++
.../agilehub/email/InvitationController.java | 28 ++++
.../agilehub/email/InvitationService.java | 12 ++
.../agilehub/email/InvitationServiceImpl.java | 128 ++++++++++++++++++
.../agilehub/email/InvitationStatus.java | 26 ++++
.../agilehub/email/SMTPService.java | 8 ++
.../agilehub/global/config/AsyncConfig.java | 12 +-
.../global/config/DefaultSESConfig.java | 33 +++++
.../global/config/TemplateConfig.java | 18 +++
.../global/header/status/ErrorStatus.java | 3 +
.../global/util/RandomStringUtil.java | 26 ++++
src/main/resources/application.yml | 4 +
src/main/resources/templates/invite.html | 4 +-
.../agilehub/email/AmazonSESServiceTest.java | 26 ++++
.../global/util/RandomStringUtilTest.java | 85 ++++++++++++
16 files changed, 476 insertions(+), 6 deletions(-)
create mode 100644 src/main/java/dynamicquad/agilehub/email/AmazonSESService.java
create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationController.java
create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationService.java
create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java
create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationStatus.java
create mode 100644 src/main/java/dynamicquad/agilehub/email/SMTPService.java
create mode 100644 src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java
create mode 100644 src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java
create mode 100644 src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java
create mode 100644 src/test/java/dynamicquad/agilehub/global/util/RandomStringUtilTest.java
diff --git a/build.gradle b/build.gradle
index 509b294..41cd867 100644
--- a/build.gradle
+++ b/build.gradle
@@ -65,6 +65,8 @@ dependencies {
// mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'com.amazonaws:aws-java-sdk-ses:1.12.408'
+
// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java
new file mode 100644
index 0000000..d017965
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java
@@ -0,0 +1,67 @@
+package dynamicquad.agilehub.email;
+
+import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
+import com.amazonaws.services.simpleemail.model.Body;
+import com.amazonaws.services.simpleemail.model.Content;
+import com.amazonaws.services.simpleemail.model.Destination;
+import com.amazonaws.services.simpleemail.model.Message;
+import com.amazonaws.services.simpleemail.model.SendEmailRequest;
+import dynamicquad.agilehub.global.exception.GeneralException;
+import dynamicquad.agilehub.global.header.status.ErrorStatus;
+import dynamicquad.agilehub.issue.aspect.Retry;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.context.IContext;
+
+@Service
+@RequiredArgsConstructor
+public class AmazonSESService implements SMTPService {
+
+ private final AmazonSimpleEmailService amazonSimpleEmailService;
+ private final TemplateEngine htmlTemplateEngine;
+
+ @Value("${aws.ses.from}")
+ private String from;
+
+ @Override
+ @Async("emailExecutor")
+ @Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500)
+ public CompletableFuture sendEmail(String subject, Map variables, String... to) {
+ // Amazon SES를 이용한 이메일 발송
+ return CompletableFuture.runAsync(() -> {
+ try {
+ String content = htmlTemplateEngine.process("invite", createContext(variables));
+ SendEmailRequest request = createSendEmailRequest(subject, content, to);
+
+ amazonSimpleEmailService.sendEmail(request);
+ } catch (Exception e) {
+ throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT);
+ }
+ });
+
+
+ }
+
+ private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) {
+ return new SendEmailRequest()
+ .withDestination(new Destination().withToAddresses(to))
+ .withMessage(new Message()
+ .withBody(new Body()
+ .withHtml(new Content().withCharset("UTF-8").withData(content)))
+ .withSubject(new Content().withCharset("UTF-8").withData(subject)))
+ .withSource(from);
+ }
+
+ private IContext createContext(Map variables) {
+ Context context = new Context();
+ context.setVariables(variables);
+
+ return context;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationController.java b/src/main/java/dynamicquad/agilehub/email/InvitationController.java
new file mode 100644
index 0000000..a5a77aa
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/InvitationController.java
@@ -0,0 +1,28 @@
+package dynamicquad.agilehub.email;
+
+import dynamicquad.agilehub.global.auth.model.Auth;
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v2/invitations")
+public class InvitationController {
+
+ private final InvitationService invitationService;
+
+ @PostMapping
+ public ResponseEntity inviteMember(@Auth AuthMember authMember,
+ @RequestBody ProjectInviteRequestDto.SendInviteMail sendInviteMail) {
+ invitationService.sendInvitation(authMember, sendInviteMail);
+ return ResponseEntity.ok().build();
+ }
+
+
+}
diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationService.java b/src/main/java/dynamicquad/agilehub/email/InvitationService.java
new file mode 100644
index 0000000..e2eda40
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/InvitationService.java
@@ -0,0 +1,12 @@
+package dynamicquad.agilehub.email;
+
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto;
+
+public interface InvitationService {
+ void sendInvitation(AuthMember authMember, ProjectInviteRequestDto.SendInviteMail sendInviteMail);
+
+ //초대토큰 유효한지 확인
+ void validateInvitation(String inviteToken);
+}
+
diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java
new file mode 100644
index 0000000..5c0beba
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java
@@ -0,0 +1,128 @@
+package dynamicquad.agilehub.email;
+
+import dynamicquad.agilehub.global.exception.GeneralException;
+import dynamicquad.agilehub.global.header.status.ErrorStatus;
+import dynamicquad.agilehub.global.util.RandomStringUtil;
+import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember;
+import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto.SendInviteMail;
+import dynamicquad.agilehub.project.service.MemberProjectService;
+import dynamicquad.agilehub.project.service.ProjectQueryService;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+@Transactional
+public class InvitationServiceImpl implements InvitationService {
+
+ private final MemberProjectService memberProjectService;
+ private final RedisTemplate redisTemplate;
+ private final SMTPService smtpService;
+ private final ProjectQueryService projectQueryService;
+
+ // 초대 코드 저장 key prefix
+ private static final String KEY_PREFIX = "i:";
+
+ // 초대 코드 상태 저장 key prefix
+ private static final String STATUS_PREFIX = "status:";
+
+ // 초대 코드 만료 시간
+ private static final int EXPIRATION_MINUTES = 10;
+
+ @Override
+ public void sendInvitation(AuthMember authMember, SendInviteMail sendInviteMail) {
+ validateMember(authMember, sendInviteMail.getProjectId());
+ // 진행 중인 초대가 있는 지 확인
+ String email = validateMail(sendInviteMail);
+
+ String token = generateInviteToken();
+ storeInviteToken(token, sendInviteMail);
+ storeInvitationStatus(email, InvitationStatus.PENDING);
+
+ sendEmail(sendInviteMail, token);
+ }
+
+ private String validateMail(SendInviteMail sendInviteMail) {
+ String email = sendInviteMail.getEmail();
+ if (hasActiveInvitation(email)) {
+ throw new GeneralException(ErrorStatus.ALREADY_INVITATION);
+ }
+ else if (isEmailServiceDown(email)) {
+ // 이메일 서비스가 고장나 있을 때
+ throw new GeneralException(ErrorStatus.EMAIL_SEND_FAIL);
+ }
+ return email;
+ }
+
+ private boolean isEmailServiceDown(String email) {
+ String statusKey = STATUS_PREFIX + email;
+ String status = (String) redisTemplate.opsForValue().get(statusKey);
+ return status != null && (InvitationStatus.isFailed(status));
+ }
+
+ private void storeInvitationStatus(String email, InvitationStatus invitationStatus) {
+ String statusKey = STATUS_PREFIX + email;
+ // 10분 동안 이메일 상태 유지
+ redisTemplate.opsForValue().set(statusKey, invitationStatus.name(), EXPIRATION_MINUTES, TimeUnit.MINUTES);
+ }
+
+ private boolean hasActiveInvitation(String email) {
+ String statusKey = STATUS_PREFIX + email;
+ String status = redisTemplate.opsForValue().get(statusKey);
+ return status != null && (InvitationStatus.isPendingOrSending(status));
+ }
+
+ private void storeInviteToken(String token, SendInviteMail sendInviteMail) {
+ String tokenBase64 = RandomStringUtil.uuidToBase64(token);
+ String key = KEY_PREFIX + tokenBase64;
+
+ Map fields = new HashMap<>();
+ fields.put("p", String.valueOf(sendInviteMail.getProjectId())); // projectId -> p
+ fields.put("u", "0"); // used 상태
+
+ redisTemplate.opsForHash().putAll(key, fields);
+ redisTemplate.expire(key, EXPIRATION_MINUTES, TimeUnit.MINUTES);
+ }
+
+
+ private void sendEmail(SendInviteMail sendInviteMail, String token) {
+ final String projectName = projectQueryService.findProjectById(sendInviteMail.getProjectId()).getName();
+
+ Map variables = new HashMap<>();
+ variables.put("inviteCode", token);
+ variables.put("projectName", projectName);
+
+ smtpService.sendEmail("AgileHub 초대 메일", variables, sendInviteMail.getEmail())
+ .thenRun(() -> {
+ storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.SENT);
+ log.info("이메일 전송 완료");
+ })
+ .exceptionally(e -> {
+ log.error("이메일 전송 실패", e);
+ storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED);
+ return null;
+ });
+ }
+
+ private void validateMember(AuthMember authMember, long projectId) {
+ memberProjectService.validateMemberInProject(authMember.getId(), projectId);
+ memberProjectService.validateMemberRole(authMember, projectId);
+ }
+
+ private String generateInviteToken() {
+ return RandomStringUtil.generateUUID();
+ }
+
+
+ @Override
+ public void validateInvitation(String inviteToken) {
+
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java b/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java
new file mode 100644
index 0000000..3c6b590
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java
@@ -0,0 +1,26 @@
+package dynamicquad.agilehub.email;
+
+public enum InvitationStatus {
+ PENDING, // 초기 상태
+ SENDING, // 발송 중
+ SENT, // 발송 완료
+ RETRY, // 재시도 대기
+ FAILED // 최종 실패
+ ;
+
+
+ // string을 전달하면 enum으로 변환
+ public static InvitationStatus fromString(String status) {
+ return InvitationStatus.valueOf(status.toUpperCase());
+ }
+
+ public static boolean isPendingOrSending(String status) {
+ InvitationStatus invitationStatus = fromString(status);
+ return invitationStatus == PENDING || invitationStatus == SENDING;
+ }
+
+ public static boolean isFailed(String status) {
+ InvitationStatus invitationStatus = fromString(status);
+ return invitationStatus == FAILED;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/email/SMTPService.java b/src/main/java/dynamicquad/agilehub/email/SMTPService.java
new file mode 100644
index 0000000..cf34459
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/email/SMTPService.java
@@ -0,0 +1,8 @@
+package dynamicquad.agilehub.email;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+public interface SMTPService {
+ CompletableFuture sendEmail(String subject, Map variables, String... to);
+}
diff --git a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
index 932352c..ea8cc63 100644
--- a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
+++ b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java
@@ -1,6 +1,7 @@
package dynamicquad.agilehub.global.config;
import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@@ -16,14 +17,17 @@ public class AsyncConfig {
@Bean(name = "emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
- executor.setCorePoolSize(5);
- executor.setMaxPoolSize(30);
- executor.setQueueCapacity(50);
+ executor.setCorePoolSize(4); // IO 바운드 작업: 코어수 * 2
+ executor.setMaxPoolSize(44); // CorePoolSize * (1 + 대기시간/서비스시간)
+ executor.setQueueCapacity(80); // (MaxPoolSize - CorePoolSize) * 2
executor.setThreadNamePrefix("email-");
executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE);
executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS);
+ // 거부 정책 설정
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+
executor.initialize();
-
+
return executor;
}
}
diff --git a/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java b/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java
new file mode 100644
index 0000000..28f4bb0
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java
@@ -0,0 +1,33 @@
+package dynamicquad.agilehub.global.config;
+
+import com.amazonaws.ClientConfiguration;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.regions.Regions;
+import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
+import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class DefaultSESConfig {
+ @Value("${aws.ses.accessKey}")
+ private String accessKey;
+
+ @Value("${aws.ses.secretKey}")
+ private String secretKey;
+
+ @Bean
+ public AmazonSimpleEmailService amazonSimpleEmailService() {
+ BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
+ return AmazonSimpleEmailServiceClientBuilder.standard()
+ .withRegion(Regions.AP_NORTHEAST_2)
+ .withCredentials(new AWSStaticCredentialsProvider(credentials))
+ .withClientConfiguration(new ClientConfiguration()
+ .withConnectionTimeout(3000)
+ .withSocketTimeout(5000)
+ .withRequestTimeout(10000))
+ .build();
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java b/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java
new file mode 100644
index 0000000..3dcaeae
--- /dev/null
+++ b/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java
@@ -0,0 +1,18 @@
+package dynamicquad.agilehub.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;
+
+@Configuration
+public class TemplateConfig {
+ @Bean
+ public TemplateEngine htmlTemplateEngine(SpringResourceTemplateResolver springResourceTemplateResolver) {
+ TemplateEngine templateEngine = new SpringTemplateEngine();
+ templateEngine.addTemplateResolver(springResourceTemplateResolver);
+
+ return templateEngine;
+ }
+}
diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
index 62ffcd4..ad681ea 100644
--- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
+++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java
@@ -60,11 +60,14 @@ public enum ErrorStatus implements BaseStatus {
// Email Error
EMAIL_NOT_SENT(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_5001", "이메일이 정상적으로 송신되지 않았습니다."),
INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."),
+ ALREADY_INVITATION(HttpStatus.BAD_REQUEST, "EMAIL_4002", "이미 진행 중인 초대가 있습니다"),
+ EMAIL_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_4003", "이메일 서비스에 문제가 발생했습니다. 나중에 다시 시도해주세요."),
// Optimistic Lock Exception
OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."),
OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER(HttpStatus.LOCKED, "LOCK_4001", "이슈 번호 생성 실패. 다시 시도해주세요.");
+
private final HttpStatus httpStatus;
private final String code;
private final String message;
diff --git a/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java b/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java
index 1c9837b..03fc5f6 100644
--- a/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java
+++ b/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java
@@ -1,7 +1,10 @@
package dynamicquad.agilehub.global.util;
+import java.nio.ByteBuffer;
import java.security.SecureRandom;
+import java.util.Base64;
import java.util.Random;
+import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@@ -13,6 +16,7 @@ public class RandomStringUtil {
private static final String ALL_CHARS = CHAR_UPPERCASE + CHAR_LOWERCASE + CHAR_DIGITS + CHAR_SPECIAL;
+
private RandomStringUtil() {
}
@@ -29,4 +33,26 @@ public static String generateRandomKey(int keyLength) {
return sb.toString();
}
+ public static String generateUUID() {
+ return java.util.UUID.randomUUID().toString();
+ }
+
+ public static String uuidToBase64(String str) {
+ UUID uuid = UUID.fromString(str);
+ ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
+ bb.putLong(uuid.getMostSignificantBits());
+ bb.putLong(uuid.getLeastSignificantBits());
+
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
+ }
+
+ public static String base64ToUUID(String str) {
+ byte[] bytes = Base64.getDecoder().decode(str);
+ ByteBuffer bb = ByteBuffer.wrap(bytes);
+ UUID uuid = new UUID(bb.getLong(), bb.getLong());
+
+ return uuid.toString();
+ }
+
+
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index bf9bcc0..82bcfbe 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -52,6 +52,10 @@ aws:
directory: /images
workingDirectory:
issue: issue
+ ses:
+ accessKey: ${AWS_SES_ACCESS_KEY}
+ secretKey: ${AWS_SES_SECRET_KEY}
+ from: ${AWS_SES_FROM}
openai:
diff --git a/src/main/resources/templates/invite.html b/src/main/resources/templates/invite.html
index 5b4f11a..78615a4 100644
--- a/src/main/resources/templates/invite.html
+++ b/src/main/resources/templates/invite.html
@@ -123,12 +123,12 @@
관리자 및 팀과 함께 작업 계획 및 이슈 추적을 시작합니다
작업을 공유하고 팀이 무엇을 하고 있는지 볼 수 있습니다
-