Fullstack 57

heo4·2026년 7월 6일

Fullstack

목록 보기
12/71

풀스택

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 / @SetterLombok — getter/setter 자동 생성

왜 User가 아닌 SiteUser?
Spring Security에 이미 User 클래스가 존재하기 때문에 이름 충돌 방지를 위해 SiteUser로 명명.


3. 폼 검증 — UserCreateForm.java

@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)

어노테이션설명
@NotEmptynull 및 빈 문자열 불허. 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스프링 서비스 레이어 빈으로 등록
@RequiredArgsConstructorLombok — 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스프링 설정 클래스 선언
@EnableWebSecuritySpring 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. 오늘의 핵심 포인트 요약

  1. 엔티티 이름 충돌 주의 — Spring Security의 User와 이름 겹치면 문제 발생
  2. @Column(unique = true) — DB 레벨에서 중복 방지, 위반 시 DataIntegrityViolationException 발생
  3. 비밀번호는 반드시 암호화 — BCryptPasswordEncoder.encode() 사용
  4. BindingResult는 @Valid 바로 뒤에 — 순서 틀리면 예외 발생
  5. 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: 로그인 상태에 따라 분기 -->
<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. 추가 핵심 포인트

  1. #lists.size() — Thymeleaf에서 컬렉션 크기 구하는 유틸 표현식
  2. #temporals.format() — Thymeleaf에서 날짜 포맷 지정 유틸 표현식
  3. loop.index — th:each의 루프 상태 변수, 0부터 시작하는 현재 행 인덱스
  4. paging.number — 현재 페이지 번호 (0부터 시작)
  5. paging.size — 한 페이지에 표시되는 항목 수
  6. paging.getTotalElements — 전체 데이터 개수
  7. disabled + sec:authorize — 비로그인 사용자 입력 제한 시 UI/UX 패턴

0개의 댓글