diff --git a/.gitignore b/.gitignore index 8f7c96e..8c51246 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ out/ ### yml ### application-dev.properties -application-infrastructure.properties \ No newline at end of file +application-infrastructure.properties + +### Claude Code ### +CLAUDE.md +.claude/ +.clinerules \ No newline at end of file diff --git a/src/main/java/org/fontory/fontorybe/authentication/adapter/inbound/security/JwtAuthenticationFilter.java b/src/main/java/org/fontory/fontorybe/authentication/adapter/inbound/security/JwtAuthenticationFilter.java index ae7ddb2..c38923a 100644 --- a/src/main/java/org/fontory/fontorybe/authentication/adapter/inbound/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/fontory/fontorybe/authentication/adapter/inbound/security/JwtAuthenticationFilter.java @@ -26,6 +26,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * JWT 기반 인증을 처리하는 Spring Security 필터 + * 모든 HTTP 요청에 대해 JWT 토큰을 검증하고 인증 처리 + * Access Token 만료 시 Refresh Token을 사용한 자동 갱신 기능 포함 + */ @Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -34,6 +39,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthService authService; private final CookieUtils cookieUtils; + /** + * JWT 토큰 검증 및 인증 처리 + * 쿠키 또는 Authorization 헤더에서 토큰을 추출하고 검증 + * + * @param req HTTP 요청 + * @param res HTTP 응답 + * @param chain 필터 체인 + */ @Override protected void doFilterInternal(HttpServletRequest req, @NonNull HttpServletResponse res, @@ -41,6 +54,7 @@ protected void doFilterInternal(HttpServletRequest req, throws ServletException, IOException { log.info("JwtAuthenticationFilter: {} {}", req.getMethod(), req.getRequestURI()); + // 쿠키 또는 Authorization 헤더에서 토큰 추출 String accessToken = cookieUtils .extractTokenFromCookieInRequest(req, ACCESS_TOKEN_COOKIE_NAME) .or(() -> Optional.ofNullable(req.getHeader("Authorization")) @@ -51,6 +65,7 @@ protected void doFilterInternal(HttpServletRequest req, .orElse(null); log.info("Access token: {}, Refresh token: {}", accessToken, refreshToken); + // 토큰 존재 여부에 따른 처리 if (accessToken != null) { log.debug("Access token found - attempting authentication"); authenticateOrRefresh(accessToken, refreshToken, res); @@ -67,6 +82,14 @@ protected void doFilterInternal(HttpServletRequest req, log.debug("JwtAuthenticationFilter completed"); } + /** + * Access Token 검증 및 만료 시 Refresh Token으로 갱신 + * + * @param accessToken 검증할 Access Token + * @param refreshToken 갱신에 사용할 Refresh Token + * @param res HTTP 응답 (새 쿠키 설정용) + * @throws JwtAuthenticationException JWT 인증 실패 시 + */ private void authenticateOrRefresh(String accessToken, String refreshToken, HttpServletResponse res) throws JwtAuthenticationException { @@ -76,6 +99,7 @@ private void authenticateOrRefresh(String accessToken, SecurityContextHolder.getContext().setAuthentication(auth); log.info("Authentication successful: user={}, authorities={}", auth.getName(), auth.getAuthorities()); } catch (ExpiredJwtException e) { + // Access Token 만료 시 Refresh Token으로 갱신 시도 log.warn("Access token expired: {}", e.getMessage()); if (refreshToken == null) { throw new JwtAuthenticationException("Refresh token missing"); diff --git a/src/main/java/org/fontory/fontorybe/authentication/adapter/outbound/JwtTokenProviderImpl.java b/src/main/java/org/fontory/fontorybe/authentication/adapter/outbound/JwtTokenProviderImpl.java index 53db259..f412d12 100644 --- a/src/main/java/org/fontory/fontorybe/authentication/adapter/outbound/JwtTokenProviderImpl.java +++ b/src/main/java/org/fontory/fontorybe/authentication/adapter/outbound/JwtTokenProviderImpl.java @@ -31,15 +31,10 @@ public class JwtTokenProviderImpl implements JwtTokenProvider { private final JwtParser provideJwtParser; private final JwtParser fontCreateJwtParser; - private SecretKey getSigningKey(String key) { - byte[] keyBytes = Decoders.BASE64.decode(key); - return Keys.hmacShaKeyFor(keyBytes); - } - - public JwtTokenProviderImpl( - JwtProperties props) { + public JwtTokenProviderImpl(JwtProperties props) { log.info("Initializing JWT token provider"); + this.props = props; this.accessSecretKey = getSigningKey(props.getAccessSecretKey()); this.refreshSecretKey = getSigningKey(props.getRefreshSecretKey()); this.provideSecretKey = getSigningKey(props.getProvideSecretKey()); @@ -49,25 +44,20 @@ public JwtTokenProviderImpl( this.refreshJwtParser = Jwts.parserBuilder().setSigningKey(refreshSecretKey).build(); this.provideJwtParser = Jwts.parserBuilder().setSigningKey(provideSecretKey).build(); this.fontCreateJwtParser = Jwts.parserBuilder().setSigningKey(fontCreateSecretKey).build(); - this.props = props; log.debug("JWT token provider initialized with token validities - access: {}ms, refresh: {}ms", props.getAccessTokenValidityMs(), props.getRefreshTokenValidityMs()); } + + private SecretKey getSigningKey(String key) { + byte[] keyBytes = Decoders.BASE64.decode(key); + return Keys.hmacShaKeyFor(keyBytes); + } public String generateTemporalProvideToken(String id) { log.debug("Generating temporal provide token for id: {}", id); - - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + props.getTempTokenValidityMs()); - String token = Jwts.builder() - .setSubject(String.valueOf(id)) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(this.provideSecretKey) - .compact(); - - log.info("Temporal provide token generated: id={}, expiresAt={}", id, expiryDate); + String token = generateToken(id, props.getTempTokenValidityMs(), provideSecretKey); + log.info("Temporal provide token generated for id: {}", id); return token; } @@ -85,56 +75,28 @@ public Long getProvideId(String token) { public String generateAccessToken(UserPrincipal user) { log.debug("Generating access token for user: {}", user.getId()); - - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + props.getAccessTokenValidityMs()); - String token = Jwts.builder() - .setSubject(String.valueOf(user.getId())) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(this.accessSecretKey) - .compact(); - - log.info("Access token generated: userId={}, expiresAt={}", user.getId(), expiryDate); + String token = generateToken(String.valueOf(user.getId()), props.getAccessTokenValidityMs(), accessSecretKey); + log.info("Access token generated for user: {}", user.getId()); return token; } public String generateRefreshToken(UserPrincipal user) { log.debug("Generating refresh token for user: {}", user.getId()); - - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + props.getRefreshTokenValidityMs()); - String token = Jwts.builder() - .setSubject(String.valueOf(user.getId())) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(refreshSecretKey) - .compact(); - - log.info("Refresh token generated: userId={}, expiresAt={}", user.getId(), expiryDate); + String token = generateToken(String.valueOf(user.getId()), props.getRefreshTokenValidityMs(), refreshSecretKey); + log.info("Refresh token generated for user: {}", user.getId()); return token; } public Long getMemberIdFromAccessToken(String token) { log.debug("Extracting member ID from access token"); - - Claims claims = accessJwtParser - .parseClaimsJws(token) - .getBody(); - Long memberId = Long.valueOf(claims.getSubject()); - + Long memberId = extractMemberIdFromToken(token, accessJwtParser); log.debug("Member ID extracted from access token: {}", memberId); return memberId; } public Long getMemberIdFromRefreshToken(String token) { log.debug("Extracting member ID from refresh token"); - - Claims claims = refreshJwtParser - .parseClaimsJws(token) - .getBody(); - Long memberId = Long.valueOf(claims.getSubject()); - + Long memberId = extractMemberIdFromToken(token, refreshJwtParser); log.debug("Member ID extracted from refresh token: {}", memberId); return memberId; } @@ -156,13 +118,25 @@ public Authentication getAuthenticationFromAccessToken(String token) { public String getFontCreateServer(String token) { log.debug("Validating font create server token"); - - Claims claims = fontCreateJwtParser - .parseClaimsJws(token) - .getBody(); + Claims claims = fontCreateJwtParser.parseClaimsJws(token).getBody(); String subject = claims.getSubject(); - log.debug("Font create server token validated: subject={}", subject); return subject; } + + private String generateToken(String subject, long validityMs, SecretKey key) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + validityMs); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(key) + .compact(); + } + + private Long extractMemberIdFromToken(String token, JwtParser parser) { + Claims claims = parser.parseClaimsJws(token).getBody(); + return Long.valueOf(claims.getSubject()); + } } diff --git a/src/main/java/org/fontory/fontorybe/authentication/application/AuthService.java b/src/main/java/org/fontory/fontorybe/authentication/application/AuthService.java index a6f6c12..e4c59b6 100644 --- a/src/main/java/org/fontory/fontorybe/authentication/application/AuthService.java +++ b/src/main/java/org/fontory/fontorybe/authentication/application/AuthService.java @@ -27,8 +27,11 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; /** - * 새롭게 토큰 발급 - * 기존에 토큰이 존재한다면 제거, 기존 토큰이 존재할 필요 X + * 새로운 Access/Refresh 토큰 쌍을 발급 + * Refresh Token은 Redis에 저장하여 검증에 사용 + * + * @param member 토큰을 발급할 회원 정보 + * @return 발급된 토큰 쌍 */ private TokenResponse issueNewTokens(Member member) { log.info("Issuing new token pair for member: memberId={}, provideId={}", diff --git a/src/main/java/org/fontory/fontorybe/bookmark/controller/BookmarkController.java b/src/main/java/org/fontory/fontorybe/bookmark/controller/BookmarkController.java index e40c301..ee448ad 100644 --- a/src/main/java/org/fontory/fontorybe/bookmark/controller/BookmarkController.java +++ b/src/main/java/org/fontory/fontorybe/bookmark/controller/BookmarkController.java @@ -25,16 +25,21 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Tag(name = "북마크 관리", description = "북마크 API") +@Tag(name = "북마크 관리", description = "폰트 북마크 추가, 삭제, 조회 API") @RestController @RequestMapping("/bookmarks") @RequiredArgsConstructor public class BookmarkController { private final BookmarkService bookmarkService; - @Operation(summary = "북마크 추가") + @Operation( + summary = "북마크 추가", + description = "특정 폰트를 북마크에 추가합니다." + ) @PostMapping("/{fontId}") - public ResponseEntity addBookmark(@Login UserPrincipal userPrincipal, @PathVariable Long fontId) { + public ResponseEntity addBookmark( + @Login UserPrincipal userPrincipal, + @PathVariable @Parameter(description = "북마크할 폰트 ID") Long fontId) { Long memberId = userPrincipal.getId(); log.info("Request received: Add bookmark for font ID: {} by member ID: {}", fontId, memberId); @@ -47,9 +52,14 @@ public ResponseEntity addBookmark(@Login UserPrincipal userPrincipal, @PathVa .body(BookmarkCreateResponse.from(createdBookmark)); } - @Operation(summary = "북마크 삭제") + @Operation( + summary = "북마크 삭제", + description = "특정 폰트를 북마크에서 제거합니다." + ) @DeleteMapping("/{fontId}") - public ResponseEntity deleteBookmark(@Login UserPrincipal userPrincipal, @PathVariable Long fontId) { + public ResponseEntity deleteBookmark( + @Login UserPrincipal userPrincipal, + @PathVariable @Parameter(description = "북마크 삭제할 폰트 ID") Long fontId) { Long memberId = userPrincipal.getId(); log.info("Request received: Delete bookmark for font ID: {} by member ID: {}", fontId, memberId); @@ -62,7 +72,10 @@ public ResponseEntity deleteBookmark(@Login UserPrincipal userPrincipal, @Pat .body(deletedBookmark); } - @Operation(summary = "북마크한 폰트 보기") + @Operation( + summary = "북마크한 폰트 보기", + description = "로그인한 사용자가 북마크한 폰트 목록을 조회합니다. 페이지네이션 및 검색을 지원합니다." + ) @GetMapping public ResponseEntity getBookmarks( @Parameter(description = "페이지 시작 오프셋 (기본값: 0)", example = "0") @RequestParam(defaultValue = "0") int page, diff --git a/src/main/java/org/fontory/fontorybe/bookmark/infrastructure/BookmarkJpaRepository.java b/src/main/java/org/fontory/fontorybe/bookmark/infrastructure/BookmarkJpaRepository.java index f30e81e..19de443 100644 --- a/src/main/java/org/fontory/fontorybe/bookmark/infrastructure/BookmarkJpaRepository.java +++ b/src/main/java/org/fontory/fontorybe/bookmark/infrastructure/BookmarkJpaRepository.java @@ -1,14 +1,26 @@ package org.fontory.fontorybe.bookmark.infrastructure; +import java.util.List; import java.util.Optional; import org.fontory.fontorybe.bookmark.infrastructure.entity.BookmarkEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface BookmarkJpaRepository extends JpaRepository { boolean existsByMemberIdAndFontId(Long memberId, Long fontId); Optional findByMemberIdAndFontId(Long memberId, Long fontId); Page findAllByMemberId(Long memberId, Pageable pageable); - + + // 성능 최적화를 위한 추가 쿼리 + @Query(value = "SELECT COUNT(*) FROM bookmark WHERE member_id = :memberId", nativeQuery = true) + long countByMemberId(@Param("memberId") Long memberId); + + @Query(value = "SELECT font_id FROM bookmark WHERE member_id = :memberId ORDER BY created_at DESC LIMIT :limit", nativeQuery = true) + List findRecentBookmarkedFontIds(@Param("memberId") Long memberId, @Param("limit") int limit); + + @Query("SELECT b FROM BookmarkEntity b WHERE b.memberId = :memberId ORDER BY b.createdAt DESC") + List findAllByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId, Pageable pageable); } diff --git a/src/main/java/org/fontory/fontorybe/bookmark/service/BookmarkServiceImpl.java b/src/main/java/org/fontory/fontorybe/bookmark/service/BookmarkServiceImpl.java index 958bcd3..c10e0a2 100644 --- a/src/main/java/org/fontory/fontorybe/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/bookmark/service/BookmarkServiceImpl.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.bookmark.controller.dto.BookmarkDeleteResponse; import org.fontory.fontorybe.bookmark.controller.port.BookmarkService; import org.fontory.fontorybe.bookmark.domain.Bookmark; @@ -23,6 +24,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +/** + * 북마크 관련 비즈니스 로직을 처리하는 서비스 구현체 + * 폰트의 북마크 추가, 삭제 및 북마크한 폰트 목록 조회 기능 제공 + */ +@Slf4j @Service @RequiredArgsConstructor public class BookmarkServiceImpl implements BookmarkService { @@ -32,10 +38,22 @@ public class BookmarkServiceImpl implements BookmarkService { private final FontService fontService; private final CloudStorageService cloudStorageService; + /** + * 폰트를 북마크에 추가 + * 북마크 추가 시 해당 폰트의 북마크 카운트를 증가 + * + * @param memberId 북마크를 추가하는 회원 ID + * @param fontId 북마크할 폰트 ID + * @return 생성된 북마크 엔티티 + * @throws BookmarkAlreadyException 이미 북마크된 폰트인 경우 + */ @Override @Transactional public Bookmark create(Long memberId, Long fontId) { + log.info("Creating bookmark: memberId={}, fontId={}", memberId, fontId); + if (bookmarkRepository.existsByMemberIdAndFontId(memberId, fontId)) { + log.warn("Bookmark already exists: memberId={}, fontId={}", memberId, fontId); throw new BookmarkAlreadyException(); } @@ -44,34 +62,70 @@ public Bookmark create(Long memberId, Long fontId) { font.increaseBookmarkCount(); fontRepository.save(font); + log.debug("Font bookmark count increased: fontId={}, newCount={}", fontId, font.getBookmarkCount()); - return bookmarkRepository.save(Bookmark.from(memberId, fontId)); + Bookmark bookmark = bookmarkRepository.save(Bookmark.from(memberId, fontId)); + log.info("Bookmark created successfully: bookmarkId={}, memberId={}, fontId={}", + bookmark.getId(), memberId, fontId); + return bookmark; } + /** + * 북마크를 삭제 + * 북마크 삭제 시 해당 폰트의 북마크 카운트를 감소 + * + * @param memberId 북마크를 삭제하는 회원 ID + * @param fontId 북마크를 해제할 폰트 ID + * @return 삭제 결과 + * @throws BookmarkNotFoundException 북마크가 존재하지 않는 경우 + */ @Override @Transactional public BookmarkDeleteResponse delete(Long memberId, Long fontId) { + log.info("Deleting bookmark: memberId={}, fontId={}", memberId, fontId); + Member member = memberLookupService.getOrThrowById(memberId); Font font = fontService.getOrThrowById(fontId); Bookmark bookmark = bookmarkRepository.findByMemberIdAndFontId(memberId, fontId) - .orElseThrow(BookmarkNotFoundException::new); + .orElseThrow(() -> { + log.warn("Bookmark not found for deletion: memberId={}, fontId={}", memberId, fontId); + return new BookmarkNotFoundException(); + }); bookmarkRepository.deleteById(bookmark.getId()); + log.debug("Bookmark deleted: bookmarkId={}", bookmark.getId()); font.decreaseBookmarkCount(); fontRepository.save(font); + log.debug("Font bookmark count decreased: fontId={}, newCount={}", fontId, font.getBookmarkCount()); + log.info("Bookmark deleted successfully: bookmarkId={}, memberId={}, fontId={}", + bookmark.getId(), memberId, fontId); return BookmarkDeleteResponse.from(bookmark.getId()); } + /** + * 회원이 북마크한 폰트 목록을 페이지네이션과 함께 조회 + * 키워드 검색 기능 포함 (폰트 이름 기준) + * + * @param memberId 조회할 회원 ID + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + * @param keyword 검색 키워드 (null 또는 빈 문자열일 경우 전체 조회) + * @return 북마크한 폰트 목록 (페이지네이션 적용) + */ @Override @Transactional(readOnly = true) public Page getBookmarkedFonts(Long memberId, int page, int size, String keyword) { + log.info("Getting bookmarked fonts: memberId={}, page={}, size={}, keyword={}", + memberId, page, size, keyword); + Member member = memberLookupService.getOrThrowById(memberId); - // If no keyword, use normal pagination + // 키워드가 없으면 일반 페이지네이션 사용 if (!StringUtils.hasText(keyword)) { + log.debug("Fetching bookmarked fonts without keyword filter: memberId={}", memberId); PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"))); Page bookmarks = bookmarkRepository.findAllByMemberId(memberId, pageRequest); @@ -89,14 +143,17 @@ public Page getBookmarkedFonts(Long memberId, int page, int size, }) .toList(); + log.debug("Bookmarked fonts retrieved: memberId={}, count={}, totalElements={}", + memberId, fontResponses.size(), bookmarks.getTotalElements()); return new PageImpl<>(fontResponses, pageRequest, bookmarks.getTotalElements()); } // With keyword, need to filter all bookmarks first, then paginate + log.debug("Fetching bookmarked fonts with keyword filter: memberId={}, keyword={}", memberId, keyword); // Get all bookmarks for the member (no pagination) PageRequest allBookmarksRequest = PageRequest.of(0, Integer.MAX_VALUE, Sort.by(Sort.Order.desc("createdAt"))); Page allBookmarks = bookmarkRepository.findAllByMemberId(memberId, allBookmarksRequest); - + List allFontIds = allBookmarks.stream() .map(Bookmark::getFontId) .toList(); @@ -107,6 +164,7 @@ public Page getBookmarkedFonts(Long memberId, int page, int size, List filteredFonts = allFonts.stream() .filter(font -> font.getName().contains(keyword)) .toList(); + log.debug("Fonts filtered by keyword: totalFonts={}, filteredCount={}", allFonts.size(), filteredFonts.size()); // Apply manual pagination int start = page * size; @@ -124,6 +182,8 @@ public Page getBookmarkedFonts(Long memberId, int page, int size, .toList(); PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"))); + log.info("Bookmarked fonts retrieved with keyword: memberId={}, keyword={}, pageContent={}, total={}", + memberId, keyword, pageContent.size(), filteredFonts.size()); return new PageImpl<>(pageContent, pageRequest, filteredFonts.size()); } } diff --git a/src/main/java/org/fontory/fontorybe/common/adapter/inbound/GlobalExceptionHandler.java b/src/main/java/org/fontory/fontorybe/common/adapter/inbound/GlobalExceptionHandler.java index 0deb977..592f5f8 100644 --- a/src/main/java/org/fontory/fontorybe/common/adapter/inbound/GlobalExceptionHandler.java +++ b/src/main/java/org/fontory/fontorybe/common/adapter/inbound/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import io.jsonwebtoken.MalformedJwtException; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.authentication.domain.exception.AuthenticationRequiredException; import org.fontory.fontorybe.authentication.domain.exception.InvalidRefreshTokenException; import org.fontory.fontorybe.authentication.domain.exception.TokenNotFoundException; @@ -38,155 +39,143 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; +/** + * 전역 예외 처리 핸들러 + * HTTP 상태 코드별로 예외를 그룹화하여 관리 + */ +@Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { - + + // ========== 400 BAD REQUEST ========== + + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MethodArgumentNotValidException.class) - public BaseErrorResponse validationException(MethodArgumentNotValidException e) { + public BaseErrorResponse handleValidationException(MethodArgumentNotValidException e) { String message = e.getBindingResult() .getFieldErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.joining(", ")); + log.warn("Validation failed: {}", message); return new BaseErrorResponse(message); } - - @ResponseStatus(HttpStatus.NOT_FOUND) - @ExceptionHandler({MemberNotFoundException.class, FontNotFoundException.class, BookmarkNotFoundException.class}) - public BaseErrorResponse notFoundException(Exception e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.FORBIDDEN) - @ExceptionHandler({MemberDuplicateNameExistsException.class, FontDuplicateNameExistsException.class}) - public BaseErrorResponse duplicateNameExists(Exception e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.FORBIDDEN) - @ExceptionHandler({MemberOwnerMismatchException.class, FontOwnerMismatchException.class}) - public BaseErrorResponse ownerMismatch(Exception e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.FORBIDDEN) - @ExceptionHandler(MemberAlreadyDisabledException.class) - public BaseErrorResponse memberAlreadyDisabled(MemberAlreadyDisabledException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.NOT_FOUND) - @ExceptionHandler(ProvideNotFoundException.class) - public BaseErrorResponse provideNotFound(ProvideNotFoundException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(MalformedJwtException.class) - public BaseErrorResponse malformedToken(MalformedJwtException e) { - return new BaseErrorResponse("Not a valid token"); + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({ + FileUploadException.class, + SingleFileRequiredException.class, + InvalidMultipartRequestException.class, + UnsupportedFileTypeException.class, + MemberAlreadyJoinedException.class, + FontInvalidStatusException.class, + FontContainsBadWordException.class, + MemberContainsBadWordException.class + }) + public BaseErrorResponse handleBadRequestExceptions(Exception e) { + log.warn("Bad request: {}", e.getMessage()); + return new BaseErrorResponse(e.getMessage()); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MaxUploadSizeExceededException.class) + public BaseErrorResponse handleMaxUploadSizeExceeded(MaxUploadSizeExceededException e) { + String message = String.format("파일 크기가 제한을 초과했습니다. (최대: %d bytes)", e.getMaxUploadSize()); + log.warn("File upload size exceeded: {}", e.getMaxUploadSize()); + return new BaseErrorResponse(message); } - + + // ========== 401 UNAUTHORIZED ========== + @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(JwtException.class) - public BaseErrorResponse invalidToken(JwtException e) { - return new BaseErrorResponse("Not a valid token"); + @ExceptionHandler({MalformedJwtException.class, JwtException.class}) + public BaseErrorResponse handleInvalidToken(Exception e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + return new BaseErrorResponse("유효하지 않은 토큰입니다"); } - + @ResponseStatus(HttpStatus.UNAUTHORIZED) @ExceptionHandler(ExpiredJwtException.class) - public BaseErrorResponse expiredToken(ExpiredJwtException e) { - return new BaseErrorResponse("Expired token"); + public BaseErrorResponse handleExpiredToken(ExpiredJwtException e) { + log.warn("Expired JWT token"); + return new BaseErrorResponse("토큰이 만료되었습니다"); } - + @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(InvalidRefreshTokenException.class) - public BaseErrorResponse invalidRefreshToken(InvalidRefreshTokenException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(TokenNotFoundException.class) - public BaseErrorResponse tokenNotFound(TokenNotFoundException e) { - return new BaseErrorResponse(e.getMessage()); - } - + @ExceptionHandler({ + InvalidRefreshTokenException.class, + TokenNotFoundException.class, + AuthenticationRequiredException.class + }) + public BaseErrorResponse handleAuthenticationExceptions(Exception e) { + log.warn("Authentication failed: {}", e.getMessage()); + return new BaseErrorResponse(e.getMessage()); + } + + // ========== 403 FORBIDDEN ========== + @ResponseStatus(HttpStatus.FORBIDDEN) - @ExceptionHandler(MemberAlreadyExistException.class) - public BaseErrorResponse memberAlreadyExist(MemberAlreadyExistException e) { + @ExceptionHandler({ + MemberDuplicateNameExistsException.class, + FontDuplicateNameExistsException.class + }) + public BaseErrorResponse handleDuplicateNameExceptions(Exception e) { + log.warn("Duplicate name: {}", e.getMessage()); return new BaseErrorResponse(e.getMessage()); } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(FileUploadException.class) - public BaseErrorResponse fileUploadException(FileUploadException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(MaxUploadSizeExceededException.class) - public BaseErrorResponse maxUploadSizeExceeded(MaxUploadSizeExceededException e) { - return new BaseErrorResponse("Maximum upload size exceeded :" + e.getMaxUploadSize()); - } - + @ResponseStatus(HttpStatus.FORBIDDEN) - @ExceptionHandler(BookmarkAlreadyException.class) - public BaseErrorResponse bookmarkAlready(BookmarkAlreadyException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) - @ExceptionHandler(FontSQSProduceExcepetion.class) - public BaseErrorResponse SQSProduceException(FontSQSProduceExcepetion e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(SingleFileRequiredException.class) - public BaseErrorResponse singleFileRequiredException(SingleFileRequiredException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(InvalidMultipartRequestException.class) - public BaseErrorResponse invalidMultipartRequest(InvalidMultipartRequestException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(UnsupportedFileTypeException.class) - public BaseErrorResponse unsupportedFileType(UnsupportedFileTypeException e) { + @ExceptionHandler({ + MemberOwnerMismatchException.class, + FontOwnerMismatchException.class + }) + public BaseErrorResponse handleOwnerMismatchExceptions(Exception e) { + log.warn("Owner mismatch: {}", e.getMessage()); return new BaseErrorResponse(e.getMessage()); } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(MemberAlreadyJoinedException.class) - public BaseErrorResponse memberAlreadyJoined(MemberAlreadyJoinedException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(FontInvalidStatusException.class) - public BaseErrorResponse fontInvalidStatusException(FontInvalidStatusException e) { - return new BaseErrorResponse(e.getMessage()); - } - + + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler({ + MemberAlreadyDisabledException.class, + MemberAlreadyExistException.class, + BookmarkAlreadyException.class + }) + public BaseErrorResponse handleAlreadyExistsExceptions(Exception e) { + log.warn("Resource already exists: {}", e.getMessage()); + return new BaseErrorResponse(e.getMessage()); + } + + // ========== 404 NOT FOUND ========== + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler({ + MemberNotFoundException.class, + FontNotFoundException.class, + BookmarkNotFoundException.class, + ProvideNotFoundException.class + }) + public BaseErrorResponse handleNotFoundExceptions(Exception e) { + log.warn("Resource not found: {}", e.getMessage()); + return new BaseErrorResponse(e.getMessage()); + } + + // ========== 500 INTERNAL SERVER ERROR ========== + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(FileNotFoundException.class) - public BaseErrorResponse fileNotFoundException(FileNotFoundException e) { - return new BaseErrorResponse(e.getMessage()); - } - - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler({FontContainsBadWordException.class, MemberContainsBadWordException.class}) - public BaseErrorResponse containsBadWordException(Exception e) { - return new BaseErrorResponse(e.getMessage()); + public BaseErrorResponse handleFileNotFoundException(FileNotFoundException e) { + log.error("File not found in storage: {}", e.getMessage()); + return new BaseErrorResponse("파일 처리 중 오류가 발생했습니다"); } - - @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(AuthenticationRequiredException.class) - public BaseErrorResponse authenticationRequiredException(AuthenticationRequiredException e) { - return new BaseErrorResponse(e.getMessage()); + + // ========== 503 SERVICE UNAVAILABLE ========== + + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + @ExceptionHandler(FontSQSProduceExcepetion.class) + public BaseErrorResponse handleSQSProduceException(FontSQSProduceExcepetion e) { + log.error("SQS produce failed: {}", e.getMessage()); + return new BaseErrorResponse("폰트 생성 요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요"); } -} + +} \ No newline at end of file diff --git a/src/main/java/org/fontory/fontorybe/common/adapter/outbound/DiscordExceptionLoggingAspect.java b/src/main/java/org/fontory/fontorybe/common/adapter/outbound/DiscordExceptionLoggingAspect.java index ed397da..6547501 100644 --- a/src/main/java/org/fontory/fontorybe/common/adapter/outbound/DiscordExceptionLoggingAspect.java +++ b/src/main/java/org/fontory/fontorybe/common/adapter/outbound/DiscordExceptionLoggingAspect.java @@ -48,18 +48,9 @@ public DiscordExceptionLoggingAspect(RestTemplateBuilder restTemplateBuilder, * Jwt관련 예외나 @SkipDiscordNotification 이 붙은 예외인 경우 알림을 보내지 않음 */ private boolean shouldSkipNotification(Throwable ex) { - if (ex instanceof io.jsonwebtoken.MalformedJwtException || - ex instanceof io.jsonwebtoken.JwtException || - ex instanceof io.jsonwebtoken.ExpiredJwtException || - ex instanceof TokenNotFoundException) { - return true; - } - - if (ex.getClass().isAnnotationPresent(SkipDiscordNotification.class)) { - return true; - } - - return false; + return ex instanceof io.jsonwebtoken.JwtException || + ex instanceof TokenNotFoundException || + ex.getClass().isAnnotationPresent(SkipDiscordNotification.class); } /** @@ -101,33 +92,20 @@ public void handleException(JoinPoint joinPoint, Throwable ex) { } private List> buildFields(JoinPoint joinPoint, Throwable ex) { - Map locationField = new LinkedHashMap<>(); - locationField.put("name", "Location"); - locationField.put("value", joinPoint.getSignature().getDeclaringTypeName()); - locationField.put("inline", false); - - Map exceptionField = new LinkedHashMap<>(); - exceptionField.put("name", "Exception Type"); - exceptionField.put("value", ex.getClass().getName()); - exceptionField.put("inline", false); - - Map messageField = new LinkedHashMap<>(); - messageField.put("name", "Message"); - messageField.put("value", ex.getMessage() != null ? ex.getMessage() : "No message"); - messageField.put("inline", false); - - Map timeField = new LinkedHashMap<>(); - timeField.put("name", "Time"); - timeField.put("value", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); - timeField.put("inline", false); - - List> fields = new ArrayList<>(); - fields.add(locationField); - fields.add(exceptionField); - fields.add(messageField); - fields.add(timeField); - - return fields; + return List.of( + createField("Location", joinPoint.getSignature().getDeclaringTypeName()), + createField("Exception Type", ex.getClass().getName()), + createField("Message", ex.getMessage() != null ? ex.getMessage() : "No message"), + createField("Time", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) + ); + } + + private Map createField(String name, String value) { + Map field = new LinkedHashMap<>(); + field.put("name", name); + field.put("value", value); + field.put("inline", false); + return field; } private Map buildEmbed(List> fields) { diff --git a/src/main/java/org/fontory/fontorybe/config/SecurityConfig.java b/src/main/java/org/fontory/fontorybe/config/SecurityConfig.java index 639464f..5f57397 100644 --- a/src/main/java/org/fontory/fontorybe/config/SecurityConfig.java +++ b/src/main/java/org/fontory/fontorybe/config/SecurityConfig.java @@ -20,9 +20,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; -import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; -import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -63,7 +60,7 @@ public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws new AntPathRequestMatcher("/swagger-resources/**"), new AntPathRequestMatcher("/webjars/**") )) - .csrf(CsrfConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()); return http.build(); @@ -81,9 +78,9 @@ public SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws E new AntPathRequestMatcher("/login/oauth2/**") )) .cors(cors -> cors.configurationSource(corsConfigurationSource)) - .csrf(CsrfConfigurer::disable) - .httpBasic(HttpBasicConfigurer::disable) - .formLogin(FormLoginConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .oauth2Login(oauth2 -> oauth2 .successHandler(oauth2SuccessHandler) @@ -106,9 +103,9 @@ public SecurityFilterChain fontCreateServerSecurityFilterChain(HttpSecurity http )) .cors(cors -> cors.configurationSource(corsConfigurationSource)) .sessionManagement(AbstractHttpConfigurer::disable) - .csrf(CsrfConfigurer::disable) - .httpBasic(HttpBasicConfigurer::disable) - .formLogin(FormLoginConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .addFilterBefore(new JwtFontCreateServerFilter(jwtTokenProvider, jwtProperties), UsernamePasswordAuthenticationFilter.class) .build(); @@ -129,9 +126,9 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws )) .cors(cors -> cors.configurationSource(corsConfigurationSource)) .sessionManagement(AbstractHttpConfigurer::disable) - .csrf(CsrfConfigurer::disable) - .httpBasic(HttpBasicConfigurer::disable) - .formLogin(FormLoginConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) .exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthenticationEntryPoint)) .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider, authService, cookieUtils), ExceptionTranslationFilter.class) .authorizeHttpRequests(auth -> auth diff --git a/src/main/java/org/fontory/fontorybe/config/WebConfig.java b/src/main/java/org/fontory/fontorybe/config/WebConfig.java index 0b4cb6e..f41b591 100644 --- a/src/main/java/org/fontory/fontorybe/config/WebConfig.java +++ b/src/main/java/org/fontory/fontorybe/config/WebConfig.java @@ -14,6 +14,10 @@ import lombok.RequiredArgsConstructor; +/** + * 웹 MVC 관련 설정을 담당하는 Configuration 클래스 + * CORS 설정, Argument Resolver, Interceptor 등록 등을 처리 + */ @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { @@ -21,11 +25,20 @@ public class WebConfig implements WebMvcConfigurer { private final LoginMemberArgumentResolver loginMemberArgumentResolver; private final PerformanceInterceptor performanceInterceptor; + /** + * @Login 어노테이션을 처리하기 위한 ArgumentResolver 등록 + * JWT 토큰에서 UserPrincipal 객체를 추출하여 컨트롤러 메서드에 주입 + */ @Override public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(loginMemberArgumentResolver); } + /** + * API 성능 모니터링을 위한 Interceptor 등록 + * 1초 이상 소요된 API는 WARN 레벨로 로그 기록 + * actuator, swagger-ui 경로는 제외 + */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(performanceInterceptor) @@ -35,6 +48,10 @@ public void addInterceptors(InterceptorRegistry registry) { /** * Spring Security에서 사용할 CORS 설정을 Bean으로 제공 + * 개발 환경(localhost)과 프로덕션 도메인을 허용 + * 쿠키 기반 인증을 위해 credentials를 true로 설정 + * + * @return CORS 설정 소스 */ @Bean public CorsConfigurationSource corsConfigurationSource() { @@ -50,7 +67,7 @@ public CorsConfigurationSource corsConfigurationSource() { )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); + configuration.setAllowCredentials(true); // 쿠키 전송을 위한 설정 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); diff --git a/src/main/java/org/fontory/fontorybe/file/adapter/outbound/persistence/FileJpaRepository.java b/src/main/java/org/fontory/fontorybe/file/adapter/outbound/persistence/FileJpaRepository.java index 877b90a..533ff81 100644 --- a/src/main/java/org/fontory/fontorybe/file/adapter/outbound/persistence/FileJpaRepository.java +++ b/src/main/java/org/fontory/fontorybe/file/adapter/outbound/persistence/FileJpaRepository.java @@ -1,6 +1,22 @@ package org.fontory.fontorybe.file.adapter.outbound.persistence; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface FileJpaRepository extends JpaRepository { + + // 파일 조회 최적화 쿼리 + @Query("SELECT f FROM FileEntity f WHERE f.uploaderId = :uploaderId ORDER BY f.createdAt DESC") + Page findByUploaderIdOrderByCreatedAtDesc(@Param("uploaderId") Long uploaderId, Pageable pageable); + + @Query(value = "SELECT * FROM file WHERE file_type = :fileType ORDER BY created_at DESC LIMIT :limit", nativeQuery = true) + List findRecentByFileType(@Param("fileType") String fileType, @Param("limit") int limit); + + @Query("SELECT COUNT(f) FROM FileEntity f WHERE f.uploaderId = :uploaderId") + long countByUploaderId(@Param("uploaderId") Long uploaderId); } diff --git a/src/main/java/org/fontory/fontorybe/file/application/FileServiceImpl.java b/src/main/java/org/fontory/fontorybe/file/application/FileServiceImpl.java index 36e0a59..aa0f964 100644 --- a/src/main/java/org/fontory/fontorybe/file/application/FileServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/file/application/FileServiceImpl.java @@ -38,8 +38,10 @@ public class FileServiceImpl implements FileService { @Override @Transactional(readOnly = true) public FileMetadata getOrThrowById(Long id) { - return Optional.ofNullable(id) - .flatMap(fileRepository::findById) + if (id == null) { + throw new FileNotFoundException(null); + } + return fileRepository.findById(id) .orElseThrow(() -> new FileNotFoundException(id)); } diff --git a/src/main/java/org/fontory/fontorybe/font/controller/FontController.java b/src/main/java/org/fontory/fontorybe/font/controller/FontController.java index 0987b73..a417a5b 100644 --- a/src/main/java/org/fontory/fontorybe/font/controller/FontController.java +++ b/src/main/java/org/fontory/fontorybe/font/controller/FontController.java @@ -45,8 +45,12 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +/** + * 폰트 관련 REST API 엔드포인트를 제공하는 컨트롤러 + * 폰트 생성, 조회, 수정, 삭제, 다운로드 및 진행 상태 확인 기능을 제공 + */ @Slf4j -@Tag(name = "폰트 관리", description = "폰트 API") +@Tag(name = "폰트 관리", description = "폰트 생성, 조회, 수정, 삭제 API") @RestController @RequestMapping("/fonts") @RequiredArgsConstructor @@ -68,12 +72,24 @@ private String toJson(Object obj) { } } - @Operation(summary = "폰트 생성") + /** + * 새로운 폰트 생성 요청을 처리 + * 사용자가 업로드한 이미지를 S3에 저장하고 AI 폰트 제작을 요청 + * + * @param userPrincipal 인증된 사용자 정보 + * @param fontCreateDTO 폰트 생성 요청 데이터 + * @param files 폰트 템플릿 이미지 파일 + * @return 생성된 폰트 정보와 업로드된 파일 정보 + */ + @Operation( + summary = "폰트 생성", + description = "사용자가 업로드한 이미지를 기반으로 AI가 폰트를 생성합니다." + ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity addFont( @Login UserPrincipal userPrincipal, - @RequestPart("fontCreateDTO") @Valid FontCreateDTO fontCreateDTO, - @SingleFileUpload @RequestPart("file") List files + @RequestPart("fontCreateDTO") @Valid @Parameter(description = "폰트 생성 정보") FontCreateDTO fontCreateDTO, + @SingleFileUpload @RequestPart("file") @Parameter(description = "폰트 생성에 사용할 이미지 파일") List files ) { Long memberId = userPrincipal.getId(); MultipartFile file = extractSingleMultipartFile(files); @@ -83,7 +99,10 @@ public ResponseEntity addFont( logFileDetails(file, "Font template image upload"); + // S3에 폰트 템플릿 이미지 업로드 FileUploadResult fileDetails = fileService.uploadFontTemplateImage(file, memberId); + + // 폰트 생성 및 SQS로 제작 요청 전송 Font createdFont = fontService.create(memberId, fontCreateDTO, fileDetails); log.info("Response sent: Font created with ID: {}, name: {} and Font template image uploaded successfully, url: {}, fileName: {}, size: {} bytes", @@ -104,7 +123,10 @@ private void logFileDetails(MultipartFile file, String context) { file.getContentType()); } - @Operation(summary = "폰트 제작 상황") + @Operation( + summary = "폰트 제작 상황", + description = "현재 제작 중인 폰트의 진행 상황을 조회합니다." + ) @GetMapping("/progress") public ResponseEntity getFontProgress(@Login UserPrincipal userPrincipal) { Long memberId = userPrincipal.getId(); @@ -118,7 +140,10 @@ public ResponseEntity getFontProgress(@Login UserPrincipal userPrincipal) { .body(fontsProgress); } - @Operation(summary = "내가 제작한 폰트") + @Operation( + summary = "내가 제작한 폰트", + description = "로그인한 사용자가 제작한 폰트 목록을 페이지네이션으로 조회합니다." + ) @GetMapping("/members") public ResponseEntity getFonts( @Parameter(description = "페이지 시작 오프셋 (기본값: 0)", example = "0") @RequestParam(defaultValue = "0") int page, @@ -136,11 +161,13 @@ public ResponseEntity getFonts( .body(fonts); } - @Operation(summary = "폰트 상세보기") - @Parameter(name = "fontId", description = "상세 조회 할 폰트 ID") + @Operation( + summary = "폰트 상세보기", + description = "특정 폰트의 상세 정보를 조회합니다." + ) @GetMapping("/{fontId}") public ResponseEntity getFont( - @PathVariable Long fontId, + @PathVariable @Parameter(description = "상세 조회할 폰트 ID") Long fontId, @Login(required = false) UserPrincipal userPrincipal ) { Long memberId = userPrincipal != null ? userPrincipal.getId() : null; @@ -155,10 +182,14 @@ public ResponseEntity getFont( .body(font); } - @Operation(summary = "내가 제작한 폰트 삭제") - @Parameter(name = "fontId", description = "삭제 할 폰트 ID") + @Operation( + summary = "내가 제작한 폰트 삭제", + description = "로그인한 사용자가 제작한 폰트를 삭제합니다." + ) @DeleteMapping("/members/{fontId}") - public ResponseEntity deleteFont(@PathVariable Long fontId, @Login UserPrincipal userPrincipal) { + public ResponseEntity deleteFont( + @PathVariable @Parameter(description = "삭제할 폰트 ID") Long fontId, + @Login UserPrincipal userPrincipal) { Long memberId = userPrincipal.getId(); log.info("Request received: Delete font ID: {} by member ID: {}", fontId, memberId); @@ -170,7 +201,10 @@ public ResponseEntity deleteFont(@PathVariable Long fontId, @Login UserPrinci .body(deletedFont); } - @Operation(summary = "폰트 둘러보기") + @Operation( + summary = "폰트 둘러보기", + description = "모든 폰트를 조회합니다. 정렬, 검색, 페이지네이션을 지원합니다." + ) @GetMapping public ResponseEntity getFontPage( @Parameter(description = "페이지 시작 오프셋 (기본값: 0)", example = "0") @RequestParam(defaultValue = "0") int page, @@ -191,8 +225,10 @@ public ResponseEntity getFontPage( .body(fontPage); } - @Operation(summary = "제작자의 다른 폰트 3개 조회") - @Parameter(name = "fontId", description = "현재 상세보기 한 폰트 ID") + @Operation( + summary = "제작자의 다른 폰트 3개 조회", + description = "같은 제작자가 만든 다른 폰트 3개를 조회합니다." + ) @GetMapping("/{fontId}/others") public ResponseEntity getOtherFontsByWriter(@PathVariable Long fontId) { log.info("Request received: Get other fonts by the creator of font ID: {}", fontId); diff --git a/src/main/java/org/fontory/fontorybe/font/infrastructure/FontJpaRepository.java b/src/main/java/org/fontory/fontorybe/font/infrastructure/FontJpaRepository.java index de66154..2b567b3 100644 --- a/src/main/java/org/fontory/fontorybe/font/infrastructure/FontJpaRepository.java +++ b/src/main/java/org/fontory/fontorybe/font/infrastructure/FontJpaRepository.java @@ -11,6 +11,11 @@ import org.springframework.data.repository.query.Param; public interface FontJpaRepository extends JpaRepository { + + // 회원별 최근 폰트 조회 + @Query("SELECT f FROM FontEntity f WHERE f.memberId = :memberId ORDER BY f.createdAt DESC") + List findTop5ByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId, Pageable pageable); + List findTop5ByMemberIdOrderByCreatedAtDesc(Long memberId); Page findAllByMemberIdAndStatus(Long memberId, PageRequest pageRequest, FontStatus status); Page findByNameContainingAndStatus(String name, PageRequest pageRequest, FontStatus status); @@ -28,5 +33,17 @@ List findTop3ByStatusOrderByDownloadAndBookmarkCountDesc( Pageable pageable ); boolean existsByName(String fontName); + + // 회원별 폰트 이름 중복 검사 + @Query("SELECT EXISTS(SELECT 1 FROM FontEntity f WHERE f.memberId = :memberId AND f.name = :name)") + boolean existsByMemberIdAndName(@Param("memberId") Long memberId, @Param("name") String name); + Page findAllByStatus(PageRequest pageRequest, FontStatus status); + + // 성능 최적화 네이티브 쿼리 + @Query(value = "SELECT COUNT(*) FROM font WHERE status = :status", nativeQuery = true) + long countByStatus(@Param("status") String status); + + @Query(value = "SELECT * FROM font WHERE member_id = :memberId AND status = 'DONE' ORDER BY created_at DESC LIMIT :limit", nativeQuery = true) + List findRecentFontsByMemberId(@Param("memberId") Long memberId, @Param("limit") int limit); } diff --git a/src/main/java/org/fontory/fontorybe/font/infrastructure/FontRepositoryImpl.java b/src/main/java/org/fontory/fontorybe/font/infrastructure/FontRepositoryImpl.java index 3bbfcb0..669de2a 100644 --- a/src/main/java/org/fontory/fontorybe/font/infrastructure/FontRepositoryImpl.java +++ b/src/main/java/org/fontory/fontorybe/font/infrastructure/FontRepositoryImpl.java @@ -14,12 +14,23 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +/** + * Font 도메인과 데이터베이스 간의 매핑을 처리하는 레포지토리 구현체 + * JPA Entity와 도메인 모델 간의 변환을 담당하고 데이터 지속성을 관리 + */ @Repository @RequiredArgsConstructor public class FontRepositoryImpl implements FontRepository { private final FontJpaRepository fontJpaRepository; private final EntityManager em; + /** + * 폰트 엔티티를 데이터베이스에 저장 + * flush와 refresh를 통해 데이터베이스의 최신 상태를 반영 (예: auto_increment ID) + * + * @param font 저장할 Font 도메인 모델 + * @return 저장된 Font 도메인 모델 (ID 포함) + */ @Override public Font save(Font font) { FontEntity savedFont = fontJpaRepository.save(FontEntity.from(font)); @@ -30,6 +41,13 @@ public Font save(Font font) { return savedFont.toModel(); } + /** + * 특정 회원의 최근 생성된 폰트 5개를 조회 + * 주로 폰트 제작 진행 상태를 보여주기 위해 사용 + * + * @param memberId 조회할 회원 ID + * @return 최근 생성된 폰트 목록 (최대 5개) + */ @Override public List findTop5ByMemberIdOrderByCreatedAtDesc(Long memberId) { List fontEntities = fontJpaRepository.findTop5ByMemberIdOrderByCreatedAtDesc(memberId); @@ -39,6 +57,12 @@ public List findTop5ByMemberIdOrderByCreatedAtDesc(Long memberId) { .collect(Collectors.toList()); } + /** + * ID로 폰트를 조회 + * + * @param id 조회할 폰트 ID + * @return 폰트 Optional 객체 + */ @Override public Optional findById(Long id) { return fontJpaRepository.findById(id).map(FontEntity::toModel); @@ -112,4 +136,9 @@ public List findTop3ByStatusOrderByDownloadAndBookmarkCountDesc(FontStatus public boolean existsByName(String fontName) { return fontJpaRepository.existsByName(fontName); } + + @Override + public boolean existsByMemberIdAndName(Long memberId, String fontName) { + return fontJpaRepository.existsByMemberIdAndName(memberId, fontName); + } } diff --git a/src/main/java/org/fontory/fontorybe/font/service/FontServiceImpl.java b/src/main/java/org/fontory/fontorybe/font/service/FontServiceImpl.java index 60c77e6..6acd81e 100644 --- a/src/main/java/org/fontory/fontorybe/font/service/FontServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/font/service/FontServiceImpl.java @@ -1,6 +1,5 @@ package org.fontory.fontorybe.font.service; -import com.vane.badwordfiltering.BadWordFiltering; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -43,6 +42,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +/** + * 폰트 관련 비즈니스 로직을 처리하는 핵심 서비스 구현체 + * 폰트 생성, 조회, 수정, 삭제 및 다운로드 기능을 제공 + * AWS SQS를 통한 폰트 제작 요청 및 SMS 알림 기능 포함 + */ @Slf4j @Service @RequiredArgsConstructor @@ -53,27 +57,40 @@ public class FontServiceImpl implements FontService { private final MemberLookupService memberLookupService; private final FontRequestProducer fontRequestProducer; private final CloudStorageService cloudStorageService; - private final BadWordFiltering badWordFiltering; + private final FontValidationService fontValidationService; private final ApplicationEventPublisher eventPublisher; + /** + * 새로운 폰트를 생성하고 제작 요청을 SQS로 전송 + * + * @param memberId 폰트를 생성하는 회원 ID + * @param fontCreateDTO 폰트 생성 요청 데이터 + * @param fileDetails 업로드된 폰트 템플릿 파일 정보 + * @return 생성된 Font 엔티티 + * @throws FontDuplicateNameExistsException 중복된 폰트 이름인 경우 + * @throws FontContainsBadWordException 금지 단어가 포함된 경우 + */ @Override @Transactional public Font create(Long memberId, FontCreateDTO fontCreateDTO, FileUploadResult fileDetails) { log.info("Service executing: Creating font for member ID: {}, font name: {}", memberId, fontCreateDTO.getName()); Member member = memberLookupService.getOrThrowById(memberId); - if (isDuplicateNameExists(memberId, fontCreateDTO.getName())) { - throw new FontDuplicateNameExistsException(); - } - - checkFontNameAndExampleContainsBadWord(fontCreateDTO.getName(), fontCreateDTO.getEngName(), fontCreateDTO.getExample()); + // 폰트 이름 중복 및 금지 단어 검증 + fontValidationService.validateFontNameNotDuplicated(memberId, fontCreateDTO.getName()); + fontValidationService.validateFontTextsNoBadWords(fontCreateDTO.getName(), fontCreateDTO.getEngName(), fontCreateDTO.getExample()); + // 파일 메타데이터 조회 FileMetadata fileMetadata = fileService.getOrThrowById(fileDetails.getId()); + // 폰트 엔티티 생성 및 저장 Font savedFont = fontRepository.save(Font.from(fontCreateDTO, member.getId(), fileMetadata.getKey())); + + // SQS로 폰트 제작 요청 전송 String fontPaperUrl = cloudStorageService.getFontPaperUrl(savedFont.getKey()); fontRequestProducer.sendFontRequest(FontRequestProduceDto.from(savedFont, member, fontPaperUrl)); + // SMS 알림 요청 처리 (선택적) if (fontCreateDTO.getPhoneNumber() != null && !fontCreateDTO.getPhoneNumber().isBlank()) { String notificationPhoneNumber = fontCreateDTO.getPhoneNumber(); eventPublisher.publishEvent(new FontCreateRequestNotificationEvent(savedFont, notificationPhoneNumber)); @@ -83,6 +100,13 @@ public Font create(Long memberId, FontCreateDTO fontCreateDTO, FileUploadResult return savedFont; } + /** + * 회원의 폰트 제작 진행 상태를 조회 + * 최근 생성된 5개의 폰트에 대한 진행 상태를 반환 + * + * @param memberId 조회할 회원 ID + * @return 폰트 진행 상태 목록 + */ @Override @Transactional(readOnly = true) public List getFontProgress(Long memberId) { @@ -98,6 +122,13 @@ public List getFontProgress(Long memberId) { return result; } + /** + * ID로 폰트를 조회하고, 없으면 예외 발생 + * + * @param id 조회할 폰트 ID + * @return 폰트 엔티티 + * @throws FontNotFoundException 폰트가 존재하지 않는 경우 + */ @Override @Transactional(readOnly = true) public Font getOrThrowById(Long id) { @@ -331,7 +362,7 @@ public FontDownloadResponse fontDownload(Long memberId, Long fontId) { @Override @Transactional(readOnly = true) public Boolean isDuplicateNameExists(Long memberId, String fontName) { - return fontRepository.existsByName(fontName); + return fontValidationService.isDuplicateNameExists(memberId, fontName); } private void checkFontOwnership(Long requestMemberId, Long targetMemberId) { @@ -354,12 +385,4 @@ private void checkFontStatusIsDone(Font targetFont) { } } - private void checkFontNameAndExampleContainsBadWord(String name, String engName, String example) { - log.debug("Service detail: Checking bad word: name={}, engName={} example={}", name, engName, example); - - if (badWordFiltering.blankCheck(name) || badWordFiltering.blankCheck(engName) || badWordFiltering.blankCheck(example)) { - log.warn("Service warning: Font contains bad word: name={}, engName={}, example={}", name, engName, example); - throw new FontContainsBadWordException(); - } - } } diff --git a/src/main/java/org/fontory/fontorybe/font/service/FontValidationService.java b/src/main/java/org/fontory/fontorybe/font/service/FontValidationService.java new file mode 100644 index 0000000..47c32e1 --- /dev/null +++ b/src/main/java/org/fontory/fontorybe/font/service/FontValidationService.java @@ -0,0 +1,64 @@ +package org.fontory.fontorybe.font.service; + +import com.vane.badwordfiltering.BadWordFiltering; +import lombok.RequiredArgsConstructor; +import org.fontory.fontorybe.font.domain.exception.FontContainsBadWordException; +import org.fontory.fontorybe.font.domain.exception.FontDuplicateNameExistsException; +import org.fontory.fontorybe.font.service.port.FontRepository; +import org.springframework.stereotype.Service; + +/** + * 폰트 관련 검증 로직을 중앙화한 서비스 + */ +@Service +@RequiredArgsConstructor +public class FontValidationService { + private final BadWordFiltering badWordFiltering; + private final FontRepository fontRepository; + + /** + * 폰트 이름 중복 여부 검사 + * @param memberId 회원 ID + * @param fontName 검사할 폰트 이름 + * @return 중복 여부 + */ + public boolean isDuplicateNameExists(Long memberId, String fontName) { + return fontRepository.existsByMemberIdAndName(memberId, fontName); + } + + /** + * 폰트 이름 중복 검증 (예외 발생) + * @param memberId 회원 ID + * @param fontName 검사할 폰트 이름 + * @throws FontDuplicateNameExistsException 중복된 이름인 경우 + */ + public void validateFontNameNotDuplicated(Long memberId, String fontName) { + if (isDuplicateNameExists(memberId, fontName)) { + throw new FontDuplicateNameExistsException(); + } + } + + /** + * 텍스트에 금지 단어가 포함되어 있는지 검사 + * @param text 검사할 텍스트 + * @throws FontContainsBadWordException 금지 단어가 포함된 경우 + */ + public void validateNoBadWords(String text) { + if (text != null && badWordFiltering.blankCheck(text)) { + throw new FontContainsBadWordException(); + } + } + + /** + * 폰트 관련 텍스트들에 금지 단어가 포함되어 있는지 일괄 검사 + * @param fontName 폰트 이름 + * @param engName 영문 이름 + * @param example 예시 텍스트 + * @throws FontContainsBadWordException 금지 단어가 포함된 경우 + */ + public void validateFontTextsNoBadWords(String fontName, String engName, String example) { + validateNoBadWords(fontName); + validateNoBadWords(engName); + validateNoBadWords(example); + } +} \ No newline at end of file diff --git a/src/main/java/org/fontory/fontorybe/font/service/port/FontRepository.java b/src/main/java/org/fontory/fontorybe/font/service/port/FontRepository.java index cb2337f..3ff1eba 100644 --- a/src/main/java/org/fontory/fontorybe/font/service/port/FontRepository.java +++ b/src/main/java/org/fontory/fontorybe/font/service/port/FontRepository.java @@ -20,4 +20,5 @@ public interface FontRepository { List findTop4ByMemberIdAndStatusOrderByDownloadAndBookmarkCountDesc(Long memberId, FontStatus status); List findTop3ByStatusOrderByDownloadAndBookmarkCountDesc(FontStatus status); boolean existsByName(String fontName); + boolean existsByMemberIdAndName(Long memberId, String fontName); } diff --git a/src/main/java/org/fontory/fontorybe/member/controller/AuthController.java b/src/main/java/org/fontory/fontorybe/member/controller/AuthController.java index add81c3..59e151f 100644 --- a/src/main/java/org/fontory/fontorybe/member/controller/AuthController.java +++ b/src/main/java/org/fontory/fontorybe/member/controller/AuthController.java @@ -20,7 +20,8 @@ public class AuthController { private final AuthService authService; @Operation( - summary = "로그아웃" + summary = "로그아웃", + description = "JWT 쿠키를 삭제하여 사용자를 로그아웃 처리합니다." ) @PostMapping("/logout") public ResponseEntity logout( diff --git a/src/main/java/org/fontory/fontorybe/member/controller/MemberController.java b/src/main/java/org/fontory/fontorybe/member/controller/MemberController.java index daf2fc2..e6899e6 100644 --- a/src/main/java/org/fontory/fontorybe/member/controller/MemberController.java +++ b/src/main/java/org/fontory/fontorybe/member/controller/MemberController.java @@ -11,12 +11,16 @@ import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - - +/** + * 회원 정보 조회를 위한 REST API 컨트롤러 + * 다른 회원의 공개 프로필 정보를 조회하는 기능 제공 + */ @Slf4j @Tag(name = "사용자 - 정보조회", description = "다른 회원 정보 조회") @Builder @@ -27,10 +31,21 @@ public class MemberController { private final CloudStorageService cloudStorageService; private final MemberLookupService memberLookupService; + /** + * 특정 회원의 공개 프로필 정보를 조회 + * + * @param me 현재 로그인한 사용자 정보 + * @param id 조회할 대상 회원의 ID + * @return 회원의 프로필 정보 + */ + @Operation( + summary = "회원 정보 조회", + description = "회원 ID를 통해 특정 회원의 공개 프로필 정보를 조회합니다." + ) @GetMapping("/{id}") public ResponseEntity getInfoMember( @Login UserPrincipal me, - @PathVariable Long id + @PathVariable @Parameter(description = "조회할 회원 ID") Long id ) { Long requestMemberId = me.getId(); log.info("Request received: Get member info ID: {} by member ID: {}", id, requestMemberId); diff --git a/src/main/java/org/fontory/fontorybe/member/controller/ProfileController.java b/src/main/java/org/fontory/fontorybe/member/controller/ProfileController.java index 4d55230..5e8e225 100644 --- a/src/main/java/org/fontory/fontorybe/member/controller/ProfileController.java +++ b/src/main/java/org/fontory/fontorybe/member/controller/ProfileController.java @@ -1,6 +1,7 @@ package org.fontory.fontorybe.member.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -46,8 +47,8 @@ public class ProfileController { private final MemberLookupService memberLookupService; @Operation( - summary = "내정보", - description = "쿠키를 바탕으로 내정보를 조회" + summary = "내 프로필 조회", + description = "JWT 토큰을 기반으로 현재 로그인한 사용자의 프로필 정보를 조회합니다." ) @GetMapping public ResponseEntity getMyProfile( @@ -75,12 +76,13 @@ public ResponseEntity getMyProfile( @Operation( - summary = "내정보 수정" + summary = "내 프로필 수정", + description = "닉네임, 소개 메시지 등 프로필 정보를 수정합니다." ) @PatchMapping public ResponseEntity updateMember( @Login UserPrincipal userPrincipal, - @RequestBody @Valid MemberUpdateRequest req + @RequestBody @Valid @Parameter(description = "수정할 회원 정보") MemberUpdateRequest req ) { Long requestMemberId = userPrincipal.getId(); log.info("Request received: update member ID: {} with request: {}", @@ -98,11 +100,12 @@ public ResponseEntity updateMember( } @Operation( - summary = "회원탈퇴" + summary = "회원 탈퇴", + description = "회원 상태를 DISABLED로 변경하고 인증 쿠키를 삭제합니다." ) @DeleteMapping public ResponseEntity disableMember( - HttpServletResponse res, + @Parameter(hidden = true) HttpServletResponse res, @Login UserPrincipal me ) { Long requestMemberId = me.getId(); diff --git a/src/main/java/org/fontory/fontorybe/member/controller/RegistrationController.java b/src/main/java/org/fontory/fontorybe/member/controller/RegistrationController.java index a6a2ac4..d65793d 100644 --- a/src/main/java/org/fontory/fontorybe/member/controller/RegistrationController.java +++ b/src/main/java/org/fontory/fontorybe/member/controller/RegistrationController.java @@ -1,6 +1,7 @@ package org.fontory.fontorybe.member.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.Builder; @@ -42,11 +43,11 @@ public class RegistrationController { @Operation( summary = "닉네임 중복 확인", - description = "주어진 닉네임으로 생성되어있는 사용자가 있는지 검색 후 결과를 반환합니다." + description = "주어진 닉네임이 이미 사용 중인지 확인합니다. true면 중복, false면 사용 가능합니다." ) @GetMapping("/check-duplicate") public ResponseEntity checkDuplicate( - @RequestParam String nickname) { + @RequestParam @Parameter(description = "중복 확인할 닉네임") String nickname) { log.info("Request received: Check if nickname is duplicate: {}", nickname); boolean duplicateNameExists = memberLookupService.existsByNickname(nickname); @@ -57,12 +58,13 @@ public ResponseEntity checkDuplicate( } @Operation( - summary = "회원가입" + summary = "회원가입 완료", + description = "온보딩 상태의 회원을 새로운 회원으로 등록하고 초기 정보를 설정합니다." ) @PostMapping public ResponseEntity register( @Login UserPrincipal user, - @RequestBody @Valid InitMemberInfoRequest req + @RequestBody @Valid @Parameter(description = "초기 회원 정보") InitMemberInfoRequest req ) { Long requestMemberId = user.getId(); log.info("Request received: Create member ID: {} with request: {}", diff --git a/src/main/java/org/fontory/fontorybe/member/infrastructure/MemberJpaRepository.java b/src/main/java/org/fontory/fontorybe/member/infrastructure/MemberJpaRepository.java index 7e06734..89cd8de 100644 --- a/src/main/java/org/fontory/fontorybe/member/infrastructure/MemberJpaRepository.java +++ b/src/main/java/org/fontory/fontorybe/member/infrastructure/MemberJpaRepository.java @@ -1,8 +1,24 @@ package org.fontory.fontorybe.member.infrastructure; import org.fontory.fontorybe.member.infrastructure.entity.MemberEntity; +import org.fontory.fontorybe.member.infrastructure.entity.MemberStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; public interface MemberJpaRepository extends JpaRepository { boolean existsByNickname(String nickname); + + // 성능 최적화를 위한 추가 쿼리 메서드 + @Query("SELECT m FROM MemberEntity m WHERE m.id = :id AND m.status != :status") + Optional findByIdAndStatusNot(@Param("id") Long id, @Param("status") MemberStatus status); + + @Query("SELECT m FROM MemberEntity m WHERE m.status = :status ORDER BY m.createdAt DESC") + List findAllByStatusOrderByCreatedAtDesc(@Param("status") MemberStatus status); + + @Query(value = "SELECT COUNT(*) FROM member WHERE status = :status", nativeQuery = true) + long countByStatus(@Param("status") String status); } diff --git a/src/main/java/org/fontory/fontorybe/member/service/MemberCreationServiceImpl.java b/src/main/java/org/fontory/fontorybe/member/service/MemberCreationServiceImpl.java index 918aedd..fe0f0bc 100644 --- a/src/main/java/org/fontory/fontorybe/member/service/MemberCreationServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/member/service/MemberCreationServiceImpl.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.member.controller.port.MemberCreationService; import org.fontory.fontorybe.member.domain.Member; import org.fontory.fontorybe.member.domain.MemberDefaults; @@ -14,6 +15,11 @@ import java.util.UUID; +/** + * 회원 생성 관련 비즈니스 로직을 처리하는 서비스 구현체 + * OAuth2 인증 후 기본 회원 정보를 생성하는 역할을 담당 + */ +@Slf4j @Builder @Service @RequiredArgsConstructor @@ -22,13 +28,39 @@ public class MemberCreationServiceImpl implements MemberCreationService { private final ProvideService provideService; private final MemberDefaults memberDefaults; + /** + * OAuth2 인증 정보를 기반으로 기본 회원을 생성 + * UUID 기반의 임시 닉네임을 생성하고, 기본 프로필 이미지와 소개글을 설정 + * + * @param p OAuth2 인증 정보를 담고 있는 Provide 엔티티 + * @return 생성된 기본 회원 정보 + * @throws MemberAlreadyExistException 이미 회원이 존재하는 경우 + */ @Override @Transactional public Member createDefaultMember(Provide p) { - if (p.getMemberId() != null) { throw new MemberAlreadyExistException(); } + log.info("Creating default member for OAuth2 provider: provideId={}, provider={}, email={}", + p.getId(), p.getProvider(), p.getEmail()); + + // 이미 회원이 연결되어 있는 경우 예외 처리 + if (p.getMemberId() != null) { + log.warn("Member already exists for provide: provideId={}, existingMemberId={}", + p.getId(), p.getMemberId()); + throw new MemberAlreadyExistException(); + } + + // UUID를 사용한 임시 닉네임 생성 String newNickname = UUID.randomUUID().toString(); + log.debug("Generated temporary nickname for new member: nickname={}", newNickname); + + // 기본값을 사용하여 회원 생성 및 저장 Member defaultMember = memberRepository.save(Member.fromDefaults(memberDefaults, newNickname, p)); + log.info("Default member created and saved: memberId={}, provideId={}", + defaultMember.getId(), p.getId()); + + // Provide와 Member 연결 provideService.setMember(p, defaultMember); + log.debug("Provide linked to member: provideId={}, memberId={}", p.getId(), defaultMember.getId()); return defaultMember; } diff --git a/src/main/java/org/fontory/fontorybe/member/service/MemberLookupServiceImpl.java b/src/main/java/org/fontory/fontorybe/member/service/MemberLookupServiceImpl.java index ca09a50..fedd027 100644 --- a/src/main/java/org/fontory/fontorybe/member/service/MemberLookupServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/member/service/MemberLookupServiceImpl.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.member.controller.port.MemberLookupService; import org.fontory.fontorybe.member.domain.Member; import org.fontory.fontorybe.member.domain.exception.MemberNotFoundException; @@ -11,6 +12,11 @@ import java.util.Optional; +/** + * 회원 조회 관련 비즈니스 로직을 처리하는 서비스 구현체 + * 회원 정보 조회 및 존재 여부 확인 기능을 제공 + */ +@Slf4j @Builder @Service @RequiredArgsConstructor @@ -18,17 +24,40 @@ public class MemberLookupServiceImpl implements MemberLookupService { private final MemberRepository memberRepository; + /** + * ID로 회원을 조회하고, 존재하지 않으면 예외 발생 + * + * @param id 조회할 회원 ID + * @return 조회된 회원 정보 + * @throws MemberNotFoundException 회원이 존재하지 않거나 ID가 null인 경우 + */ @Override @Transactional(readOnly = true) public Member getOrThrowById(Long id) { - return Optional.ofNullable(id) - .flatMap(memberRepository::findById) - .orElseThrow(MemberNotFoundException::new); + log.debug("Looking up member by ID: memberId={}", id); + if (id == null) { + log.warn("Member lookup failed: memberId is null"); + throw new MemberNotFoundException(); + } + return memberRepository.findById(id) + .orElseThrow(() -> { + log.warn("Member not found: memberId={}", id); + return new MemberNotFoundException(); + }); } + /** + * 특정 닉네임이 이미 사용 중인지 확인 + * + * @param targetName 확인할 닉네임 + * @return 사용 중이면 true, 사용 가능하면 false + */ @Override @Transactional(readOnly = true) public boolean existsByNickname(String targetName) { - return memberRepository.existsByNickname(targetName); + log.debug("Checking nickname existence: nickname={}", targetName); + boolean exists = memberRepository.existsByNickname(targetName); + log.debug("Nickname existence check result: nickname={}, exists={}", targetName, exists); + return exists; } } diff --git a/src/main/java/org/fontory/fontorybe/member/service/MemberOnboardServiceImpl.java b/src/main/java/org/fontory/fontorybe/member/service/MemberOnboardServiceImpl.java index b189b7c..1d27d20 100644 --- a/src/main/java/org/fontory/fontorybe/member/service/MemberOnboardServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/member/service/MemberOnboardServiceImpl.java @@ -1,8 +1,8 @@ package org.fontory.fontorybe.member.service; -import com.vane.badwordfiltering.BadWordFiltering; import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.file.application.port.FileService; import org.fontory.fontorybe.file.domain.FileMetadata; import org.fontory.fontorybe.file.domain.FileUploadResult; @@ -12,14 +12,17 @@ import org.fontory.fontorybe.member.controller.port.MemberOnboardService; import org.fontory.fontorybe.member.domain.Member; import org.fontory.fontorybe.member.domain.exception.MemberAlreadyJoinedException; -import org.fontory.fontorybe.member.domain.exception.MemberContainsBadWordException; -import org.fontory.fontorybe.member.domain.exception.MemberDuplicateNameExistsException; import org.fontory.fontorybe.member.infrastructure.entity.MemberStatus; import org.fontory.fontorybe.member.service.port.MemberRepository; import org.fontory.fontorybe.provide.domain.Provide; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 회원 온보딩 관련 비즈니스 로직을 처리하는 서비스 구현체 + * OAuth2 로그인 후 신규 회원의 초기 정보 설정 및 기존 회원 조회를 담당 + */ +@Slf4j @Builder @Service @RequiredArgsConstructor @@ -28,37 +31,70 @@ public class MemberOnboardServiceImpl implements MemberOnboardService { private final MemberLookupService memberLookupService; private final MemberCreationService memberCreationService; private final FileService fileService; - private final BadWordFiltering badWordFiltering; + private final MemberValidationService memberValidationService; + /** + * OAuth2 로그인 시 기존 회원을 조회하거나 신규 회원을 생성 + * Provide 엔티티의 memberId 존재 여부로 신규/기존 회원을 구분 + * + * @param p OAuth2 인증 정보를 담은 Provide 엔티티 + * @return 조회되거나 생성된 회원 정보 + */ @Override @Transactional public Member fetchOrCreateMember(Provide p) { + log.info("Fetching or creating member for OAuth2 provide: provideId={}, memberId={}", + p.getId(), p.getMemberId()); + if (p.getMemberId()==null) { - return memberCreationService.createDefaultMember(p); + // 신규 회원: 기본 정보로 회원 생성 + log.info("No existing member found, creating new member: provideId={}", p.getId()); + Member newMember = memberCreationService.createDefaultMember(p); + log.info("New member created successfully: memberId={}, provideId={}", + newMember.getId(), p.getId()); + return newMember; } else { + // 기존 회원: ID로 조회 + log.info("Existing member found, fetching member: memberId={}, provideId={}", + p.getMemberId(), p.getId()); return memberLookupService.getOrThrowById(p.getMemberId()); } } + /** + * 신규 회원의 초기 정보를 설정 (온보딩 프로세스) + * 닉네임, 성별, 출생년도 등 추가 정보를 입력받아 회원 상태를 활성화 + * + * @param requestMemberId 정보를 초기화할 회원 ID + * @param initNewMemberInfoRequest 초기화할 회원 정보 + * @return 초기화된 회원 정보 + * @throws MemberAlreadyJoinedException 이미 활성화된 회원인 경우 + * @throws MemberContainsBadWordException 금지 단어가 포함된 경우 + * @throws MemberDuplicateNameExistsException 중복된 닉네임인 경우 + */ @Override @Transactional public Member initNewMemberInfo(Long requestMemberId, InitMemberInfoRequest initNewMemberInfoRequest) { + log.info("Initializing new member info: memberId={}, nickname={}", + requestMemberId, initNewMemberInfoRequest.getNickname()); + Member targetMember = memberLookupService.getOrThrowById(requestMemberId); + if (targetMember.getStatus() == MemberStatus.ACTIVATE) { + log.warn("Member already activated, cannot reinitialize: memberId={}, status={}", + requestMemberId, targetMember.getStatus()); throw new MemberAlreadyJoinedException(); - } else if (memberLookupService.existsByNickname(initNewMemberInfoRequest.getNickname())) { - throw new MemberDuplicateNameExistsException(); } - checkContainsBadWord(initNewMemberInfoRequest.getNickname()); + // 닉네임 유효성 검사 (금지 단어 + 중복) + log.debug("Validating nickname for member: memberId={}, nickname={}", + requestMemberId, initNewMemberInfoRequest.getNickname()); + memberValidationService.validateNickname(initNewMemberInfoRequest.getNickname()); - return memberRepository.save(targetMember.initNewMemberInfo(initNewMemberInfoRequest)); - } - - private void checkContainsBadWord(String nickname) { - if (badWordFiltering.blankCheck(nickname)) { - throw new MemberContainsBadWordException(); - } + Member updated = memberRepository.save(targetMember.initNewMemberInfo(initNewMemberInfoRequest)); + log.info("Member info initialized successfully: memberId={}, nickname={}, status={}", + updated.getId(), updated.getNickname(), updated.getStatus()); + return updated; } } diff --git a/src/main/java/org/fontory/fontorybe/member/service/MemberUpdateServiceImpl.java b/src/main/java/org/fontory/fontorybe/member/service/MemberUpdateServiceImpl.java index 21afb92..d13befd 100644 --- a/src/main/java/org/fontory/fontorybe/member/service/MemberUpdateServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/member/service/MemberUpdateServiceImpl.java @@ -1,22 +1,25 @@ package org.fontory.fontorybe.member.service; -import com.vane.badwordfiltering.BadWordFiltering; import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.authentication.application.port.JwtTokenProvider; import org.fontory.fontorybe.member.controller.port.MemberLookupService; import org.fontory.fontorybe.member.controller.port.MemberUpdateService; import org.fontory.fontorybe.member.domain.Member; import org.fontory.fontorybe.member.controller.dto.MemberUpdateRequest; import org.fontory.fontorybe.member.domain.exception.MemberAlreadyDisabledException; -import org.fontory.fontorybe.member.domain.exception.MemberContainsBadWordException; -import org.fontory.fontorybe.member.domain.exception.MemberDuplicateNameExistsException; import org.fontory.fontorybe.member.service.port.MemberRepository; import org.fontory.fontorybe.provide.controller.port.ProvideService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 회원 정보 수정 및 비활성화 관련 비즈니스 로직을 처리하는 서비스 구현체 + * 회원 프로필 업데이트 및 탈퇴 처리 기능을 제공 + */ +@Slf4j @Service @Builder @RequiredArgsConstructor @@ -25,39 +28,64 @@ public class MemberUpdateServiceImpl implements MemberUpdateService { private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; private final ProvideService provideService; - private final BadWordFiltering badWordFiltering; + private final MemberValidationService memberValidationService; + /** + * 회원 프로필 정보를 업데이트 + * 닉네임 변경 시 금지 단어 및 중복 여부를 검사 + * + * @param requestMemberId 수정할 회원의 ID + * @param memberUpdateRequest 수정할 회원 정보 + * @return 업데이트된 회원 정보 + * @throws MemberContainsBadWordException 금지 단어가 포함된 경우 + * @throws MemberDuplicateNameExistsException 중복된 닉네임인 경우 + */ @Override @Transactional public Member update(Long requestMemberId, MemberUpdateRequest memberUpdateRequest) { - Member targetMember = memberLookupService.getOrThrowById(requestMemberId); + log.info("Updating member info: memberId={}, newNickname={}", + requestMemberId, memberUpdateRequest.getNickname()); - if (!targetMember.getNickname().equals(memberUpdateRequest.getNickname()) && - memberLookupService.existsByNickname(memberUpdateRequest.getNickname())) { - throw new MemberDuplicateNameExistsException(); - } + Member targetMember = memberLookupService.getOrThrowById(requestMemberId); - checkContainsBadWord(memberUpdateRequest.getNickname()); + // 닉네임 변경 시 유효성 검사 + String newNickname = memberUpdateRequest.getNickname(); + log.debug("Validating nickname update: memberId={}, oldNickname={}, newNickname={}", + requestMemberId, targetMember.getNickname(), newNickname); + memberValidationService.validateNoBadWords(newNickname); + memberValidationService.validateNicknameChangeNotDuplicated(targetMember.getNickname(), newNickname); - return memberRepository.save(targetMember.update(memberUpdateRequest)); + Member updated = memberRepository.save(targetMember.update(memberUpdateRequest)); + log.info("Member info updated successfully: memberId={}, nickname={}", + updated.getId(), updated.getNickname()); + return updated; } + /** + * 회원 계정을 비활성화 (소프트 삭제) + * 이미 비활성화된 회원인 경우 예외 발생 + * + * @param requestMemberId 비활성화할 회원의 ID + * @return 비활성화된 회원 정보 + * @throws MemberAlreadyDisabledException 이미 비활성화된 회원인 경우 + */ @Override @Transactional public Member disable(Long requestMemberId) { + log.info("Disabling member account: memberId={}", requestMemberId); + Member targetMember = memberLookupService.getOrThrowById(requestMemberId); if (targetMember.getDeletedAt() != null) { + log.warn("Member already disabled: memberId={}, deletedAt={}", + requestMemberId, targetMember.getDeletedAt()); throw new MemberAlreadyDisabledException(); } - targetMember.disable(); - return memberRepository.save(targetMember); - } - - private void checkContainsBadWord(String nickname) { - if (badWordFiltering.blankCheck(nickname)) { - throw new MemberContainsBadWordException(); - } + targetMember.disable(); + Member disabled = memberRepository.save(targetMember); + log.info("Member account disabled successfully: memberId={}, deletedAt={}", + disabled.getId(), disabled.getDeletedAt()); + return disabled; } } diff --git a/src/main/java/org/fontory/fontorybe/member/service/MemberValidationService.java b/src/main/java/org/fontory/fontorybe/member/service/MemberValidationService.java new file mode 100644 index 0000000..09e1113 --- /dev/null +++ b/src/main/java/org/fontory/fontorybe/member/service/MemberValidationService.java @@ -0,0 +1,61 @@ +package org.fontory.fontorybe.member.service; + +import com.vane.badwordfiltering.BadWordFiltering; +import lombok.RequiredArgsConstructor; +import org.fontory.fontorybe.member.controller.port.MemberLookupService; +import org.fontory.fontorybe.member.domain.exception.MemberContainsBadWordException; +import org.fontory.fontorybe.member.domain.exception.MemberDuplicateNameExistsException; +import org.springframework.stereotype.Service; + +/** + * 회원 관련 검증 로직을 중앙화한 서비스 + */ +@Service +@RequiredArgsConstructor +public class MemberValidationService { + private final BadWordFiltering badWordFiltering; + private final MemberLookupService memberLookupService; + + /** + * 닉네임에 금지 단어가 포함되어 있는지 검사 + * @param nickname 검사할 닉네임 + * @throws MemberContainsBadWordException 금지 단어가 포함된 경우 + */ + public void validateNoBadWords(String nickname) { + if (badWordFiltering.blankCheck(nickname)) { + throw new MemberContainsBadWordException(); + } + } + + /** + * 닉네임 중복 여부를 검사 + * @param nickname 검사할 닉네임 + * @throws MemberDuplicateNameExistsException 중복된 닉네임인 경우 + */ + public void validateNicknameNotDuplicated(String nickname) { + if (memberLookupService.existsByNickname(nickname)) { + throw new MemberDuplicateNameExistsException(); + } + } + + /** + * 닉네임 변경 시 중복 검사 (현재 닉네임과 다른 경우에만) + * @param currentNickname 현재 닉네임 + * @param newNickname 변경할 닉네임 + * @throws MemberDuplicateNameExistsException 중복된 닉네임인 경우 + */ + public void validateNicknameChangeNotDuplicated(String currentNickname, String newNickname) { + if (!currentNickname.equals(newNickname) && memberLookupService.existsByNickname(newNickname)) { + throw new MemberDuplicateNameExistsException(); + } + } + + /** + * 닉네임 전체 유효성 검사 (금지 단어 + 중복) + * @param nickname 검사할 닉네임 + */ + public void validateNickname(String nickname) { + validateNoBadWords(nickname); + validateNicknameNotDuplicated(nickname); + } +} \ No newline at end of file diff --git a/src/main/java/org/fontory/fontorybe/provide/infrastructure/ProvideJpaRepository.java b/src/main/java/org/fontory/fontorybe/provide/infrastructure/ProvideJpaRepository.java index 60e7afd..3b338c8 100644 --- a/src/main/java/org/fontory/fontorybe/provide/infrastructure/ProvideJpaRepository.java +++ b/src/main/java/org/fontory/fontorybe/provide/infrastructure/ProvideJpaRepository.java @@ -3,9 +3,19 @@ import org.fontory.fontorybe.provide.infrastructure.entity.ProvideEntity; import org.fontory.fontorybe.provide.infrastructure.entity.Provider; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ProvideJpaRepository extends JpaRepository { Optional findByProvidedIdAndProvider(String providedId, Provider provider); + + // 성능 최적화를 위한 추가 쿼리 + @Query("SELECT p FROM ProvideEntity p WHERE p.memberId = :memberId ORDER BY p.createdAt DESC") + List findByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId); + + @Query(value = "SELECT * FROM provide WHERE member_id = :memberId ORDER BY created_at DESC LIMIT 1", nativeQuery = true) + Optional findLatestByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/org/fontory/fontorybe/provide/service/ProvideServiceImpl.java b/src/main/java/org/fontory/fontorybe/provide/service/ProvideServiceImpl.java index 7d14d77..df91992 100644 --- a/src/main/java/org/fontory/fontorybe/provide/service/ProvideServiceImpl.java +++ b/src/main/java/org/fontory/fontorybe/provide/service/ProvideServiceImpl.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fontory.fontorybe.member.domain.Member; import org.fontory.fontorybe.provide.controller.port.ProvideService; import org.fontory.fontorybe.provide.domain.Provide; @@ -12,36 +13,85 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * OAuth2 제공자 정보 관리 서비스 구현체 + * 외부 OAuth2 제공자(Google 등)로부터 받은 사용자 정보를 관리 + */ +@Slf4j @Service @Builder @RequiredArgsConstructor public class ProvideServiceImpl implements ProvideService { private final ProvideRepository provideRepository; + /** + * ID로 Provide 엔티티를 조회 + * + * @param id 조회할 Provide ID + * @return 조회된 Provide 엔티티 + * @throws ProvideNotFoundException 해당 ID의 Provide가 없는 경우 + */ @Override public Provide getOrThrownById(Long id) { + log.debug("Looking up provide by ID: provideId={}", id); return provideRepository.findById(id) - .orElseThrow(ProvideNotFoundException::new); + .orElseThrow(() -> { + log.warn("Provide not found: provideId={}", id); + return new ProvideNotFoundException(); + }); } + /** + * Provide DTO를 바탕으로 새로운 Provide 엔티티 생성 + * + * @param provideCreateDto 생성할 Provide 정보 + * @return 생성된 Provide 엔티티 + */ @Override @Transactional public Provide create(ProvideCreateDto provideCreateDto) { + log.info("Creating provide from DTO: provider={}, email={}", + provideCreateDto.getProvider(), provideCreateDto.getEmail()); Provide provide = Provide.from(provideCreateDto); - return provideRepository.save(provide); + Provide saved = provideRepository.save(provide); + log.info("Provide created successfully: provideId={}", saved.getId()); + return saved; } + /** + * OAuth2 제공자 정보를 바탕으로 새로운 Provide 엔티티 생성 + * + * @param provider OAuth2 제공자 타입 (Google, Facebook 등) + * @param email 사용자 이메일 + * @param providedId OAuth2 제공자가 발급한 고유 사용자 ID + * @return 생성된 Provide 엔티티 + */ @Override @Transactional public Provide create(Provider provider, String email, String providedId) { + log.info("Creating provide: provider={}, email={}, providedId={}", + provider, email, providedId); Provide provide = Provide.from(provider, providedId, email); - return provideRepository.save(provide); + Provide saved = provideRepository.save(provide); + log.info("Provide created successfully: provideId={}", saved.getId()); + return saved; } + /** + * Provide와 Member를 연결 + * OAuth2 로그인 후 회원 가입 시 사용 + * + * @param provide 연결할 Provide 엔티티 + * @param member 연결할 회원 엔티티 + * @return 업데이트된 Provide 엔티티 + */ @Override @Transactional public Provide setMember(Provide provide, Member member) { + log.info("Linking member to provide: provideId={}, memberId={}", provide.getId(), member.getId()); provide.setMember(member.getId()); - return provideRepository.save(provide); + Provide updated = provideRepository.save(provide); + log.debug("Member linked successfully: provideId={}, memberId={}", provide.getId(), member.getId()); + return updated; } } diff --git a/src/test/java/org/fontory/fontorybe/unit/mock/FakeFontRepository.java b/src/test/java/org/fontory/fontorybe/unit/mock/FakeFontRepository.java index 315d968..9a54323 100644 --- a/src/test/java/org/fontory/fontorybe/unit/mock/FakeFontRepository.java +++ b/src/test/java/org/fontory/fontorybe/unit/mock/FakeFontRepository.java @@ -171,6 +171,12 @@ public boolean existsByName(String fontName) { return data.stream() .anyMatch(font -> font.getName().equals(fontName)); } + + @Override + public boolean existsByMemberIdAndName(Long memberId, String fontName) { + return data.stream() + .anyMatch(font -> font.getMemberId().equals(memberId) && font.getName().equals(fontName)); + } // Helper methods for testing public List findAll() { diff --git a/src/test/java/org/fontory/fontorybe/unit/mock/TestContainer.java b/src/test/java/org/fontory/fontorybe/unit/mock/TestContainer.java index e81b47f..3f75e99 100644 --- a/src/test/java/org/fontory/fontorybe/unit/mock/TestContainer.java +++ b/src/test/java/org/fontory/fontorybe/unit/mock/TestContainer.java @@ -34,6 +34,7 @@ import org.fontory.fontorybe.member.service.MemberLookupServiceImpl; import org.fontory.fontorybe.member.service.MemberOnboardServiceImpl; import org.fontory.fontorybe.member.service.MemberUpdateServiceImpl; +import org.fontory.fontorybe.member.service.MemberValidationService; import org.fontory.fontorybe.member.service.port.MemberRepository; import org.fontory.fontorybe.provide.controller.port.ProvideService; import org.fontory.fontorybe.provide.domain.Provide; @@ -138,12 +139,15 @@ public TestContainer() { .memberRepository(memberRepository) .build(); + MemberValidationService memberValidationService = new MemberValidationService( + badWordFiltering, memberLookupService); + memberUpdateService = MemberUpdateServiceImpl.builder() .memberLookupService(memberLookupService) .memberRepository(memberRepository) .provideService(provideService) .jwtTokenProvider(jwtTokenProvider) - .badWordFiltering(badWordFiltering) + .memberValidationService(memberValidationService) .build(); memberDefaults = new MemberDefaults( @@ -179,7 +183,7 @@ public TestContainer() { .memberRepository(memberRepository) .memberLookupService(memberLookupService) .memberCreationService(memberCreationService) - .badWordFiltering(badWordFiltering) + .memberValidationService(memberValidationService) .build(); bookmarkService = new BookmarkServiceImpl(