반응형
알겠습니다. 앞서 제공했던 구조(스프링 부트 3 + 스프링 시큐리티 6 + JWT, 기존 User/Authority 엔티티, H2 또는 MariaDB 사용, PasswordEncoder, Security 설정, 컨트롤러/서비스/리포지토리 분리)를 그대로 준수하면서 “회원가입(사용자 생성)” 코드를 깨끗하게 다시 정리해 드립니다.
아래 코드는 다음을 충족합니다:
- Spring Boot 3, Security 6 방식(Lambda DSL, jakarta.*)
- PasswordEncoder(BCrypt)로 비밀번호 암호화
- 이메일 중복 검사
- 기본 권한 ROLE_USER 자동 부여
- DTO 검증(@Valid)
- 서비스 계층 트랜잭션
- Security에서 회원가입 엔드포인트 permitAll
- 앞서 답변해 드린 User/Authority 구조와 호환
프로젝트 패키지 예시
- com.example.jwt
- config
- user
- controller
- dto
- entity
- repository
- service
1) DTO
CreateUserRequest.java
package com.example.jwt.user.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100) String password,
@NotBlank @Size(min = 2, max = 50) String name,
String phone
) {}
UserResponse.java
package com.example.jwt.user.dto;
import java.time.LocalDateTime;
import java.util.Set;
public record UserResponse(
Long id,
String email,
String name,
String phone,
Set roles,
LocalDateTime createdDate
) {}
2) Entity
Authority.java
package com.example.jwt.user.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "authority")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
@Column(length = 255)
private String description;
}
User.java
package com.example.jwt.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.Set;
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100, unique = true, nullable = false)
private String email;
@Column(nullable = false, length = 255)
private String password;
@Column(length = 50, nullable = false)
private String name;
@Column(length = 20)
private String phone;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdDate;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_authority",
joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")}
)
private Set authorities;
@PrePersist
public void onCreate() {
if (createdDate == null) createdDate = LocalDateTime.now();
}
}
3) Repository
AuthorityRepository.java
package com.example.jwt.user.repository;
import com.example.jwt.user.entity.Authority;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorityRepository extends JpaRepository {
}
UserRepository.java
package com.example.jwt.user.repository;
import com.example.jwt.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository {
boolean existsByEmail(String email);
Optional findByEmail(String email);
}
4) Service
UserService.java
package com.example.jwt.user.service;
import com.example.jwt.user.dto.CreateUserRequest;
import com.example.jwt.user.dto.UserResponse;
public interface UserService {
UserResponse createUser(CreateUserRequest request);
}
UserServiceImpl.java
package com.example.jwt.user.service;
import com.example.jwt.user.dto.CreateUserRequest;
import com.example.jwt.user.dto.UserResponse;
import com.example.jwt.user.entity.Authority;
import com.example.jwt.user.entity.User;
import com.example.jwt.user.repository.AuthorityRepository;
import com.example.jwt.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserResponse createUser(CreateUserRequest request) {
// 이메일 중복 체크
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
// 기본 권한 확보(없으면 생성)
Authority roleUser = authorityRepository.findById("ROLE_USER")
.orElseGet(() -> authorityRepository.save(
Authority.builder()
.authorityName("ROLE_USER")
.description("일반 사용자 권한")
.build()
));
// 비밀번호 암호화
String encodedPw = passwordEncoder.encode(request.password());
// 엔티티 생성
User user = User.builder()
.email(request.email())
.password(encodedPw)
.name(request.name())
.phone(request.phone())
.authorities(Set.of(roleUser))
.build();
// 저장
User saved = userRepository.save(user);
// 응답 변환
return new UserResponse(
saved.getId(),
saved.getEmail(),
saved.getName(),
saved.getPhone(),
saved.getAuthorities().stream()
.map(Authority::getAuthorityName)
.collect(Collectors.toSet()),
saved.getCreatedDate()
);
}
}
5) Controller
UserController.java
package com.example.jwt.user.controller;
import com.example.jwt.user.dto.CreateUserRequest;
import com.example.jwt.user.dto.UserResponse;
import com.example.jwt.user.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
private final UserService userService;
// 회원가입 (비인증 접근 허용)
@PostMapping
public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUser(request));
}
}
6) Security 설정
SecurityConfig.java
package com.example.jwt.config;
import com.example.jwt.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.*;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCrypt
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsConfigurationSource()))
.headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
// 회원가입/로그인/H2 콘솔 허용
.requestMatchers("/api/users", "/api/authenticate", "/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
var cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(Arrays.asList("*"));
cfg.setAllowedMethods(Arrays.asList("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
cfg.setAllowedHeaders(Arrays.asList("*"));
cfg.setAllowCredentials(true);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
}
7) 예외 처리(선택)
GlobalExceptionHandler.java
package com.example.jwt.common;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(err -> err.getField() + " " + err.getDefaultMessage())
.orElse("유효성 검증 실패");
return ResponseEntity.badRequest().body(Map.of("message", msg));
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleOther(Exception e) {
return ResponseEntity.internalServerError().body(Map.of("message", "서버 오류가 발생했습니다."));
}
}
8) application.yml 예시(H2 메모리로 빠르게 테스트)
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# CORS, 서버 포트 등 필요시 추가
server:
port: 8080
9) 요청/응답 예시
요청
POST /api/users
{
"email": "user1@example.com",
"password": "Password123!",
"name": "홍길동",
"phone": "010-1234-5678"
}
성공 응답
{
"id": 1,
"email": "user1@example.com",
"name": "홍길동",
"phone": "010-1234-5678",
"roles": ["ROLE_USER"],
"createdDate": "2025-08-18T10:30:25.123"
}
구현 체크포인트
- 엔티티 이름/필드(users, authority, user_authority)는 앞서 만든 테이블/엔티티와 일치합니다.
- PasswordEncoder를 반드시 빈으로 등록해 BCrypt 해싱 사용.
- SecurityConfig에서 /api/users(POST)와 /api/authenticate는 permitAll.
- Authority “ROLE_USER”가 없으면 회원가입 시 자동 생성.
- Service 계층에서 트랜잭션 처리와 검증 예외(IllegalArgumentException) 명확화.
- DTO(@Valid)로 요청 검증.
MariaDB를 사용할 경우
- datasource URL/드라이버 교체
- 앞서 제공한 DDL(users, authority, user_authority)을 사용하거나 JPA ddl-auto=update로 생성
혹시 기존 프로젝트의 패키지/클래스명이 다르다면, import/패키지 경로만 맞춰 붙여 넣으면 바로 동작합니다. 필요한 경우 이메일 인증(토큰 발송), 추가 유효성 검사(정규식), 관리자 생성/권한 부여, 응답 마스킹 등도 확장해 드릴 수 있습니다.
반응형