diff --git a/.github/workflows/cd-test.yml b/.github/workflows/cd-test.yml index 58a17f6..8b0a654 100644 --- a/.github/workflows/cd-test.yml +++ b/.github/workflows/cd-test.yml @@ -63,7 +63,7 @@ jobs: echo "[INFO] Deploying test build with IMAGE_TAG=$IMAGE_TAG" # app만 최신 SHA 태그로 pull & restart - sudo -E docker compose -f docker-compose.prod.yml pull app - sudo -E docker compose -f docker-compose.prod.yml up -d --no-deps app + sudo -E docker-compose -f docker-compose.prod.yml pull app + sudo -E docker-compose -f docker-compose.prod.yml up -d --no-deps app sudo docker image prune -f diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 4e53cf7..a9992a8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -54,9 +54,10 @@ jobs: export DB_USERNAME=${{ secrets.DB_USERNAME }} export DB_PASSWORD=${{ secrets.DB_PASSWORD }} - # spring boot 이미지 최신화 및 업데이트 - sudo -E docker-compose -f docker-compose.prod.yml pull app - sudo -E docker-compose -f docker-compose.prod.yml up -d --no-deps app + # 이미지 최신화 및 컨테이너 재시작 + sudo docker-compose pull + sudo docker-compose down + sudo docker-compose up -d --remove-orphans # 필요 없는 이미지 정리 sudo docker image prune -f diff --git a/build.gradle b/build.gradle index 6d58ddb..042ac05 100644 --- a/build.gradle +++ b/build.gradle @@ -61,9 +61,10 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.apache.commons:commons-pool2' // redisson - implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' + // implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' } tasks.named('test') { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1acf422..ac8a995 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,14 +7,58 @@ services: environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: 'broker,controller' - KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093' KAFKA_LISTENERS: 'PLAINTEXT://kafka-1:9092,CONTROLLER://kafka-1:9093,PLAINTEXT_HOST://0.0.0.0:29092' KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-1:9092,PLAINTEXT_HOST://${EC2_PRIVATE_IP}:29092' KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 2 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + CLUSTER_ID: 'ciWo7IWazngRchmPES6q5A==' + KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs' + networks: + - app-network + + kafka-2: + image: confluentinc/cp-kafka:latest + container_name: kafka-2 + ports: + - "29093:29093" + environment: + KAFKA_NODE_ID: 2 + KAFKA_PROCESS_ROLES: 'broker,controller' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093' + KAFKA_LISTENERS: 'PLAINTEXT://kafka-2:9092,CONTROLLER://kafka-2:9093,PLAINTEXT_HOST://0.0.0.0:29093' + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-2:9092,PLAINTEXT_HOST://${EC2_PRIVATE_IP}:29093' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 2 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + CLUSTER_ID: 'ciWo7IWazngRchmPES6q5A==' + KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs' + networks: + - app-network + + kafka-3: + image: confluentinc/cp-kafka:latest + container_name: kafka-3 + ports: + - "29094:29094" + environment: + KAFKA_NODE_ID: 3 + KAFKA_PROCESS_ROLES: 'broker,controller' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093' + KAFKA_LISTENERS: 'PLAINTEXT://kafka-3:9092,CONTROLLER://kafka-3:9093,PLAINTEXT_HOST://0.0.0.0:29094' + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka-3:9092,PLAINTEXT_HOST://${EC2_PRIVATE_IP}:29094' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 2 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 CLUSTER_ID: 'ciWo7IWazngRchmPES6q5A==' KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs' @@ -28,9 +72,11 @@ services: - "8090:8080" environment: KAFKA_CLUSTERS_0_NAME: local - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka-1:9092 + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka-1:9092,kafka-2:9092,kafka-3:9092 depends_on: - kafka-1 + - kafka-2 + - kafka-3 networks: - app-network @@ -57,7 +103,7 @@ services: - "80:8080" environment: SPRING_PROFILES_ACTIVE: prod - SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka-1:9092 + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka-1:9092,kafka-2:9092,kafka-3:9092 DB_HOST: ${DB_HOST} DB_PORT: ${DB_PORT} DB_NAME: ${DB_NAME} @@ -68,6 +114,10 @@ services: condition: service_healthy kafka-1: condition: service_started + kafka-2: + condition: service_started + kafka-3: + condition: service_started restart: unless-stopped networks: - app-network diff --git a/src/main/java/com/practice/course_registration/domain/member/domain/Member.java b/src/main/java/com/practice/course_registration/domain/member/domain/Member.java index b6d671f..5696746 100644 --- a/src/main/java/com/practice/course_registration/domain/member/domain/Member.java +++ b/src/main/java/com/practice/course_registration/domain/member/domain/Member.java @@ -9,6 +9,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/practice/course_registration/domain/member/repository/MemberRepository.java b/src/main/java/com/practice/course_registration/domain/member/repository/MemberRepository.java index 36ea385..3548716 100644 --- a/src/main/java/com/practice/course_registration/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/practice/course_registration/domain/member/repository/MemberRepository.java @@ -12,8 +12,11 @@ public interface MemberRepository extends JpaRepository { Member findByLoginId(String loginId); - @Query("select m from Member m " + - "left join fetch m.memberSubjects " + - "where m.id = :id") - Optional findWithSubjectsById(@Param("id") Long id); + @Query("select m from Member m " + + "left join fetch m.memberSubjects ms " + + "left join fetch ms.subject " + // 어차피 membersubject에서 eager로 subject를 들고와서 N+1 이 생긴다면, 한번에 fetch join 하는 게? + "where m.id = :id") + Optional findWithSubjectsById(@Param("id") Long id); + + Optional findById(long id); } diff --git a/src/main/java/com/practice/course_registration/domain/subject/controller/SubjectController.java b/src/main/java/com/practice/course_registration/domain/subject/controller/SubjectController.java index b64c4de..7768efc 100644 --- a/src/main/java/com/practice/course_registration/domain/subject/controller/SubjectController.java +++ b/src/main/java/com/practice/course_registration/domain/subject/controller/SubjectController.java @@ -80,8 +80,13 @@ public ResponseEntity> applyCourse(@RequestParam String code Map response = new HashMap<>(); try { + long start = System.currentTimeMillis(); + log.debug("Start to apply course with code {}", code); // 대기열 등록 subjectService.enqueueCourseRequest(memberId, code); + long end = System.currentTimeMillis(); + + log.debug("Apply course request took " + (end - start) + " ms"); // 성공 시 JSON 응답 response.put("status", "WAITING"); diff --git a/src/main/java/com/practice/course_registration/domain/subject/domain/MemberSubject.java b/src/main/java/com/practice/course_registration/domain/subject/domain/MemberSubject.java index 92fed89..3d5ca16 100644 --- a/src/main/java/com/practice/course_registration/domain/subject/domain/MemberSubject.java +++ b/src/main/java/com/practice/course_registration/domain/subject/domain/MemberSubject.java @@ -20,7 +20,7 @@ public class MemberSubject extends BaseEntity { @JoinColumn(name = "member_id") private Member member; - @ManyToOne(fetch = FetchType.EAGER) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "subject_id") private Subject subject; } diff --git a/src/main/java/com/practice/course_registration/domain/subject/service/SubjectQueryService.java b/src/main/java/com/practice/course_registration/domain/subject/service/SubjectQueryService.java index 4360d12..6de62d4 100644 --- a/src/main/java/com/practice/course_registration/domain/subject/service/SubjectQueryService.java +++ b/src/main/java/com/practice/course_registration/domain/subject/service/SubjectQueryService.java @@ -39,7 +39,7 @@ public class SubjectQueryService { public Page searchAllSubject(Long memberId, CourseFilterRequestDTO filters, Pageable pageable) { - log.info("===========search 시작=============="); + //log.info("===========search 시작=============="); Member member = findById(memberId); String code = nullIfBlank(filters.getCode()); @@ -47,7 +47,7 @@ public Page searchAllSubject(Long memberId, CourseFilterRequ String subjectName = nullIfBlank(filters.getSubjectName()); - log.info("===========code: " + code + " =======professorName : " + professorName + " ========subjectName" + subjectName + " \n"); + //log.info("===========code: " + code + " =======professorName : " + professorName + " ========subjectName" + subjectName + " \n"); Page subjects = subjectRepository.findAllByCodeAndProfessorNameAndSubjectName(code, professorName, subjectName, pageable); @@ -60,7 +60,7 @@ public Page searchAllSubject(Long memberId, CourseFilterRequ List subjectIds = subjects.getContent().stream() .map(Subject::getId) .toList(); - log.info("===========페이지 크기 : " + subjectIds.size()); + //log.info("===========페이지 크기 : " + subjectIds.size()); Set registeredIds = memberSubjectRepository.findAllIdByMemberAndSubject(member, subjectIds); Set likedIds = likeSubjectRepository.findAllByMemberAndSubject(member, subjectIds); diff --git a/src/main/java/com/practice/course_registration/domain/subject/service/SubjectService.java b/src/main/java/com/practice/course_registration/domain/subject/service/SubjectService.java index 0683025..4dbc3f4 100644 --- a/src/main/java/com/practice/course_registration/domain/subject/service/SubjectService.java +++ b/src/main/java/com/practice/course_registration/domain/subject/service/SubjectService.java @@ -64,17 +64,18 @@ public void enqueueCourseRequest(Long memberId, String code) { boolean held = false; try { // 멤버 찾기 - Member member = findMemberById(memberId); + Member member = findMemberById(memberId); // 해당 과목 찾기 - Subject subject = findByCode(code); + Subject subject = findByCode(code); // 유효성 검사 - validateCheck(member, subject); + validateCheck(member, subject); // 원자성 추가 - waitQueueService.enqueueGlobal(memberId, subject.getId(), System.currentTimeMillis()); - log.info("수강신청 접수 성공 (대기열 삽입). Course: {}, Member: {}", subject.getId(), memberId); + waitQueueService.enqueueGlobal(memberId, subject.getId(), System.currentTimeMillis()); +// waitQueueService.enqueueGlobal(memberId, 1L, System.currentTimeMillis()); + //log.info("수강신청 접수 성공 (대기열 삽입). Course: {}, Member: {}", subject.getId(), memberId); } catch (ErrorHandler e) { idempotencyService.releaseIdempotency(memberId, code); @@ -140,14 +141,16 @@ public void applyCourseWithToken(Long memberId, String code) { * - 신청가능학점을 넘긴경우 -> 위 코드에서 lua 결과로 판단 * */ private void validateCheck(Member member, Subject subject) { - if (memberSubjectRepository.findByMemberAndSubject(member, subject).isPresent()) { + boolean alreadyApplied = member.getMemberSubjects().stream() + .anyMatch(ms -> ms.getSubject().getId().equals(subject.getId())); + + if (alreadyApplied) { throw new ErrorHandler(ErrorStatus.ALREADY_APPLY_SUBJECT); } if (member.getRegisteredScore() + subject.getScore() > MAX_SCORE) { throw new ErrorHandler(ErrorStatus.OVER_SOCRE_POSSIBLE); } - boolean conflict = member.getMemberSubjects().stream() .map(MemberSubject::getSubject) .filter(subj -> diff --git a/src/main/java/com/practice/course_registration/global/config/RedisConfig.java b/src/main/java/com/practice/course_registration/global/config/RedisConfig.java index 2465320..d0bde12 100644 --- a/src/main/java/com/practice/course_registration/global/config/RedisConfig.java +++ b/src/main/java/com/practice/course_registration/global/config/RedisConfig.java @@ -4,12 +4,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; diff --git a/src/main/java/com/practice/course_registration/global/redis/service/IdempotencyService.java b/src/main/java/com/practice/course_registration/global/redis/service/IdempotencyService.java index 36a853d..6afbb58 100644 --- a/src/main/java/com/practice/course_registration/global/redis/service/IdempotencyService.java +++ b/src/main/java/com/practice/course_registration/global/redis/service/IdempotencyService.java @@ -20,7 +20,7 @@ public class IdempotencyService { public boolean rateLimitAllow(Long memberId, int limit, Duration ttl) { String key = RedisKeyUtils.rateLimitKey(memberId); Long cnt = stringRedisTemplate.opsForValue().increment(key); - System.out.println("cnt : " + cnt); + // System.out.println("cnt : " + cnt); // 첫 요청인 경우 -> TTL 1로 세팅 if (cnt != null && cnt == 1L) { stringRedisTemplate.expire(key, ttl); diff --git a/src/main/java/com/practice/course_registration/global/redis/service/PublishIssueTokenScheduler.java b/src/main/java/com/practice/course_registration/global/redis/service/PublishIssueTokenScheduler.java index 56459e2..72e4765 100644 --- a/src/main/java/com/practice/course_registration/global/redis/service/PublishIssueTokenScheduler.java +++ b/src/main/java/com/practice/course_registration/global/redis/service/PublishIssueTokenScheduler.java @@ -13,15 +13,15 @@ public class PublishIssueTokenScheduler { private final LuaRepository luaRepository; - private static final int PERMITS_PER_TICKS = 50; // tick당 최대 발급 수 + private static final int PERMITS_PER_TICKS = 100; // tick당 최대 발급 수 private static final int TOKEN_TTL = 5; // token ttl - @Scheduled(fixedDelay = 100) + // @Scheduled(fixedRate = 20) public void issue() { try { long cnt = luaRepository.publishIssueTokens(PERMITS_PER_TICKS, TOKEN_TTL); if (cnt > 0) { - log.info("토큰 발급 성공: count={}, sample={}", cnt); + // log.info("토큰 발급 성공: count={}, sample={}", cnt); } } catch (Exception e) { log.error("토큰 발급 실패", e); diff --git a/src/main/java/com/practice/course_registration/global/redis/service/WaitQueueService.java b/src/main/java/com/practice/course_registration/global/redis/service/WaitQueueService.java index 40c9300..29f7c65 100644 --- a/src/main/java/com/practice/course_registration/global/redis/service/WaitQueueService.java +++ b/src/main/java/com/practice/course_registration/global/redis/service/WaitQueueService.java @@ -21,12 +21,14 @@ public class WaitQueueService { // 전역 대기열에 요청 삽입 public void enqueueGlobal(Long memberId, Long subjectId, long nowMillis) { + long start = System.currentTimeMillis(); // 측정 시작 String queueKey = RedisKeyUtils.globalApplyQueueKey(); String value = memberId + ":" + subjectId; // payload redisTemplate.opsForZSet().add(queueKey, value, nowMillis); - - Long size = redisTemplate.opsForZSet().size(queueKey); - log.info("대기열 등록: {}, queueSize={}", value, size); + long end = System.currentTimeMillis(); // 측정 종료 + log.info("Redis [enqueueGlobal] 소요시간: {}ms", (end - start)); + // Long size = redisTemplate.opsForZSet().size(queueKey); + // log.info("대기열 등록: {}, queueSize={}", value, size); } // 사용자에게 보여줄 대기열 순번 조회 diff --git a/src/main/java/com/practice/course_registration/global/security/utils/HeaderUserIdProvider.java b/src/main/java/com/practice/course_registration/global/security/utils/HeaderUserIdProvider.java index 47ca3f4..e04a4ef 100644 --- a/src/main/java/com/practice/course_registration/global/security/utils/HeaderUserIdProvider.java +++ b/src/main/java/com/practice/course_registration/global/security/utils/HeaderUserIdProvider.java @@ -17,6 +17,7 @@ public Long getUserId() { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String userId = request.getHeader("userId"); + // System.out.println("userId = " + userId); if (userId == null || userId.isEmpty()){ throw new ErrorHandler(ErrorStatus._UNAUTHORIZED); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 48ddac3..e6790ae 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -34,6 +34,17 @@ spring: resources: add-mappings: false +logging: + level: + io.lettuce.core.protocol: DEBUG + org.springframework.data.redis: DEBUG + org: + hibernate: + SQL: debug +# apache.tomcat.util.http.Parameters: debug +# springframework.web.servlet.DispatcherServlet: debug +# com.practice.course_registration: debug + server: servlet: session: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fb0c32f..3fcad90 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,4 +1,9 @@ spring: + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:redis} + port: 6379 + h2: console: enabled: false @@ -20,16 +25,8 @@ spring: properties: hibernate: format_sql: true + open-in-view: false - data: - redis: - host: redis - port: 6379 - connect-timeout: 2000 - database: 0 - - # mvc: - # throw-exception-if-no-handler-found: true web: resources: add-mappings: false