어제 오늘 내일

[Spring Security] 5편 - 회원가입 & 비밀번호 암호화 (BCrypt) 본문

IT/SpringBoot

[Spring Security] 5편 - 회원가입 & 비밀번호 암호화 (BCrypt)

hi.anna 2026. 2. 18. 07:40

지금까지는 서버가 켜질 때 자동으로 생성되는 admin 계정만 사용했습니다.
이번 5편에서는 사용자가 직접 아이디와 비밀번호를 입력해 가입하고, 그 비밀번호를 안전하게 암호화하여 DB에 저장하는 기능을 구현합니다.

특히 마지막에는 DB를 직접 조회해서 비밀번호가 정말 암호화되었는지 확인해보겠습니다.


Step 1. SecurityConfig 수정 (H2 Console 허용)

나중에 DB에 데이터가 잘 들어갔는지 확인하려면 H2 Console(localhost:8080/h2-console)에 접속해야 합니다. 하지만 시큐리티는 기본적으로 이 경로도 막아버립니다.

접속을 허용하고, 화면이 깨지지 않도록 설정을 추가하겠습니다.

  • 위치: src/main/java/com/example/board/config/SecurityConfig.java
package com.example.board.config;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest; // [중요] 추가하세요
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf((csrf) -> csrf.disable())

            // ▼▼▼ 1. H2 Console 화면 깨짐 방지 (Frame 허용) ▼▼▼
            .headers((headers) -> headers
                .frameOptions((frame) -> frame.sameOrigin())
            )

            .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/", "/login", "/join", "/test").permitAll()
                // ▼▼▼ 2. H2 Console 접속 허용 (시큐리티 무시) ▼▼▼
                .requestMatchers(PathRequest.toH2Console()).permitAll() 
                .anyRequest().authenticated()
            )
            .formLogin((form) -> form
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/", true)
                .permitAll()
            )
            .logout((logout) -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

💡 코드 상세 해석

  • PathRequest.toH2Console(): /h2-console/** 하위 모든 요청을 가리킵니다. 이를 permitAll() 하여 로그인 검사를 면제해줍니다.
  • frameOptions().sameOrigin(): H2 Console은 내부적으로 <iframe> 태그를 사용하는데, 시큐리티는 보안상 이를 차단합니다. 같은 도메인 내에서는 허용하도록 설정을 푼 것입니다.

Step 2. 회원가입용 DTO 만들기

Entity(Member)를 컨트롤러에서 직접 노출하지 않고, 가입용 데이터를 전달받을 객체(DTO)를 따로 만듭니다.

  • 위치: src/main/java/com/example/board/dto/MemberJoinDto.java
package com.example.board.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class MemberJoinDto {
    private String username;
    private String password;
}

Step 3. 회원가입 로직 만들기 (MemberService)

비밀번호 암호화의 핵심 로직이 들어있는 곳입니다. SecurityConfig에서 등록한 PasswordEncoder를 주입받아 사용합니다.

  • 위치: src/main/java/com/example/board/service/MemberService.java
package com.example.board.service;

import com.example.board.domain.entity.Member;
import com.example.board.domain.repository.MemberRepository;
import com.example.board.dto.MemberJoinDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder; // 암호화 객체 주입

    public void join(MemberJoinDto dto) {
        // 1. 아이디 중복 체크
        if (memberRepository.findByUsername(dto.getUsername()).isPresent()) {
            throw new IllegalStateException("이미 존재하는 아이디입니다.");
        }

        // 2. [핵심] 비밀번호 암호화
        // 사용자가 입력한 "1234"를 "$2a$10$..." 형태의 암호문으로 변환합니다.
        String encodedPassword = passwordEncoder.encode(dto.getPassword());

        // 3. DB 저장
        Member member = Member.builder()
                .username(dto.getUsername())
                .password(encodedPassword) // 암호화된 비번 저장
                .role("USER")
                .build();

        memberRepository.save(member);
    }
}

Step 4. 컨트롤러 연결하기 (MemberController)

  • 위치: src/main/java/com/example/board/controller/MemberController.java
package com.example.board.controller;

import com.example.board.dto.MemberJoinDto;
import com.example.board.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@RequiredArgsConstructor
@Controller
public class MemberController {

    private final MemberService memberService;

    // 기존 로그인 페이지 매핑
    @GetMapping("/login")
    public String login() {
        return "login";
    }

    // 회원가입 페이지 이동
    @GetMapping("/join")
    public String joinForm() {
        return "join"; // templates/join.html
    }

    // 회원가입 처리
    @PostMapping("/join")
    public String join(MemberJoinDto dto) {
        try {
            memberService.join(dto); // 서비스 호출
        } catch (IllegalStateException e) {
            return "redirect:/join?error=duplicate"; // 중복 아이디 발생 시
        }
        return "redirect:/login"; // 성공 시 로그인 페이지로
    }
}

Step 5. 회원가입 화면 만들기 (join.html)

로그인 화면과 비슷하지만 action/join입니다.

  • 위치: src/main/resources/templates/join.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>회원가입</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .join-form { width: 300px; margin: 100px auto; }
    </style>
</head>
<body>
<div class="container join-form">
    <h2 class="text-center mb-4">📝 회원가입</h2>

    <div th:if="${param.error}" class="alert alert-danger">
        이미 사용 중인 아이디입니다.
    </div>

    <form action="/join" method="post">
        <div class="mb-3">
            <label for="username" class="form-label">아이디</label>
            <input type="text" class="form-control" id="username" name="username" placeholder="아이디" required>
        </div>
        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" class="form-control" id="password" name="password" placeholder="비밀번호" required>
        </div>
        <button type="submit" class="btn btn-primary w-100">가입하기</button>
    </form>

    <div class="text-center mt-3">
        <a href="/login">로그인 하러가기</a>
    </div>
</div>
</body>
</html>

Step 6. 기능 동작 테스트 (웹 화면)

자, 이제 코드는 다 짰으니 실제로 사용자가 되어 회원가입부터 로그인까지의 흐름을 타봅시다.

  1. 서버 실행 (Run)
    • 프로젝트를 다시 시작합니다.
  2. 메인 화면 접속
    • http://localhost:8080 에 접속해서 우측 상단의 [회원가입] 버튼을 클릭합니다.
  3. 회원가입 진행
    • 아이디: dev_user
    • 비밀번호: 1234
    • 입력 후 [가입하기] 버튼을 누릅니다.
  4. 로그인 화면 이동
    • 가입이 성공하면 자동으로 로그인 페이지로 이동됩니다.
  5. 로그인 시도
    • 방금 가입한 dev_user / 1234 를 입력하고 로그인을 누릅니다.
  6. 성공 확인
    • 다시 메인 화면으로 돌아왔을 때, 우측 상단에 "dev_user님 환영합니다!" 라는 문구가 뜨면 성공입니다!

Step 7. 진짜 암호화됐을까? (DB 데이터 검증)

"로그인은 잘 되는데, 내 비밀번호 1234가 DB에 그대로 들어있는 건 아니겠지?"
의심 많은 개발자를 위해 DB 내부를 직접 확인해 보겠습니다.

  1. H2 Console 접속
    • 브라우저 새 탭을 열고 http://localhost:8080/h2-console 에 접속합니다.
    • (Step 1에서 설정을 추가했으므로 로그인 창으로 튕기지 않고 접속되어야 합니다.)
  2. DB 연결
    • JDBC URLjdbc:h2:mem:testdb 인지 확인하고 [Connect] 버튼을 클릭합니다.
  3. 데이터 조회
    • SQL 입력창에 아래 명령어를 치고 [Run]을 누릅니다.
SELECT * FROM MEMBER;
  1. 결과 확인 (충격과 공포?)
ID USERNAME PASSWORD ROLE
1 admin $2a$10$r6.f... USER
2 dev_user $2a$10$Xk9z... USER

 

보시다시피 dev_user의 비밀번호 칸에 1234는 온데간데없고, $2a$10$으로 시작하는 알 수 없는 외계어(해시값)가 들어있습니다.

이것이 바로 BCrypt 암호화의 힘입니다.

이제 DB 관리자조차 여러분의 비밀번호가 무엇인지 알 수 없게 되었습니다. 보안 성공! 🎉


마무리

오늘 우리는 "사용자 입력 -> 컨트롤러 -> 서비스(암호화) -> DB 저장"으로 이어지는 완벽한 회원가입 프로세스를 구축했습니다.
그리고 H2 Console을 열어 비밀번호가 안전하게 암호화되어 저장된 것까지 직접 눈으로 확인했습니다.

이제 이 프로젝트는 안전한 회원가입과 로그인이 가능한 어엿한 웹 애플리케이션이 되었습니다.

 

다음 6편에서는 "세션(Session) 방식과 JWT(Token) 방식의 차이"에 대해 알아보고,

요즘 트렌드인 API 보안으로 넘어가는 준비를 해보겠습니다.

고생하셨습니다!

반응형
Comments