어제 오늘 내일

[Spring Security] 4편 - 로그인 페이지 커스텀 & 로그아웃 처리 본문

IT/SpringBoot

[Spring Security] 4편 - 로그인 페이지 커스텀 & 로그아웃 처리

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

네, 3편까지 잘 따라오셨습니다!

이제 기능은 다 갖췄으니 "사용자에게 보이는 화면"을 다듬을 차례입니다.

개발자인 우리 눈에는 기본 로그인 화면도 나쁘지 않지만, 실제 서비스라면 우리 서비스만의 디자인이 적용된 로그인 페이지가 필수겠죠?

이번 4편에서는 Thymeleaf와 Bootstrap을 이용해 예쁜 로그인 화면을 만들고, 로그인 상태에 따라 버튼이 바뀌는(로그인 vs 로그아웃) 기능을 구현해 보겠습니다.


Step 1. 로그인 화면 만들기 (login.html)

먼저 templates 폴더에 로그인 HTML 파일을 만듭니다.
여기서 가장 중요한 건 <input> 태그의 name 속성입니다. 시큐리티는 기본적으로 usernamepassword라는 이름을 찾기 때문입니다.

  • 위치: src/main/resources/templates/login.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>
        .login-form { width: 300px; margin: 100px auto; }
    </style>
</head>
<body>
<div class="container login-form">
    <h2 class="text-center mb-4">🔒 로그인</h2>

    <form action="/login" method="post">

        <div th:if="${param.error}" class="alert alert-danger" role="alert">
            아이디나 비밀번호가 맞지 않습니다.
        </div>
        <div th:if="${param.logout}" class="alert alert-primary" role="alert">
            로그아웃 되었습니다.
        </div>

        <div class="mb-3">
            <label for="username" class="form-label">아이디</label>
            <input type="text" class="form-control" id="username" name="username" placeholder="ID" required>
        </div>
        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
        </div>

        <button type="submit" class="btn btn-primary w-100">로그인</button>
    </form>

    <div class="text-center mt-3">
        <a href="/join">회원가입</a> | <a href="/">홈으로</a>
    </div>
</div>
</body>
</html>

Step 2. 화면 이동용 Controller 추가

단순히 login.html을 보여주는 컨트롤러가 필요합니다.
(기존 컨트롤러에 메소드만 추가하거나, 새로 만드셔도 됩니다.)

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

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MemberController {

    @GetMapping("/login")
    public String login() {
        return "login"; // templates/login.html을 찾아갑니다.
    }
}

Step 3. SecurityConfig 수정 (★여기가 핵심)

이제 시큐리티에게 "야, 기본 화면 쓰지 말고 내가 만든 /login 페이지 써!" 라고 알려줘야 합니다.

  • 위치: src/main/java/com/example/board/config/SecurityConfig.java
// ... 기존 코드 ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/", "/login", "/join", "/test").permitAll()
                .anyRequest().authenticated()
            )
            // ▼▼▼ 여기가 변경되었습니다 ▼▼▼
            .formLogin((form) -> form
                .loginPage("/login")        // 1. 우리가 만든 로그인 페이지 컨트롤러 주소
                .loginProcessingUrl("/login") // 2. <form action="/login">과 일치시켜야 함 (POST 요청을 가로챔)
                .defaultSuccessUrl("/", true) // 3. 성공 시 이동할 주소
                .permitAll()
            )
            .logout((logout) -> logout
                .logoutSuccessUrl("/login?logout") // 로그아웃 후 로그인 페이지로 이동하며 'logout' 파라미터 전달
                .permitAll()
            );

        return http.build();
    }

💡 코드 상세 해석

  • .loginPage("/login"): 이 설정이 들어가면 시큐리티는 더 이상 기본 페이지를 만들지 않고, 우리가 만든 /login 컨트롤러를 호출합니다.
  • .loginProcessingUrl("/login"): HTML 폼이 전송(Submit)될 주소입니다. 우리가 이 주소에 대한 Controller(@PostMapping)를 만들 필요가 없습니다! 시큐리티가 이 주소로 들어오는 요청을 낚아채서 로그인을 진행합니다.

Step 4. 타임리프 시큐리티 확장 라이브러리 추가

