런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 12. 회원 , 프로필 도메인 및 회원가입 API 구현

sson-coding 2026. 1. 11. 15:20

🏅오늘의 목표

  • 회원 (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 관계)

주요 내용

  1. User 와 Profile 분리
    • 인증 정보와 화면 노출 정보를 분리
    • 프로필은 설계(기획)에 따라 필드가 변경이 자주 일어날 수 있기 때문에 분리
  2. 양방향 관계 설정
    • attachProfile(), attachUser() 로 양방향 관계 설정
    • Profile 은 User 없이 존재할 수 없기 때문에 외래키를 Profile 이 가지고 있음
    • cascade = CascadeType.ALL 로 User 저장 시 Profile 도 함께 저장
  3. 소프트 삭제
    • deleteAt 필드로 탈퇴 시점 기록
  4. 상태 관리
    • UserStatus 로 계정 상태 관리(ACTIVE,INACTIVE,WITHDRAW)
  5. @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 : 지역 정보

주요 내용

  1. User 와 관계
    • 1 : 1 관계
    • 외래키 소유
    • FetchType.LAZY 로 지연 로딩 설정
  2. 접근 제어
    • 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);
		}
	}
}

주요 내용

  1. 회원 가입
    • PasswordEncoder 를 이용한 비밀번호 암호화
    • User-Profile 연결
    • 이메일, 닉네임 중복 검증
    • @Transactional 로 User와 Profile 함께 저장
  2. 이메일, 닉네임 중복 확인 메서드
  3. 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();
	}
}

참고자료

https://ddururiiiiiii.tistory.com/604