feat: Sprint Mission 7 - 나은비#150
Hidden character warning
Conversation
joonfluence
left a comment
There was a problem hiding this comment.
PR 리뷰 요약
Discodeit Sprint Mission 7 구현으로, JPA 엔티티 전환, 계층별 테스트, 커스텀 예외 체계, MDC 로깅 등 대규모 리팩토링이 잘 구성되어 있습니다. 전반적으로 구조가 좋으나 보안 이슈와 일부 로직 버그가 있어 수정이 필요합니다.
- P1: 1건 (비밀번호 평문 저장/비교)
- P2: 2건 (유저 수정 중복체크 버그, ReadStatus 예외 불일치)
- P3: 4건
- P4: 1건
- P5: 1건
|
|
||
| User user = userRepository.findByUsername(username) | ||
| .orElseThrow( | ||
| () -> { |
There was a problem hiding this comment.
p1. 비밀번호 평문 비교 — 보안 취약점
비밀번호를 평문으로 저장하고 equals()로 비교하고 있습니다. DB 유출 시 모든 사용자의 비밀번호가 노출되는 심각한 보안 취약점입니다.
BCryptPasswordEncoder 등을 사용하여 해시 저장 + matches() 비교로 변경해야 합니다. Sprint Mission 범위 상 의도된 것이라면 최소한 // TODO: BCrypt 적용 필요 주석을 남겨주세요.
|
|
||
| userRepository.save(user); | ||
| log.info("사용자 생성 완료 - userId: {}", user.getId()); | ||
| return userMapper.toDto(user); |
There was a problem hiding this comment.
p2. 중복 체크 시 자기 자신 제외 누락
사용자 수정 시 이메일/사용자명 중복 체크에서 자기 자신을 제외하지 않습니다. 예를 들어 username만 변경하고 email은 그대로 유지할 경우, 본인의 기존 email로 UserAlreadyExistsException이 발생합니다.
| return userMapper.toDto(user); | |
| if (newEmail != null && !newEmail.equals(user.getEmail()) && userRepository.existsByEmail(newEmail)) { |
username 체크도 동일하게 수정이 필요합니다:
if (newUsername != null && !newUsername.equals(user.getUsername()) && userRepository.existsByUsername(newUsername)) {| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> { | ||
| log.error("읽음 상태 생성 실패 - 사용자 없음: {}", userId); | ||
| return new NoSuchElementException("User with id " + userId + " does not exist"); |
There was a problem hiding this comment.
p2. NoSuchElementException 사용 — 예외 처리 불일치
다른 서비스들은 UserNotFoundException, ChannelNotFoundException 등 커스텀 예외를 사용하는데, 이 서비스만 NoSuchElementException을 던지고 있습니다.
GlobalExceptionHandler에 NoSuchElementException 핸들러가 없으므로 500 Internal Server Error로 응답됩니다. 도메인별 커스텀 예외(UserNotFoundException, ChannelNotFoundException)로 통일해 주세요.
| ); | ||
| }) | ||
| .toList(); | ||
| BinaryContent nullableProfile = optionalProfileCreateRequest |
There was a problem hiding this comment.
p3. @Transactional(readOnly = true) 누락
find() 메서드에 @Transactional이 없습니다. User 엔티티는 profile(LAZY), status(LAZY) 관계를 가지고 있어, 영속성 컨텍스트 종료 후 Lazy 필드에 접근하면 LazyInitializationException이 발생할 수 있습니다.
findAll()은 fetch join을 사용하므로 괜찮을 수 있지만, find()와 findAll() 모두 @Transactional(readOnly = true) 추가를 권장합니다.
| + "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") | ||
| Slice<Message> findAllByChannelIdWithAuthor(@Param("channelId") UUID channelId, | ||
| @Param("createdAt") Instant createdAt, | ||
| Pageable pageable); |
There was a problem hiding this comment.
p3. JPQL에서 LIMIT 키워드 사용
LIMIT 1은 표준 JPQL 문법이 아닙니다. Hibernate 6+에서 지원하긴 하지만, 이식성을 위해 Pageable.ofSize(1)을 파라미터로 받거나, @Query(nativeQuery = true)로 변경하는 것을 권장합니다.
| public ResponseEntity<UserStatusDto> updateUserStatusByUserId(@PathVariable("userId") UUID userId, | ||
| @Valid @RequestBody UserStatusUpdateRequest request) { | ||
| log.info("사용자 상태 수정 요청 - userId: {}", userId); | ||
| UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); |
There was a problem hiding this comment.
p3. RuntimeException 래핑
파일 업로드 시 IOException을 RuntimeException으로 감싸면 GlobalExceptionHandler의 일반 Exception 핸들러에 걸려 500 에러로 응답됩니다.
이미 BinaryContentUploadFailedException이 존재하니 이를 활용해 주세요:
| UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); | |
| throw new BinaryContentUploadFailedException(profileFile.getOriginalFilename(), e); |
| .map(ReadStatus::getChannel) | ||
| .map(Channel::getId) | ||
| .toList(); | ||
|
|
There was a problem hiding this comment.
p3. 채널 삭제 시 첨부파일 스토리지 미정리
메시지와 읽음상태는 삭제하지만, 메시지에 포함된 첨부파일(BinaryContent)의 실제 스토리지 파일은 정리되지 않습니다. DB 레코드는 cascade로 삭제될 수 있지만, 로컬 파일시스템의 파일은 orphan으로 남게 됩니다. 스토리지 정리 로직 추가 또는 후속 배치 작업 등을 고려해 주세요.
| "[{}] {} - details: {}", | ||
| e.getErrorCode().getCode(), | ||
| e.getErrorCode().getMessage(), | ||
| e.getDetails() |
There was a problem hiding this comment.
p4. 중복 핸들러 통합 가능
UserException, ChannelException, MessageException, BinaryContentException 모두 DiscodeitException을 상속한다면, handleDiscodeitException 하나로 통합할 수 있습니다. 현재 동작에 문제는 없지만, 새 도메인 예외 추가 시마다 핸들러를 만들어야 하는 불편함이 있습니다. 통합을 고려해 보세요.
| return userRepository.findById(userId) | ||
| .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); | ||
| if (userRepository.existsByUsername(username)) { | ||
| log.warn("사용자 생성 실패 - 이미 존재하는 사용자명: {}", username); |
There was a problem hiding this comment.
p5. UserStatus 명시적 저장 없음
UserStatus를 생성만 하고 userStatusRepository.save()를 호출하지 않습니다. User.status의 cascade = CascadeType.ALL에 의존하는 구조인데, UserStatus 생성자에서 양방향 관계 설정(user.setStatus(this))이 되어 있어야 동작합니다. 의도된 cascade 활용이라면 주석을 남겨주시면 좋겠습니다.
요구사항
기본
레포지토리 레이어의 슬라이스 테스트 작성
@DataJpaTest를 활용한 테스트 구현테스트 환경 프로파일 구성 (
test)application-test.yaml생성 및 설정H2 인메모리 데이터베이스 적용 (PostgreSQL 모드)
H2 의존성 추가
테스트 시작 시 스키마 자동 생성 설정 (
ddl-auto: create)SQL 로그 확인을 위한 로깅 레벨 설정
테스트 실행 시
test프로파일 활성화JPA Auditing 활성화 (
@EnableJpaAuditing)주요 레포지토리(User, Channel, Message) 테스트 작성
커스텀 쿼리 메소드 테스트
페이징 및 정렬 테스트
컨트롤러 레이어의 슬라이스 테스트 작성
@WebMvcTest를 활용한 테스트 구현필요한 Bean을
@Import로 추가주요 컨트롤러(User, Channel, Message) 테스트 작성
MockMvc를 활용한 컨트롤러 테스트
서비스 레이어 Mock 처리 (
@MockBean)JSON 응답 검증 테스트 포함
심화
주요 변경사항
레포지토리 슬라이스 테스트 추가
테스트 전용 환경 구성
application-test.yaml생성JPA Auditing 테스트 환경 적용
컨트롤러 슬라이스 테스트 추가
@WebMvcTest기반 API 테스트 구현JSON 응답 검증 로직 추가
스크린샷
멘토에게