diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 50c74852..0bd78ee6 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -16,25 +16,34 @@ permissions: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x] + redis-version: [6, 7] steps: - name: Setup MySQL - uses: samin/mysql-action@v1 + uses: mirromutth/mysql-action@v1 with: - character set server: 'utf8' - mysql database: 'asap_dev' - mysql user: 'asap_dev_admin' - mysql password: ${{ secrets.DatabasePassword }} - + mysql root password: 'asap' + mysql user: 'asap' + mysql password: 'asap' + host port: 3306 + container port: 3306 + + - name: Set up redis + uses: supercharge/redis-github-action@1.7.0 + with: + node-version: ${{ matrix.node-version }} + - uses: actions/checkout@v3 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - + - name: make application.properties 파일 생성 run: | ## create application.yml @@ -51,10 +60,16 @@ jobs: # application.yml 파일 확인 cat ./application.yml shell: bash - + # 이 워크플로우는 gradle build - name: Grant execute permission for gradlew run: chmod +x gradlew - + + - name: Wait for MySQL + run: | + while ! mysqladmin ping --host=127.0.0.1 --password='asap' --silent; do + sleep 1 + done + - name: Build with Gradle # 실제 application build - run: ./gradlew build -PactiveProfiles=local + run: ./gradlew clean --stacktrace --info build diff --git a/.gitignore b/.gitignore index 104cbc18..91d53e82 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ output/ *.log *.log.gz *.log-*.gz -logs/ \ No newline at end of file +logs/ + +/src/main/resources/application.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index 82f43f2b..fa571819 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,9 @@ repositories { } dependencies { -// QueryDSL Implementation + implementation "org.redisson:redisson:3.29.0" + + // QueryDSL Implementation implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" @@ -41,7 +43,7 @@ dependencies { // JPA & Database implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'mysql:mysql-connector-java:8.0.32' + implementation 'mysql:mysql-connector-java:8.0.33' // SWAGGER implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' diff --git a/src/main/java/com/asap/server/common/advice/ControllerExceptionAdvice.java b/src/main/java/com/asap/server/common/advice/ControllerExceptionAdvice.java index 0b82892c..47cabbaf 100644 --- a/src/main/java/com/asap/server/common/advice/ControllerExceptionAdvice.java +++ b/src/main/java/com/asap/server/common/advice/ControllerExceptionAdvice.java @@ -10,8 +10,13 @@ import com.asap.server.exception.model.ConflictException; import com.asap.server.exception.model.ForbiddenException; import com.asap.server.exception.model.HostTimeForbiddenException; +import com.asap.server.exception.model.InternalErrorException; import com.asap.server.exception.model.NotFoundException; +import com.asap.server.exception.model.TooManyRequestException; import com.asap.server.exception.model.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.ValidationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -24,9 +29,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.NoHandlerFoundException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolationException; -import jakarta.validation.ValidationException; import java.io.IOException; import static com.asap.server.exception.Error.METHOD_NOT_ALLOWED_EXCEPTION; @@ -133,9 +135,25 @@ protected ErrorResponse handleConflictException(final ConflictException e) { return ErrorResponse.error(e.getError()); } + /* + * 429 Too Many Requests + */ + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + @ExceptionHandler(TooManyRequestException.class) + protected ErrorResponse handleTooManyConflictException(final TooManyRequestException e) { + return ErrorResponse.error(e.getError()); + } + /** * 500 Internal Server */ + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(InternalErrorException.class) + protected ErrorResponse handleInternalErrorException(final InternalErrorException e) { + return ErrorResponse.error(e.getError()); + } + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) protected ErrorResponse handleException(final Exception error, final HttpServletRequest request) throws IOException { diff --git a/src/main/java/com/asap/server/common/aspect/LoggingAspect.java b/src/main/java/com/asap/server/common/aspect/LoggingAspect.java index f944265d..9556594b 100644 --- a/src/main/java/com/asap/server/common/aspect/LoggingAspect.java +++ b/src/main/java/com/asap/server/common/aspect/LoggingAspect.java @@ -1,6 +1,7 @@ package com.asap.server.common.aspect; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.asap.server.common.filter.CustomHttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -9,9 +10,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; -import org.springframework.web.util.ContentCachingRequestWrapper; -import jakarta.servlet.http.HttpServletRequest; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; @@ -21,8 +20,6 @@ @Slf4j public class LoggingAspect { - private final ObjectMapper objectMapper = new ObjectMapper(); - @Pointcut("execution(* com.asap.server.controller..*(..)) || ( execution(* com.asap.server.common.advice..*(..)) && !execution(* com.asap.server.common.advice.ControllerExceptionAdvice.handleException*(..)))") public void controllerInfoLevelExecute() { } @@ -34,7 +31,6 @@ public void controllerErrorLevelExecute() { @Around("com.asap.server.common.aspect.LoggingAspect.controllerInfoLevelExecute()") public Object requestInfoLevelLogging(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); - final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request; long startAt = System.currentTimeMillis(); Object returnValue = proceedingJoinPoint.proceed(proceedingJoinPoint.getArgs()); long endAt = System.currentTimeMillis(); @@ -42,7 +38,7 @@ public Object requestInfoLevelLogging(ProceedingJoinPoint proceedingJoinPoint) t log.info("================================================NEW==============================================="); log.info("====> Request: {} {} ({}ms)\n *Header = {}", request.getMethod(), request.getRequestURL(), endAt - startAt, getHeaders(request)); if ("POST".equalsIgnoreCase(request.getMethod())) { - log.info("====> Body: {}", objectMapper.readTree(cachingRequest.getContentAsByteArray())); + log.info("====> Body: {}", ((CustomHttpServletRequestWrapper) request).getBody()); } if (returnValue != null) { log.info("====> Response: {}", returnValue); @@ -54,14 +50,13 @@ public Object requestInfoLevelLogging(ProceedingJoinPoint proceedingJoinPoint) t @Around("com.asap.server.common.aspect.LoggingAspect.controllerErrorLevelExecute()") public Object requestErrorLevelLogging(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); - final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request; long startAt = System.currentTimeMillis(); Object returnValue = proceedingJoinPoint.proceed(proceedingJoinPoint.getArgs()); long endAt = System.currentTimeMillis(); log.error("====> Request: {} {} ({}ms)\n *Header = {}", request.getMethod(), request.getRequestURL(), endAt - startAt, getHeaders(request)); if ("POST".equalsIgnoreCase(request.getMethod())) { - log.error("====> Body: {}", objectMapper.readTree(cachingRequest.getContentAsByteArray())); + log.error("====> Body: {}", ((CustomHttpServletRequestWrapper) request).getBody()); } if (returnValue != null) { log.error("====> Response: {}", returnValue); diff --git a/src/main/java/com/asap/server/common/filter/CustomHttpServletRequestWrapper.java b/src/main/java/com/asap/server/common/filter/CustomHttpServletRequestWrapper.java new file mode 100644 index 00000000..5cd33593 --- /dev/null +++ b/src/main/java/com/asap/server/common/filter/CustomHttpServletRequestWrapper.java @@ -0,0 +1,61 @@ +package com.asap.server.common.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import lombok.Getter; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +@Getter +public class CustomHttpServletRequestWrapper extends HttpServletRequestWrapper { + private final String body; + + public CustomHttpServletRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + this.body = readBody(request); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream byteInputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) { + + } + + @Override + public int read() throws IOException { + return byteInputStream.read(); + } + }; + } + + private String readBody(HttpServletRequest request) throws IOException { + BufferedReader input = new BufferedReader(new InputStreamReader(request.getInputStream())); + StringBuilder sb = new StringBuilder(); + + var line = input.readLine(); + while (line != null) { + sb.append(line.trim()); + line = input.readLine(); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/asap/server/common/filter/CustomServletWrappingFilter.java b/src/main/java/com/asap/server/common/filter/CustomServletWrappingFilter.java index 4fa9a358..9bf7cea3 100644 --- a/src/main/java/com/asap/server/common/filter/CustomServletWrappingFilter.java +++ b/src/main/java/com/asap/server/common/filter/CustomServletWrappingFilter.java @@ -1,27 +1,19 @@ package com.asap.server.common.filter; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.ContentCachingResponseWrapper; - +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -@Component -public class CustomServletWrappingFilter extends OncePerRequestFilter { - @Override - protected void doFilterInternal(final HttpServletRequest request, - final HttpServletResponse response, - final FilterChain chain) throws ServletException, IOException { - ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request); - ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response); +import java.io.IOException; - chain.doFilter(requestWrapper, responseWrapper); +public class CustomServletWrappingFilter implements Filter { - responseWrapper.copyBodyToResponse(); + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = new CustomHttpServletRequestWrapper((HttpServletRequest) servletRequest); + filterChain.doFilter(request, servletResponse); } } diff --git a/src/main/java/com/asap/server/common/interceptor/DuplicatedInterceptor.java b/src/main/java/com/asap/server/common/interceptor/DuplicatedInterceptor.java new file mode 100644 index 00000000..ef65eb1a --- /dev/null +++ b/src/main/java/com/asap/server/common/interceptor/DuplicatedInterceptor.java @@ -0,0 +1,60 @@ +package com.asap.server.common.interceptor; + + +import com.asap.server.common.filter.CustomHttpServletRequestWrapper; +import com.asap.server.exception.Error; +import com.asap.server.exception.model.TooManyRequestException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RMap; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +@Component +@RequiredArgsConstructor +public class DuplicatedInterceptor implements HandlerInterceptor { + private static final String REDIS_KEY = "ASAP_REDIS"; + private static final String RMAP_VALUE = "ASAP"; + private static final String RMAP_KEY_FORMAT = "LOCK [ ip : %s , body : %s ]"; + private static final String USER_IP_HEADER = "x-real-ip"; + private final RedissonClient redissonClient; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (lock(request)) return true; + throw new TooManyRequestException(Error.TOO_MANY_REQUEST_EXCEPTION); + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { + unLock(request); + HandlerInterceptor.super.postHandle(request, response, handler, modelAndView); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + unLock(request); + HandlerInterceptor.super.afterCompletion(request, response, handler, ex); + } + + private String getRmapKey(HttpServletRequest request) { + final String body = ((CustomHttpServletRequestWrapper) request).getBody(); + final String userIp = request.getHeader(USER_IP_HEADER); + return String.format(RMAP_KEY_FORMAT, userIp, body); + } + + private boolean lock(HttpServletRequest request) { + final String rmapKey = getRmapKey(request); + RMap redissonClientMap = redissonClient.getMap(REDIS_KEY); + return redissonClientMap.putIfAbsent(rmapKey, RMAP_VALUE) == null; + } + + private void unLock(HttpServletRequest request) { + final String rmapKey = getRmapKey(request); + RMap redissonClientMap = redissonClient.getMap(REDIS_KEY); + redissonClientMap.remove(rmapKey); + } +} diff --git a/src/main/java/com/asap/server/config/WebConfig.java b/src/main/java/com/asap/server/config/WebConfig.java index 5d3a3b9b..42143da7 100644 --- a/src/main/java/com/asap/server/config/WebConfig.java +++ b/src/main/java/com/asap/server/config/WebConfig.java @@ -1,5 +1,6 @@ package com.asap.server.config; +import com.asap.server.common.interceptor.DuplicatedInterceptor; import com.asap.server.config.resolver.meeting.MeetingPathVariableResolver; import com.asap.server.config.resolver.user.UserIdResolver; import lombok.RequiredArgsConstructor; @@ -9,6 +10,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -18,6 +20,7 @@ public class WebConfig implements WebMvcConfigurer { private final UserIdResolver userIdResolver; private final MeetingPathVariableResolver meetingPathVariableResolver; + private final DuplicatedInterceptor duplicatedInterceptor; @Bean public PasswordEncoder getPasswordEncoder() { @@ -37,4 +40,10 @@ public void addArgumentResolvers(List resolvers) resolvers.add(userIdResolver); resolvers.add(meetingPathVariableResolver); } + + @Override + public void addInterceptors(InterceptorRegistry interceptorRegistry) { + interceptorRegistry.addInterceptor(duplicatedInterceptor) + .addPathPatterns("/meeting", "/user/{meetingId}/time", "/user/host/{meetingId}/time"); + } } diff --git a/src/main/java/com/asap/server/config/redis/RedisConfig.java b/src/main/java/com/asap/server/config/redis/RedisConfig.java new file mode 100644 index 00000000..d3f8c2c7 --- /dev/null +++ b/src/main/java/com/asap/server/config/redis/RedisConfig.java @@ -0,0 +1,25 @@ +package com.asap.server.config.redis; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + @Value("${spring.data.redis.port}") + private int redisPort; + private static final String REDISSON_HOST_PREFIX = "redis://"; + + @Bean + RedissonClient getRedissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/asap/server/controller/dto/request/MeetingSaveRequestDto.java b/src/main/java/com/asap/server/controller/dto/request/MeetingSaveRequestDto.java index 6d816f42..f9070d89 100644 --- a/src/main/java/com/asap/server/controller/dto/request/MeetingSaveRequestDto.java +++ b/src/main/java/com/asap/server/controller/dto/request/MeetingSaveRequestDto.java @@ -2,57 +2,43 @@ import com.asap.server.domain.enums.Duration; import com.asap.server.domain.enums.PlaceType; - -import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import java.util.List; -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Schema(description = "회의 생성 DTO") -public class MeetingSaveRequestDto { - - @NotBlank(message = "회의 제목이 입력되지 않았습니다.") - @Size(max = 15, message = "제목의 최대 입력 길이(15자)를 초과했습니다.") - @Schema(description = "회의 주제") - private String title; - - @Schema(description = "회의 가능 날짜", example = "[\"2023/07/09/MON\"]") - private List<@Pattern(regexp = "\\d\\d\\d\\d/\\d\\d/\\d\\d/[a-zA-Z][a-zA-Z][a-zA-Z]", message = "회의 가능 날짜 형식은 YYYY/mm/dd/ddd 입니다.") String> availableDates; - - @Schema(description = "회의 선호 시간") - private List preferTimes; - - @NotNull(message = "회의 형식이 입력되지 않았습니다.") - @Schema(description = "회의 방식", example = "ONLINE", allowableValues = {"ONLINE", "OFFLINE", "UNDEFINED"}) - private PlaceType place; - - @Schema(description = "회의 장소 설명") - private String placeDetail; - - @Schema(description = "회의 진행 시간", example = "HALF", allowableValues = {"HALF" , "HOUR", "HOUR_HALF", "TWO_HOUR", "TWO_HOUR_HALF", "THREE_HOUR"}) - @NotNull(message = "회의 진행 시간이 입력되지 않았습니다.") - private Duration duration; - - @Schema(description = "회의 방장 이름", example = "김아삽") - @NotBlank(message = "방장의 이름이 입력되지 않았습니다.") - @Size(max = 8, message = "방장 이름의 최대 입력 길이(8자)를 초과했습니다.") - private String name; - - @Schema(description = "회의 비밀번호", example = "0808") - @NotBlank(message = "회의 비밀번호가 입력되지 않았습니다.") - @Size(min = 4, message = "비밀번호의 최소 입력 길이는 4자입니다.") - @Pattern(regexp = "\\d{4,}", message = "비밀번호는 4자리 이상 숫자입니다.") - private String password; - - @Schema(description = "회의 추가 정보") - @Size(max = 50, message = "추가 내용의 최대 입력 길이(50자)를 초과했습니다.") - private String additionalInfo; +public record MeetingSaveRequestDto( + @NotBlank(message = "회의 제목이 입력되지 않았습니다.") + @Size(max = 15, message = "제목의 최대 입력 길이(15자)를 초과했습니다.") + @Schema(description = "회의 주제") + String title, + @Schema(description = "회의 가능 날짜", example = "[\"2024/07/09/MON\"]") + List<@Pattern(regexp = "\\d\\d\\d\\d/\\d\\d/\\d\\d/[a-zA-Z][a-zA-Z][a-zA-Z]", message = "회의 가능 날짜 형식은 YYYY/mm/dd/ddd 입니다.") String> availableDates, + @Schema(description = "회의 선호 시간") + List preferTimes, + @NotNull(message = "회의 형식이 입력되지 않았습니다.") + @Schema(description = "회의 방식", example = "ONLINE", allowableValues = {"ONLINE", "OFFLINE", "UNDEFINED"}) + PlaceType place, + @Schema(description = "회의 장소 설명") + String placeDetail, + @Schema(description = "회의 진행 시간", example = "HALF", allowableValues = {"HALF", "HOUR", "HOUR_HALF", "TWO_HOUR", "TWO_HOUR_HALF", "THREE_HOUR"}) + @NotNull(message = "회의 진행 시간이 입력되지 않았습니다.") + Duration duration, + @Schema(description = "회의 방장 이름", example = "김아삽") + @NotBlank(message = "방장의 이름이 입력되지 않았습니다.") + @Size(max = 8, message = "방장 이름의 최대 입력 길이(8자)를 초과했습니다.") + String name, + @Schema(description = "회의 비밀번호", example = "0808") + @NotBlank(message = "회의 비밀번호가 입력되지 않았습니다.") + @Size(min = 4, message = "비밀번호의 최소 입력 길이는 4자입니다.") + @Pattern(regexp = "\\d{4,}", message = "비밀번호는 4자리 이상 숫자입니다.") + String password, + @Schema(description = "회의 추가 정보") + @Size(max = 50, message = "추가 내용의 최대 입력 길이(50자)를 초과했습니다.") + String additionalInfo +) { } diff --git a/src/main/java/com/asap/server/controller/dto/request/PreferTimeSaveRequestDto.java b/src/main/java/com/asap/server/controller/dto/request/PreferTimeSaveRequestDto.java index 80cdff5c..e3596ed4 100644 --- a/src/main/java/com/asap/server/controller/dto/request/PreferTimeSaveRequestDto.java +++ b/src/main/java/com/asap/server/controller/dto/request/PreferTimeSaveRequestDto.java @@ -2,18 +2,12 @@ import com.asap.server.domain.enums.TimeSlot; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +public record PreferTimeSaveRequestDto( + @Schema(description = "선호 시간(시작)", example = "09:00") + TimeSlot startTime, -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PreferTimeSaveRequestDto { - - @Schema(description = "선호 시간(시작)", example = "09:00") - private TimeSlot startTime; - - @Schema(description = "선호 시간(끝)", example = "11:00") - private TimeSlot endTime; + @Schema(description = "선호 시간(끝)", example = "11:00") + TimeSlot endTime +) { } diff --git a/src/main/java/com/asap/server/exception/Error.java b/src/main/java/com/asap/server/exception/Error.java index 971754ce..b3d28e67 100644 --- a/src/main/java/com/asap/server/exception/Error.java +++ b/src/main/java/com/asap/server/exception/Error.java @@ -53,6 +53,10 @@ public enum Error { */ MEETING_VALIDATION_FAILED_EXCEPTION(HttpStatus.CONFLICT, "이미 확정된 회의입니다."), HOST_TIME_EXIST_EXCEPTION(HttpStatus.CONFLICT, "이미 방장의 회의 가능시간이 이미 존재합니다."), + /* + * 429 TOO MANY REQUEST + */ + TOO_MANY_REQUEST_EXCEPTION(HttpStatus.TOO_MANY_REQUESTS, "중복된 요청입니다."), /** * 500 INTERNAL SERVER ERROR */ diff --git a/src/main/java/com/asap/server/exception/model/InternalErrorException.java b/src/main/java/com/asap/server/exception/model/InternalErrorException.java new file mode 100644 index 00000000..130349c9 --- /dev/null +++ b/src/main/java/com/asap/server/exception/model/InternalErrorException.java @@ -0,0 +1,9 @@ +package com.asap.server.exception.model; + +import com.asap.server.exception.Error; + +public class InternalErrorException extends AsapException { + public InternalErrorException(final Error error) { + super(error); + } +} diff --git a/src/main/java/com/asap/server/exception/model/TooManyRequestException.java b/src/main/java/com/asap/server/exception/model/TooManyRequestException.java new file mode 100644 index 00000000..6ada2dba --- /dev/null +++ b/src/main/java/com/asap/server/exception/model/TooManyRequestException.java @@ -0,0 +1,10 @@ +package com.asap.server.exception.model; + +import com.asap.server.exception.Error; + +public class TooManyRequestException extends AsapException { + + public TooManyRequestException(final Error error) { + super(error); + } +} diff --git a/src/main/java/com/asap/server/service/MeetingService.java b/src/main/java/com/asap/server/service/MeetingService.java index 7de1b099..910ea1c1 100644 --- a/src/main/java/com/asap/server/service/MeetingService.java +++ b/src/main/java/com/asap/server/service/MeetingService.java @@ -54,25 +54,25 @@ public class MeetingService { @Transactional public MeetingSaveResponseDto create(final MeetingSaveRequestDto meetingSaveRequestDto) { - String encryptedPassword = passwordEncoder.encode(meetingSaveRequestDto.getPassword()); + String encryptedPassword = passwordEncoder.encode(meetingSaveRequestDto.password()); Meeting meeting = Meeting.builder() - .title(meetingSaveRequestDto.getTitle()) + .title(meetingSaveRequestDto.title()) .password(encryptedPassword) - .additionalInfo(meetingSaveRequestDto.getAdditionalInfo()) - .duration(meetingSaveRequestDto.getDuration()) + .additionalInfo(meetingSaveRequestDto.additionalInfo()) + .duration(meetingSaveRequestDto.duration()) .place( Place.builder() - .placeType(meetingSaveRequestDto.getPlace()) - .placeDetail(meetingSaveRequestDto.getPlaceDetail()) + .placeType(meetingSaveRequestDto.place()) + .placeDetail(meetingSaveRequestDto.placeDetail()) .build()) .build(); meetingRepository.save(meeting); - User host = userService.createUser(meeting, meetingSaveRequestDto.getName(), Role.HOST); + User host = userService.createUser(meeting, meetingSaveRequestDto.name(), Role.HOST); - preferTimeService.create(meeting, meetingSaveRequestDto.getPreferTimes()); - availableDateService.create(meeting, meetingSaveRequestDto.getAvailableDates()); + preferTimeService.create(meeting, meetingSaveRequestDto.preferTimes()); + availableDateService.create(meeting, meetingSaveRequestDto.availableDates()); meeting.setHost(host); diff --git a/src/main/java/com/asap/server/service/PreferTimeService.java b/src/main/java/com/asap/server/service/PreferTimeService.java index 6153cdcd..8e40110e 100644 --- a/src/main/java/com/asap/server/service/PreferTimeService.java +++ b/src/main/java/com/asap/server/service/PreferTimeService.java @@ -27,12 +27,12 @@ public void create(final Meeting meeting, throw new BadRequestException(Error.DUPLICATED_TIME_EXCEPTION); } saveRequestDtos.stream() - .sorted(Comparator.comparing(preferTime -> preferTime.getStartTime().getTime())) + .sorted(Comparator.comparing(preferTime -> preferTime.startTime().getTime())) .map(preferTime -> preferTimeRepository.save( PreferTime.builder() .meeting(meeting) - .startTime(preferTime.getStartTime()) - .endTime(preferTime.getEndTime()).build())) + .startTime(preferTime.startTime()) + .endTime(preferTime.endTime()).build())) .collect(Collectors.toList()); } @@ -50,7 +50,7 @@ public List getPreferTimes(final Meeting meeting) { private boolean isPreferTimeDuplicated(List requestDtos) { List timeSlots = requestDtos.stream() - .flatMap(requestDto -> TimeSlot.getTimeSlots(requestDto.getStartTime().ordinal(), requestDto.getEndTime().ordinal() - 1).stream()) + .flatMap(requestDto -> TimeSlot.getTimeSlots(requestDto.startTime().ordinal(), requestDto.endTime().ordinal() - 1).stream()) .collect(Collectors.toList()); return timeSlots.size() != timeSlots.stream().distinct().count(); } diff --git a/src/test/java/com/asap/server/concurrency/DuplicatedInterceptorTest.java b/src/test/java/com/asap/server/concurrency/DuplicatedInterceptorTest.java new file mode 100644 index 00000000..65522b4b --- /dev/null +++ b/src/test/java/com/asap/server/concurrency/DuplicatedInterceptorTest.java @@ -0,0 +1,96 @@ +package com.asap.server.concurrency; + +import com.asap.server.ServerApplication; +import com.asap.server.controller.dto.request.MeetingSaveRequestDto; +import com.asap.server.controller.dto.request.PreferTimeSaveRequestDto; +import com.asap.server.domain.enums.Duration; +import com.asap.server.domain.enums.PlaceType; +import com.asap.server.domain.enums.TimeSlot; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + + +@SpringBootTest +@AutoConfigureMockMvc +public class DuplicatedInterceptorTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("같은 사용자에게 회의 생성 요청이 연속해서 들어온다면 429 에러를 반환한다") + public void multipleRequestTest() throws Exception { + // given + int numberOfThread = 4; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThread); + CountDownLatch latch = new CountDownLatch(numberOfThread); + + MeetingSaveRequestDto bodyDto = new MeetingSaveRequestDto( + "title", + List.of("2024/07/09/MON", "2024/07/10/TUE"), + List.of(new PreferTimeSaveRequestDto(TimeSlot.SLOT_6_00, TimeSlot.SLOT_6_30)), + PlaceType.OFFLINE, + "회의 장소 설명", + Duration.HOUR, + "방장 이름", + "1234", + "회의 추가 정보" + ); + + String body = objectMapper.writeValueAsString(bodyDto); + + // when + List results = new ArrayList<>(); + for (int i = 0; i < numberOfThread; i++) { + executorService.submit(() -> { + try { + MvcResult result = mockMvc.perform( + post("/meeting") + .header("x-real-ip", "0.0.0.1") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andReturn(); + synchronized (results) { + results.add(result); + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + int count200 = 0; + int count429 = 0; + for (MvcResult mvcResult : results) { + if (mvcResult.getResponse().getStatus() == HttpStatus.OK.value()) count200++; + else if (mvcResult.getResponse().getStatus() == HttpStatus.TOO_MANY_REQUESTS.value()) count429++; + } + + assertThat(count200).isEqualTo(1); + assertThat(count429).isEqualTo(numberOfThread - 1); + } +} diff --git a/src/test/java/com/asap/server/service/meeting/ConfirmMeetingMethodTest.java b/src/test/java/com/asap/server/service/meeting/ConfirmMeetingMethodTest.java index 18b49056..bb101d73 100644 --- a/src/test/java/com/asap/server/service/meeting/ConfirmMeetingMethodTest.java +++ b/src/test/java/com/asap/server/service/meeting/ConfirmMeetingMethodTest.java @@ -10,13 +10,13 @@ import com.asap.server.domain.enums.Role; import com.asap.server.domain.enums.TimeSlot; import com.asap.server.service.MeetingService; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; 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 jakarta.persistence.EntityManager; -import jakarta.transaction.Transactional; import java.util.List; import static org.assertj.core.api.AssertionsForClassTypes.assertThat;