풀스택
SBB 회원가입 기능 구현 정리
프로젝트: Spring Boot Board (SBB)
DB: MariaDB / 프레임워크: Spring Boot 3.3.1 + Thymeleaf + Spring Security
1. 전체 구성 흐름
SiteUser (엔티티)
└── UserRepository (JPA)
└── UserService (비즈니스 로직 + 비밀번호 암호화)
└── UserController (요청 처리)
└── signup_form.html (화면)
2. 엔티티 — SiteUser.java
@Getter
@Setter
@Entity
public class SiteUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Column(unique = true)
private String email;
}
핵심 어노테이션
| 어노테이션 | 설명 |
|---|
@Entity | 이 클래스를 JPA 엔티티(DB 테이블)로 등록 |
@Id | 기본 키(PK) 지정 |
@GeneratedValue(strategy = GenerationType.IDENTITY) | PK를 DB가 자동 증가(AUTO_INCREMENT)로 생성 |
@Column(unique = true) | 해당 컬럼에 유니크 제약 조건 추가 (중복값 불허) |
@Getter / @Setter | Lombok — getter/setter 자동 생성 |
왜 User가 아닌 SiteUser?
Spring Security에 이미 User 클래스가 존재하기 때문에 이름 충돌 방지를 위해 SiteUser로 명명.
@Getter
@Setter
public class UserCreateForm {
@Size(min = 3, max = 25)
@NotEmpty(message = "사용자ID는 필수항목입니다.")
private String username;
@NotEmpty(message = "비밀번호는 필수항목입니다.")
private String password1;
@NotEmpty(message = "비밀번호 확인은 필수항목입니다.")
private String password2;
@NotEmpty(message = "이메일은 필수항목입니다.")
@Email
private String email;
}
핵심 어노테이션 (jakarta.validation)
| 어노테이션 | 설명 |
|---|
@NotEmpty | null 및 빈 문자열 불허. message로 오류 메시지 지정 |
@Size(min, max) | 문자열 길이 범위 제한 |
@Email | 이메일 형식 유효성 검사 |
4. 서비스 — UserService.java
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
핵심 어노테이션
| 어노테이션 | 설명 |
|---|
@Service | 스프링 서비스 레이어 빈으로 등록 |
@RequiredArgsConstructor | Lombok — final 필드를 인자로 받는 생성자 자동 생성 (의존성 주입) |
BCryptPasswordEncoder
SecurityConfig에서 @Bean으로 등록
passwordEncoder.encode(password) 호출 시 비밀번호를 해시화하여 저장
- 같은 비밀번호라도 매번 다른 해시값 생성 (salt 포함)
- DB에서
SELECT * FROM SITE_USER로 확인하면 $2a$10$... 형태로 저장된 것을 확인 가능
5. 컨트롤러 — UserController.java
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup")
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "signup_form";
}
if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
bindingResult.rejectValue("password2", "passwordInCorrect",
"2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
try {
userService.create(...);
} catch (DataIntegrityViolationException e) {
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "signup_form";
} catch (Exception e) {
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
핵심 어노테이션
| 어노테이션 | 설명 |
|---|
@Controller | 스프링 MVC 컨트롤러 빈으로 등록 |
@RequestMapping("/user") | 이 컨트롤러의 기본 URL 경로 설정 |
@GetMapping("/signup") | GET 요청 처리 (회원가입 폼 화면 표시) |
@PostMapping("/signup") | POST 요청 처리 (회원가입 데이터 저장) |
@Valid | 폼 객체에 대해 jakarta.validation 검증 실행 |
주요 처리 패턴
BindingResult: @Valid 검증 결과를 담는 객체. 반드시 @Valid 바로 뒤에 위치해야 함
bindingResult.rejectValue(): 특정 필드 오류 등록
bindingResult.reject(): 전체(글로벌) 오류 등록 — form_errors.html에 표시됨
DataIntegrityViolationException: DB 유니크 제약 조건 위반 시 발생하는 예외 (중복 ID/이메일)
6. Spring Security 설정 — SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
...
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
핵심 어노테이션
| 어노테이션 | 설명 |
|---|
@Configuration | 스프링 설정 클래스 선언 |
@EnableWebSecurity | Spring Security 활성화 |
@Bean | 해당 메서드의 반환값을 스프링 빈으로 등록 |
7. Thymeleaf + Spring Security 연동 — navbar.html
html
<nav xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<a sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
<a sec:authorize="isAnonymous()" th:href="@{/user/signup}">회원가입</a>
</nav>
sec:authorize 표현식
| 표현식 | 설명 |
|---|
isAnonymous() | 비로그인 사용자에게만 표시 |
isAuthenticated() | 로그인한 사용자에게만 표시 |
hasRole('ADMIN') | 특정 역할을 가진 사용자에게만 표시 |
의존성 필요: build.gradle에 thymeleaf-extras-springsecurity6 추가 필요
네임스페이스 필요: xmlns:sec="..." 선언 없으면 sec:authorize 인식 안 됨
8. 오늘의 핵심 포인트 요약
- 엔티티 이름 충돌 주의 — Spring Security의
User와 이름 겹치면 문제 발생
@Column(unique = true) — DB 레벨에서 중복 방지, 위반 시 DataIntegrityViolationException 발생
- 비밀번호는 반드시 암호화 —
BCryptPasswordEncoder.encode() 사용
BindingResult는 @Valid 바로 뒤에 — 순서 틀리면 예외 발생
sec:authorize로 로그인 상태에 따라 메뉴 분기 — xmlns:sec 네임스페이스 선언 필수
9. 질문 목록 화면 개선 — question_list.html
변경 내용
- 글쓴이 컬럼 추가:
question.author.username 표시
- 번호 역순 계산: 단순
id 대신 페이징 기반 역순 번호 적용
- 답변 수 뱃지: 답변이 있을 경우 제목 옆에 빨간색 숫자 표시
html
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
<span th:if="${#lists.size(question.answerList) > 0}"
th:text="${#lists.size(question.answerList)}">
</span>
</td>
<td>
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</td>
역순 번호 계산 공식
전체 개수 - (현재 페이지 번호 × 페이지 사이즈) - 현재 행 인덱스
예) 전체 15개, 1페이지(0번), 한 페이지 10개일 때:
- 첫 번째 행: 15 - (0 × 10) - 0 = 15
- 열 번째 행: 15 - (0 × 10) - 9 = 6
10. 질문/답변 상세 화면 개선 — question_detail.html
변경 내용
- 질문 카드 우측 하단에 글쓴이 + 작성일시 뱃지 추가
- 답변 카드 우측 하단에 글쓴이 + 작성일시 뱃지 추가
- 비로그인 사용자는 답변 textarea 비활성화(disabled) 처리
html
<div class="flex justify-end">
<div class="bg-gray-100 text-gray-600 text-xs rounded-lg p-3 text-right">
<div class="mb-1">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<textarea sec:authorize="isAnonymous()" disabled th:field="*{content}">
</textarea>
<textarea sec:authorize="isAuthenticated()" th:field="*{content}">
</textarea>
엔티티 연관관계 구조
Question
├── @ManyToOne SiteUser author (질문 작성자)
└── @OneToMany List<Answer> answerList
Answer
├── @ManyToOne Question question (소속 질문)
└── @ManyToOne SiteUser author (답변 작성자)
th:if="${question.author != null}" — author가 없는 기존 데이터(null)가 있을 수 있으므로
null 체크 필수. 없으면 NullPointerException 발생.
11. 추가 핵심 포인트
#lists.size() — Thymeleaf에서 컬렉션 크기 구하는 유틸 표현식
#temporals.format() — Thymeleaf에서 날짜 포맷 지정 유틸 표현식
loop.index — th:each의 루프 상태 변수, 0부터 시작하는 현재 행 인덱스
paging.number — 현재 페이지 번호 (0부터 시작)
paging.size — 한 페이지에 표시되는 항목 수
paging.getTotalElements — 전체 데이터 개수
disabled + sec:authorize — 비로그인 사용자 입력 제한 시 UI/UX 패턴