🏅오늘의 목표
- 회원 (User) , 프로필 (Profile) 도메인 구현
- 회원가입 API 구현
✅ 진행한 작업
- 회원 엔티티 생성
- 프로필 엔티티 생성
- 회원 서비스 생성
- 회원가입 API 구현
📃 개발내용
회원(User) 엔티티 생성
@Entity
@Table(name="users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {
@Column(nullable = false, unique = true, length = 255)
private String email; // 이메일(아이디)
@Column(nullable = false, length = 60)
private String password; // 비밀번호
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role; // 회원 역할
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserStatus status; // 회원 상태
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider authProvider; // OAuth
private LocalDateTime lastLoginAt; // 마지막 로그인 시간
private LocalDateTime deletedAt; // 탈퇴 시간
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Profile profile;
public User(String email, String password) {
this.email = email;
this.password = password;
this.role = Role.USER;
this.authProvider = AuthProvider.LOCAL;
this.status = UserStatus.ACTIVE;
}
// OAuth 로그인용 생성자
public User(String email, AuthProvider authProvider) {
this.email = email;
this.password = "OAUTH"; // OAuth는 비밀번호 불필요
this.authProvider = authProvider;
this.role = Role.USER;
this.status = UserStatus.ACTIVE;
}
public void attachProfile(Profile profile) {
if (profile == null) {
throw new BaseException(UserErrorCode.PROFILE_CANNOT_NULL);
}
this.profile = profile;
profile.attachUser(this);
}
// 휴먼 계정 전환
public void markInactive() {
if (this.status == UserStatus.WITHDRAWN) {
throw new BaseException(UserErrorCode.USER_ALREADY_WITHDRAW);
}
this.status = UserStatus.INACTIVE;
}
//탈퇴 여부 확인
public boolean isWithdraw(){
return this.status == UserStatus.WITHDRAWN;
}
// 탈퇴
public void withdraw(){
this.status = UserStatus.WITHDRAWN;
this.deletedAt = LocalDateTime.now();
}
// 마지막 로그인 시간
public void updateLastLoginAt() {
this.lastLoginAt = LocalDateTime.now();
}
}
주요 필드
- email, password : 인증 정보
- role, status : 권한 및 상태
- authProvider : OAuth 로그인 ( Local, Kakao, Google)
- Profile : 사용자 프로필 정보(1:1 관계)
주요 내용
- User 와 Profile 분리
- 인증 정보와 화면 노출 정보를 분리
- 프로필은 설계(기획)에 따라 필드가 변경이 자주 일어날 수 있기 때문에 분리
- 양방향 관계 설정
- attachProfile(), attachUser() 로 양방향 관계 설정
- Profile 은 User 없이 존재할 수 없기 때문에 외래키를 Profile 이 가지고 있음
- cascade = CascadeType.ALL 로 User 저장 시 Profile 도 함께 저장
- 소프트 삭제
- deleteAt 필드로 탈퇴 시점 기록
- 상태 관리
- UserStatus 로 계정 상태 관리(ACTIVE,INACTIVE,WITHDRAW)
- @NoArgsConstructor(access = AccessLevel.PROTECTED)
- 파라미터가 없는 기본 생성자를 외부에서 생성하지 못하게 막음
프로필(Profile) 엔티티 생성
@Entity
@Table(name="profiles")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Profile extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name="user_id",nullable=false,unique = true)
private User user;
@Column(nullable = false, length = 50)
private String name; // 실제 이름
@Column(nullable = false,unique = true,length = 30)
private String nickname; // 화면 표시용
@Column(nullable = false, length = 20)
private String phoneNumber; // 전화번호(010-0000-1111)
@Column(length = 255)
private String profileUrl; // 프로필 이미지 url
@Column(length = 50)
private String region;
public Profile(String nickname, String name, String phoneNumber, String profileUrl, String region) {
this.nickname = nickname;
this.name = name;
this.phoneNumber = phoneNumber;
this.profileUrl = profileUrl;
this.region = region;
}
void attachUser(User user) {
this.user = user;
}
public void update(String newNickname, String newName, String newPhoneNumber, String newProfileUrl,
String newRegion) {
if (newNickname != null && !newNickname.equals(this.nickname))
this.nickname = newNickname;
if (newName != null && !newName.equals(this.name))
this.name = newName;
if (newPhoneNumber != null && !newPhoneNumber.equals(this.phoneNumber))
this.phoneNumber = newPhoneNumber;
if (newProfileUrl != null && !newProfileUrl.equals(this.profileUrl))
this.profileUrl = newProfileUrl;
if (newRegion != null && !newRegion.equals(this.region))
this.region = newRegion;
}
}
주요 필드
- name : 실제 이름
- nickname : 화면 표시용
- phoneNumber : 전화번호
- profileUrl : 프로필 이미지 URL
- region : 지역 정보
주요 내용
- User 와 관계
- 1 : 1 관계
- 외래키 소유
- FetchType.LAZY 로 지연 로딩 설정
- 접근 제어
- attachUser() 는 public 설정 x
- User.attachProfile() 을 통해서만 관계 설정 가능
- 양방향 관계 설정 로직을 User 에 캡슐화
- 개발자가 잘못된 방법으로 관계 설정하는 것을 차단
회원 , 프로필 리포지토리 생성
public interface UserRepository extends JpaRepository<User, Long> {
// 이메일 중복 확인
boolean existsByEmail(String email);
// 이메일로 조회
Optional<User> findByEmail(String email);
// UUID로 조회
Optional<User> findByUuid(UUID uuid);
}
public interface ProfileRepository extends JpaRepository<Profile, Long> {
// 닉네임 중복 확인
boolean existsByNickname(String nickname);
}
- JPA Repository 사용
회원 서비스 생성
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final ProfileRepository profileRepository;
private final PasswordEncoder passwordEncoder;
private final UserMapper userMapper;
private final ProfileMapper profileMapper;
// 회원가입
@Transactional
public UserDto createUser(UserCreateRequest request) {
log.debug("사용자 생성 시작: email={}", request.email());
// 중복검증
validateDuplicateUser(request.email(), request.nickname());
// 유저 생성
String hashedPassword = passwordEncoder.encode(request.password());
User user = new User(request.email(), hashedPassword);
Profile profile = new Profile(
request.nickname(),
request.name(),
request.phoneNumber(),
request.profileUrl(),
request.region()
);
user.attachProfile(profile);
// 저장
User savedUser = userRepository.save(user);
log.info("사용자 생성 완료 : id={} , email={}, username={}", savedUser.getId(), savedUser.getEmail(),
savedUser.getProfile().getName());
// userDto 변환 후 반환
return userMapper.toDto(savedUser);
}
// 이메일, 닉네임 중복 검증
private void validateDuplicateUser(String email, String nickname) {
if (userRepository.existsByEmail(email)) {
throw new BaseException(UserErrorCode.USER_EMAIL_EXISTS);
}
if (profileRepository.existsByNickname(nickname)) {
throw new BaseException(UserErrorCode.USER_NICKNAME_EXISTS);
}
}
// 이메일 중복 확인 (API 용도)
@Transactional(readOnly = true)
public void checkDuplicateEmail(String email) {
if(userRepository.existsByEmail(email)) {
throw new BaseException(UserErrorCode.USER_EMAIL_EXISTS);
}
}
// 닉네임 중복 확인 (API 용도)
@Transactional(readOnly = true)
public void checkDuplicateNickname(String nickname) {
if (profileRepository.existsByNickname(nickname)) {
throw new BaseException(UserErrorCode.USER_NICKNAME_EXISTS);
}
}
}
주요 내용
- 회원 가입
- PasswordEncoder 를 이용한 비밀번호 암호화
- User-Profile 연결
- 이메일, 닉네임 중복 검증
- @Transactional 로 User와 Profile 함께 저장
- 이메일, 닉네임 중복 확인 메서드
- API 용도로 사용할 이메일/닉네임 중복 메서드
회원 , 프로필 관련 DTO
UserCreateRequest
public record UserCreateRequest(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "유효한 이메일 형식이어야 합니다.")
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다.")
@Pattern(regexp = "^(?=.*\\\\d)(?=.*[a-zA-Z])(?=.*[!@#$%^&*])\\\\S{8,}$",
message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다.")
String password,
@NotBlank(message = "닉네임은 필수입니다.")
@Size(max = 20, message = "닉네임은 20자 이하여야 합니다.")
String nickname,
@NotBlank(message = "사용자 이름은 필수입니다.")
@Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하입니다.")
String name,
//010-1234-5678 , +82-10-1234-5678
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^(010-\\\\d{4}-\\\\d{4}|\\\\+82-10-\\\\d{4}-\\\\d{4})$")
String phoneNumber,
String profileUrl,
String region
) {
}
UserUpdateRequest
public record UserUpdateRequest(
@NotBlank(message = "닉네임은 필수입니다.")
@Size(max = 20, message = "닉네임은 20자 이하여야 합니다.")
String nickname,
@NotBlank(message = "사용자 이름은 필수입니다.")
@Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하입니다.")
String name,
//010-1234-5678 , +82-10-1234-5678
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^(010-\\\\d{4}-\\\\d{4}|\\\\+82-10-\\\\d{4}-\\\\d{4})$")
String phoneNumber,
String profileUrl,
String region
) {
}
UserDto
public record UserDto(
UUID userId, // 외부 식별자
String email,
String name,
String nickname,
String phoneNumber,
String profileUrl,
Role role
) {
}
회원 컨트롤러
@RestController
@RequestMapping("/users")
@Slf4j
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 회원가입
@PostMapping("/signup")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserCreateRequest request) {
UserDto userDto = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(userDto);
}
// 이메일 중복 확인
@GetMapping("/check-email")
public ResponseEntity<Void> checkEmail(
@RequestParam
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "유효한 이메일 형식이어야 합니다.")
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
String email) {
userService.checkDuplicateEmail(email);
return ResponseEntity.ok().build();
}
// 닉네임 중복 확인
@GetMapping("/check-nickname")
public ResponseEntity<Void> checkNickname(
@RequestParam
@NotBlank(message = "닉네임은 필수입니다.")
@Size(max = 20, message = "닉네임은 20자 이하여야 합니다.")
String nickname) {
userService.checkDuplicateNickname(nickname);
return ResponseEntity.ok().build();
}
}
참고자료
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 14. 회원가입 전, 이메일 인증 (0) | 2026.01.11 |
|---|---|
| [런닝 코스 공유 서비스] - 13. 회원 정보 조회 및 수정 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 11. JWT 로그인 (1) | 2026.01.02 |
| [런닝 코스 공유 서비스] - 10. 공통 예외 처리 (0) | 2025.12.21 |
| [런닝 코스 공유 서비스] - 9. 공통 엔티티 - BaseEntity (0) | 2025.12.21 |