오늘은 Spring Boot로 회원가입 시스템을 구현해보려고 합니다.
실제 서비스를 개발하다 보면, "아 이거 그냥 Controller에서 한번에 다 처리하면 되는 거 아닌가?"라는 생각이 들 수 있습니다.
예를 들어, 회원가입 시 다음과 같은 코드를 작성했다고 해봅시다:
@PostMapping("/signup")
public String signup(@RequestBody SignupRequest request) {
// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
// 중복 체크
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException();
}
// 이메일 발송
emailService.sendVerificationEmail(request.getEmail());
// 유저 저장
User user = new User(request.getEmail(), encodedPassword);
userRepository.save(user);
return "redirect:/";
}
이런 코드가 어떤 문제를 일으킬까요?
그래서 우리는 Service 계층을 다음과 같이 분리합니다:
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
public User signup(SignupRequest request) {
// 비즈니스 로직 집중화
validateSignupRequest(request);
User user = createUser(request);
sendVerificationEmail(user);
return user;
}
private void validateSignupRequest(SignupRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("이미 사용중인 이메일입니다");
}
}
private User createUser(SignupRequest request) {
String encodedPassword = passwordEncoder.encode(request.getPassword());
return userRepository.save(new User(request.getEmail(), encodedPassword));
}
}
이렇게 하면 어떤 장점이 있을까요?
@Transactional로 모든 데이터 처리가 원자적으로 이루어져요.회원가입 시스템을 구현할 때는 다음 세 가지를 특히 중요하게 고려해야 합니다
이제 각 계층별로 어떻게 구현하는지 살펴보겠습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String nickname;
@Enumerated(EnumType.STRING)
private Role role;
@Builder
public Member(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname;
this.role = Role.USER;
}
}
여기서 주목할 점은:
@Column(unique = true)로 이메일 중복 방지@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByNickname(String nickname);
}
단순해 보이지만, 실제로는 Spring Data JPA가 많은 일을 해주고 있습니다. 메소드 이름만으로 쿼리가 자동 생성되죠.
Service 계층은 실제 비즈니스 로직이 동작하는 곳입니다:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Long signUp(SignUpRequest request) {
validateSignUpInfo(request);
Member member = Member.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.nickname(request.getNickname())
.build();
Member savedMember = memberRepository.save(member);
eventPublisher.publishEvent(new MemberSignedUpEvent(savedMember));
return savedMember.getId();
}
private void validateSignUpInfo(SignUpRequest request) {
if (memberRepository.existsByEmail(request.getEmail())) {
throw new DuplicateMemberException("이미 가입된 이메일입니다.");
}
if (memberRepository.existsByNickname(request.getNickname())) {
throw new DuplicateMemberException("이미 사용중인 닉네임입니다.");
}
}
}
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<ApiResponse<Long>> signUp(
@Valid @RequestBody SignUpRequest request) {
Long memberId = memberService.signUp(request);
return ResponseEntity.ok(ApiResponse.success(memberId));
}
@ExceptionHandler(DuplicateMemberException.class)
public ResponseEntity<ApiResponse<Void>> handleDuplicateMemberException(
DuplicateMemberException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
}

html도 간단하게 작성하면 이렇게 회원가입 폼을 볼 수 있습니다.
@Transactional(readOnly = true)
public class MemberService {
@Transactional
public Long signUp(SignUpRequest request) {
// 회원가입 로직
}
}
읽기 전용 트랜잭션을 기본으로 설정하고, 쓰기가 필요한 메서드에만 @Transactional을 붙여 성능을 최적화합니다.
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
@Component
@RequiredArgsConstructor
public class MemberSignUpEventListener {
private final EmailService emailService;
@EventListener
@Async
public void handleSignUpEvent(MemberSignedUpEvent event) {
emailService.sendWelcomeEmail(event.getMember());
}
}
이벤트 기반으로 구현하면 회원가입 후의 부가 기능(이메일 발송, 포인트 지급 등)을 유연하게 추가할 수 있습니다.
이러한 점들을 고려하면서 개발하면 실제 서비스에서도 잘 동작하는 회원가입 시스템을 만들 수 있습니다.