728x90
320x100
🎯 오늘의 개발 내용 (요약)
- User 도메인 리팩토링 (단위 테스트, Postman 테스트 포함)
🛠️ 개발내용
기존의 User 도메인 즉, UserService는 유저와 관련된 모든 서비스가 모여있어, 한 서비스가 너무 비대해져 복잡해지고 유지보수성이 떨어지는 현상이 발생했다. 그래서 UserSerivce를 3개의 서비스로 분리하기로 한다.
ProfileImageService
프로필 이미지를 등록/수정/삭제하는 내용을 담은 서비스이며, 최대한 메서드를 분리하여 단일책임 원칙에 맞추려 노력하였으며, 용량이나 확장자를 제한하는 유효성 검사들을 조금 더 꼼꼼히 체크할 수 있도록 리팩토링 하였다.
package com.bookbookclub.bbc_user_service.user.service;
/**
* 프로필 이미지 저장 및 삭제를 담당하는 서비스
* - 이미지 타입 및 크기 검증
* - UUID 기반 파일명으로 저장
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProfileImageService {
private final UserProperties userProperties;
private static final List<String> ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif", "webp");
/**
* 프로필 이미지 저장
*/
public String store(MultipartFile file) {
validateImage(file);
String filename = generateStoredFileName(file);
Path dirPath = Paths.get(userProperties.getProfileImageUploadPath());
Path filePath = dirPath.resolve(filename);
try {
createDirectoryIfNotExists(dirPath);
saveFile(file, filePath);
} catch (IOException e) {
throw new AuthException(UserErrorCode.PROFILE_IMAGE_UPLOAD_FAIL);
}
return userProperties.getProfileImageUrlPrefix() + filename;
}
/**
* 프로필 이미지 삭제
*/
public void delete(String imageUrl) {
if (!hasText(imageUrl)) return;
if (isDefaultImage(imageUrl)) return;
String filename = extractFilename(imageUrl);
File file = new File(userProperties.getProfileImageUploadPath(), filename);
if (file.exists()) {
if (!file.delete()) {
log.warn("프로필 이미지 삭제 실패: {}", file.getAbsolutePath());
throw new AuthException(UserErrorCode.PROFILE_IMAGE_DELETE_FAIL);
}
}
}
/**
* 기본 이미지 URL 반환
*/
public String getDefaultImageUrl() {
return userProperties.getDefaultProfileImageUrl();
}
/**
* 기본 이미지 여부 확인
*/
public boolean isDefaultImage(String imageUrl) {
return imageUrl != null && imageUrl.equals(getDefaultImageUrl());
}
// 이미지 유효성 검사 (용량, 확장자)
private void validateImage(MultipartFile file) {
// 최대 용량 초과 검사
if (file.getSize() > userProperties.getMaxProfileImageSize()) {
throw new AuthException(UserErrorCode.PROFILE_IMAGE_TOO_LARGE);
}
try (InputStream input = file.getInputStream()) {
// 확장자 화이트리스트 검사
String ext = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw new AuthException(UserErrorCode.INVALID_PROFILE_IMAGE_TYPE);
}
} catch (IOException e) {
throw new AuthException(UserErrorCode.INVALID_PROFILE_IMAGE_TYPE);
}
}
// UUID + 정제된 파일명으로 저장용 파일명 생성
// ex) "내 프로필 이미지.png" → "550e8400-e29b-41d4-a716-446655440000_naegongilji.png"
private String generateStoredFileName(MultipartFile file) {
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); // 이미 유효하다고 가정
String originalName = FilenameUtils.getBaseName(file.getOriginalFilename());
originalName = originalName.replaceAll("[^a-zA-Z0-9-_]", "");
return UUID.randomUUID() + "_" + originalName + "." + extension;
}
// 디렉토리가 없으면 생성
// ex) "./uploads/images/" 없을 경우 생성
private void createDirectoryIfNotExists(Path dirPath) throws IOException {
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath);
}
}
// 파일 저장 (MultipartFile → 로컬 경로)
// ex) file → "./uploads/images/uuid_filename.png"
private void saveFile(MultipartFile file, Path filePath) throws IOException {
try (InputStream in = file.getInputStream()) {
Files.copy(in, filePath, StandardCopyOption.REPLACE_EXISTING);
}
}
// 이미지 URL에서 파일명 추출 (URL 검증 포함)
// ex) "http://localhost:8080/images/abc123.png" → "abc123.png"
// 잘못된 형식이면 예외
private String extractFilename(String imageUrl) {
int idx = imageUrl.lastIndexOf("/");
if (idx == -1 || idx == imageUrl.length() - 1) {
throw new AuthException(UserErrorCode.INVALID_PROFILE_IMAGE_URL);
}
if (!imageUrl.startsWith(userProperties.getProfileImageUrlPrefix())) {
throw new AuthException(UserErrorCode.INVALID_PROFILE_IMAGE_URL);
}
return imageUrl.substring(idx + 1);
}
}
ProfileService
사용자 정보를 수정, 조회하는 기능을 담은 서비스. 닉네임과 자기소개를 수정할 때는 금칙어 규정을 추가하여 유효성 검사를 진행하였다.
**
* 사용자 프로필 관련 비즈니스 로직을 담당하는 서비스
*
* - 닉네임, 자기소개, 프로필 이미지 수정 기능 제공
* - 사용자 정보 조회 기능 제공
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProfileService {
private final UserRepository userRepository;
private final ProfileImageService profileImageService;
/**
* 내 프로필 정보 조회
*/
public UserResponse getMyProfile(Long userId) {
return UserResponse.from(getUser(userId));
}
/**
* 전체 프로필 수정 (닉네임, 이미지, 자기소개)
*/
@Transactional
public UserResponse updateProfile(Long userId, ProfileUpdateRequest request) {
User user = getUser(userId);
if (hasText(request.getNickname()) && !user.getNickname().equals(request.getNickname())) {
updateNickname(user, request.getNickname());
}
if (!user.getBio().equals(request.getBio())) {
updateBio(user, request.getBio());
}
handleProfileImageUpdate(user, request);
return UserResponse.from(user);
}
// 닉네임 변경
private void updateNickname(User user, String nickname) {
if (containsBannedWord(nickname)) {
throw new AuthException(UserErrorCode.BANNED_WORD_DETECTED);
}
if (userRepository.existsByNickname(nickname)) {
throw new AuthException(UserErrorCode.DUPLICATE_NICKNAME);
}
user.updateNickname(nickname);
}
// 자기소개 변경
private void updateBio(User user, String bio) {
if (containsBannedWord(bio)) {
throw new AuthException(UserErrorCode.BANNED_WORD_DETECTED);
}
user.updateBio(bio);
}
// 프로필 이미지 변경 처리
private void handleProfileImageUpdate(User user, ProfileUpdateRequest request) {
String currentImageUrl = user.getProfileImageUrl();
// 1. 삭제 요청이면 → 기본 이미지로 변경
if (request.isImageDeleted()) {
if (!profileImageService.isDefaultImage(currentImageUrl)) {
profileImageService.delete(currentImageUrl);
}
user.updateProfileImage(profileImageService.getDefaultImageUrl());
return;
}
// 2. 새 이미지 업로드 → 기존 사용자 이미지 삭제 → 새 이미지 저장
if (request.hasNewImage()) {
String newImageUrl = profileImageService.store(request.getProfileImage());
if (!profileImageService.isDefaultImage(currentImageUrl)) {
profileImageService.delete(currentImageUrl);
}
user.updateProfileImage(newImageUrl);
}
// 3. 둘 다 아니라면 → 아무 것도 안 함
}
// 유저 조회 공통 처리
private User getUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new AuthException(UserErrorCode.USER_NOT_FOUND));
}
}
AuthService
회원가입과 로그인 안에 포함되는 모든 인증 관련 기능들을 모아둔 서비스. 회원가입시, 유효성 검사가 실제 운영중인 서비스와 비슷하게끔 유효성 검사를 추가하여 데이터 정합성을 높이기 위해 노력하였다.
/**
* 인증 관련 서비스 클래스
* - 회원가입
* - 로그인
*/
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class AuthService {
private final UserRepository userRepository;
private final EmailVerificationService emailVerificationService;
private final PasswordEncoder passwordEncoder;
private final UserProperties userProperties;
private final RedisTemplate<String, String> redisTemplate;
private static final String PASSWORD_REGEX = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=-]).{8,20}$";
private static final String NICKNAME_REGEX = "^[a-zA-Z0-9가-힣]{2,12}$";
private static final String EMAIL_REGEX = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
/**
* 회원가입
*/
@Transactional
public UserResponse signup(SignupRequest request) {
validateEmailVerification(request.getEmail());
validateDuplicateEmail(request.getEmail());
validateRejoinAvailable(request.getEmail());
validateEmailFormat(request.getEmail());;
validateNicknameFormat(request.getNickname());
validateDuplicateNickname(request.getNickname());
validatePasswordFormat(request.getPassword());
User user = User.create(
request.getEmail(),
passwordEncoder.encode(request.getPassword()),
request.getNickname(),
userProperties.getDefaultProfileImageUrl()
);
userRepository.save(user);
return UserResponse.from(user);
}
/**
* 로그인
* - 비밀번호 확인
*/
public UserResponse login(LoginRequest request) {
User user = validateUserLogin(request.getEmail(), request.getPassword());
return UserResponse.from(user);
}
/**
* 회원 탈퇴
*/
@Transactional
public void withdrawUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new AuthException(UserErrorCode.USER_NOT_FOUND));
user.withdraw(); // → status, withdrawnAt 변경
redisTemplate.delete("refreshToken:" + userId);
}
// 이메일 중복 확인 (API 용도)
public boolean isEmailDuplicate(String email) {
return userRepository.existsByEmail(email);
}
//닉네임 중복 확인 (API 용도)
public boolean isNicknameDuplicate(String nickname) {
return userRepository.existsByNickname(nickname);
}
/**
* 유저 ID로 유저 조회
*/
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new AuthException(UserErrorCode.USER_NOT_FOUND));
}
// 이메일 인증 여부 검증
private void validateEmailVerification(String email) {
if (!emailVerificationService.isEmailVerified(email)) {
throw new EmailVerificationException(UserErrorCode.EMAIL_VERIFICATION_INVALID_TOKEN);
}
}
//이메일 중복 확인
private void validateDuplicateEmail(String email) {
if (userRepository.existsByEmail(email)) {
throw new AuthException(UserErrorCode.DUPLICATE_EMAIL);
}
}
// 탈퇴 후 재가입 제한 검증
private void validateRejoinAvailable(String email) {
userRepository.findByEmail(email).ifPresent(user -> {
if (user.getStatus() == UserStatus.WITHDRAWN) {
LocalDateTime rejoinAvailableDate = user.getWithdrawnAt().plusMonths(UserPolicy.REJOIN_RESTRICTION_MONTHS);
if (LocalDateTime.now().isBefore(rejoinAvailableDate)) {
throw new AuthException(UserErrorCode.REJOIN_RESTRICTED);
}
} else {
throw new AuthException(UserErrorCode.DUPLICATE_EMAIL);
}
});
}
// 이메일 형식 검증
private void validateEmailFormat(String email) {
if (!Pattern.matches(EMAIL_REGEX, email)) {
throw new AuthException(UserErrorCode.INVALID_EMAIL_FORMAT);
}
}
// 닉네임 형식 검증 (한글, 영문, 숫자 / 2~12자 / 공백 금지 / 금칙어 )
private void validateNicknameFormat(String nickname) {
if (!StringUtils.isNotBlank(nickname)) {
throw new AuthException(UserErrorCode.INVALID_NICKNAME_FORMAT);
}
if (nickname.contains(" ")) {
throw new AuthException(UserErrorCode.INVALID_NICKNAME_FORMAT);
}
if (!nickname.matches(NICKNAME_REGEX)) {
throw new AuthException(UserErrorCode.INVALID_NICKNAME_FORMAT);
}
if (containsBannedWord(nickname)) {
throw new AuthException(UserErrorCode.BANNED_WORD_DETECTED);
}
}
//닉네임 중복 확인
private void validateDuplicateNickname(String nickname) {
if (userRepository.existsByNickname(nickname)) {
throw new AuthException(UserErrorCode.DUPLICATE_NICKNAME);
}
}
// 비밀번호 형식 검증 (8~20자 / 영문 + 숫자 + 특수문자 / 연속 또는 반복 문자 금지)
private void validatePasswordFormat(String password) {
if (!password.matches(PASSWORD_REGEX)) {
throw new AuthException(UserErrorCode.INVALID_PASSWORD_FORMAT);
}
// 연속 문자 (ex: abc, 123) 혹은 반복 문자 (ex: aaaa)
if (hasSequentialChars(password) || hasRepeatedChars(password)) {
throw new AuthException(UserErrorCode.TOO_SIMPLE_PASSWORD);
}
}
// 연속 문자 검출
private boolean hasSequentialChars(String input) {
for (int i = 0; i < input.length() - 2; i++) {
char c1 = input.charAt(i);
char c2 = input.charAt(i + 1);
char c3 = input.charAt(i + 2);
if ((c2 - c1 == 1) && (c3 - c2 == 1)) {
return true;
}
}
return false;
}
// 반복 문자 검출
private boolean hasRepeatedChars(String input) {
for (int i = 0; i < input.length() - 2; i++) {
char c1 = input.charAt(i);
if (input.charAt(i + 1) == c1 && input.charAt(i + 2) == c1) {
return true;
}
}
return false;
}
//비밀번호 확인
private User validateUserLogin(String email, String rawPassword) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new AuthException(UserErrorCode.USER_NOT_FOUND));
if (user.getStatus() == UserStatus.WITHDRAWN) {
throw new AuthException(UserErrorCode.USER_WITHDRAWN);
}
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new AuthException(UserErrorCode.INVALID_PASSWORD);
}
return user;
}
}
(+) UserPolicy
가입 관련된 정책이나 규정들은 별도의 클래스를 만들어 관리 한다.
package com.bookbookclub.bbc_user_service.user.policy;
/**
* 사용자 정책 상수 정의 클래스
* - 정책 변경이 필요한 경우 이곳에서 일괄 관리
*/
public class UserPolicy {
/** Refresh Token 유효기간 (단위: 일) */
public static final long REFRESH_EXPIRATION_DAYS = 7;
/** 탈퇴 후 재가입 제한 기간 (단위: 개월) */
public static final int REJOIN_RESTRICTION_MONTHS = 6;
}
📌 단위 테스트 진행
서비스가 많은 부분이 바뀐 만큼 단위 테스트도 Junit5을 활용하여 다시 작성해보기로 한다.
ProfileImageServiceTest
@ExtendWith(MockitoExtension.class)
class ProfileImageServiceTest {
@InjectMocks
private ProfileImageService profileImageService;
@Mock
private UserProperties userProperties;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
// 공통적으로 진짜 필요한 것만 여기에 둠
}
@Test
void 이미지_용량_초과시_예외() {
byte[] big = new byte[3_000_000];
MultipartFile bigFile = new MockMultipartFile("file", "big.png", "image/png", big);
when(userProperties.getMaxProfileImageSize()).thenReturn(2_000_000L);
assertThrows(AuthException.class, () -> profileImageService.store(bigFile));
}
@Test
void 기본_이미지인지_확인() {
when(userProperties.getDefaultProfileImageUrl()).thenReturn("http://localhost:8080/images/default.png");
assertTrue(profileImageService.isDefaultImage("http://localhost:8080/images/default.png"));
assertFalse(profileImageService.isDefaultImage("http://localhost:8080/images/not-default.png"));
}
@Test
void 이미지_저장_성공() {
byte[] data = new byte[1000];
MultipartFile file = new MockMultipartFile("file", "profile.png", "image/png", data);
when(userProperties.getMaxProfileImageSize()).thenReturn(2_000_000L);
when(userProperties.getProfileImageUploadPath()).thenReturn(tempDir.toString() + "/");
when(userProperties.getProfileImageUrlPrefix()).thenReturn("http://localhost:8080/images/");
String result = profileImageService.store(file);
assertTrue(result.startsWith("http://localhost:8080/images/"));
}
@Test
void 잘못된_확장자일_때_예외() {
MultipartFile badFile = new MockMultipartFile("file", "x.exe", "application/octet-stream", new byte[100]);
assertThrows(AuthException.class, () -> profileImageService.store(badFile));
}
@Test
void 없는_파일_삭제해도_예외_안남() {
when(userProperties.getProfileImageUrlPrefix()).thenReturn("http://localhost:8080/images/");
String url = "http://localhost:8080/images/not-exist.png";
assertDoesNotThrow(() -> profileImageService.delete(url));
}
@Test
void 실제_파일_삭제_정상작동() throws IOException {
String uploadDir = tempDir.toString() + "/";
String fileName = "test-delete.png";
String prefix = "http://localhost:8080/images/";
when(userProperties.getProfileImageUploadPath()).thenReturn(uploadDir);
when(userProperties.getProfileImageUrlPrefix()).thenReturn(prefix);
File file = new File(uploadDir, fileName);
assertTrue(file.createNewFile()); // 존재 확인
assertTrue(file.exists());
String imageUrl = prefix + fileName;
profileImageService.delete(imageUrl);
assertFalse(file.exists());
}
}
ProfileServiceTest
@ExtendWith(MockitoExtension.class)
class ProfileServiceTest {
@InjectMocks
private ProfileService profileService;
@Mock
private UserRepository userRepository;
@Mock
private ProfileImageService profileImageService;
private User user;
@BeforeEach
void setUp() {
user = User.create(
"test@email.com",
"password123!",
"oldNickname",
"default.png"
);
user.updateBio("기존 자기소개");
}
@Test
void 프로필_조회_성공() {
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
UserResponse result = profileService.getMyProfile(1L);
assertEquals("oldNickname", result.getNickname());
}
@Test
void 닉네임_수정_성공() {
ProfileUpdateRequest request = new ProfileUpdateRequest("newNick", "기존 자기소개", null, false);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(userRepository.existsByNickname("newNick")).thenReturn(false);
UserResponse result = profileService.updateProfile(1L, request);
assertEquals("newNick", result.getNickname());
}
@Test
void 자기소개_수정_성공() {
ProfileUpdateRequest request = new ProfileUpdateRequest("oldNickname", "새로운 자기소개", null, false);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
UserResponse result = profileService.updateProfile(1L, request);
assertEquals("새로운 자기소개", user.getBio());
}
@Test
void 기본_이미지로_변경_성공() {
user.updateProfileImage("custom.png");
ProfileUpdateRequest request = new ProfileUpdateRequest("oldNickname", "기존 자기소개", null, true);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(profileImageService.isDefaultImage("custom.png")).thenReturn(false);
when(profileImageService.getDefaultImageUrl()).thenReturn("default.png");
profileService.updateProfile(1L, request);
verify(profileImageService).delete("custom.png");
assertEquals("default.png", user.getProfileImageUrl());
}
@Test
void 새_이미지_업로드_성공() {
// 기존 이미지가 기본 이미지가 아니라고 가정
user.updateProfileImage("http://localhost:8080/images/old.png");
MultipartFile mockImage = new MockMultipartFile("image", "test.png", "image/png", new byte[100]);
ProfileUpdateRequest request = new ProfileUpdateRequest("oldNickname", "기존 자기소개", mockImage, false);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(profileImageService.isDefaultImage(user.getProfileImageUrl())).thenReturn(false);
when(profileImageService.store(mockImage)).thenReturn("http://localhost:8080/images/new.png");
profileService.updateProfile(1L, request);
verify(profileImageService).delete("http://localhost:8080/images/old.png");
verify(profileImageService).store(mockImage);
assertEquals("http://localhost:8080/images/new.png", user.getProfileImageUrl());
}
}
AuthServiceTest
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@InjectMocks
private AuthService authService;
@Mock
private UserRepository userRepository;
@Mock
private EmailVerificationService emailVerificationService;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private UserProperties userProperties;
private SignupRequest signupRequest;
private LoginRequest loginRequest;
private User activeUser;
@Mock
private RedisTemplate<String, String> redisTemplate;
@BeforeEach
void setUp() {
signupRequest = new SignupRequest("test@email.com", "Password913!", "nickname");
loginRequest = new LoginRequest("test@email.com", "Password913!");
activeUser = User.create(
"test@email.com",
"encodedPassword",
"nickname",
"default.png"
);
}
@Test
void 회원가입_성공() {
when(emailVerificationService.isEmailVerified(signupRequest.getEmail())).thenReturn(true);
when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(false);
when(userRepository.findByEmail(signupRequest.getEmail())).thenReturn(Optional.empty());
when(userRepository.existsByNickname(signupRequest.getNickname())).thenReturn(false);
when(passwordEncoder.encode(signupRequest.getPassword())).thenReturn("encodedPassword");
when(userProperties.getDefaultProfileImageUrl()).thenReturn("default.png");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
UserResponse response = authService.signup(signupRequest);
assertEquals("nickname", response.getNickname());
}
@Test
void 로그인_성공() {
when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(activeUser));
when(passwordEncoder.matches(loginRequest.getPassword(), activeUser.getPassword())).thenReturn(true);
UserResponse response = authService.login(loginRequest);
assertEquals("nickname", response.getNickname());
}
@Test
void 로그인_실패_비밀번호_불일치() {
when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(activeUser));
when(passwordEncoder.matches("wrongPassword", activeUser.getPassword())).thenReturn(false);
assertThrows(AuthException.class, () ->
authService.login(new LoginRequest(loginRequest.getEmail(), "wrongPassword"))
);
}
@Test
void 회원_탈퇴_성공() {
// given
Long userId = 1L;
User mockUser = mock(User.class);
when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));
ValueOperations<String, String> valueOps = mock(ValueOperations.class);
// when
authService.withdrawUser(userId);
// then
verify(userRepository).findById(userId);
verify(mockUser).withdraw();
verify(redisTemplate).delete("refreshToken:" + userId);
}
@Test
void 회원_탈퇴_실패_유저없음() {
// given
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// when & then
assertThatThrownBy(() -> authService.withdrawUser(userId))
.isInstanceOf(AuthException.class)
.hasMessage(UserErrorCode.USER_NOT_FOUND.getMessage());
verify(userRepository).findById(userId);
verifyNoInteractions(redisTemplate);
}
}
[테스트 결과 (성공)]



