Skip to content

Commit 33c5d40

Browse files
authored
Feat: 회원가입 API 구현 (#50) (#54)
* Feat: DTO 작성 * Feat: 서비스 로직 구현 * Feat: DTO 검증 보완 * Feat: 컨트롤러 구현 및 문서화 * Feat: 서비스 트랜잭션 설정 * Test: 서비스 테스트 작성 및 개선 * Test: 컨트롤러 테스트 작성 및 개선 * Comment: 테스트 코드 주석 추가
1 parent 1159989 commit 33c5d40

File tree

12 files changed

+615
-12
lines changed

12 files changed

+615
-12
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.back.domain.user.controller;
2+
3+
import com.back.domain.user.dto.UserRegisterRequest;
4+
import com.back.domain.user.dto.UserResponse;
5+
import com.back.domain.user.service.UserService;
6+
import com.back.global.common.dto.RsData;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.web.bind.annotation.PostMapping;
15+
import org.springframework.web.bind.annotation.RequestBody;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
@RestController
20+
@RequestMapping("/api/auth")
21+
@RequiredArgsConstructor
22+
public class AuthController {
23+
private final UserService userService;
24+
25+
@PostMapping("/register")
26+
@Operation(
27+
summary = "회원가입",
28+
description = "신규 사용자를 등록합니다."
29+
)
30+
@ApiResponses({
31+
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
32+
@ApiResponse(responseCode = "400", description = "잘못된 요청 / 비밀번호 정책 위반"),
33+
@ApiResponse(responseCode = "409", description = "중복된 아이디/이메일/닉네임"),
34+
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
35+
})
36+
public ResponseEntity<RsData<UserResponse>> register(
37+
@Valid @RequestBody UserRegisterRequest request
38+
) {
39+
UserResponse response = userService.register(request);
40+
return ResponseEntity
41+
.status(HttpStatus.CREATED)
42+
.body(RsData.success(
43+
"회원가입이 성공적으로 완료되었습니다. 이메일 인증을 완료해주세요.",
44+
response
45+
));
46+
}
47+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.back.domain.user.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
6+
/**
7+
* 사용자 회원 가입 요청을 나타내는 DTO
8+
*
9+
* @param username 사용자의 로그인 id
10+
* @param email 사용자의 이메일 주소
11+
* @param password 사용자의 비밀번호
12+
* @param nickname 사용자의 별명
13+
*/
14+
public record UserRegisterRequest(
15+
@NotBlank String username,
16+
@NotBlank @Email String email,
17+
@NotBlank String password,
18+
@NotBlank String nickname
19+
) {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.back.domain.user.dto;
2+
3+
import com.back.domain.user.entity.Role;
4+
import com.back.domain.user.entity.User;
5+
import com.back.domain.user.entity.UserProfile;
6+
import com.back.domain.user.entity.UserStatus;
7+
8+
import java.time.LocalDateTime;
9+
10+
/**
11+
* 사용자 응답을 나타내는 DTO
12+
*
13+
* @param userId 사용자의 고유 ID
14+
* @param username 사용자의 로그인 id
15+
* @param email 사용자의 이메일 주소
16+
* @param nickname 사용자의 별명
17+
* @param role 사용자의 역할 (예: USER, ADMIN)
18+
* @param status 사용자의 상태 (예: ACTIVE, PENDING)
19+
* @param createdAt 사용자가 생성된 날짜 및 시간
20+
*/
21+
public record UserResponse(
22+
Long userId,
23+
String username,
24+
String email,
25+
String nickname,
26+
Role role,
27+
UserStatus status,
28+
LocalDateTime createdAt
29+
) {
30+
public static UserResponse from(User user, UserProfile profile) {
31+
return new UserResponse(
32+
user.getId(),
33+
user.getUsername(),
34+
user.getEmail(),
35+
profile.getNickname(),
36+
user.getRole(),
37+
user.getUserStatus(),
38+
user.getCreatedAt()
39+
);
40+
}
41+
}

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ public class User extends BaseEntity {
3636
@Enumerated(EnumType.STRING)
3737
private UserStatus userStatus;
3838

39-
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
40-
private List<UserProfile> userProfiles = new ArrayList<>();
39+
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
40+
private UserProfile userProfile;
4141

4242
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
4343
private List<UserToken> userTokens = new ArrayList<>();
@@ -81,21 +81,42 @@ public class User extends BaseEntity {
8181
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
8282
private List<FileAttachment> fileAttachments = new ArrayList<>();
8383

84+
// -------------------- 생성자 --------------------
85+
public User(String username, String email, String password, Role role, UserStatus userStatus) {
86+
this.username = username;
87+
this.email = email;
88+
this.password = password;
89+
this.role = role;
90+
this.userStatus = userStatus;
91+
}
92+
93+
// -------------------- 정적 팩토리 메서드 --------------------
94+
// 일반 사용자 생성
95+
public static User createUser(String username, String email, String password) {
96+
return new User(username, email, password, Role.USER, UserStatus.PENDING);
97+
}
98+
99+
// 관리자 사용자 생성
100+
public static User createAdmin(String username, String email, String password) {
101+
return new User(username, email, password, Role.ADMIN, UserStatus.ACTIVE);
102+
}
103+
104+
// -------------------- 연관관계 메서드 --------------------
105+
public void setUserProfile(UserProfile profile) {
106+
this.userProfile = profile;
107+
profile.setUser(this);
108+
}
109+
84110
// -------------------- 헬퍼 메서드 --------------------
85111
// 현재 사용자의 닉네임 조회
86112
public String getNickname() {
87-
return userProfiles.stream()
88-
.findFirst()
89-
.map(UserProfile::getNickname)
90-
.filter(nickname -> nickname != null && !nickname.trim().isEmpty())
91-
.orElse(this.username);
113+
return userProfile != null && userProfile.getNickname() != null && !userProfile.getNickname().trim().isEmpty()
114+
? userProfile.getNickname()
115+
: this.username;
92116
}
93117

94118
// 현재 사용자의 프로필 이미지 URL 조회
95119
public String getProfileImageUrl() {
96-
return userProfiles.stream()
97-
.findFirst()
98-
.map(UserProfile::getProfileImageUrl)
99-
.orElse(null);
120+
return userProfile != null ? userProfile.getProfileImageUrl() : null;
100121
}
101122
}

src/main/java/com/back/domain/user/entity/UserProfile.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
import com.back.global.entity.BaseEntity;
44
import jakarta.persistence.*;
5+
import lombok.AllArgsConstructor;
56
import lombok.Getter;
67
import lombok.NoArgsConstructor;
8+
import lombok.Setter;
79

810
import java.time.LocalDateTime;
911

1012
@Entity
1113
@Getter
14+
@Setter
1215
@NoArgsConstructor
16+
@AllArgsConstructor
1317
public class UserProfile extends BaseEntity {
14-
@ManyToOne(fetch = FetchType.LAZY)
18+
@OneToOne(fetch = FetchType.LAZY)
1519
@JoinColumn(name = "user_id")
1620
private User user;
1721

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.back.domain.user.repository;
2+
3+
import com.back.domain.user.entity.UserProfile;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
@Repository
8+
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {
9+
boolean existsByNickname(String nickname);
10+
}

src/main/java/com/back/domain/user/repository/UserRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import org.springframework.data.jpa.repository.JpaRepository;
55
import org.springframework.stereotype.Repository;
66

7+
import java.util.Optional;
8+
79
@Repository
810
public interface UserRepository extends JpaRepository<User, Long> {
11+
boolean existsByUsername(String username);
12+
boolean existsByEmail(String email);
13+
Optional<User> findByUsername(String username);
914
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.back.domain.user.service;
2+
3+
import com.back.domain.user.dto.UserRegisterRequest;
4+
import com.back.domain.user.dto.UserResponse;
5+
import com.back.domain.user.entity.User;
6+
import com.back.domain.user.entity.UserProfile;
7+
import com.back.domain.user.repository.UserProfileRepository;
8+
import com.back.domain.user.repository.UserRepository;
9+
import com.back.global.exception.CustomException;
10+
import com.back.global.exception.ErrorCode;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.security.crypto.password.PasswordEncoder;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Transactional
19+
public class UserService {
20+
private final UserRepository userRepository;
21+
private final UserProfileRepository userProfileRepository;
22+
private final PasswordEncoder passwordEncoder;
23+
24+
/**
25+
* 회원가입 서비스
26+
* 1. 중복 검사 (username, email, nickname)
27+
* 2. 비밀번호 정책 검증
28+
* 3. User + UserProfile 생성 및 연관관계 설정
29+
* 4. 저장 후 UserResponse 변환
30+
*/
31+
public UserResponse register(UserRegisterRequest request) {
32+
33+
// 중복 검사 (username, email, nickname)
34+
validateDuplicate(request);
35+
36+
// 비밀번호 정책 검증
37+
validatePasswordPolicy(request.password());
38+
39+
// User 엔티티 생성 (기본 Role.USER, Status.PENDING)
40+
User user = User.createUser(
41+
request.username(),
42+
request.email(),
43+
passwordEncoder.encode(request.password())
44+
);
45+
46+
// UserProfile 엔티티 생성
47+
UserProfile profile = new UserProfile(
48+
user,
49+
request.nickname(),
50+
null,
51+
null,
52+
null,
53+
0
54+
);
55+
56+
// 연관관계 설정
57+
user.setUserProfile(profile);
58+
59+
// 저장 (cascade로 Profile도 함께 저장됨)
60+
User saved = userRepository.save(user);
61+
62+
// UserResponse 변환 및 반환
63+
return UserResponse.from(saved, profile);
64+
}
65+
66+
/**
67+
* 회원가입 시 중복 검증
68+
* - username, email, nickname
69+
*/
70+
private void validateDuplicate(UserRegisterRequest request) {
71+
if (userRepository.existsByUsername(request.username())) {
72+
throw new CustomException(ErrorCode.USERNAME_DUPLICATED);
73+
}
74+
if (userRepository.existsByEmail(request.email())) {
75+
throw new CustomException(ErrorCode.EMAIL_DUPLICATED);
76+
}
77+
if (userProfileRepository.existsByNickname(request.nickname())) {
78+
throw new CustomException(ErrorCode.NICKNAME_DUPLICATED);
79+
}
80+
}
81+
82+
/**
83+
* 비밀번호 정책 검증
84+
* - 최소 8자 이상
85+
* - 숫자 및 특수문자 반드시 포함
86+
*/
87+
private void validatePasswordPolicy(String password) {
88+
String regex = "^(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$";
89+
if (!password.matches(regex)) {
90+
throw new CustomException(ErrorCode.INVALID_PASSWORD);
91+
}
92+
}
93+
}

src/main/java/com/back/global/exception/ErrorCode.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ public enum ErrorCode {
1010

1111
// ======================== 사용자 관련 ========================
1212
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "존재하지 않는 사용자입니다."),
13+
USERNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_002", "이미 사용 중인 아이디입니다."),
14+
EMAIL_DUPLICATED(HttpStatus.CONFLICT, "USER_003", "이미 사용 중인 이메일입니다."),
15+
NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_004", "이미 사용 중인 닉네임입니다."),
16+
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_005", "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다."),
1317

1418
// ======================== 스터디룸 관련 ========================
1519
ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."),

src/main/java/com/back/global/exception/GlobalExceptionHandler.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.back.global.exception;
22

33
import com.back.global.common.dto.RsData;
4+
import org.springframework.http.HttpStatus;
45
import org.springframework.http.ResponseEntity;
6+
import org.springframework.web.bind.MethodArgumentNotValidException;
57
import org.springframework.web.bind.annotation.ExceptionHandler;
68
import org.springframework.web.bind.annotation.RestControllerAdvice;
79

@@ -17,4 +19,11 @@ public ResponseEntity<RsData<Void>> handleCustomException(
1719
.status(errorCode.getStatus())
1820
.body(RsData.fail(errorCode));
1921
}
22+
23+
@ExceptionHandler(MethodArgumentNotValidException.class)
24+
public ResponseEntity<RsData<Void>> handleValidationException(MethodArgumentNotValidException ex) {
25+
return ResponseEntity
26+
.status(HttpStatus.BAD_REQUEST)
27+
.body(RsData.fail(ErrorCode.BAD_REQUEST));
28+
}
2029
}

0 commit comments

Comments
 (0)