Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
>- MySQL
>- Docker Compose
>- IntelliJ Http Request
>- K6
>- Junit5
>- Jacoco
>- Google guava ratelimiter (단일 서버)
>- Redisson (분산 애플리케이션)

**Clean Code 작성 요구 사항**
>- 가독성 (클래스, 변수, 메서드 이름)
Expand Down Expand Up @@ -546,6 +551,8 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함.
> 동시성 제어를 위해 *좌석 점유와 결제 이벤트를 분리*한 것으로 보임.

=> 궁금한 점 : 보통 예매 시스템에서 좌석을 누르는 순간 바로 점유 여부를 체크하는지 아니면 좌석을 누른 후 선택하기 버튼을 추가로 눌렀을 때 점유 여부를 체크하는지?
> 상황에 따라 다르게 적용하지만 유저 입장에서는 빠르게 점유 여부를 체크할 수 있는 편이 좋음.
> 다만, 빠르게 점유 여부를 체크할수록 그만큼 요청이 많아지므로 리소스가 많이 들게 됨. 회사의 리소스 가용여부에 따라 효율적 판단 요구됨.

### Pessimistic Lock (비관적 락)
- 트랜잭션이 시작될 때 해당 행을 잠금 처리하고, 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 막는 방식.
Expand All @@ -567,6 +574,27 @@ Querydsl로 변경하여 한 번에 조회할 수 있도록 변경함.
> waitTime 을 5초로 설정한 것은 사용자가 너무 길게 느껴지지 않는 시간이면서 서버 처리시간에 여유를 주었고,
> leaseTime 보다 살짝 길게 설정하여 데이터 정합성이 유지될 수 있도록 하였음.

---

## [3주차] 피드백 후 수정사항
1. ✅ 예외 처리는 try-catch보다는 Exception Handler에서 처리하는 것이 책임 구분이 명확해짐.
2. ✅ DTO에 Setter와 같은 과도한 어노테이션 적용을 지양하고 해당 객체에 적절한 책임을 줄 것
- 절차지향적인 방법이 아닌 객체지향적 방법으로 개선할 것
3. ✅ 반복문에서 size() 메서드를 호출하는 것은 성능상 좋지 않음.
4. ✅ for문이나 배열의 인덱스 조회와 같은 부분은 가독성에 좋지 않고 런타임시 예외가 발생할 수 있음.
5. ✅ 락을 획득하지 않은 요청도 모두 Transactional을 적용받고 있으므로 함수형 락을 사용하는 이점을 살리기 위해 분리할 것
6. ✅ 클라이언트에서 전달받은 user 값에 대한 검증 추가


---
# [4주차] 서버 안정성을 높이기 위한 RateLimit 구현
## Google Guava Ratelimiter
- 조회API 테스트 시 429 에러가 51번째부터 발생해야 하는데, 두번째 요청부터 훨씬 빠르게 발생함.
- 예매API 테스트 429 반환 성공.

## Redisson
- 조회API, 예매API 테스트 모두 429 반환 성공.




30 changes: 30 additions & 0 deletions cinema-adapter/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot'
id 'io.spring.dependency-management' // 루트의 dependency-management 상속
id 'jacoco'
}