이제 컨트롤러단도 UserController 하나에서 모든 서비스를 처리하던 것을 AuthController, ProfileController로 나누어 리팩토링을 진행했다.
AuthController
/**
* 인증 관련 API를 처리하는 컨트롤러
* - 회원가입, 로그인, 로그아웃, 토큰 재발급
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final JwtRefreshTokenService refreshTokenService;
private final JwtBlacklistService blacklistService;
private final JwtUtil jwtUtil;
/**
* 회원가입
*/
@PostMapping("/signup")
public ResponseEntity<ApiResponse<UserResponse>> signup(@Valid @RequestBody SignupRequest request) {
UserResponse response = authService.signup(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 이메일 중복 확인
*/
@GetMapping("/check-email")
public ResponseEntity<ApiResponse<Boolean>> checkEmailDuplicate(@RequestParam @Email String email) {
return ResponseEntity.ok(ApiResponse.success(authService.isEmailDuplicate(email)));
}
/**
* 닉네임 중복 확인
*/
@GetMapping("/check-nickname")
public ResponseEntity<ApiResponse<Boolean>> checkNicknameDuplicate(@RequestParam String nickname) {
return ResponseEntity.ok(ApiResponse.success(authService.isNicknameDuplicate(nickname)));
}
/**
* 로그인 - AccessToken, RefreshToken 발급
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request) {
UserResponse user = authService.login(request);
String accessToken = jwtUtil.createToken(
user.getId(), user.getEmail(), user.getNickname(), user.getProfileImageUrl(), user.getRole()
);
String refreshToken = jwtUtil.createRefreshToken();
refreshTokenService.save(user.getId(), refreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
return ResponseEntity.ok(ApiResponse.success(new LoginResponse(accessToken, refreshToken, user)));
}
/**
* 토큰 재발급
*/
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<LoginResponse>> refresh(@RequestBody RefreshTokenRequest request) {
if (!refreshTokenService.isValid(request.getUserId(), request.getRefreshToken())) {
log.warn("Refresh token invalid: userId={}, token={}", request.getUserId(), request.getRefreshToken());
throw new AuthException(UserErrorCode.INVALID_TOKEN);
}
User user = authService.findById(request.getUserId());
String newAccessToken = jwtUtil.createToken(
user.getId(), user.getEmail(), user.getNickname(), user.getProfileImageUrl(), user.getRole().name()
);
String newRefreshToken = jwtUtil.createRefreshToken();
refreshTokenService.save(user.getId(), newRefreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);
return ResponseEntity.ok(ApiResponse.success(new LoginResponse(newAccessToken, newRefreshToken, UserResponse.from(user))));
}
/**
* 로그아웃 - RefreshToken 제거 및 AccessToken 블랙리스트 등록
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(@AuthenticationPrincipal CustomUserDetails userDetails,
HttpServletRequest request) {
refreshTokenService.delete(userDetails.getId());
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String accessToken = header.substring(7);
long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
blacklistService.blacklist(accessToken, remainingTime);
}
return ResponseEntity.ok(ApiResponse.success(null));
}
}
ProfileController
* 프로필 관련 API 컨트롤러
* - 프로필 조회
* - 프로필 수정 (닉네임, 자기소개, 이미지 업로드 및 삭제 포함)
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/profile")
public class ProfileController {
private final ProfileService profileService;
/**
* 내 프로필 조회 API
*/
@GetMapping("/me")
public ApiResponse<UserResponse> getMyProfile(@AuthenticationPrincipal CustomUserDetails userDetails) {
UserResponse response = profileService.getMyProfile(userDetails.getUser().getId());
return ApiResponse.success(response);
}
/**
* 내 프로필 수정 API
* - 닉네임, 자기소개, 프로필 이미지 수정 및 삭제
*/
@PutMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> updateProfile(@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @ModelAttribute ProfileUpdateRequest request) {
return ResponseEntity.ok(ApiResponse.success(profileService.updateProfile(userDetails.getUser().getId(), request)));
}
}
Postman 테스트
Junit을 활용한 단위 테스트를 마쳤으니, 이제 포스트맨을 활용한 컨트롤러 테스트를 진행해본다.
1. 이메일 인증 / 요청



2. 회원가입

3. 로그인

4. 내정보조회

5. 내정보수정

6. refreshToken 재발급

7. 회원탈퇴

728x90
320x100
'💻 뚝딱뚝딱 > 북북클럽' 카테고리의 다른 글
[개발일지 #042] 리팩토링 - (4) Feed 도메인 (단위 테스트, Postman 테스트 포함) (0) | 2025.06.16 |
---|---|
[개발일지 #041] 리팩토링 - (3) Follow 도메인 (단위 테스트, Postman 테스트 포함) (0) | 2025.06.16 |
[개발일지 #039] 리팩토링 - (1) 공통 응답 구조, 공통 예외 처리 (0) | 2025.06.13 |
[개발일지 #038] 공통모듈 구조 만들기, 예외 처리 분리하기 (0) | 2025.06.10 |
[개발일지 #037] 모놀리식 아키텍처를 MSA 아키텍처로 전환하기 (3) - MSA 환경에서의 서비스 간 통신 구축 (0) | 2025.06.09 |