Skip to content

Commit 24ad8a9

Browse files
authored
feat: 회원가입 api 구현
feat: 회원가입 api 구현
2 parents c0a25f4 + 6d3156d commit 24ad8a9

13 files changed

Lines changed: 447 additions & 12 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.bootsignal.domain.auth.controller;
2+
3+
import com.bootsignal.domain.auth.dto.SignupRequest;
4+
import com.bootsignal.domain.auth.dto.SignupResponse;
5+
import com.bootsignal.domain.auth.service.AuthService;
6+
import jakarta.validation.Valid;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestBody;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.ResponseStatus;
13+
import org.springframework.web.bind.annotation.RestController;
14+
15+
@RestController
16+
@RequestMapping("/api/auth")
17+
@RequiredArgsConstructor
18+
public class AuthController {
19+
20+
private final AuthService authService;
21+
22+
@PostMapping("/signup")
23+
@ResponseStatus(HttpStatus.CREATED)
24+
public SignupResponse signup(@Valid @RequestBody SignupRequest request) {
25+
return authService.signup(request);
26+
}
27+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.bootsignal.domain.auth.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
public record SignupRequest(
8+
@NotBlank(message = "이메일은 필수입니다.")
9+
@Email(message = "이메일 형식이 올바르지 않습니다.")
10+
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
11+
String email,
12+
13+
@NotBlank(message = "비밀번호는 필수입니다.")
14+
@Size(min = 8, max = 64, message = "비밀번호는 8자 이상 64자 이하이어야 합니다.")
15+
String password,
16+
17+
@NotBlank(message = "닉네임은 필수입니다.")
18+
@Size(max = 30, message = "닉네임은 30자 이하여야 합니다.")
19+
String nickname
20+
) {
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.bootsignal.domain.auth.dto;
2+
3+
import com.bootsignal.domain.user.entity.User;
4+
5+
public record SignupResponse(
6+
Long id,
7+
String email,
8+
String nickname
9+
) {
10+
11+
public static SignupResponse from(User user) {
12+
return new SignupResponse(user.getId(), user.getEmail(), user.getNickname());
13+
}
14+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.bootsignal.domain.auth.service;
2+
3+
import com.bootsignal.domain.auth.dto.SignupRequest;
4+
import com.bootsignal.domain.auth.dto.SignupResponse;
5+
import com.bootsignal.domain.user.entity.User;
6+
import com.bootsignal.domain.user.repository.UserRepository;
7+
import com.bootsignal.global.exception.BootSignalException;
8+
import com.bootsignal.global.exception.ErrorCode;
9+
import java.util.Locale;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.dao.DataIntegrityViolationException;
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(readOnly = true)
19+
public class AuthService {
20+
21+
private final UserRepository userRepository;
22+
private final PasswordEncoder passwordEncoder;
23+
24+
@Transactional
25+
public SignupResponse signup(SignupRequest request) {
26+
String email = normalizeEmail(request.email());
27+
String nickname = request.nickname().strip();
28+
if (userRepository.existsByEmail(email)) {
29+
throw new BootSignalException(ErrorCode.DUPLICATE_EMAIL);
30+
}
31+
if (userRepository.existsByNickname(nickname)) {
32+
throw new BootSignalException(ErrorCode.DUPLICATE_NICKNAME);
33+
}
34+
35+
User user = User.signupLocal(
36+
email,
37+
passwordEncoder.encode(request.password()),
38+
nickname
39+
);
40+
41+
try {
42+
return SignupResponse.from(userRepository.save(user));
43+
} catch (DataIntegrityViolationException exception) {
44+
throw new BootSignalException(resolveDuplicateErrorCode(exception));
45+
}
46+
}
47+
48+
private ErrorCode resolveDuplicateErrorCode(DataIntegrityViolationException exception) {
49+
String message = exception.getMostSpecificCause().getMessage();
50+
// 동시 요청으로 닉네임 유니크 제약이 발생한 경우를 분리한다.
51+
if (message != null && message.contains("uk_users_nickname")) {
52+
return ErrorCode.DUPLICATE_NICKNAME;
53+
}
54+
return ErrorCode.DUPLICATE_EMAIL;
55+
}
56+
57+
private String normalizeEmail(String email) {
58+
return email.strip().toLowerCase(Locale.ROOT);
59+
}
60+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.bootsignal.domain.user.entity;
2+
3+
public enum AuthProvider {
4+
LOCAL,
5+
GOOGLE,
6+
KAKAO
7+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.bootsignal.domain.user.entity;
2+
3+
import com.bootsignal.global.entity.BaseEntity;
4+
import jakarta.persistence.Column;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.EnumType;
7+
import jakarta.persistence.Enumerated;
8+
import jakarta.persistence.GeneratedValue;
9+
import jakarta.persistence.GenerationType;
10+
import jakarta.persistence.Id;
11+
import jakarta.persistence.Table;
12+
import jakarta.persistence.UniqueConstraint;
13+
import java.time.LocalDateTime;
14+
import lombok.AccessLevel;
15+
import lombok.Getter;
16+
import lombok.NoArgsConstructor;
17+
18+
@Entity
19+
@Getter
20+
@Table(
21+
name = "users",
22+
uniqueConstraints = {
23+
@UniqueConstraint(name = "uk_users_email", columnNames = "email"),
24+
@UniqueConstraint(name = "uk_users_nickname", columnNames = "nickname")
25+
}
26+
)
27+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
28+
public class User extends BaseEntity {
29+
30+
@Id
31+
@GeneratedValue(strategy = GenerationType.IDENTITY)
32+
private Long id;
33+
34+
@Column(nullable = false, unique = true, length = 255)
35+
private String email;
36+
37+
@Column(name = "password_hash", length = 255)
38+
private String passwordHash;
39+
40+
@Column(nullable = false, length = 100)
41+
private String name;
42+
43+
@Column(nullable = false, unique = true, length = 50)
44+
private String nickname;
45+
46+
@Enumerated(EnumType.STRING)
47+
@Column(nullable = false, length = 20)
48+
private UserRole role;
49+
50+
@Enumerated(EnumType.STRING)
51+
@Column(nullable = false, length = 20)
52+
private AuthProvider provider;
53+
54+
@Column(name = "profile_image_url", columnDefinition = "text")
55+
private String profileImageUrl;
56+
57+
@Column(name = "is_deleted", nullable = false)
58+
private boolean deleted;
59+
60+
@Column(name = "deleted_at")
61+
private LocalDateTime deletedAt;
62+
63+
private User(String email, String passwordHash, String name, String nickname) {
64+
this.email = email;
65+
this.passwordHash = passwordHash;
66+
this.name = name;
67+
this.nickname = nickname;
68+
this.role = UserRole.USER;
69+
this.provider = AuthProvider.LOCAL;
70+
this.deleted = false;
71+
}
72+
73+
public static User signupLocal(String email, String encodedPassword, String nickname) {
74+
// 기존 users 스키마의 필수 name 컬럼은 닉네임과 같은 값으로 저장한다.
75+
return new User(email, encodedPassword, nickname, nickname);
76+
}
77+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.bootsignal.domain.user.entity;
2+
3+
public enum UserRole {
4+
USER,
5+
ADMIN
6+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.bootsignal.domain.user.repository;
2+
3+
import com.bootsignal.domain.user.entity.User;
4+
import java.util.Optional;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
public interface UserRepository extends JpaRepository<User, Long> {
8+
9+
boolean existsByEmail(String email);
10+
11+
boolean existsByNickname(String nickname);
12+
13+
Optional<User> findByEmail(String email);
14+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ public enum ErrorCode {
88
NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
99
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP 메서드입니다."),
1010
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "지원하지 않는 미디어 타입입니다."),
11+
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "이미 가입된 이메일입니다."),
12+
DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "DUPLICATE_NICKNAME", "이미 사용 중인 닉네임입니다."),
1113
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.");
1214

1315
private final HttpStatus status;
Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
spring:
22
datasource:
3-
url: jdbc:h2:mem:bootsignal;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
4-
driver-class-name: org.h2.Driver
5-
username: sa
6-
password:
7-
h2:
8-
console:
9-
enabled: true
10-
path: /h2-console
3+
url: ${DB_URL}
4+
driver-class-name: com.mysql.cj.jdbc.Driver
5+
username: ${DB_USERNAME}
6+
password: ${DB_PASSWORD}
117
jpa:
128
hibernate:
13-
ddl-auto: create-drop
14-
database-platform: org.hibernate.dialect.H2Dialect
9+
ddl-auto: none
10+
database-platform: org.hibernate.dialect.MySQLDialect
1511
data:
1612
redis:
1713
host: ${REDIS_HOST:localhost}
@@ -20,4 +16,3 @@ spring:
2016
logging:
2117
level:
2218
org.hibernate.SQL: debug
23-

0 commit comments

Comments
 (0)