gradle.build 파일에 필요한 라이브러리를 작성했습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
validation
의 경우 dto에서 사용할 어노테이션을 위해 추가했습니다.
@Entity
@Table(name = "users")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String nickname;
private String gender;
private int age;
private LocalDate birthdate;
private LocalDateTime created_at;
private LocalDateTime updated_at;
private int access;
// getter setter 메서드 생략..
}
@Table(name = "users")
을 통해 userEntity테이블이 아닌 users테이블로 접근할 수 있습니다.
id필드가 자동으로 1씩 증가하게 만들기 위해 @GeneratedValue(strategy = GenerationType.IDENTITY)
를 사용했습니다.
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByEmail(String email);
}
public interface UserRepository extends JpaRepository<UserEntity, Long>
UserRepository
는 인터페이스이고 이 인터페이스는 JpaRepository
를 확장하고 있습니다.
JpaRepository
는 Spring Data JPA에서 제공하는 인터페이스로 CRUD 기능들을 기본적으로 제공합니다.
<UserEntity, Long>
엔터티 타입은 UserEntity
이며 Long
은 이 엔터티의 id가 Long임을 뜻합니다.
Optional<UserEntity> findByEmail(String email)
이 메서드는 이메일을 통해 사용자를 찾는 메서드입니다.
findByEmail이라는 이름만으로 JPA가 이메일을 통해 사용자를 찾아줍니다.
optional
은 값이 있을 수도 없을 수도 있는 java객체인데 이메일을 통해 사용자를 찾을 수도 있지만 해당 이메일의 사용자가 없을 수도 있기에 사용했습니다.
public class UserRequestDto {
@Email
@NotBlank(message = "이메일을 입력해주세요")
private String email;
@NotBlank(message = "비밀번호를 입력해주세요")
private String password;
@NotBlank(message = "비밀번호가 다릅니다, 비밀번호를 확인해주세요")
private String password2;
private String nickname;
private String gender;
private int age;
// getter setter 메서드 생략..
}
DTO를 작성해 필요한 데이터만 제공하고 불필요한 데이터의 노출을 방지할 수 있습니다.
회원가입 시 사용될 DTO로 회원가입 시 요구할 필드로만 작성을 했습니다.
@email
은 이메일 형식을 판단해주는 어노테이션이며
@NotBlank
는 null, "", " "와 같은 데이터를 받지 않는 어노테이션입니다.
위와 같은 데이터가 들어오면 message에 적힌 문구를 반환합니다.
password2
의 경우 비밀번호 확인용 필드로 UserEntity
에는 들어가지 않지만 회원가입 단계에서 해당 검증이 필요하기에 작성했습니다.
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
사용자의 정보를 데이터베이스에 저장할 때 비밀번호를 그대로 저장하지 않고 해시화한 상태에서 저장할 수 있도록 SecurityConfig
클래스를 작성했습니다.
bean에서 관리할 수 있도록 @Bean
어노테이션을 주고 spring security에 있는 PasswordEncoder
와 BCryptPasswordEncoder
를 사용했습니다.
PasswordEncoder
는 spring security에서 제공하는 인터페이스로 비밀번호 인코딩을 위한 다양한 메서드를 가지고 있습니다.
BCryptPasswordEncoder
는 PasswordEncoder를 구현한 클래스 중 하나로 BCrypt 해싱 알고리즘을 사용하여 비밀번호를 인코딩합니다.
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
UserService
는 UserRepository
와 PasswordEncoder
에 의존하며 두 변수는 클래스 내에서 사용되고 final
이 붙어있기에 한 번 할당되면 변경되지 않습니다.
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
생성자를 통해 UserRepository
와 TokenUtil
객체가 주입됩니다. 이렇게 되면 UserService
는 외부에서 의존성을 주입받아 사용하기 때문에 코드의 유연성과 테스트 용이성이 향상됩니다.
private void validateDuplicateusers(UserRequestDto userRequestDto) {
if(userRepository.findByEmail(userRequestDto.getEmail()).isPresent()) {
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
회원가입 시 입력한 이메일로 등록한 계정이 있는지 검사하는 메서드입니다.
UserRequestDto
객체로 회원가입 시 작성한 데이터를 받아 데이터베이스에 해당 이메일로 가입한 계정이 있는지 검사하고 해당 계정이 존재할 경우 IllegalStateException
에러를 발생시킵니다.
private void validatepassword(UserRequestDto userRequestDto) {
if(!userRequestDto.getPassword().equals(userRequestDto.getPassword2())) {
throw new IllegalArgumentException("비밀번호와 확인용 비밀번호가 일치하지 않습니다.");
}
}
회원가입 시 확인용 비밀번호와 입력한 비밀번호가 일치하는지 확인해주는 메서드입니다.
2개의 비밀번호가 맞지 않는 경우 IllegalArgumentException
에러를 발생시킵니다.
public String join(UserRequestDto userRequestDto) {
validateDuplicateusers(userRequestDto); //중복 회원 검증
validatepassword(userRequestDto); // 비밀번호 일치 검증
UserEntity userEntity = new UserEntity();
userEntity.setEmail(userRequestDto.getEmail());
// 비밀번호 해시화
String rawPassword = userRequestDto.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
userEntity.setPassword(encodedPassword);
userEntity.setNickname(userRequestDto.getNickname());
userEntity.setGender(userRequestDto.getGender());
userEntity.setAge(userRequestDto.getAge());
// 한국 시간으로 created_at 저장
LocalDateTime koreatime = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
userEntity.setCreated_at(koreatime);
userRepository.save(userEntity);
return "회원가입 완료!";
}
UserRequestDto
객체로 회원가입시 입력한 정보를 받고 위에 설명한 검증메서드 2개를 검사한 다음 각 필드별로 set메서드를 사용합니다.
rawPassword
는 사용자가 회원가입 시 작성한 비밀번호입니다. 이 비밀번호를 위에 작성했던 encodedPassword
메서드를 사용해 해시화 한 후 set메서드를 사용합니다.
created_at
필드는 회원가입을 하는 당시의 날짜와 시간을 담는 필드입니다. now()
를 이용해 작성할 당시 다른 지역의 시간이 찍혔기에 정확하게 서울의 시간을 담는 koreatime
변수를 적용해 set메서드를 사용했습니다.
각 필드들에 대한 적용이 끝나면 save메서드를 이용해 데이터를 저장합니다.
Spring Security를 사용하면 권한이 없는 경우 모두 차단되는데 권한을 요구하지 않는 회원가입의 경우에도 미리 차단되어 있습니다.
따라서 위에서 작성했던 SecurityConfig
에 회원가입 시에는 권한이 없어도 사용할 수 있도록 코드를 작성하겠습니다.
@Configuration
public class SecurityConfig {
private static final String[] WHITE_LIST = {
"/users/join",
"/**"
};
@Bean
protected SecurityFilterChain config(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(WHITE_LIST).permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
String 배열로 WHITE_LIST
라는 상수를 선언했습니다. WHITE_LIST
는 권한이 없어도 사용가능한 경로를 입력해주었습니다.
먼저 회원가입의 경로인 /users/join
을 등록해주고 /**
의 경우 모든 경로를 지칭합니다.
/**
가 쓰여져 있을 경우 모든 경로를 포함하고 있기에 /users/join
을 따로 등록해주지 않아도 되지만 명시적으로 표시하고 싶을 경우엔 표시할 수 있고, 현재는 개발편의를 위해 /**
를 잠시 사용하지만 향후 개발이 진행됨에 따라 사용하지 않을 예정이기에 미리 예외경로를 입력해두는 것이 좋다고 생각했습니다.
SecurityFilterChain타입 객체를 반환하는 config
메서드를 만들었습니다.
Spring Security 5.7.0 부터 WebSecurityConfigurerAdapter
는 더이상 사용되지 않고 SecurityFilterChain
를 사용하게 되며 반환값이 있고 bean을 등록하게 되었습니다.
jwt토큰을 사용할 예정이기에 csrf를 disable로 설정했습니다.
Spring Security는 기본적으로 CSRF 방어 기능을 활성화하는데 CSRF토큰을 보유하지 않는다면 모든 요청이 차단됩니다.
.authorizeHttpRequests(authorize -> authorize
이 코드는 HTTP 요청에 대한 접근 제어를 설정합니다.
.requestMatchers(WHITE_LIST).permitAll())
WHITE_LIST에 정의된 경로들에 대한 요청을 모두 허용하며, permitAll()은 인증 여부와 관계없이 접근을 허용하겠다는 의미입니다.
return http.build()
최종적으로 구성된 HttpSecurity 설정을 바탕으로 SecurityFilterChain 객체를 빌드하여 반환합니다.
요약하면, CSRF 방지 기능을 비활성화하고, WHITE_LIST
에 명시된 경로에 대한 요청을 모든 사용자에게 허용하는 Spring Security 설정을 정의하는 것입니다.
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping("/join")
public ResponseEntity<?> join(@Valid @RequestBody UserRequestDto userRequestDto, BindingResult result) {
if (result.hasErrors()) {
List<String> errors = result.getAllErrors().stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok(userService.join(userRequestDto));
}
}
@RestController
는 @Controller
에 @ResponseBody
가 추가된 것으로 주용도는 Json 형태로 객체 데이터를 반환하는 것입니다.
@RequestMapping("/users")
를 사용해서 /users로 들어오는 모든 요청을UserController
가 받게 했습니다. 이 방식을 이용해 메서드들이 /users를 생략하고 url을 기입할 수 있습니다.(/users/join -> /join)
이후 UserService
의존성을 주입해주었습니다.
@PostMapping
어노테이션을 사용해 /join으로 오는 요청을 join메서드가 받게 했습니다.
join메서드는 ResponseEntity
객체를 반환하는데 HTTP 응답의 본문, 상태 코드, 헤더 등을 포함할 수 있으며, <?>
는 반환될 본문의 타입이 무엇이든 될 수 있음을 나타냅니다.
@RequestBody
: HTTP 요청 본문을 UserRequestDto 객체로 변환해줍니다.
@Valid
: UserRequestDto에서 정의한대로 객체의 유효성 검사를 수행합니다.
BindingResult
: 검사 결과를 포함하는 객체로 검사 중 발생한 오류 정보를 담고 있습니다.
if문과 hasErrors
를 사용해 에러가 있었을 때 해당 에러들의 메세지를 가지고 클라이언트에게 상태코드와 메세지를 반환합니다.
유효성 검사에 문제가 없었을 경우 UserService
의 join
메서드를 호출해 회원가입 로직을 수행하고 결과를 반환합니다.