group = 'com.cinema'
Expand All @@ -10,6 +11,7 @@ version = '0.0.1-SNAPSHOT'
dependencies {
implementation project(':cinema-application')
implementation project(':cinema-core')
implementation project(':cinema-common')
implementation project(':cinema-infra')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand All @@ -21,8 +23,36 @@ dependencies {
// Spring Retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop' // AOP 활성화 필요

// Google Guava RateLimiter
implementation 'com.google.guava:guava:32.0.0-jre'

// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.21.0'
}

tasks.named('bootJar') {
mainClass = 'com.cinema.CinemaApplication'
}

tasks.jacocoTestReport {
dependsOn test // test 실행 후 보고서 생성
reports {
xml.required = true
csv.required = false
html.destination file("${buildDir}/jacocoHtml")
}
}

tasks.jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.60 // 최소 60% 커버리지 필요
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.cinema.adapter.config;

import com.google.common.util.concurrent.RateLimiter;
import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RateLimiterConfig {

// Google Guava
/*@Bean
public RateLimiter rateLimiter() {
return RateLimiter.create(50.0 / 60.0); // 1분당 50회
}*/


// Redisson
private static final String RATE_LIMIT_KEY = "rate_limit:requests";

private final RedissonClient redissonClient;

public RateLimiterConfig(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}

@Bean
public RRateLimiter rateLimiter() {
RRateLimiter rateLimiter = redissonClient.getRateLimiter(RATE_LIMIT_KEY);
rateLimiter.trySetRate(RateType.OVERALL, 50, 1, RateIntervalUnit.MINUTES); // 1분당 50회
return rateLimiter;
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package com.cinema.adapter.config;

import com.cinema.adapter.interceptor.RateLimitingInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.nio.charset.StandardCharsets;
import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final RateLimitingInterceptor rateLimitingInterceptor;

@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
for (HttpMessageConverter<?> converter : converters) {
Expand All @@ -19,4 +25,10 @@ public void configureMessageConverters(List<HttpMessageConverter<?>> converters)
}
}
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitingInterceptor)
.addPathPatterns("/api/v1/movies/**", "/api/v1/tickets/**");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.cinema.application.dto.MovieRequestDTO;
import com.cinema.application.dto.MovieResponseDTO;
import com.cinema.application.dto.TicketRequestDTO;
import com.cinema.application.service.MovieService;
import com.cinema.common.response.ApiResponseDTO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -22,16 +22,18 @@ public class MovieController {
* */
// TODO : 상영일자가 추가되는 경우, bookable 쿼리파라미터를 추가하여 상영가능한 상태의 영화만 조회할 수 있도록 확장 가능
@GetMapping
public List<MovieResponseDTO> getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) {
return movieService.getMovieScreenings(movieRequestDTO);
public ResponseEntity<ApiResponseDTO<List<MovieResponseDTO>>> getMovieScreenings(@Valid @RequestBody MovieRequestDTO movieRequestDTO) {
List<MovieResponseDTO> movieList = movieService.getMovieScreenings(movieRequestDTO);
return ResponseEntity.ok(ApiResponseDTO.success(movieList, "영화 목록 조회 성공"));
}

/**
* 영화별 상영시간표 캐시 삭제
*/
@GetMapping("/evictRedisCache")
public void evictRedisCache() {
public ResponseEntity<ApiResponseDTO<String>> evictRedisCache() {
movieService.evictShowingMovieCache();
return ResponseEntity.ok(ApiResponseDTO.success(null, "캐시 삭제 완료"));
}
}

Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package com.cinema.adapter.controller;

import com.cinema.application.dto.MovieRequestDTO;
import com.cinema.application.dto.MovieResponseDTO;
import com.cinema.application.dto.TicketRequestDTO;
import com.cinema.application.service.MovieService;
import com.cinema.application.service.TicketService;
import com.cinema.common.response.ApiResponseDTO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/tickets")
Expand All @@ -22,15 +18,9 @@ public class TicketController {
* 예매
* */
@PostMapping
public ResponseEntity<String> bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO ) {
try {
ticketService.bookTickets(ticketRequestDTO);
return ResponseEntity.ok("예매가 성공적으로 완료되었습니다.");
} catch (Exception e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
public ResponseEntity<ApiResponseDTO<String>> bookTickets(@Valid @RequestBody TicketRequestDTO ticketRequestDTO) {
ticketService.bookTickets(ticketRequestDTO);
return ResponseEntity.ok(ApiResponseDTO.success(null, "예매 성공"));
}


}

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.cinema.adapter.interceptor;

import com.google.common.util.concurrent.RateLimiter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RRateLimiter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@Component
@RequiredArgsConstructor
public class RateLimitingInterceptor implements HandlerInterceptor {

// private final RateLimiter rateLimiter; // Google Guava
private final RRateLimiter rateLimiter; // Redisson

private final Map<String, Integer> requestCounts = new ConcurrentHashMap<>();
private final Map<String, Long> blockedIps = new ConcurrentHashMap<>();

private static final int MAX_REQUESTS = 50; // 1분 내 최대 50회
private static final long BLOCK_TIME_MS = 60 * 60 * 1000; // 1시간 차단

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String clientIp = request.getRemoteAddr();
long currentTime = System.currentTimeMillis();

// 1. 조회 API 제한 - 차단된 IP인지 확인
if (blockedIps.containsKey(clientIp)) {
long blockedTime = blockedIps.get(clientIp);
if (currentTime - blockedTime < BLOCK_TIME_MS) {
String unblockTime = Instant.ofEpochMilli(blockedTime + BLOCK_TIME_MS)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("해당 IP는 1시간 동안 요청이 차단되었습니다. 차단 해제 시각: " + unblockTime);
return false;
} else {
blockedIps.remove(clientIp);
requestCounts.remove(clientIp);
}
}

// 2. 조회 API 제한 - 1분 내 50회
if (request.getRequestURI().startsWith("/api/v1/movies") && request.getMethod().equalsIgnoreCase("GET")) {
requestCounts.put(clientIp, requestCounts.getOrDefault(clientIp, 0) + 1);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동일한 clientIp로 두 개의 요청이 동시에 들어왔다고 가정했을 때, 해당 코드는 정상 실행이 될까요?
동시성에 대해 다시 생각해보셨으면 좋겠습니다!

if (requestCounts.get(clientIp) > MAX_REQUESTS) {
blockedIps.put(clientIp, currentTime);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("너무 많은 요청으로 해당 IP는 요청이 차단되었습니다.");
return false;
}

// 실시간 요청 속도 제한 적용 (RateLimiter)
// Google Guava
/*if (!rateLimiter.tryAcquire()) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요.");
return false;
}*/

// Redisson
if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("현재 요청량이 너무 많습니다. 잠시 후 다시 시도해주세요.");
return false;
}
Comment on lines +72 to +77

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클라이언트 ip별로 다르게 rateLimiter 가 존재해야 할듯 합니다.

}

return true;
}
}
23 changes: 0 additions & 23 deletions cinema-adapter/src/test/java/com/cinema/PessimisticLockTest.java

This file was deleted.

Loading