From aea5f027ca47dc8bc614fbd89f27afa6bd8ff35a Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:28:09 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20workflow=EC=97=90=20EC2=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workflow에 EC2 자동 배포 추가 * chore: appleboy/ssh-action 최신버전으로 변경 --- .github/workflows/release-workflow.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index c1acc51..08f7cd1 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -49,7 +49,9 @@ jobs: uses: actions/checkout@v4 - name: Setting for Developent - run: echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_DEV_YML }}" > src/main/resources/application-dev.yml - name: Sign in github container registry uses: docker/login-action@v3 @@ -75,3 +77,22 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + deploy: + name: EC2 자동 배포 + runs-on: ubuntu-latest + needs: build-image + + steps: + - name: EC2에 SSH로 접속 후 배포 + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + script: | + cd ${{ secrets.EC2_DEPLOY_DIR }} + docker compose pull + docker compose down + docker compose up -d \ No newline at end of file From 4d341f80a7de2b79b3bb8884612bd1a1fd0299ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:42:11 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=EB=B0=A9=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=B0=8F=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20feat=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 발표방 생성 요청 dto 스웨거 수정 * feat: Room secretCode Unique 설정 * feat: Room 캐시 적용 * test: Room 캐시 테스트 * test: 비밀코드 중복 문제 해결 * fix: 로그인 500에러 수정 * fix: 로그인 500에러 수정 --- build.gradle | 5 + .../join/emoji/service/EmojiFacade.java | 6 +- .../join/global/config/CacheConfig.java | 40 +++++++ .../join/global/config/CacheType.java | 14 +++ .../join/global/dev/DevController.java | 2 + .../join/global/dev/HealthController.java | 28 +++++ .../join/member/security/SecurityConfig.java | 3 +- .../com/oronaminc/join/room/domain/Room.java | 2 + .../join/room/dto/CreateRoomRequest.java | 4 +- .../join/room/service/RoomReader.java | 13 +++ .../join/room/service/RoomService.java | 20 ++-- src/main/resources/static/favicon.ico | 0 .../join/emoji/service/EmojiFacadeTests.java | 34 +++--- .../join/room/service/RoomCacheTests.java | 108 ++++++++++++++++++ src/test/resources/application.yml | 11 ++ 15 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheConfig.java create mode 100644 src/main/java/com/oronaminc/join/global/config/CacheType.java create mode 100644 src/main/java/com/oronaminc/join/global/dev/HealthController.java create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java diff --git a/build.gradle b/build.gradle index 83ce1a5..7945a4a 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-cache' testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' @@ -58,6 +59,10 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + + // caffeine + implementation 'com.github.ben-manes.caffeine:caffeine' + } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java index bba1635..8bc099c 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiFacade.java @@ -1,12 +1,14 @@ package com.oronaminc.join.emoji.service; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; + import com.oronaminc.join.emoji.dto.EmojiRequest; import com.oronaminc.join.emoji.dto.EmojiResponse; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; + import lombok.RequiredArgsConstructor; -import org.springframework.orm.ObjectOptimisticLockingFailureException; -import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor diff --git a/src/main/java/com/oronaminc/join/global/config/CacheConfig.java b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java new file mode 100644 index 0000000..590e322 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheConfig.java @@ -0,0 +1,40 @@ +package com.oronaminc.join.global.config; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Scheduler; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + List caches = Arrays.stream(CacheType.values()) + .map(cache -> new CaffeineCache( + cache.cacheName, + Caffeine.newBuilder() + .expireAfterWrite(cache.expireAfterWrite, TimeUnit.SECONDS) + .maximumSize(cache.maximumSize) + .scheduler(Scheduler.systemScheduler()) + .build() + ) + ) + .toList(); + + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches); + + return cacheManager; + } +} diff --git a/src/main/java/com/oronaminc/join/global/config/CacheType.java b/src/main/java/com/oronaminc/join/global/config/CacheType.java new file mode 100644 index 0000000..71af6e1 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/CacheType.java @@ -0,0 +1,14 @@ +package com.oronaminc.join.global.config; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum CacheType { + ROOM_BY_ID("roomById", 300, 1000), + ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000) + ; + + public final String cacheName; + public final long expireAfterWrite; + public final long maximumSize; +} diff --git a/src/main/java/com/oronaminc/join/global/dev/DevController.java b/src/main/java/com/oronaminc/join/global/dev/DevController.java index 722dccd..5ab3347 100644 --- a/src/main/java/com/oronaminc/join/global/dev/DevController.java +++ b/src/main/java/com/oronaminc/join/global/dev/DevController.java @@ -18,11 +18,13 @@ import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.security.MemberDetails; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RestController +@Tag(name = "개발용 API") @RequestMapping("/dev") @RequiredArgsConstructor public class DevController { diff --git a/src/main/java/com/oronaminc/join/global/dev/HealthController.java b/src/main/java/com/oronaminc/join/global/dev/HealthController.java new file mode 100644 index 0000000..b4defaa --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/dev/HealthController.java @@ -0,0 +1,28 @@ +package com.oronaminc.join.global.dev; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@Tag(name = "개발용 API") +public class HealthController { + + @Operation(summary = "애플리케이션 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/health") + public String health() { + return "Server is Healthy!"; + } + + @Operation(summary = "홈 헬스체크") + @ResponseStatus(HttpStatus.OK) + @GetMapping("/") + public String home() { + return "It's Home!"; + } +} diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 89bdeb9..fd6861b 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -12,6 +13,7 @@ import lombok.RequiredArgsConstructor; @Configuration +@Profile("!test") @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -47,5 +49,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } - } diff --git a/src/main/java/com/oronaminc/join/room/domain/Room.java b/src/main/java/com/oronaminc/join/room/domain/Room.java index cd33c0a..2598be7 100644 --- a/src/main/java/com/oronaminc/join/room/domain/Room.java +++ b/src/main/java/com/oronaminc/join/room/domain/Room.java @@ -6,6 +6,7 @@ import com.oronaminc.join.global.entity.BaseEntity; import com.oronaminc.join.room.dto.RoomUpdateRequest; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -33,6 +34,7 @@ public class Room extends BaseEntity { private String title; private String description; + @Column(unique = true) private String secretCode; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java index 9e54e7b..c75396b 100644 --- a/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java +++ b/src/main/java/com/oronaminc/join/room/dto/CreateRoomRequest.java @@ -1,11 +1,11 @@ package com.oronaminc.join.room.dto; -import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; import org.hibernate.validator.constraints.Length; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -38,7 +38,7 @@ public record CreateRoomRequest( @NotNull @Size(max = 5) - @Schema(description = "발표방 추가한 팀원 목록", example = "{팀원1@example.com, 팀원2@example.com}") + @Schema(description = "발표방 추가한 팀원 목록") List teamEmail ) { } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomReader.java b/src/main/java/com/oronaminc/join/room/service/RoomReader.java index e0fef8f..d1e73a4 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomReader.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomReader.java @@ -4,6 +4,7 @@ import java.util.Optional; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import com.oronaminc.join.global.exception.ErrorException; @@ -26,6 +27,12 @@ public Room getById(Long roomId) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomById") + public Room getCacheById(Long roomId) { + return findById(roomId) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Optional findBySecretCode(String secretCode) { return roomRepository.findBySecretCode(secretCode); } @@ -35,6 +42,12 @@ public Room getBySecretCode(String secretCode) { .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); } + @Cacheable(cacheNames = "roomBySecretCode") + public Room getCacheBySecretCode(String secretCode) { + return this.findBySecretCode(secretCode) + .orElseThrow(() -> new ErrorException(NOT_FOUND_ROOM)); + } + public Boolean existsBySecretCode(String secretCode) { return roomRepository.existsBySecretCode(secretCode); } diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index 6fb42c5..e28e429 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,8 +65,10 @@ public class RoomService { private static final int CODE_LENGTH = 6; @Transactional - public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, - String presenterEmail) { + public CreateRoomResponse createRoom( + CreateRoomRequest createRoomRequest, + String presenterEmail + ) { String code = this.generateCode(); Room room = RoomMapper.toRoom(createRoomRequest, code); roomRepository.save(room); @@ -78,7 +81,7 @@ public CreateRoomResponse createRoom(CreateRoomRequest createRoomRequest, @Transactional public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) { - Room room = roomReader.getBySecretCode(joinRoomRequest.secretCode()); + Room room = roomReader.getCacheBySecretCode(joinRoomRequest.secretCode()); if (room.getRoomStatus().equals(RoomStatus.BEFORE_START)) { throw new ErrorException(UNAUTHORIZED_JOIN_ROOM); } @@ -89,7 +92,7 @@ public JoinRoomResponse joinRoom(Long memberId, JoinRoomRequest joinRoomRequest) public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Participant presenter = participantService.getPresenter(roomId); List team = participantService.getTeam(roomId); @@ -101,6 +104,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomRequest) { participantService.validatePresenter(roomId, memberId); @@ -116,6 +120,7 @@ public void updateRoom(Long memberId, Long roomId, RoomUpdateRequest updateRoomR } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void deleteRoom(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); @@ -132,6 +137,7 @@ public void deleteRoom(Long memberId, Long roomId) { } @Transactional + @CacheEvict(cacheNames = "roomById", key = "#roomId") public void updateRoomStatus(Long memberId, Long roomId, RoomUpdateStatusRequest roomUpdateStatusRequest) { participantService.validatePresenter(roomId, memberId); @@ -148,7 +154,7 @@ public void updateRoomStatus(Long memberId, Long roomId, @Transactional public RoomUpdateInfoResponse getRoomUpdateInfo(Long memberId, Long roomId) { participantService.validatePresenter(roomId, memberId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); List team = participantService.getTeam(roomId); return RoomMapper.toRoomUpdateInfoResponse(room, team); } @@ -170,7 +176,7 @@ public ReportResponse getRoomReport(Long roomId, Long memberId) { throw new ErrorException(UNAUTHORIZED_REPORT_READ); } - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); Long totalView = participantReader.countTotalView(roomId); Long totalQuestions = questionReader.countByRoomId(roomId); Long totalAnswerByQuestion = answerReader.countAnsweredQuestionsByRoomId(roomId); @@ -217,7 +223,7 @@ private Double calculateAnswerRate(Long totalQuestions, Long totalAnswerByQuesti public RoomJoinResponse subscribeRoom(Long roomId, Long memberId) { participantService.validateParticipant(memberId, roomId); - Room room = roomReader.getById(roomId); + Room room = roomReader.getCacheById(roomId); if (!room.getRoomStatus().canSubscribeRoom) { throw new ErrorException(UNAUTHORIZED_SUBSCRIBE_ROOM); } diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java index aeb3d4c..b0c727e 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiFacadeTests.java @@ -1,27 +1,14 @@ package com.oronaminc.join.emoji.service; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; -import com.oronaminc.join.answer.service.AnswerReader; -import com.oronaminc.join.config.TestQueryDslConfig; -import com.oronaminc.join.emoji.dao.EmojiRepository; -import com.oronaminc.join.emoji.domain.Emoji; -import com.oronaminc.join.emoji.domain.TargetType; -import com.oronaminc.join.emoji.dto.EmojiRequest; -import com.oronaminc.join.member.dao.MemberRepository; -import com.oronaminc.join.member.domain.Member; -import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.question.service.QuestionReader; -import com.oronaminc.join.room.dao.RoomRepository; -import com.oronaminc.join.room.domain.Room; -import com.oronaminc.join.room.domain.RoomStatus; -import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +18,21 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.oronaminc.join.answer.service.AnswerReader; +import com.oronaminc.join.config.TestQueryDslConfig; +import com.oronaminc.join.emoji.dao.EmojiRepository; +import com.oronaminc.join.emoji.domain.Emoji; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.dto.EmojiRequest; +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.question.service.QuestionReader; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.service.RoomReader; + @DataJpaTest @Import({EmojiFacade.class, EmojiService.class, MemberReader.class, EmojiReader.class, RoomReader.class, QuestionReader.class, AnswerReader.class, TestQueryDslConfig.class}) @@ -118,7 +120,7 @@ void deleteEmoji_success_test() throws InterruptedException { Room.builder() .title("제목") .description("내용") - .secretCode("123456") + .secretCode("654321") .emojiCount(emojiCount) .participantLimit(0) .endedAt(LocalDateTime.now()) diff --git a/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java new file mode 100644 index 0000000..9c92108 --- /dev/null +++ b/src/test/java/com/oronaminc/join/room/service/RoomCacheTests.java @@ -0,0 +1,108 @@ +package com.oronaminc.join.room.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import com.oronaminc.join.member.dao.MemberRepository; +import com.oronaminc.join.member.domain.Member; +import com.oronaminc.join.participant.dao.ParticipantRepository; +import com.oronaminc.join.participant.domain.Participant; +import com.oronaminc.join.participant.domain.ParticipantType; +import com.oronaminc.join.room.dao.RoomRepository; +import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.room.domain.RoomStatus; +import com.oronaminc.join.room.domain.RoomType; +import com.oronaminc.join.room.dto.RoomUpdateStatusRequest; + +@SpringBootTest +@ActiveProfiles("test") +@EnableCaching +class RoomCacheTests { + @Autowired + private RoomService roomService; + + @Autowired + private RoomReader roomReader; + + @MockitoSpyBean + private RoomRepository roomRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @BeforeEach + void setUp() { + Room room = Room.builder() + .title("Test Room") + .description("Test Description") + .roomStatus(RoomStatus.BEFORE_START) + .roomType(RoomType.PUBLIC) + .build(); + + roomRepository.save(room); + + Member member = Member.builder() + .build(); + + memberRepository.save(member); + + Participant participant = Participant.builder() + .room(room) + .member(member) + .participantType(ParticipantType.PRESENTER) + .build(); + + participantRepository.save(participant); + } + + @Test + void 캐시가_적용되어_두번째_조회는_DB_접근이_없어야_한다() { + Long roomId = 1L; + + Room room = roomReader.getCacheById(roomId); + + int repeat = 10; + for (int count = 0; count < repeat; count++) { + Room cacheRoom = roomReader.getCacheById(roomId); + assertThat(room).isSameAs(cacheRoom); + } + + verify(roomRepository, times(1)).findById(roomId); + + Cache roomCache = cacheManager.getCache("roomById"); + Room cached = roomCache.get(roomId, Room.class); + assertThat(cached).isNotNull(); + } + + @Test + void updateRoomStatus_호출시_캐시가_삭제되어야_한다() { + Long roomId = 1L; + Long memberId = 1L; + + roomReader.getCacheById(roomId); + + Room cachedBefore = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedBefore).isNotNull(); + + roomService.updateRoomStatus(memberId, roomId, new RoomUpdateStatusRequest(RoomStatus.STARTED)); + + Room cachedAfter = cacheManager.getCache("roomById").get(roomId, Room.class); + assertThat(cachedAfter).isNull(); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4ea2014..469322b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,3 +10,14 @@ spring: hibernate: ddl-auto: create +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: test-bucket + stack: + auto: false + credentials: + access-key: dummy + secret-key: dummy \ No newline at end of file From bdcf47addd5aaf6b71aae6409d3bbf7500d120c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:57:48 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cors disable 추가 * feat: 카카오 로그인 API * feat: 시큐리티에 카카오 로그인 url 추가 * docs: swagger 수정 * test: test yml 수정 --- .../join/member/dto/KakaoLoginRequest.java | 7 ++ .../join/member/dto/KakaoLoginResponse.java | 9 ++ .../join/member/dto/KakaoUserResponse.java | 11 ++ .../join/member/security/AuthController.java | 37 ++++++- .../join/member/security/AuthService.java | 104 ++++++++++++++---- .../join/member/security/SecurityConfig.java | 2 + .../join/member/util/MemberMapper.java | 18 +++ src/test/resources/application.yml | 22 ++++ 8 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java create mode 100644 src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java new file mode 100644 index 0000000..e6a99e8 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginRequest.java @@ -0,0 +1,7 @@ +package com.oronaminc.join.member.dto; + +public record KakaoLoginRequest( + String code, + String state +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java new file mode 100644 index 0000000..9e0ef7d --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoLoginResponse.java @@ -0,0 +1,9 @@ +package com.oronaminc.join.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoLoginResponse( + @Schema(description = "회원 id", example = "1001") + Long id +) { +} diff --git a/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java new file mode 100644 index 0000000..0ce9106 --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/dto/KakaoUserResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.member.dto; + +import lombok.Builder; + +@Builder +public record KakaoUserResponse( + String email, + String nickname, + String profileImageUrl +) { +} diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 85b9741..08e5d1f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,10 +1,5 @@ package com.oronaminc.join.member.security; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.tags.Tags; import java.util.List; import org.springframework.http.HttpStatus; @@ -24,8 +19,13 @@ import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.GuestLoginResponse; +import com.oronaminc.join.member.dto.KakaoLoginRequest; +import com.oronaminc.join.member.dto.KakaoLoginResponse; import com.oronaminc.join.member.dto.SessionInfoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -40,6 +40,33 @@ public class AuthController { private final AuthService authService; + @Operation( + summary = "카카오 로그인", + description = "redirect url 에 포함된 파라미터의 code와 state를 입력해주세요. 이후 모든 요청에 세션 인증이 적용됩니다." + ) + @PostMapping("/kakao") + @ResponseStatus(HttpStatus.OK) + public KakaoLoginResponse kakaoLogin( + @RequestBody KakaoLoginRequest kakaoLoginRequest, + HttpServletRequest request + ) { + MemberDetails memberDetails = authService.kakaoLogin(kakaoLoginRequest.code()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + memberDetails, null, List.of(new SimpleGrantedAuthority(memberDetails.getRole())) + ); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + return new KakaoLoginResponse(memberDetails.getId()); + } + @Operation( summary = "비회원 로그인", description = "닉네임을 입력하면 비회원 세션이 생성되고 인증이 설정됩니다. 이후 모든 요청에 세션 인증이 적용됩니다.", diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index ba1c2ad..3d64d9f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -3,19 +3,26 @@ import static com.oronaminc.join.member.util.MemberMapper.*; import java.util.Map; -import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import com.oronaminc.join.member.dao.MemberRepository; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.service.MemberReader; +import com.oronaminc.join.member.util.MemberMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,32 +34,91 @@ public class AuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final MemberReader memberReader; - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - Map attributes = oAuth2User.getAttributes(); + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectUri; + + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String clientSecret; + + // @Override + // public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2User oAuth2User = super.loadUser(userRequest); + // Map attributes = oAuth2User.getAttributes(); + // + // log.info("attributes :: " + attributes); + // + // Map kakaoAccount = (Map) attributes.get("kakao_account"); + // Map profile = (Map) kakaoAccount.get("profile"); + // + // + // Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + // + // Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + // + // return toOAuth2MemberDetails(member); + // } - log.info("attributes :: " + attributes); + @Transactional + public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { + Member guest = toGuestMember(guestLoginRequest); - Map kakaoAccount = (Map) attributes.get("kakao_account"); - Map profile = (Map) kakaoAccount.get("profile"); + memberRepository.save(guest); + guest.registerGuest(); + return toGuestMemberDetails(guest); + } - Optional optionalMember = memberReader.findByEmail(kakaoAccount.get("email").toString()); + @Transactional + public MemberDetails kakaoLogin(String code) { + String accessToken = getAccessToken(code); + KakaoUserResponse kakaoUser = getUserInfo(accessToken); - Member member = optionalMember.orElseGet(() -> memberRepository.save(toKakaoMember(kakaoAccount, profile))); + Member member = memberRepository.findByEmail(kakaoUser.email()) + .orElseGet(() -> memberRepository.save(MemberMapper.toNewKakaoMember(kakaoUser))); return toOAuth2MemberDetails(member); } - @Transactional - public MemberDetails loadGuest(GuestLoginRequest guestLoginRequest) { - Member guest = toGuestMember(guestLoginRequest); + private String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - memberRepository.save(guest); - guest.registerGuest(); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("redirect_uri", redirectUri); + params.add("code", code); + params.add("client_secret", clientSecret); - return toGuestMemberDetails(guest); + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.postForEntity(TOKEN_URI, request, Map.class); + + return (String) response.getBody().get("access_token"); } + private KakaoUserResponse getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(USER_INFO_URI, HttpMethod.GET, entity, Map.class); + + Map attributes = response.getBody(); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return MemberMapper.toKakaoUserResponse(kakaoAccount, profile); + } } diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index fd6861b..582dac7 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -23,9 +23,11 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", + "/api/auth/kakao", "/login" ) .anonymous() diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 673fa6d..7835d9b 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -5,6 +5,7 @@ import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.domain.MemberType; import com.oronaminc.join.member.dto.GuestLoginRequest; +import com.oronaminc.join.member.dto.KakaoUserResponse; import com.oronaminc.join.member.security.MemberDetails; import lombok.AccessLevel; @@ -47,4 +48,21 @@ public static Member toKakaoMember(Map kakaoAccount, Map kakaoAccount, Map profile) { + return KakaoUserResponse.builder() + .email((String) kakaoAccount.get("email")) + .nickname((String) profile.get("nickname")) + .profileImageUrl((String) profile.get("profile_image_url")) + .build(); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 469322b..c1c6fd1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -10,6 +10,28 @@ spring: hibernate: ddl-auto: create + security: + oauth2: + client: + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + registration: + kakao: + client-name: Kakao + client-id: KAKAO_CLIENT_ID + client-secret: KAKAO_CLIENT_SECRET + redirect-uri: KAKAO_REDIRECT_URI + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - profile_nickname + - profile_image + - account_email + cloud: aws: region: From 262293114e28620dd1a3515391d32788afe008dc Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:15:46 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20Presigned=20URL=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=9A=A9,=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 기존 presigned-url 발급 로직을 업로드용, 조회용으로 분리 * docs: 발표자료 Swagger tag 추가 --- .../join/document/api/DocumentController.java | 4 +++- .../document/service/DocumentService.java | 4 ++-- .../join/infra/service/S3Service.java | 23 ++++++++++++++++++- .../join/room/service/RoomService.java | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/oronaminc/join/document/api/DocumentController.java b/src/main/java/com/oronaminc/join/document/api/DocumentController.java index 57dccbd..86dc696 100644 --- a/src/main/java/com/oronaminc/join/document/api/DocumentController.java +++ b/src/main/java/com/oronaminc/join/document/api/DocumentController.java @@ -7,12 +7,14 @@ import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "발표자료") @RestController @RequiredArgsConstructor @RequestMapping("/api/documents") @@ -37,6 +39,6 @@ public DocumentResponse generatePresignedUrl( @AuthenticationPrincipal MemberDetails memberDetails ) { String memberRole = memberDetails.getRole(); - return documentService.generatePresignedUrl(documentRequest, memberRole); + return documentService.generateUploadPresignedUrl(documentRequest, memberRole); } } diff --git a/src/main/java/com/oronaminc/join/document/service/DocumentService.java b/src/main/java/com/oronaminc/join/document/service/DocumentService.java index 6ada500..9216712 100644 --- a/src/main/java/com/oronaminc/join/document/service/DocumentService.java +++ b/src/main/java/com/oronaminc/join/document/service/DocumentService.java @@ -37,7 +37,7 @@ public void deleteByRoomId(Long roomId) { documentRepository.deleteByRoomId(roomId); } - public DocumentResponse generatePresignedUrl(DocumentRequest request, String memberRole) { + public DocumentResponse generateUploadPresignedUrl(DocumentRequest request, String memberRole) { if (!memberRole.equals(MemberType.MEMBER.name())) { throw new ErrorException(ErrorCode.UNAUTHORIZED_MEMBER); } @@ -52,7 +52,7 @@ public DocumentResponse generatePresignedUrl(DocumentRequest request, String mem String uuid = UUID.randomUUID().toString(); String objectKey = "temp/" + uuid + extension; - String presignedUrl = s3Service.generatePresignedUrl(objectKey); + String presignedUrl = s3Service.generateUploadPresignedUrl(objectKey); return new DocumentResponse(presignedUrl, objectKey); } diff --git a/src/main/java/com/oronaminc/join/infra/service/S3Service.java b/src/main/java/com/oronaminc/join/infra/service/S3Service.java index 3bc2261..f5ac28d 100644 --- a/src/main/java/com/oronaminc/join/infra/service/S3Service.java +++ b/src/main/java/com/oronaminc/join/infra/service/S3Service.java @@ -11,6 +11,8 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.time.Duration; @@ -25,7 +27,8 @@ public class S3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; - public String generatePresignedUrl(String key) { + // 조회용 + public String generateGetPresignedUrl(String key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucket) .key(key) @@ -41,6 +44,24 @@ public String generatePresignedUrl(String key) { return presignedRequest.url().toString(); } + // 업로드용 + public String generateUploadPresignedUrl(String key) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .contentType("application/pdf") + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(3)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + + return presignedRequest.url().toString(); + } + public void deleteFile(String key) { try { if (isFileExist(key)) { diff --git a/src/main/java/com/oronaminc/join/room/service/RoomService.java b/src/main/java/com/oronaminc/join/room/service/RoomService.java index e28e429..9f04704 100644 --- a/src/main/java/com/oronaminc/join/room/service/RoomService.java +++ b/src/main/java/com/oronaminc/join/room/service/RoomService.java @@ -98,7 +98,7 @@ public RoomDetailResponse getRoomDetail(Long memberId, Long roomId) { List team = participantService.getTeam(roomId); Document document = documentReader.getByRoomId(roomId); int participantCount = currentParticipantManager.getRoomParticipants(roomId).size(); - String presignedUrl = s3Service.generatePresignedUrl(document.getFileUrl()); + String presignedUrl = s3Service.generateGetPresignedUrl(document.getFileUrl()); return RoomMapper.toRoomDetailResponse(room, presenter, team, presignedUrl, memberId, participantCount); } From 12c801a96156d3167ad037277656990f929e1b47 Mon Sep 17 00:00:00 2001 From: Huiwoong Choi <95081400+chw0912@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:20:44 +0900 Subject: [PATCH 05/17] =?UTF-8?q?refactor:=20profiles=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=A3=BC=EC=9E=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-workflow.yml | 2 ++ Dockerfile | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index a4fcfc8..aea75e9 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -78,6 +78,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + SPRING_PROFILES_ACTIVE=dev deploy: name: EC2 자동 배포 diff --git a/Dockerfile b/Dockerfile index 94b6ec4..98af97d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,8 @@ WORKDIR /app COPY --from=builder /libs/build/libs/*.jar app.jar -ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "app.jar"] +ARG SPRING_PROFILES_ACTIVE +ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "app.jar"] From 65e05fd9007ea9a3568400c096b80ff2e61b9ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:30:20 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81,=20=EB=B0=A9=20=EC=B0=B8=EA=B0=80=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: restTemplate 재시도 로직 추가 * feat: 방 참가자 관리 동시성 수정 및 비밀코드 방 참가 수정 --- build.gradle | 1 + .../global/config/RestTemplateConfig.java | 38 +++++++++++++++++++ .../join/room/api/RoomController.java | 2 +- .../session/CurrentParticipantManager.java | 27 +++++++++++-- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java diff --git a/build.gradle b/build.gradle index 7945a4a..828451c 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,7 @@ dependencies { // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.springframework.retry:spring-retry:2.0.12' } tasks.named('test') { diff --git a/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..1e98591 --- /dev/null +++ b/src/main/java/com/oronaminc/join/global/config/RestTemplateConfig.java @@ -0,0 +1,38 @@ +package com.oronaminc.join.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestTemplate; + +@Configuration +class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .additionalInterceptors(clientHttpRequestInterceptor()) + .build(); + } + + // 3번 재시도 + public ClientHttpRequestInterceptor clientHttpRequestInterceptor() { + return (request, body, execution) -> { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); + try { + return retryTemplate.execute(context -> execution.execute(request, body)); + } catch (Throwable throwable) { + throw new RuntimeException(throwable); + } + }; + } + +} diff --git a/src/main/java/com/oronaminc/join/room/api/RoomController.java b/src/main/java/com/oronaminc/join/room/api/RoomController.java index ff58993..d64cc68 100644 --- a/src/main/java/com/oronaminc/join/room/api/RoomController.java +++ b/src/main/java/com/oronaminc/join/room/api/RoomController.java @@ -63,7 +63,7 @@ public CreateRoomResponse createRoom( description = "비밀코드를 통해 해당 발표방에 참가자로 등록합니다. 시작 전 상태이면 참가할 수 없습니다.", security = @SecurityRequirement(name = "sessionAuth") ) - @GetMapping("/code") + @PostMapping("/code") @ResponseStatus(HttpStatus.OK) public JoinRoomResponse joinRoom( @RequestBody JoinRoomRequest joinRoomRequest, diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java index 3716745..d976c9c 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantManager.java @@ -23,17 +23,36 @@ public void createRoom(Long roomId) { roomParticipants.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()); } + // public void addParticipant(Long roomId, Long memberId, int limit) { + // Set participants = getRoomParticipants(roomId); + // + // if (participants.contains(memberId)) return; + // + // synchronized (participants) { + // if (participants.size() >= limit) { + // throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); + // } + // participants.add(memberId); + // } + // } + public void addParticipant(Long roomId, Long memberId, int limit) { - Set participants = getRoomParticipants(roomId); + roomParticipants.compute(roomId, (id, participants) -> { + participants = getRoomParticipants(roomId); - if (participants.contains(memberId)) return; + // 중복 참가자일 경우 그대로 반환 (변화 없음) + if (participants.contains(memberId)) { + return participants; + } - synchronized (participants) { + // 인원 초과 시 예외 발생 if (participants.size() >= limit) { throw new ErrorException(UNAUTHORIZED_LIMIT_PARTICIPANT); } + participants.add(memberId); - } + return participants; + }); } public void removeParticipant(Long memberId, Long roomId) { From a0ef2c6f9ba1bed49c4fdb01dd554e117031c667 Mon Sep 17 00:00:00 2001 From: chcch529 <146617430+chcch529@users.noreply.github.com> Date: Sat, 26 Jul 2025 17:37:02 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor,fix:=202=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=EB=A7=81=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../join/answer/dto/AnswerCreateResponse.java | 3 +- .../join/answer/dto/AnswerDeleteResponse.java | 3 +- .../join/answer/dto/AnswerUpdateResponse.java | 3 +- .../join/answer/mapper/AnswerMapper.java | 5 ++- .../join/emoji/dto/EmojiResponse.java | 3 +- .../join/emoji/service/EmojiService.java | 5 ++- .../join/global/exception/ErrorCode.java | 3 +- .../join/global/exception/ErrorException.java | 18 ++++++++- .../global/exception/ExceptionAdvice.java | 2 +- .../dao/ParticipantRepository.java | 40 +++++++++---------- .../join/question/api/QuestionController.java | 3 +- .../question/dto/QuestionCreateResponse.java | 3 +- .../question/dto/QuestionDeleteResponse.java | 3 +- .../question/dto/QuestionUpdateResponse.java | 3 +- .../question/service/QuestionService.java | 14 ++++--- .../join/question/util/QuestionMapper.java | 7 ++-- .../join/room/util/CodeGenerator.java | 13 ++---- .../api/AnswerWebsocketController.java | 3 +- .../join/websocket/common/EventType.java | 5 +++ .../CurrentParticipantEventHandler.java | 5 ++- .../join/emoji/service/EmojiServiceTests.java | 13 +++--- 22 files changed, 96 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/common/EventType.java diff --git a/build.gradle b/build.gradle index 828451c..3fb9bea 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,8 @@ dependencies { // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' + // commons-lang3 + implementation 'org.apache.commons:commons-lang3:3.18.0' // caffeine implementation 'com.github.ben-manes.caffeine:caffeine' diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java index 191ab18..c8e8c88 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerCreateResponse.java @@ -1,6 +1,7 @@ package com.oronaminc.join.answer.dto; import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -13,7 +14,7 @@ public record AnswerCreateResponse( @Schema(description = "답변이 생성될 질문 ID") Long questionId, @Schema(description = "답변 생성/삭제/수정 상태", example = "CREATE") - String event, + EventType event, @Schema(description = "답변 ID", example = "11") Long answerId, @Schema(description = "답변 내용", example = "답변입니다.") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java index d8f4f64..36b5c9b 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerDeleteResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "답변 삭제 응답 DTO") public record AnswerDeleteResponse( Long answerId, @Schema(description = "삭제 이벤트", example = "DELETE") - String event + EventType event ) { } diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java index 3248b78..11f3835 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerUpdateResponse.java @@ -1,5 +1,6 @@ package com.oronaminc.join.answer.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -8,7 +9,7 @@ public record AnswerUpdateResponse( Long answerId, @Schema(description = "수정 이벤트", example = "UPDATE") - String event, + EventType event, @Schema(description = "수정된 내용", example = "수정된 답변입니다.") String content diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 95dace3..0d27485 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -8,6 +8,7 @@ import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -17,7 +18,7 @@ public class AnswerMapper { public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { return AnswerCreateResponse.builder() .questionId(answer.getQuestion().getId()) - .event("CREATE") + .event(EventType.CREATE) .answerId(answer.getId()) .content(answer.getContent()) .emojiCount(0) @@ -51,7 +52,7 @@ public static Answer toEntity(Question question, Member member, AnswerRequest re public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { return AnswerUpdateResponse.builder() .answerId(answer.getId()) - .event("UPDATE") + .event(EventType.UPDATE) .content(answer.getContent()) .build(); } diff --git a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java index eda6d68..99fc8a5 100644 --- a/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java +++ b/src/main/java/com/oronaminc/join/emoji/dto/EmojiResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.emoji.dto; import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "발표방/질문/답변 공감 생성/삭제 응답 DTO") public record EmojiResponse( @Schema(description = "이벤트 타입 (CREATE, DELETE)", example = "CREATE") - String event, + EventType event, @Schema(description = "공감 대상 타입 (ROOM, QUESTION, ANSWER)", example = "ROOM") TargetType targetType, @Schema(description = "공감 대상 ID", example = "1") diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java index 8bdc68b..4b20cae 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiService.java @@ -11,6 +11,7 @@ import com.oronaminc.join.member.service.MemberReader; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +47,7 @@ public EmojiResponse createEmoji(Long memberId, EmojiRequest emojiRequest) { emojiCount = incrementEmojiCount(targetType, targetId); - return new EmojiResponse("CREATE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.CREATE, targetType, targetId, emojiCount); } @@ -62,7 +63,7 @@ public EmojiResponse deleteEmoji(Long memberId, EmojiRequest emojiRequest) { ); emojiCount = decrementEmojiCount(targetType, targetId); - return new EmojiResponse("DELETE", targetType, targetId, emojiCount); + return new EmojiResponse(EventType.DELETE, targetType, targetId, emojiCount); } diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java index 5fc9cd7..865658b 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorCode.java @@ -31,7 +31,6 @@ public enum ErrorCode { UNAUTHORIZED_LIMIT_PARTICIPANT("PARTICIPANT-005", "인원이 가득 차 참가할 수 없습니다.", UNAUTHORIZED), UNAUTHORIZED_NOT_JOIN_ROOM("PARTICIPANT-005", "발표방에 참여하지 않았습니다. 먼저 참여해주세요.", UNAUTHORIZED), - FILE_UPLOAD_FAILED("FILE-001", "파일 업로드에 실패하였습니다.", INTERNAL_SERVER_ERROR), NOT_FOUND_FILE("FILE-002", "존재하지 않는 파일입니다.", NOT_FOUND), MOVEMENT_FILE_FAILED("FILE-003", "파일 이동이 실패하였습니다.", INTERNAL_SERVER_ERROR), @@ -51,7 +50,6 @@ public enum ErrorCode { UNAUTHORIZED_DELETE_ANSWER("ANSWER-005", "작성자 혹은 팀원, 발표자가 아니면 해당 댓글을 삭제할 수 없습니다.", UNAUTHORIZED), TOO_MANY_REQUESTS_ANSWER("ANSWER-006", "잠시 후 다시 시도해주세요.", UNAUTHORIZED), - ACCESS_DENIED_SESSION("SESSION-1201", "접근 권한이 없습니다.", FORBIDDEN), NOT_FOUND_SESSION("SESSION-1202", "세션이 유효하지 않습니다.", UNAUTHORIZED), EXPIRED_SESSION("SESSION-1203", "세션이 만료되었습니다.", UNAUTHORIZED), @@ -62,6 +60,7 @@ public enum ErrorCode { SOCKET_BAD_REQUEST_PATH("SOCKET-1002", "경로가 유효하지 않습니다.", BAD_REQUEST), SOCKET_BAD_REQUEST_MEMBER("SOCKET-1003", "회원이 유효하지 않습니다.", BAD_REQUEST), + STOMP_INVALID_DESTINATION("STOMP-001", "경로가 유효하지 않습니다.", BAD_REQUEST), CONFLICT_EMOJI("EMOJI-001", "공감 처리 중 충돌이 발생했습니다.", CONFLICT), NOT_FOUND_EMOJI("EMOJI-002", "해당 이모지가 존재하지 않습니다.", NOT_FOUND), diff --git a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java index 979f2c1..76828f7 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ErrorException.java +++ b/src/main/java/com/oronaminc/join/global/exception/ErrorException.java @@ -1,5 +1,7 @@ package com.oronaminc.join.global.exception; +import com.oronaminc.join.global.util.StringUtil; +import java.text.MessageFormat; import lombok.AllArgsConstructor; import lombok.Getter; @@ -8,5 +10,19 @@ public class ErrorException extends RuntimeException { private final ErrorCode errorCode; + private final String errorMessage; -} + public ErrorException(ErrorCode errorCode) { + this(errorCode, null); + } + + public static ErrorException of(ErrorCode errorCode, String message, Object... args) { + String errorMessage = createMessage(message, args); + return new ErrorException(errorCode, errorMessage); + } + + public static String createMessage(String message, Object... args) { + return StringUtil.format(message, args); + } + +} \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java index c166075..3572740 100644 --- a/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/oronaminc/join/global/exception/ExceptionAdvice.java @@ -18,7 +18,7 @@ public class ExceptionAdvice { public ResponseEntity handleErrorException(ErrorException ex) { ErrorCode errorCode = ex.getErrorCode(); - log.error(errorCode.getMessage(), ex); + log.error("ErrorCode: {}, ErrorMessage: {}", ex.getErrorCode(), ex.getErrorMessage()); HttpStatus httpStatus = switch (errorCode.getErrorStatus()) { case NOT_FOUND -> HttpStatus.NOT_FOUND; diff --git a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java index 2b86ccc..6f200f8 100644 --- a/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java +++ b/src/main/java/com/oronaminc/join/participant/dao/ParticipantRepository.java @@ -15,11 +15,11 @@ public interface ParticipantRepository extends JpaRepository { - @Query( - "SELECT COUNT(p) > 0 " + - "FROM Participant p " + - "WHERE p.room.id = :roomId AND p.member.id = :memberId" - ) + @Query(""" + select COUNT(p) > 0 + from Participant p + where p.room.id = :roomId and p.member.id = :memberId + """) boolean existsByRoomIdAndMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); Optional findByRoomIdAndParticipantType(Long roomId, ParticipantType participantType); @@ -30,7 +30,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p where p.member.id = :memberId group by p.participantType - """) + """) List countByMemberIdGroupByParticipantType(Long memberId); @Query(""" @@ -38,7 +38,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId - """) + """) Page findByMemberId(Long memberId, Pageable pageable); @Query(""" @@ -46,7 +46,7 @@ public interface ParticipantRepository extends JpaRepository from Participant p join fetch p.room where p.member.id = :memberId and p.participantType = :pType - """) + """) Page findByMemberIdAndParticipantType(Long memberId, ParticipantType pType, Pageable pageable); @@ -55,30 +55,30 @@ Page findByMemberIdAndParticipantType(Long memberId, ParticipantTyp from Participant p join fetch p.room where p.member.id = :memberId and p.participantType != :pType - """) + """) Page findByMemberIdAndParticipantTypeNot(Long memberId, ParticipantType pType, Pageable pageable); Optional findByRoomIdAndMemberId( @Param("roomId") Long roomId, @Param("memberId") Long memberId ); @Query(""" - SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END - FROM Participant p - WHERE p.room.id = :roomId - AND p.member.id = :memberId - AND (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER - OR p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) + select case when COUNT(p) > 0 then true else false end + from Participant p + where p.room.id = :roomId + and p.member.id = :memberId + and (p.participantType = com.oronaminc.join.participant.domain.ParticipantType.PRESENTER + or p.participantType = com.oronaminc.join.participant.domain.ParticipantType.TEAM) """) boolean existsPresenterOrTeamByMemberId(@Param("roomId") Long roomId, @Param("memberId") Long memberId); void deleteByRoomId(Long roomId); @Query(value = """ - SELECT COUNT(*) - FROM participant - WHERE room_id = :roomId - AND exited_at IS NOT NULL - AND TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 + select COUNT(*) + from participant + where room_id = :roomId + and exited_at is not null + and TIMESTAMPDIFF(SECOND, created_at, exited_at) >= 30 """, nativeQuery = true) Long countParticipantsStayedOver30Seconds(@Param("roomId") Long roomId); } \ No newline at end of file diff --git a/src/main/java/com/oronaminc/join/question/api/QuestionController.java b/src/main/java/com/oronaminc/join/question/api/QuestionController.java index 2d183ee..901cfb7 100644 --- a/src/main/java/com/oronaminc/join/question/api/QuestionController.java +++ b/src/main/java/com/oronaminc/join/question/api/QuestionController.java @@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -30,7 +31,7 @@ public ResponseEntity getQuestions( @RequestParam(required = false) Long lastEmojiCount, @RequestParam(defaultValue = "10") int size, @RequestParam Long memberId, - @RequestParam Long roomId + @PathVariable Long roomId ) { Slice result = questionService.getQuestions( sort, lastId, lastEmojiCount, size, memberId, roomId diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java index 6cd4055..6e53af7 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionCreateResponse.java @@ -2,6 +2,7 @@ import com.oronaminc.join.global.dto.WriterDto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import lombok.Builder; @@ -10,7 +11,7 @@ @Schema(description = "질문 생성 응답 DTO") public record QuestionCreateResponse( @Schema(description = "", example = "CREATE") - String event, + EventType event, @Schema(description = "질문 ID", example = "11") Long questionId, @Schema(description = "질문 내용", example = "질문있습니다. 질문생성DTO가 맞나요?") diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java index a140a2a..a3f1044 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionDeleteResponse.java @@ -1,11 +1,12 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Schema(description = "질문 삭제 응답 DTO") public record QuestionDeleteResponse( - String event, + EventType event, Long questionId ) { } diff --git a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java index efd0396..ee4388e 100644 --- a/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java +++ b/src/main/java/com/oronaminc/join/question/dto/QuestionUpdateResponse.java @@ -1,12 +1,13 @@ package com.oronaminc.join.question.dto; +import com.oronaminc.join.websocket.common.EventType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder @Schema(description = "질문 수정 응답 DTO") public record QuestionUpdateResponse( - String event, + EventType event, Long questionId, String content diff --git a/src/main/java/com/oronaminc/join/question/service/QuestionService.java b/src/main/java/com/oronaminc/join/question/service/QuestionService.java index c12597e..7a283a1 100644 --- a/src/main/java/com/oronaminc/join/question/service/QuestionService.java +++ b/src/main/java/com/oronaminc/join/question/service/QuestionService.java @@ -45,9 +45,7 @@ public class QuestionService { public Question create(Long roomId, Long memberId, QuestionRequest requestDto) { Member member = memberReader.getById(memberId); - Room room = roomReader.getById(roomId); - participantService.validateParticipant(memberId, roomId); Question question = QuestionMapper.toQuestion(room, member, requestDto); @@ -86,12 +84,14 @@ public Question update(Long memberId, Long roomId, Long questionId, QuestionRequ // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 작성자가 아님 if (!question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_EDIT_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_EDIT_QUESTION, + "{}번 회원은 {}번 질문을 수정할 권한이 없습니다.", memberId, questionId); } question.updateContent(request.content()); @@ -105,13 +105,15 @@ public Long delete(Long memberId, Long roomId, Long questionId) { // 참여자가 아님 if (!participantReader.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new ErrorException(ErrorCode.NOT_FOUND_PARTICIPANT); + throw ErrorException.of(ErrorCode.NOT_FOUND_PARTICIPANT, + "{}번 발표방에는 {}번 회원이 잠가 중이지 않습니다.", roomId, memberId); } // 관리자가 아님 && 작성자도 아님 if (!participantReader.existsPresenterOrTeamByMemberId(roomId, memberId) && !question.getMember().getId().equals(memberId)) { - throw new ErrorException(ErrorCode.UNAUTHORIZED_DELETE_QUESTION); + throw ErrorException.of(ErrorCode.UNAUTHORIZED_DELETE_QUESTION, + "{}번 회원은 {}번 질문을 삭제할 권한이 없습니다.", memberId, questionId); } answerService.deleteByQuestion(questionId); diff --git a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java index 9a784f0..4a045e8 100644 --- a/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java +++ b/src/main/java/com/oronaminc/join/question/util/QuestionMapper.java @@ -12,6 +12,7 @@ import com.oronaminc.join.question.dto.QuestionListResponse; import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.room.domain.Room; +import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.springframework.data.domain.Slice; @@ -25,7 +26,7 @@ public static Question toQuestion(Room room, Member member, QuestionRequest requ public static QuestionCreateResponse toQuestionCreateResponse (Question question) { return QuestionCreateResponse.builder() - .event("CREATE") + .event(EventType.CREATE) .questionId(question.getId()) .content(question.getContent()) .emojiCount(0L) @@ -56,7 +57,7 @@ public static QuestionAssembleResponse toQuestionListResponse(QuestionFlatRespon public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) { return QuestionUpdateResponse.builder() - .event("UPDATE") + .event(EventType.UPDATE) .questionId(question.getId()) .content(question.getContent()) .build(); @@ -64,7 +65,7 @@ public static QuestionUpdateResponse toQuestionUpdateResponse(Question question) public static QuestionDeleteResponse toQuestionDeleteResponse(Long questionId) { return new QuestionDeleteResponse( - "DELETE", + EventType.DELETE, questionId ); } diff --git a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java index 39b86c4..558077a 100644 --- a/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java +++ b/src/main/java/com/oronaminc/join/room/util/CodeGenerator.java @@ -1,21 +1,16 @@ package com.oronaminc.join.room.util; -import java.util.Random; - import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CodeGenerator { + private static final String CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static final Random random = new Random(); public static String generateCode(int length) { - StringBuilder sb = new StringBuilder(length); - for (int i = 0; i < length; i++) { - int idx = random.nextInt(CHAR_POOL.length()); - sb.append(CHAR_POOL.charAt(idx)); - } - return sb.toString(); + + return RandomStringUtils.random(length, CHAR_POOL); } } diff --git a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java index 313e60f..12350e4 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/AnswerWebsocketController.java @@ -11,6 +11,7 @@ import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.global.exception.ErrorException; +import com.oronaminc.join.websocket.common.EventType; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; import io.github.bucket4j.Bucket; @@ -84,7 +85,7 @@ public AnswerDeleteResponse delete( log.info("삭제되었습니다."); - return new AnswerDeleteResponse(answerId, "DELETE"); + return new AnswerDeleteResponse(answerId, EventType.DELETE); } private Long getMemberId(Principal principal) { diff --git a/src/main/java/com/oronaminc/join/websocket/common/EventType.java b/src/main/java/com/oronaminc/join/websocket/common/EventType.java new file mode 100644 index 0000000..b3bb363 --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/common/EventType.java @@ -0,0 +1,5 @@ +package com.oronaminc.join.websocket.common; + +public enum EventType { + CREATE, UPDATE, DELETE +} diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c98a2ce..c6b25c2 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,6 +2,7 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; +import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; @@ -30,7 +31,7 @@ public void handleSubscribe(SessionSubscribeEvent event) { Principal principal = accessor.getUser(); if (destination == null) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } if (!destination.startsWith(ROOM_PREFIX)) { @@ -66,7 +67,7 @@ private Long parseRoomId(String destination) { String[] parts = destination.split("/"); return Long.valueOf(parts[3]); } catch (Exception e) { - throw new ErrorException(SOCKET_BAD_REQUEST_PATH); + throw new ErrorException(STOMP_INVALID_DESTINATION); } } diff --git a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java index bea6d9d..e369c8d 100644 --- a/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java +++ b/src/test/java/com/oronaminc/join/emoji/service/EmojiServiceTests.java @@ -20,6 +20,7 @@ import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import com.oronaminc.join.websocket.common.EventType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -83,7 +84,7 @@ void toggleEmoji_createRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -121,7 +122,7 @@ void toggleEmoji_createQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -159,7 +160,7 @@ void toggleEmoji_createAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("CREATE"); + assertThat(response.event()).isEqualTo(EventType.CREATE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount + 1); @@ -228,7 +229,7 @@ void toggleEmoji_deleteRoomEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -264,7 +265,7 @@ void toggleEmoji_deleteQuestionEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); @@ -300,7 +301,7 @@ void toggleEmoji_deleteAnswerEmoji_success() { new EmojiRequest(targetType, targetId)); // then - assertThat(response.event()).isEqualTo("DELETE"); + assertThat(response.event()).isEqualTo(EventType.DELETE); assertThat(response.targetType()).isEqualTo(targetType); assertThat(response.targetId()).isEqualTo(targetId); assertThat(response.emojiCount()).isEqualTo(emojiCount - 1); From a34d68066f0cf6118e38795f48cfe07f9e7a95f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Sun, 27 Jul 2025 12:44:37 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20MemberController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: existsMemberByEmail에 RequestBody 추가 * fix: existsMemberByEmail 수정 * feat: 로그인 API 반환 수정 --- .../join/member/api/MemberController.java | 4 ++-- .../join/member/security/AuthController.java | 17 +++++++---------- .../join/member/util/MemberMapper.java | 10 ++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/api/MemberController.java b/src/main/java/com/oronaminc/join/member/api/MemberController.java index d70b916..b48a3ce 100644 --- a/src/main/java/com/oronaminc/join/member/api/MemberController.java +++ b/src/main/java/com/oronaminc/join/member/api/MemberController.java @@ -46,8 +46,8 @@ public class MemberController { @GetMapping("/exists") @ResponseStatus(HttpStatus.OK) public ExistsMemberResponse existsMemberByEmail( - @Valid ExistsMemberRequest existsMemberRequest) { - boolean exists = memberService.existsMemberByEmail(existsMemberRequest.email()); + @RequestParam String email) { + boolean exists = memberService.existsMemberByEmail(email); return new ExistsMemberResponse(exists); } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 08e5d1f..4ac9476 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -1,5 +1,7 @@ package com.oronaminc.join.member.security; +import static com.oronaminc.join.member.util.MemberMapper.toSessionInfoResponse; + import java.util.List; import org.springframework.http.HttpStatus; @@ -46,7 +48,7 @@ public class AuthController { ) @PostMapping("/kakao") @ResponseStatus(HttpStatus.OK) - public KakaoLoginResponse kakaoLogin( + public SessionInfoResponse kakaoLogin( @RequestBody KakaoLoginRequest kakaoLoginRequest, HttpServletRequest request ) { @@ -64,7 +66,7 @@ public KakaoLoginResponse kakaoLogin( request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new KakaoLoginResponse(memberDetails.getId()); + return toSessionInfoResponse(memberDetails); } @Operation( @@ -77,7 +79,7 @@ public KakaoLoginResponse kakaoLogin( ) @PostMapping("/guest") @ResponseStatus(HttpStatus.CREATED) - public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { + public SessionInfoResponse guestLogin(@RequestBody @Valid GuestLoginRequest guestLoginRequest, HttpServletRequest request) { MemberDetails guest = authService.loadGuest(guestLoginRequest); Authentication authentication = new UsernamePasswordAuthenticationToken( @@ -90,7 +92,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest request.getSession(true).setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - return new GuestLoginResponse(guest.getId()); + return toSessionInfoResponse(guest); } @Operation( @@ -106,12 +108,7 @@ public GuestLoginResponse guestLogin(@RequestBody @Valid GuestLoginRequest guest @ResponseStatus(HttpStatus.OK) public SessionInfoResponse getSessionInfo(@AuthenticationPrincipal MemberDetails memberDetails) { - return new SessionInfoResponse( - memberDetails.getId(), - memberDetails.getName(), - memberDetails.getNickname(), - memberDetails.getRole() - ); + return toSessionInfoResponse(memberDetails); } @Operation( diff --git a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java index 7835d9b..831d3c1 100644 --- a/src/main/java/com/oronaminc/join/member/util/MemberMapper.java +++ b/src/main/java/com/oronaminc/join/member/util/MemberMapper.java @@ -1,5 +1,6 @@ package com.oronaminc.join.member.util; +import com.oronaminc.join.member.dto.SessionInfoResponse; import java.util.Map; import com.oronaminc.join.member.domain.Member; @@ -65,4 +66,13 @@ public static KakaoUserResponse toKakaoUserResponse(Map kakaoAcc .profileImageUrl((String) profile.get("profile_image_url")) .build(); } + + public static SessionInfoResponse toSessionInfoResponse(MemberDetails memberDetails) { + return new SessionInfoResponse( + memberDetails.getId(), + memberDetails.getName(), + memberDetails.getNickname(), + memberDetails.getRole() + ); + } } From 492992a26cc84005e6619b26a77e0862fea9a8d2 Mon Sep 17 00:00:00 2001 From: SeungTae <122506273+gffd94@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:47:01 +0900 Subject: [PATCH 09/17] =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/answer/api/AnswerController.java | 18 ++- .../join/answer/dao/AnswerRepository.java | 49 +++++-- .../oronaminc/join/answer/domain/Answer.java | 1 - .../join/answer/dto/AnswerGetResponse.java | 2 +- .../join/answer/dto/AnswerListResponse.java | 11 ++ .../join/answer/mapper/AnswerMapper.java | 18 ++- .../join/answer/service/AnswerReader.java | 31 +++-- .../join/answer/service/AnswerService.java | 48 +++++-- .../join/emoji/dao/EmojiRepository.java | 17 +++ .../join/emoji/service/EmojiReader.java | 7 + .../join/question/domain/Question.java | 1 - .../answer/service/AnswerServiceTests.java | 129 +++++++++++++----- 12 files changed, 252 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java diff --git a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java index cc6c30c..7452a6a 100644 --- a/src/main/java/com/oronaminc/join/answer/api/AnswerController.java +++ b/src/main/java/com/oronaminc/join/answer/api/AnswerController.java @@ -1,17 +1,22 @@ package com.oronaminc.join.answer.api; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.mapper.AnswerMapper; import com.oronaminc.join.answer.service.AnswerService; import com.oronaminc.join.member.security.MemberDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -33,14 +38,19 @@ public class AnswerController { ) @GetMapping("/rooms/{roomId}/questions/{questionId}/answers") @ResponseStatus(HttpStatus.OK) - public ResponseEntity getAnswer( + public ResponseEntity getAnswers( @PathVariable Long roomId, @PathVariable Long questionId, - @AuthenticationPrincipal MemberDetails memberDetails + @AuthenticationPrincipal MemberDetails memberDetails, + @RequestParam(required = false) Long lastId, + @RequestParam(required = false) LocalDateTime lastCreatedAt, + @RequestParam(defaultValue = "10") int size ) { Long memberId = memberDetails.getId(); - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); - return ResponseEntity.ok(response); + + Slice response = answerService.getAnswers(roomId, questionId, memberId, + lastId, lastCreatedAt, size); + return ResponseEntity.ok(AnswerMapper.toAnswerListResponse(response)); } } diff --git a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java index 265fb45..d06e03a 100644 --- a/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java +++ b/src/main/java/com/oronaminc/join/answer/dao/AnswerRepository.java @@ -1,10 +1,12 @@ package com.oronaminc.join.answer.dao; +import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.question.domain.Question; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.question.domain.Question; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,24 +14,49 @@ public interface AnswerRepository extends JpaRepository { Optional findByQuestionId(Long questionId); - boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId); + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + ORDER BY a.createdAt DESC, a.id DESC + """) + List findFirstPageByQuestionId( + @Param("questionId") Long questionId, + Pageable pageable + ); + + @Query(""" + SELECT a + FROM Answer a + JOIN FETCH a.member m + WHERE a.question.id = :questionId + AND (a.createdAt < :lastCreatedAt OR (a.createdAt = :lastCreatedAt AND a.id < :lastId)) + ORDER BY a.createdAt DESC, a.id DESC + """) + List findByQuestionIdWithCursor( + @Param("questionId") Long questionId, + @Param("lastCreatedAt") LocalDateTime lastCreatedAt, + @Param("lastId") Long lastId, + Pageable pageable + ); void deleteByQuestionId(Long questionId); void deleteByQuestionIn(List questions); @Query(""" - select count(distinct a.question.id) - from Answer a - where a.question.room.id = :roomId - """) + select count(distinct a.question.id) + from Answer a + where a.question.room.id = :roomId + """) Long countAnsweredQuestionsByRoomId(@Param("roomId") Long roomId); @Query(""" - select a - from Answer a - where a.question.id in :questionIds - """) + select a + from Answer a + where a.question.id in :questionIds + """) List findAllByQuestionIds(@Param("questionIds") List questionIds); } diff --git a/src/main/java/com/oronaminc/join/answer/domain/Answer.java b/src/main/java/com/oronaminc/join/answer/domain/Answer.java index fe782ee..7abdf84 100644 --- a/src/main/java/com/oronaminc/join/answer/domain/Answer.java +++ b/src/main/java/com/oronaminc/join/answer/domain/Answer.java @@ -26,7 +26,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "answer", indexes = { @Index(name = "idx_answer_question_member", columnList = "question_id, member_id") }) diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java index 5970acc..9de2804 100644 --- a/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerGetResponse.java @@ -12,7 +12,7 @@ public record AnswerGetResponse( @Schema(description = "답변 내용에 대한 공감 수", example = "23") Long emojiCount, @Schema(description = "답변 공감 여부", example = "true") - boolean Emojied, + boolean isEmojied, @Schema(description = "답변 내용", example = "답변입니다.") String content, @Schema(description = "작성자 정보 DTO") diff --git a/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java new file mode 100644 index 0000000..56870b3 --- /dev/null +++ b/src/main/java/com/oronaminc/join/answer/dto/AnswerListResponse.java @@ -0,0 +1,11 @@ +package com.oronaminc.join.answer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "답변 목록을 묶기 위한 DTO") +public record AnswerListResponse( + List answers +) { + +} diff --git a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java index 0d27485..2e7d62d 100644 --- a/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/oronaminc/join/answer/mapper/AnswerMapper.java @@ -1,16 +1,20 @@ package com.oronaminc.join.answer.mapper; import com.oronaminc.join.answer.domain.Answer; -import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerCreateResponse; import com.oronaminc.join.answer.dto.AnswerGetResponse; +import com.oronaminc.join.answer.dto.AnswerListResponse; +import com.oronaminc.join.answer.dto.AnswerRequest; import com.oronaminc.join.answer.dto.AnswerUpdateResponse; +import com.oronaminc.join.emoji.domain.TargetType; +import com.oronaminc.join.emoji.service.EmojiReader; import com.oronaminc.join.global.dto.WriterDto; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.websocket.common.EventType; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class AnswerMapper { @@ -31,11 +35,12 @@ public static AnswerCreateResponse toAnswerCreateResponse(Answer answer) { .build(); } - public static AnswerGetResponse toAnswerGetResponse(Answer answer, Long emojiCount, boolean isEmojied) { + public static AnswerGetResponse toAnswerGetResponse(Answer answer, boolean isEmojied) { + return AnswerGetResponse.builder() .answerId(answer.getId()) - .emojiCount(emojiCount) - .Emojied(isEmojied) + .emojiCount(answer.getEmojiCount()) + .isEmojied(isEmojied) .content(answer.getContent()) .writer(new WriterDto( answer.getMember().getId(), @@ -57,4 +62,9 @@ public static AnswerUpdateResponse toAnswerUpdateResponse(Answer answer) { .build(); } + public static AnswerListResponse toAnswerListResponse( + Slice slice) { + return new AnswerListResponse(slice.getContent()); + } + } diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java index 1935242..773a534 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerReader.java @@ -1,33 +1,36 @@ package com.oronaminc.join.answer.service; -import static com.oronaminc.join.global.exception.ErrorCode.*; - -import com.oronaminc.join.global.exception.ErrorCode; -import com.oronaminc.join.room.domain.Room; -import java.util.List; -import java.util.Optional; - -import org.springframework.stereotype.Component; - import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; +import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; - +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class AnswerReader { - private final AnswerRepository answerRepository; - public boolean existsByQuestionIdAndMemberId(Long questionId, Long memberId) { - return answerRepository.existsByQuestionIdAndMemberId(questionId, memberId); - } + private final AnswerRepository answerRepository; public Optional findById(Long answerId) { return answerRepository.findById(answerId); } + public List getFirstPageByQuestionId(Long questionId, Pageable pageable) { + return answerRepository.findFirstPageByQuestionId(questionId, pageable); + } + + public List getAnswerByQuestionIdWithCursor(Long questionId, + LocalDateTime lastCreatedAt, Long lastId, Pageable pageable) { + return answerRepository.findByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + } + public Answer getByQuestionId(Long questionId) { return answerRepository.findByQuestionId(questionId) .orElseThrow(() -> new ErrorException(ErrorCode.NOT_FOUND_EXIST_ANSWER)); diff --git a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java index 6436723..cc06dff 100644 --- a/src/main/java/com/oronaminc/join/answer/service/AnswerService.java +++ b/src/main/java/com/oronaminc/join/answer/service/AnswerService.java @@ -9,15 +9,20 @@ import com.oronaminc.join.answer.util.PermissionValidator; import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.emoji.service.EmojiReader; +import com.oronaminc.join.global.util.SliceUtil; import com.oronaminc.join.member.domain.Member; import com.oronaminc.join.member.service.MemberReader; -import com.oronaminc.join.participant.service.ParticipantService; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.service.QuestionReader; import com.oronaminc.join.room.domain.Room; import com.oronaminc.join.room.service.RoomReader; +import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,18 +53,43 @@ public Answer create(Long roomId, Long memberId, Long questionId, } - @Transactional - public AnswerGetResponse getAnswer(Long roomId, Long questionId, Long memberId) { - Member member = memberReader.getById(memberId); + @Transactional(readOnly = true) + public Slice getAnswers( + Long roomId, + Long questionId, + Long memberId, + Long lastId, + LocalDateTime lastCreatedAt, + int size + ) { + memberReader.getById(memberId); roomReader.getById(roomId); questionReader.getByIdAndRoomId(questionId, roomId); - Answer answer = answerReader.getByQuestionId(questionId); + answerReader.getByQuestionId(questionId); + + Pageable pageable = PageRequest.of(0, size + 1); + + List answers = (lastCreatedAt == null || lastId == null) + ? answerReader.getFirstPageByQuestionId(questionId, pageable) + : answerReader.getAnswerByQuestionIdWithCursor(questionId, lastCreatedAt, lastId, + pageable); + + // 공감 여부 일괄 조회 + List answerIds = answers.stream().map(Answer::getId).toList(); + + Set emojiedAnswerIds = memberId != null + ? emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(memberId, TargetType.ANSWER, answerIds) + : Set.of(); + + List responseList = answers.stream() + .map(answer -> { + boolean isEmojied = emojiedAnswerIds.contains(answer.getId()); + return AnswerMapper.toAnswerGetResponse(answer, isEmojied); + }) + .toList(); - Long emojiCount = answer.getEmojiCount(); - boolean isEmojied = emojiReader.findByMemberIdAndTargetIdAndTargetType(member.getId(), - answer.getId(), TargetType.ANSWER).isPresent(); + return SliceUtil.toSlice(responseList, PageRequest.of(0, size)); - return AnswerMapper.toAnswerGetResponse(answer, emojiCount, isEmojied); } @Transactional diff --git a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java index a4c62e9..3389a9b 100644 --- a/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java +++ b/src/main/java/com/oronaminc/join/emoji/dao/EmojiRepository.java @@ -2,8 +2,12 @@ import com.oronaminc.join.emoji.domain.Emoji; import com.oronaminc.join.emoji.domain.TargetType; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EmojiRepository extends JpaRepository { @@ -16,4 +20,17 @@ Optional findByMemberIdAndTargetIdAndTargetType(Long memberId, Long targe boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targetId, TargetType targetType); + + @Query(""" + SELECT e.targetId + FROM Emoji e + WHERE e.member.id = :memberId + AND e.targetType = :targetType + AND e.targetId IN :targetIds + """) + Set findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn( + @Param("memberId") Long memberId, + @Param("targetType") TargetType targetType, + @Param("targetIds") List targetIds + ); } diff --git a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java index 99a3552..6bf8182 100644 --- a/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java +++ b/src/main/java/com/oronaminc/join/emoji/service/EmojiReader.java @@ -5,7 +5,9 @@ import com.oronaminc.join.emoji.domain.TargetType; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; +import java.util.List; import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -38,4 +40,9 @@ public boolean existsByMemberIdAndTargetIdAndTargetType(Long memberId, Long targ targetType); } + public Set findTargetIdsByMemberAndTargetTypeInBatch(Long memberId, TargetType targetType, List targetIds) { + if (targetIds.isEmpty() || targetType == null) return Set.of(); + return emojiRepository.findTargetIdsByMemberIdAndTargetTypeAndTargetIdIn(memberId, targetType, targetIds); + } + } diff --git a/src/main/java/com/oronaminc/join/question/domain/Question.java b/src/main/java/com/oronaminc/join/question/domain/Question.java index d3d7d26..d3e9caa 100644 --- a/src/main/java/com/oronaminc/join/question/domain/Question.java +++ b/src/main/java/com/oronaminc/join/question/domain/Question.java @@ -25,7 +25,6 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -// TODO: ddl-auto: create,update에만 유효 -> 추후 flyway sql 생성 @Table(name = "question", indexes = { @Index(name = "idx_question_id_room", columnList = "room_id") }) diff --git a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java index b5f14af..9571356 100644 --- a/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java +++ b/src/test/java/com/oronaminc/join/answer/service/AnswerServiceTests.java @@ -3,10 +3,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.NOT_FOUND_ROOM; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import com.oronaminc.join.answer.dao.AnswerRepository; import com.oronaminc.join.answer.domain.Answer; @@ -31,7 +32,8 @@ import com.oronaminc.join.room.domain.RoomStatus; import com.oronaminc.join.room.service.RoomReader; import java.time.LocalDateTime; -import java.util.Optional; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,6 +41,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class AnswerServiceTests { @@ -142,47 +146,102 @@ void createAnswer_success() { } @Test - @DisplayName("답변 조회 성공") - void getAnswer_success() { + @DisplayName("답변 목록 조회 - 커서 없이 최초 페이지 조회") + void getAnswers_firstPage_success() { // given - Long memberId = 1L; - Long roomId = 1L; - Long questionId = 1L; + List answers = List.of(createAnswer(100L,LocalDateTime.now()), createAnswer(99L,LocalDateTime.now() )); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(100L, 99L))) + .willReturn(Set.of(100L)); - Answer mockAnswer = Answer.builder() - .id(10L) - .question(mockQuestion) - .member(mockMember) - .content("답변입니다.") - .emojiCount(5L) - .version(0) - .build(); + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); - mockEmoji = Emoji.builder() - .id(1L) - .member(mockMember) - .targetType(TargetType.ANSWER) - .targetId(mockAnswer.getId()) - .build(); + // then + assertThat(response.getContent().get(0).answerId()).isEqualTo(100L); + assertThat(response.getContent().get(0).isEmojied()).isTrue(); + } - // mocking - given(memberReader.getById(memberId)).willReturn(mockMember); - given(roomReader.getById(roomId)).willReturn(mockRoom); - given(questionReader.getByIdAndRoomId(questionId, roomId)).willReturn(mockQuestion); - given(answerReader.getByQuestionId(questionId)).willReturn(mockAnswer); - given(emojiReader.findByMemberIdAndTargetIdAndTargetType(memberId, mockAnswer.getId(), - TargetType.ANSWER)).willReturn(Optional.of(mockEmoji)); + @Test + @DisplayName("답변 목록 조회 - 커서 기준 이후 답변 조회") + void getAnswers_cursorPaging_success() { + // given + List answers = List.of(createAnswer(80L,LocalDateTime.now()), createAnswer(79L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getAnswerByQuestionIdWithCursor(eq(1L), any(), any(), any())).willReturn( + answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(80L, 79L))) + .willReturn(Set.of()); // when - AnswerGetResponse response = answerService.getAnswer(roomId, questionId, memberId); + Slice response = answerService.getAnswers(1L, 1L, 1L, 90L, + LocalDateTime.now(), 10); // then - assertThat(response.answerId()).isEqualTo(mockAnswer.getId()); - assertThat(response.content()).isEqualTo(mockAnswer.getContent()); - assertThat(response.emojiCount()).isEqualTo(5L); - assertThat(response.Emojied()).isTrue(); - assertThat(response.writer().memberId()).isEqualTo(mockMember.getId()); - assertThat(response.writer().nickname()).isEqualTo(mockMember.getNickname()); + assertThat(response.getContent().get(0).answerId()).isEqualTo(80L); + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + } + + @Test + @DisplayName("답변 목록 조회 - 공감이 포함된 답변들 조회") + void getAnswers_containsEmojiedAnswers() { + // given + List answers = List.of(createAnswer(1L,LocalDateTime.now()), createAnswer(2L,LocalDateTime.now())); + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(answers); + given(emojiReader.findTargetIdsByMemberAndTargetTypeInBatch(1L, TargetType.ANSWER, + List.of(1L, 2L))) + .willReturn(Set.of(2L)); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent().get(0).isEmojied()).isFalse(); + assertThat(response.getContent().get(1).isEmojied()).isTrue(); + } + + + @Test + @DisplayName("답변 목록 조회 - 결과가 비어도 예외 없이 처리") + void getAnswers_emptyList_noError() { + // given + given(memberReader.getById(1L)).willReturn(mockMember); + given(roomReader.getById(1L)).willReturn(mockRoom); + given(questionReader.getByIdAndRoomId(1L, 1L)).willReturn(mockQuestion); + given(answerReader.getByQuestionId(1L)).willReturn(null); + given(answerReader.getFirstPageByQuestionId(eq(1L), any())).willReturn(List.of()); + + // when + Slice response = answerService.getAnswers(1L, 1L, 1L, null, null, 10); + + // then + assertThat(response.getContent()).asInstanceOf(LIST).isEmpty(); + } + + private Answer createAnswer(Long id, LocalDateTime createdAt) { + Answer answer = Answer.builder() + .id(id) + .member(mockMember) + .question(mockQuestion) + .content("답변입니다") + .emojiCount(0L) + .build(); + + ReflectionTestUtils.setField(answer, "createdAt", createdAt); + return answer; } @Test From b0bb3b1a85bbddf756da4b27679a8047050cdc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:12:55 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20cors=20=EC=84=A4=EC=A0=95=20(#102?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/member/security/SecurityConfig.java | 20 ++++++++++++++++++- .../handshake/CustomHandshakeHandler.java | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 582dac7..8332001 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -2,6 +2,8 @@ import static org.springframework.security.config.Customizer.*; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,6 +11,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.RequiredArgsConstructor; @@ -23,7 +28,7 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) - .cors(cors -> cors.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/guest", @@ -51,4 +56,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(withDefaults()) .build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java index 2b5b78a..9910dae 100644 --- a/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/handshake/CustomHandshakeHandler.java @@ -58,5 +58,4 @@ protected Principal determineUser(ServerHttpRequest request, WebSocketHandler ws // fallback 경로로 전송 return null; } - } From 12ad4ba23abfec9c507308c2af1dff11bb63a1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:17:49 +0900 Subject: [PATCH 11/17] Refactor/101 room detail (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 --- .../join/websocket/config/WebSocketConfig.java | 10 +++++----- .../session/CurrentParticipantEventHandler.java | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index a2afca6..797a0c9 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,7 +9,6 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -48,15 +47,16 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler); + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) + ; registry.setErrorHandler(stompErrorHandler); } diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c6b25c2..83d9bf3 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,7 +2,6 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; -import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; From e9fdf79a055b97226b5b5ff536737a2cbc8f01fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:47:35 +0900 Subject: [PATCH 12/17] Refactor/101 room detail (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 --- .../api/QuestionWebsocketController.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 7186fb2..a0fb291 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -1,5 +1,13 @@ package com.oronaminc.join.websocket.api; +import java.security.Principal; + +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; @@ -11,16 +19,11 @@ import com.oronaminc.join.question.dto.QuestionUpdateResponse; import com.oronaminc.join.question.service.QuestionService; import com.oronaminc.join.question.util.QuestionMapper; + import io.github.bucket4j.Bucket; import jakarta.validation.Valid; -import java.security.Principal; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.handler.annotation.SendTo; -import org.springframework.stereotype.Controller; @Slf4j @Controller @@ -37,8 +40,12 @@ public QuestionCreateResponse createQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + log.debug("수신한 메시지 = {}", request.content()); + Long memberId = Long.valueOf(principal.getName()); + log.debug("회원 아이디 = {}", memberId); + Bucket bucket = rateLimitService.getBucket(RateLimitType.CREATE_QUESTION, roomId, memberId); if (!bucket.tryConsume(1)) { @@ -47,7 +54,6 @@ public QuestionCreateResponse createQuestion( Question question = questionService.create(roomId, memberId, request); - log.info("수신한 메시지 = {}", request.content()); return QuestionMapper.toQuestionCreateResponse(question); } From bf2a94247a3961da06d4859cc795a0c65f2647c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:55:48 +0900 Subject: [PATCH 13/17] Refactor/101 room detail (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../oronaminc/join/websocket/config/WebSocketConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index 797a0c9..bbee92f 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -47,15 +47,15 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - // .addInterceptors(new HttpSessionHandshakeInterceptor()) + .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - // .setHandshakeHandler(handshakeHandler) + .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - // .addInterceptors(new HttpSessionHandshakeInterceptor()) - // .setHandshakeHandler(handshakeHandler) + .addInterceptors(new HttpSessionHandshakeInterceptor()) + .setHandshakeHandler(handshakeHandler) ; registry.setErrorHandler(stompErrorHandler); From 790be9ab7a8b4c3020b0afa3961ee0349b1a85d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:01:56 +0900 Subject: [PATCH 14/17] Refactor/101 room detail (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 From 8145fa0c6ee43846feae1cd78421ed7797947688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:13:21 +0900 Subject: [PATCH 15/17] Update WebSocketConfig.java --- .../com/oronaminc/join/websocket/config/WebSocketConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index bbee92f..be7c913 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; From 129d08ff42a0fa85e40cb74f506ed318a07169ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:13:02 +0900 Subject: [PATCH 16/17] Refactor/101 room detail (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../api/QuestionWebsocketController.java | 6 ++- .../config/StompAuthChannelInterceptor.java | 29 ++++++++++++ .../websocket/config/WebSocketConfig.java | 16 ++++--- .../CurrentParticipantEventHandler.java | 45 +++++++++---------- 4 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index a0fb291..773828b 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -6,12 +6,14 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import com.oronaminc.join.global.exception.ErrorCode; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.global.ratelimit.RateLimitService; import com.oronaminc.join.global.ratelimit.RateLimitType; +import com.oronaminc.join.member.security.MemberDetails; import com.oronaminc.join.question.domain.Question; import com.oronaminc.join.question.dto.QuestionCreateResponse; import com.oronaminc.join.question.dto.QuestionDeleteResponse; @@ -42,7 +44,8 @@ public QuestionCreateResponse createQuestion( ) { log.debug("수신한 메시지 = {}", request.content()); - Long memberId = Long.valueOf(principal.getName()); + MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); + Long memberId = Long.valueOf(memberDetails.getId()); log.debug("회원 아이디 = {}", memberId); @@ -66,6 +69,7 @@ public QuestionUpdateResponse updateQuestion( @Payload @Valid QuestionRequest request, Principal principal ) { + Long memberId = Long.valueOf(principal.getName()); Question updated = questionService.update(memberId, roomId, questionId, request); diff --git a/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java new file mode 100644 index 0000000..0cc944d --- /dev/null +++ b/src/main/java/com/oronaminc/join/websocket/config/StompAuthChannelInterceptor.java @@ -0,0 +1,29 @@ +package com.oronaminc.join.websocket.config; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class StompAuthChannelInterceptor implements ChannelInterceptor { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.isAuthenticated()) { + accessor.setUser(authentication); + } + } + + return message; + } +} diff --git a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java index be7c913..5c8c2ad 100644 --- a/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/oronaminc/join/websocket/config/WebSocketConfig.java @@ -3,13 +3,13 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import com.oronaminc.join.websocket.handshake.CustomHandshakeHandler; import com.oronaminc.join.websocket.session.CustomWebSocketHandlerDecorator; @@ -27,6 +27,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompErrorHandler stompErrorHandler; private final WebsocketSessionManager sessionManager; private final ApplicationEventPublisher publisher; + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; @Bean public WebSocketHandlerDecoratorFactory webSocketHandlerDecoratorFactory( @@ -48,15 +49,15 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") // websocket 연결 전 쿠키 체크 - .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) // websocket 연결 후 principal 생성 - .setHandshakeHandler(handshakeHandler) + // .setHandshakeHandler(handshakeHandler) .withSockJS(); registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") - .addInterceptors(new HttpSessionHandshakeInterceptor()) - .setHandshakeHandler(handshakeHandler) + // .addInterceptors(new HttpSessionHandshakeInterceptor()) + // .setHandshakeHandler(handshakeHandler) ; registry.setErrorHandler(stompErrorHandler); @@ -66,4 +67,9 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { public void configureWebSocketTransport(WebSocketTransportRegistration registry) { registry.setDecoratorFactories(webSocketHandlerDecoratorFactory(sessionManager, publisher)); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompAuthChannelInterceptor); + } } diff --git a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java index c6b25c2..806c75d 100644 --- a/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java +++ b/src/main/java/com/oronaminc/join/websocket/session/CurrentParticipantEventHandler.java @@ -2,14 +2,11 @@ import static com.oronaminc.join.global.exception.ErrorCode.*; -import com.oronaminc.join.global.exception.ErrorCode; import java.security.Principal; import java.util.Set; import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionSubscribeEvent; import com.oronaminc.join.global.exception.ErrorException; import com.oronaminc.join.room.event.RoomExitEvent; @@ -24,27 +21,27 @@ public class CurrentParticipantEventHandler { private static final String ROOM_PREFIX = "/topic/rooms/"; private static final String JOIN_SUFFIX = "/join"; - @EventListener - public void handleSubscribe(SessionSubscribeEvent event) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); - String destination = accessor.getDestination(); - Principal principal = accessor.getUser(); - - if (destination == null) { - throw new ErrorException(STOMP_INVALID_DESTINATION); - } - - if (!destination.startsWith(ROOM_PREFIX)) { - return; - } - - Long memberId = parseMemberId(principal); - Long roomId = parseRoomId(destination); - - if (!isRoomJoinPath(destination)) { - validateParticipantRoomJoin(roomId, memberId); - } - } + // @EventListener + // public void handleSubscribe(SessionSubscribeEvent event) { + // StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + // String destination = accessor.getDestination(); + // Principal principal = accessor.getUser(); + // + // if (destination == null) { + // throw new ErrorException(STOMP_INVALID_DESTINATION); + // } + // + // if (!destination.startsWith(ROOM_PREFIX)) { + // return; + // } + // + // Long memberId = parseMemberId(principal); + // Long roomId = parseRoomId(destination); + // + // if (!isRoomJoinPath(destination)) { + // validateParticipantRoomJoin(roomId, memberId); + // } + // } @EventListener public void handleUnsubscribe(RoomExitEvent event) { From ab1ebb5f713df1edf1117e48a3406a02942511ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= <96411818+rjswjddn@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:26:52 +0900 Subject: [PATCH 17/17] Refactor/101 room detail (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: cors 설정 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 * 웹소켓 --- .../java/com/oronaminc/join/member/security/SecurityConfig.java | 2 +- .../join/websocket/api/QuestionWebsocketController.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index 8332001..42067b2 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); - configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedOriginPatterns(List.of("http://localhost:5173")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java index 773828b..076eea9 100644 --- a/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java +++ b/src/main/java/com/oronaminc/join/websocket/api/QuestionWebsocketController.java @@ -43,6 +43,7 @@ public QuestionCreateResponse createQuestion( Principal principal ) { log.debug("수신한 메시지 = {}", request.content()); + log.debug("principal = {}", principal); MemberDetails memberDetails = (MemberDetails)((Authentication)principal).getPrincipal(); Long memberId = Long.valueOf(memberDetails.getId());