Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
e508157
refactor: 스더티룸 권한에 대한 로직 개선
loseminho Oct 2, 2025
8b766f3
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 2, 2025
ef46ed0
fix: ci에서 통과 못한 테스트코드 수정
loseminho Oct 3, 2025
23e55ea
fix:rest api와 웹소켓 중간 경로 통합
loseminho Oct 4, 2025
2979e6e
fix:rest api와 웹소켓 중간 경로 통합
loseminho Oct 4, 2025
2de8631
fix: 병합 충돌 제어
loseminho Oct 4, 2025
e576231
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 5, 2025
98c9e4c
fix: 에러 확인을 위한 통합테스트 추가, Room.create()메서드 수정
loseminho Oct 5, 2025
8cb4561
refactor, feat
loseminho Oct 5, 2025
be970fd
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 5, 2025
57c38ae
refactor: redis 로직 최적화 및 중복 검증 로직 제거
loseminho Oct 7, 2025
ad752e7
fix : 병합 충돌 제어
loseminho Oct 7, 2025
1af4d1e
fix: 에러 번호 수정
loseminho Oct 8, 2025
483a0fc
feat: 스터디룸 방 비밀번호 변경 및 삭제 기능 구현
loseminho Oct 9, 2025
89971f2
Merge remote-tracking branch 'origin/dev' into feat-215
loseminho Oct 9, 2025
77ab976
fix:app-dev 제거
loseminho Oct 9, 2025
7a17167
feat: 웹소켓 기반 소극적 하트비트
loseminho Oct 9, 2025
dd229f2
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 10, 2025
cf32230
feat: 스터디룸 썸네일 기능 추가 및 webrtc 설정 변경에서 주석처리
loseminho Oct 10, 2025
be00c11
Merge branch 'feat/217' of https://github.com/prgrms-web-devcourse-fi…
loseminho Oct 10, 2025
da453f6
fix:소극적 하트비트 사용 주석처리
loseminho Oct 10, 2025
70813b6
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 11, 2025
2de5447
Feat: 스터디 룸 내에 고양이 아바타 시스템과 프로필 이미지 url 연동
loseminho Oct 11, 2025
2bc4e95
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 11, 2025
7d4237c
fix: 기존 작성되어있던 test 코드 수정
loseminho Oct 11, 2025
ad988c8
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 12, 2025
030cbc8
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 12, 2025
08a976f
test: 아바타 테스트 코드 완료
loseminho Oct 12, 2025
8761b03
refactor: 프론트엔드 요청 사항에 따른 스터디룸 조회 마스킹 제거
loseminho Oct 13, 2025
4a770d6
Merge remote-tracking branch 'origin/dev' into refactor/257
loseminho Oct 13, 2025
a096d93
Merge remote-tracking branch 'origin/dev' into refactor/257
loseminho Oct 13, 2025
df98fc7
feat: 스터디룸 방 초대 코드 시스템
loseminho Oct 13, 2025
4be3aab
Infra: main branch 로컬 환경과 운영 환경 동기화
namgigun Oct 13, 2025
e0a3af3
Infra: docker-compose 파일 수정
namgigun Oct 1, 2025
3aaebb6
Fix: 백엔드 CD 파일 수정
namgigun Oct 2, 2025
cdf13b7
Infra: EC2 환경변수 수정
namgigun Oct 13, 2025
bc471c4
Chore: CD 파일 수정
namgigun Oct 2, 2025
eee9529
Chore: 백엔드 CD 파일 수정
namgigun Oct 2, 2025
849fbf5
Infra: 백엔드 CD 파일 수정
namgigun Oct 2, 2025
670984c
Infra: 도커 컴포즈 수정
namgigun Oct 5, 2025
2fc31f0
Infra: 운영환경 설정
namgigun Oct 13, 2025
401fa4b
Fix: SecurityConfig 수정
namgigun Oct 13, 2025
f68ff22
test,fix: 방 초대에 대한 테스트 코드 작성 및 에러 수정
loseminho Oct 13, 2025
2c49fc6
Merge remote-tracking branch 'origin/dev' into fix/279
loseminho Oct 14, 2025
8cbb11f
fix: 스터디룸 파일 업로드 맵핑 형식으로 변환
loseminho Oct 14, 2025
6c9e254
Merge remote-tracking branch 'origin' into fix/279
loseminho Oct 14, 2025
0cea892
fix: 병합충돌 제어 수정
loseminho Oct 14, 2025
4563fef
fix: 병합충돌 제어
loseminho Oct 14, 2025
6f5596a
fix: 스터디 룸 내 프론트엔드 요구 사항 및 오류사항 수정
loseminho Oct 14, 2025
b23ba21
Merge remote-tracking branch 'origin/dev' into fix/287
loseminho Oct 14, 2025
8769179
Merge branch 'dev' into fix/287
loseminho Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,31 @@ public ResponseEntity<RsData<Void>> removeRoomPassword(
.body(RsData.success("방 비밀번호 제거 완료", null));
}