HTML 화면에서 "로그인했으면 로그아웃 버튼 보여주기", "로그인 안 했으면 로그인 버튼 보여주기"를 하려면 타임리프 확장 기능이 필요합니다.

  • 위치: build.gradle
dependencies {
    // ... 기존 의존성 ...
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
}

(추가 후 Gradle 새로고침(Load Gradle Changes) 필수!)


Step 5. 메인 화면(index.html) 버튼 수정

이제 메인 페이지 상단에 로그인 상태에 따라 다른 버튼을 보여줍시다.

  • 위치: src/main/resources/templates/index.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<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">
</head>
<body>
    <div class="container mt-5">
        <h1>🚀 스프링 부트 게시판</h1>

        <div class="row mb-3">
            <div class="col text-end">
                <div sec:authorize="isAnonymous()">
                    <a href="/login" class="btn btn-outline-primary me-2">로그인</a>
                    <a href="/join" class="btn btn-secondary">회원가입</a>
                </div>

                <div sec:authorize="isAuthenticated()">
                    <span class="me-3">
                        👋 <b sec:authentication="name">사용자</b>님 환영합니다!
                    </span>
                    <a href="/logout" class="btn btn-outline-danger">로그아웃</a>
                </div>
            </div>
        </div>
        <div class="row">
            <div class="col">
                <button class="btn btn-primary" onclick="location.href='/posts/save'">글 등록</button>
            </div>
        </div>

        <br>
        <table class="table table-horizontal table-bordered">
            <thead class="table-dark">
            <tr>
                <th>게시글번호</th>
                <th>제목</th>
                <th>작성자</th>
                <th>최종수정일</th>
            </tr>
            </thead>
            <tbody id="tbody">
            <tr th:each="post : ${posts}">
                <td th:text="${post.id}"></td>
                <td><a th:href="@{/posts/update/{id}(id=${post.id})}" th:text="${post.title}"></a></td>
                <td th:text="${post.author}"></td>
                <td th:text="${#temporals.format(post.modifiedDate, 'yyyy-MM-dd HH:mm')}"></td>
            </tr>
            </tbody>
        </table>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

💡 HTML 코드 상세 분석 (중요!)

이 부분이 오늘 추가된 핵심 기능입니다.

  1. xmlns:sec="...":
    • 타임리프에서 스프링 시큐리티의 기능을 쓰겠다고 선언하는 부분입니다. 이게 없으면 sec: 태그가 작동하지 않습니다.
  2. sec:authorize="isAnonymous()":
    • 이 태그로 감싸진 부분은 로그인하지 않은 익명 사용자에게만 보입니다.
    • 그래서 '로그인', '회원가입' 버튼을 감쌌습니다.
  3. sec:authorize="isAuthenticated()":
    • 반대로 이 부분은 로그인에 성공한 사용자에게만 보입니다.
    • '환영 문구'와 '로그아웃' 버튼을 감쌌습니다.
  4. sec:authentication="name":
    • 현재 로그인한 사용자의 **아이디(username)**를 가져와서 화면에 출력합니다. (UserDetailsService에서 User 객체에 담았던 그 이름입니다.)

✅ 최종 결과 확인

  1. 서버 재시작 (Run)
  2. http://localhost:8080 접속 -> [로그인] 버튼 클릭.
  3. 우리가 만든 부트스트랩 로그인 화면 등장!
  4. ID: admin, PW: 1234 입력.
  5. 로그인 실패 시 -> 빨간색 경고창("아이디나 비밀번호가...") 뜸.
  6. 로그인 성공 시 -> 메인 화면 우측 상단에 "admin님 환영합니다! [로그아웃]" 버튼이 보임.
  7. [로그아웃] 클릭 -> 로그인 화면으로 돌아가며 "로그아웃 되었습니다" 파란색 알림창 뜸.

마무리

이제 디자인까지 입혀진 완벽한 로그인 시스템이 되었습니다! 🎉

하지만 여전히 admin 계정 하나로만 테스트하고 있습니다.
다른 아이디를 만들고 싶어도 회원가입 기능이 없어서 불가능하죠.

다음 5편에서는 "회원가입 폼을 만들고, 비밀번호를 암호화해서 DB에 저장하는 기능"을 구현해 보겠습니다.

이제 진짜 유저를 받을 준비를 합시다!

반응형
Comments