@PostMapping("/{roomId}/password")
@Operation(
summary = "방 비밀번호 설정",
description = "비밀번호가 없는 방에 비밀번호를 설정합니다. 이미 비밀번호가 있는 경우 비밀번호 변경 API(PUT)를 사용. 방장만 실행 가능합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "비밀번호 설정 성공"),
@ApiResponse(responseCode = "400", description = "이미 비밀번호가 설정되어 있음"),
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
public ResponseEntity<RsData<Void>> setRoomPassword(
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
@Valid @RequestBody SetRoomPasswordRequest request) {

Long currentUserId = currentUser.getUserId();

roomService.setRoomPassword(roomId, request.getNewPassword(), currentUserId);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("방 비밀번호 설정 완료", null));
}

@DeleteMapping("/{roomId}")
@Operation(
summary = "방 종료",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ public class RoomInvitePublicController {
@SecurityRequirement(name = "Bearer Authentication")
@Operation(
summary = "초대 코드로 방 입장",
description = "초대 코드를 사용하여 방에 입장합니다. " +
description = "초대 코드를 사용하여 방 입장 권한을 획득합니다. " +
"비밀번호가 걸린 방도 초대 코드로 입장 가능합니다. " +
"실제 온라인 등록은 WebSocket 연결 시 자동으로 처리됩니다. " +
"비로그인 사용자는 401 응답을 받습니다 (프론트에서 로그인 페이지로 이동)."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "입장 성공"),
@ApiResponse(responseCode = "200", description = "입장 권한 획득 성공 (WebSocket 연결 필요)"),
@ApiResponse(responseCode = "400", description = "만료되었거나 유효하지 않은 코드"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 초대 코드"),
@ApiResponse(responseCode = "401", description = "인증 필요 (비로그인)")
Expand All @@ -56,12 +57,12 @@ public ResponseEntity<RsData<JoinRoomResponse>> joinByInviteCode(
// 초대 코드 검증 및 방 조회
Room room = inviteService.getRoomByInviteCode(inviteCode);

// 방 입장 (비밀번호 무시)
RoomMember member = roomService.joinRoom(room.getId(), null, userId);
// 방 입장 권한 체크 (비밀번호 무시, Redis 등록 건너뜀)
RoomMember member = roomService.joinRoom(room.getId(), null, userId, false);
JoinRoomResponse response = JoinRoomResponse.from(member);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("초대 코드로 입장 완료", response));
.body(RsData.success("초대 코드로 입장 권한 획득 완료. WebSocket 연결 후 실제 입장됩니다.", response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public class MyRoomResponse {
private Long roomId;
private String title;
private String description;
private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용)
private Boolean isPrivate;
private String thumbnailUrl;
private int currentParticipants;
private int maxParticipants;
private RoomStatus status;
Expand All @@ -27,6 +28,7 @@ public static MyRoomResponse of(Room room, long currentParticipants, RoomRole my
.title(room.getTitle())
.description(room.getDescription() != null ? room.getDescription() : "")
.isPrivate(room.isPrivate()) // 비공개 방 여부
.thumbnailUrl(room.getThumbnailUrl()) // 썸네일 URL
.currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값
.maxParticipants(room.getMaxParticipants())
.status(room.getStatus())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class RoomDetailResponse {
private String title;
private String description;
private boolean isPrivate;
private String thumbnailUrl; // 썸네일 이미지 URL
private int maxParticipants;
private int currentParticipants;
private RoomStatus status;
Expand All @@ -31,6 +32,7 @@ public static RoomDetailResponse of(Room room, long currentParticipants, List<Ro
.title(room.getTitle())
.description(room.getDescription() != null ? room.getDescription() : "")
.isPrivate(room.isPrivate())
.thumbnailUrl(room.getThumbnailUrl()) // 썸네일 URL
.maxParticipants(room.getMaxParticipants())
.currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값
.status(room.getStatus())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.back.domain.studyroom.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "방 비밀번호 설정 요청 (비밀번호가 없는 방에 비밀번호 추가)")
public class SetRoomPasswordRequest {

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Size(min = 4, max = 20, message = "비밀번호는 4~20자 사이여야 합니다.")
@Schema(description = "설정할 비밀번호", example = "1234", required = true)
private String newPassword;
}
5 changes: 4 additions & 1 deletion src/main/java/com/back/domain/studyroom/entity/Room.java
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,12 @@ public void updateWebRTCSettings(boolean allowCamera, boolean allowAudio, boolea
/**
* 방 비밀번호 변경 메서드
방장이 방의 비밀번호를 변경할 때
별도 메서드로 분리한 이유: 비밀번호는 보안상 별도로 관리되어야 하기 때문 (ai의 추천)
별도 메서드로 분리한 이유: 비밀번호는 보안상 별도로 관리되어야 하기 때문
*/
public void updatePassword(String newPassword) {
this.password = newPassword;

// 비밀번호 유무에 따라 isPrivate 자동 설정
this.isPrivate = (newPassword != null && !newPassword.trim().isEmpty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,23 @@ public Page<Room> findJoinablePublicRooms(Pageable pageable) {
}

/**
* 사용자가 참여 중인 방 조회
* 사용자가 참여 중인 방 조회 (MEMBER 이상만)
* 조회 조건:
* - 특정 사용자가 멤버로 등록된 방 (DB에 저장된 멤버십)
* TODO: Redis에서 온라인 상태 확인하도록 변경
* - 특정 사용자가 MEMBER 이상으로 등록된 방 (VISITOR 제외)
* - DB에 저장된 멤버십만 조회
* @param userId 사용자 ID
* @return 참여 중인 방 목록
* @return 참여 중인 방 목록 (VISITOR 제외)
*/
@Override
public List<Room> findRoomsByUserId(Long userId) {
return queryFactory
.selectFrom(room)
.leftJoin(room.createdBy, user).fetchJoin() // N+1 방지
.join(room.roomMembers, roomMember) // 멤버 조인
.where(roomMember.user.id.eq(userId))
.where(
roomMember.user.id.eq(userId),
roomMember.role.ne(com.back.domain.studyroom.entity.RoomRole.VISITOR) // VISITOR 제외
)
.fetch();
}

Expand Down
89 changes: 71 additions & 18 deletions src/main/java/com/back/domain/studyroom/service/RoomService.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public Room createRoom(String title, String description, boolean isPrivate,
}

/**
* 방 입장 메서드
* 방 입장 메서드 (WebSocket 연결과 함께 사용)
*
* 입장 검증 과정:
* 1. 방 존재 확인 (비관적 락으로 동시성 제어)
Expand All @@ -117,17 +117,33 @@ public Room createRoom(String title, String description, boolean isPrivate,
*/
@Transactional
public RoomMember joinRoom(Long roomId, String password, Long userId) {
return joinRoom(roomId, password, userId, true);
}

/**
* 방 입장 메서드 (오버로드 - WebSocket 등록 여부 선택 가능)
*
* @param roomId 방 ID
* @param password 비밀번호 (비공개 방인 경우)
* @param userId 사용자 ID
* @param registerOnline WebSocket 세션 등록 여부 (true: Redis 등록, false: 권한 체크만)
* @return RoomMember (메모리상 또는 DB 저장된 객체)
*/
@Transactional
public RoomMember joinRoom(Long roomId, String password, Long userId, boolean registerOnline) {

// 1. 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지
Room room = roomRepository.findByIdWithLock(roomId)
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));

// 2. Redis에서 현재 온라인 사용자 수 조회
long currentOnlineCount = roomParticipantService.getParticipantCount(roomId);
// 2. Redis에서 현재 온라인 사용자 수 조회 (WebSocket 등록하는 경우만)
if (registerOnline) {
long currentOnlineCount = roomParticipantService.getParticipantCount(roomId);

// 3. 정원 확인 (Redis 기반)
if (currentOnlineCount >= room.getMaxParticipants()) {
throw new CustomException(ErrorCode.ROOM_FULL);
// 3. 정원 확인 (Redis 기반)
if (currentOnlineCount >= room.getMaxParticipants()) {
throw new CustomException(ErrorCode.ROOM_FULL);
}
}

// 4. 방 입장 가능 여부 확인 (활성화 + 입장 가능한 상태)
Expand All @@ -140,8 +156,8 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) {
throw new CustomException(ErrorCode.ROOM_NOT_JOINABLE);
}

// 5. 비밀번호 확인
if (room.needsPassword() && !room.getPassword().equals(password)) {
// 5. 비밀번호 확인 (초대 코드 입장 시에는 password가 null일 수 있음)
if (room.needsPassword() && password != null && !room.getPassword().equals(password)) {
throw new CustomException(ErrorCode.ROOM_PASSWORD_INCORRECT);
}

Expand All @@ -156,23 +172,31 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) {
// 기존 멤버 재입장: DB에 있는 역할 그대로 사용
RoomMember member = existingMember.get();

// Redis에 온라인 등록 (아바타 포함)
roomParticipantService.enterRoom(userId, roomId, avatarId);

log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}",
roomId, userId, member.getRole(), avatarId);
// WebSocket 연결과 함께 입장하는 경우에만 Redis 등록
if (registerOnline) {
roomParticipantService.enterRoom(userId, roomId, avatarId);
log.info("기존 멤버 재입장 (Redis 등록) - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}",
roomId, userId, member.getRole(), avatarId);
} else {
log.info("기존 멤버 권한 확인 (Redis 등록 건너뜀) - RoomId: {}, UserId: {}, Role: {}",
roomId, userId, member.getRole());
}

return member;
}

// 신규 입장자: VISITOR로 입장 (DB 저장 안함!)
RoomMember visitorMember = RoomMember.createVisitor(room, user);

// Redis에만 온라인 등록 (아바타 포함)
roomParticipantService.enterRoom(userId, roomId, avatarId);

log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함, AvatarId: {}",
roomId, userId, avatarId);
// WebSocket 연결과 함께 입장하는 경우에만 Redis 등록
if (registerOnline) {
roomParticipantService.enterRoom(userId, roomId, avatarId);
log.info("신규 입장 (VISITOR, Redis 등록) - RoomId: {}, UserId: {}, AvatarId: {}",
roomId, userId, avatarId);
} else {
log.info("신규 입장 권한 확인 (Redis 등록 건너뜀) - RoomId: {}, UserId: {}",
roomId, userId);
}

// 메모리상 객체 반환 (DB에 저장되지 않음)
return visitorMember;
Expand Down Expand Up @@ -360,6 +384,35 @@ public void removeRoomPassword(Long roomId, Long userId) {
log.info("방 비밀번호 제거 완료 - RoomId: {}, UserId: {}", roomId, userId);
}

/**
* 방 비밀번호 설정 (비밀번호가 없는 방에 비밀번호 추가)
* - 방장만 설정 가능
* - 기존 비밀번호가 없는 경우에만 사용
* @param roomId 방 ID
* @param newPassword 새 비밀번호
* @param userId 요청자 ID (방장)
*/
@Transactional
public void setRoomPassword(Long roomId, String newPassword, Long userId) {
Room room = roomRepository.findById(roomId)
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));

// 방장 권한 확인
if (!room.isOwner(userId)) {
throw new CustomException(ErrorCode.NOT_ROOM_HOST);
}

// 이미 비밀번호가 있는 경우 에러
if (room.getPassword() != null && !room.getPassword().isEmpty()) {
throw new CustomException(ErrorCode.ROOM_PASSWORD_ALREADY_EXISTS);
}

// 새 비밀번호 설정
room.updatePassword(newPassword);

log.info("방 비밀번호 설정 완료 - RoomId: {}, UserId: {}", roomId, userId);
}

@Transactional
public void terminateRoom(Long roomId, Long userId) {

Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/back/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public enum ErrorCode {
CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_016", "채팅 삭제 중 오류가 발생했습니다."),
ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."),
NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."),
ROOM_PASSWORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "ROOM_019", "이미 비밀번호가 설정되어 있습니다. 비밀번호 변경 API를 사용하세요."),

// ======================== 초대 코드 관련 ========================
INVALID_INVITE_CODE(HttpStatus.NOT_FOUND, "INVITE_001", "유효하지 않은 초대 코드입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.back.global.websocket.controller;

import com.back.domain.studyroom.service.AvatarService;
import com.back.global.exception.CustomException;
import com.back.global.security.user.CustomUserDetails;
import com.back.global.websocket.service.RoomParticipantService;
import com.back.global.websocket.service.WebSocketSessionManager;
import com.back.global.websocket.util.WebSocketAuthHelper;
import com.back.global.websocket.util.WebSocketErrorHelper;
Expand All @@ -25,9 +27,12 @@ public class WebSocketMessageController {

private final WebSocketSessionManager sessionManager;
private final WebSocketErrorHelper errorHelper;
private final RoomParticipantService roomParticipantService;
private final AvatarService avatarService;

// WebSocket 방 입장 확인 메시지
// 클라이언트가 REST API로 입장 후 WebSocket 세션 동기화 대기를 위해 전송
// 클라이언트가 REST API로 입장 후 WebSocket 세션 동기화를 위해 전송
// 초대 코드로 입장한 경우 Redis 등록이 안 되어 있으므로 여기서 처리
@MessageMapping("/rooms/{roomId}/join")
public void handleWebSocketJoinRoom(@DestinationVariable Long roomId,
@Payload Map<String, Object> payload,
Expand All @@ -44,8 +49,23 @@ public void handleWebSocketJoinRoom(@DestinationVariable Long roomId,
// 활동 시간 업데이트
sessionManager.updateLastActivity(userId);

// 실제 방 입장 로직은 REST API에서 이미 처리했으므로
// 여기서는 단순히 WebSocket 세션이 준비되었음을 확인하는 용도
// Redis에 이미 등록되어 있는지 확인
Long currentRoomId = roomParticipantService.getCurrentRoomId(userId);

if (currentRoomId == null || !currentRoomId.equals(roomId)) {
// Redis에 등록되지 않은 경우 (초대 코드 입장 등)
// 아바타 로드/생성
Long avatarId = avatarService.loadOrCreateAvatar(roomId, userId);

// Redis에 온라인 등록
roomParticipantService.enterRoom(userId, roomId, avatarId);

log.info("📥 [WebSocket] Redis 등록 완료 - roomId: {}, userId: {}, avatarId: {}",
roomId, userId, avatarId);
} else {
log.info("📥 [WebSocket] 이미 Redis에 등록된 사용자 - roomId: {}, userId: {}",
roomId, userId);
}
}

// Heartbeat 처리
Expand Down
Loading