Framework/Spring

[Spring] 시큐어 코딩 예제

IT수정 2024. 11. 22. 11:51

의존성 선택

 

Spring Security

Spring Security는 스프링 프레임워크를 기반으로 한 인증권한 부여를 제공하는 강력한 보안 프레임워크이다. 웹 애플리케이션 및 서비스의 보안을 손쉽게 설정하고 관리할 수 있도록 다양한 기능과 확장성을 제공한다.

 

스프링 시큐리티를 처음 설정하고 애플리케이션을 빌드했을 때 나타나는 이 메시지는, 기본 보안 설정에 따라 생성된 임시 비밀번호를 알려주는 것이다.

 

초기화면이 로그인 페이지가 뜨게 되며, 제공된 임시 비밀번호를 사용해 로그인 후 설정을 점검하고, 기능을 개발하면 된다.

 

환경설정

application.yml

spring:
  application:
    name: security
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher
  datasource:
    url: jdbc:h2:tcp://localhost/~/testApi
    driver-class-name: org.h2.Driver
    username: sa
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

 

layout-dialect를 사용하기 위해서 build.gradle에 다음 코드 추가

implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

 

static에 css, js 경로 생성 후 부트스트랩 파일 추가

 

<Back>

DAO

EzenMember.java

package spring.security.dao;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter @Setter
public class EzenMember {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String grade;

    private String loginId;

    private String name;

    private String password;
}

 

UserRoll.java

package spring.security.dao;

import lombok.Getter;

@Getter
public enum UserRoll {

    //열거형 상수
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    UserRoll(String value){
        this.value = value;
    }

    private String value;
}

DTO

MemberForm.java

package spring.security.dto;

import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class MemberForm {
    private Long id;

    private String grade;

    @NotEmpty(message = "아이디를 적어주세요.")
    private String loginId;

    @NotEmpty(message = "이름을 적어주세요.")
    private String name;

    @NotEmpty(message = "비밀번호를 적어주세요.")
    private String password;
}

 

LoginForm.java

package spring.security.dto;

import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class LoginForm {

    private String loginId;
    
    private String password;
}

 

Repository

EzenMemberRepository.java

package spring.security.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import spring.security.dao.EzenMember;

import java.util.Optional;

public interface EzenMemberRepository extends JpaRepository<EzenMember, Long> {

    //사용자 아이디로 조회
    Optional<EzenMember> findByLoginId(String loginId);
}

 

Service

MemberService.java

package spring.security.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import spring.security.dao.EzenMember;
import spring.security.repository.EzenMemberRepository;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final EzenMemberRepository ezenMemberRepository;

    //회원 가입
    public Long join(EzenMember ezenMember) {
        validateDuplicateEzenMember(ezenMember);
        this.ezenMemberRepository.save(ezenMember);
        return ezenMember.getId();
    }

    //중복회원 검증
    private void validateDuplicateEzenMember(EzenMember ezenMember){
        Optional<EzenMember> findMember = ezenMemberRepository.findByLoginId(ezenMember.getLoginId());

        if(findMember.isPresent()){
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

    //전체회원 조회
    public List<EzenMember> findMember(){
        return ezenMemberRepository.findAll();
    }

    //회원한명 조회
    public Optional<EzenMember> findOne(Long id){
        return ezenMemberRepository.findById(id);
    }

    //회원 수정
    public Long update(EzenMember ezenMember) {
        this.ezenMemberRepository.save(ezenMember);
        return ezenMember.getId();
    }

    //회원 삭제
    public void delete(EzenMember ezenMember) {
        this.ezenMemberRepository.deleteById(ezenMember.getId());
    }
}

 

LoginService.java

package spring.security.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import spring.security.dao.EzenMember;
import spring.security.dao.UserRoll;
import spring.security.repository.EzenMemberRepository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Service
@Slf4j
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {
    private final EzenMemberRepository ezenMemberRepository;

    // Alt + Insert, 오버라이딩 클릭
    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        Optional<EzenMember> findLoginId = ezenMemberRepository.findByLoginId(loginId);
        log.info("loginId는 뭐지? {}",loginId);

        if(findLoginId.isEmpty()){
            log.info("없어? {}", "사용자를 찾을 수 없음");
            throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
        }

        EzenMember findMember = findLoginId.get();
        log.info("꺼낸 멤버 누구야? {}", findMember);
        log.info("꺼낸 멤버의 권한? {}", findMember.getGrade());

        //권한 부여
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        if("user".equals(findMember.getGrade())){
            grantedAuthorities.add(new SimpleGrantedAuthority(UserRoll.USER.getValue()));
        } else {
            grantedAuthorities.add(new SimpleGrantedAuthority(UserRoll.ADMIN.getValue()));
        }
        log.info("찍어봐? {} {} {}", findMember.getLoginId(), findMember.getPassword(), grantedAuthorities);

        return new User(findMember.getLoginId(), findMember.getPassword(), grantedAuthorities);
    }
}

 

Controller

HomeController.java

package spring.security.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class HomeController {

    //사용자 인증 번호가 있으면 loginHome으로 이동, 그렇지 않으면 home으로 이동 (해당 사용자의 loginId 가져옴)
    @GetMapping("/")
    public String home(Model model){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); //사용자 인증 정보 가져오기

        //만약 사용자 인증 정보가 null이 아니고 인증된 상태이며,
        //유효한 인증 토큰을 가지고 있는지?
        if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
            String loginId = authentication.getName();
            model.addAttribute("loginMember", loginId);
            return "loginHome";
        }

        return "home";
    }
}

//AnonymousAuthenticationToken: 로그인하지 않은 익명 사용자에게 자동으로 부여되는 토큰

 

MemberController.java

package spring.security.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import spring.security.SecurityConfig;
import spring.security.dao.EzenMember;
import spring.security.dto.MemberForm;
import spring.security.service.MemberService;
import java.util.List;
import java.util.Optional;

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;

    //회원 가입
    @GetMapping("/add")
    public String add(Model model){
        model.addAttribute("memberForm", new MemberForm());
        return "user/addMemberForm";
    }

    @PostMapping("/add")
    public String create(@Valid @ModelAttribute("memberForm") MemberForm memberForm, BindingResult bindingResult){

        if(bindingResult.hasErrors()){
            return "user/addMemberForm";
        }

        EzenMember ezenMember = new EzenMember();
        ezenMember.setName(memberForm.getName());
        ezenMember.setLoginId(memberForm.getLoginId());
        ezenMember.setPassword(passwordEncoder.encode(memberForm.getPassword()));
        ezenMember.setGrade("user");
        memberService.join(ezenMember);
        return "redirect:/";
    }

    //회원 목록
    //사용자 인증 정보가 있으면 해당 사용자의 loginId 가져옴
    @GetMapping("/members")
    public String memberList(Model model){
        List<EzenMember> ezenMembers = memberService.findMember();
        model.addAttribute("ezenMembers", ezenMembers);

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
            String loginId = authentication.getName();
            model.addAttribute("loginMember", loginId);
        }
        return "admin/memberList";
    }

    //회원 수정
    //사용자 인증 정보가 있으면 해당 사용자의 loginId 가져옴
    @GetMapping("/members/edit/{id}")
    public String editMember(@PathVariable("id") Long id, Model model){
        Optional<EzenMember> findOne = memberService.findOne(id);
        EzenMember findMemberGet = findOne.get();

        MemberForm memberForm = new MemberForm();
        memberForm.setId(findMemberGet.getId());
        memberForm.setLoginId(findMemberGet.getLoginId());
        memberForm.setPassword(findMemberGet.getPassword());
        memberForm.setName(findMemberGet.getName());
        memberForm.setGrade(findMemberGet.getGrade());
        model.addAttribute("memberForm", memberForm);

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
            String loginId = authentication.getName();
            model.addAttribute("loginMember", loginId);
        }
        return "admin/updateMemberForm";
    }

    @PostMapping("/members/edit/{id}")
    public String updateMember(@Valid @ModelAttribute("memberForm") MemberForm memberForm, BindingResult bindingResult, Model model){

        if(bindingResult.hasErrors()){
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

            if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
                String loginId = authentication.getName();
                model.addAttribute("loginMember", loginId);
            }
            return "admin/updateMemberForm";
        }

        EzenMember ezenMember = new EzenMember();
        ezenMember.setId(memberForm.getId());
        ezenMember.setName(memberForm.getName());
        ezenMember.setLoginId(memberForm.getLoginId());
        ezenMember.setPassword(memberForm.getPassword());
        ezenMember.setGrade(memberForm.getGrade());
        memberService.update(ezenMember);
        return "redirect:/members";
    }

    //회원 삭제
    @GetMapping("members/delete/{id}")
    public String deleteMember(@PathVariable("id") Long id){
        Optional<EzenMember> findOne = memberService.findOne(id);
        if(findOne.isPresent()){
            memberService.delete(findOne.get());
        }
        return "redirect:/members";
    }
}

 

LoginController.java

package spring.security.controller;

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


@Controller
@RequiredArgsConstructor
public class LoginController {

    //로그인
    @GetMapping("/login")
    public String loginForm(){
        return "user/loginForm";
    }
}

 

 

Security

SecurityConfig.java

package spring.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration //환경설정 파일임을 의미
@EnableWebSecurity
public class SecurityConfig {

    @Bean //스프링에서 관리
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/css/**", "/js/**", "/images/**", "/favicon/**", "/*/icon-*").permitAll() //인가랑 상관없는 것들을 허용
                        .requestMatchers("/", "/add", "/error/**").permitAll()
                        .anyRequest().authenticated()) //이외의 것들은 인가 필요
                .formLogin(form -> form
                        .loginPage("/login").permitAll()
                        .usernameParameter("loginId") //기본으로 설정된 매개변수 이름이 username이므로, 내가 원하는 이름을 사용하기 위해 파라미터 이름을 바꿀 수 있음
                        .defaultSuccessUrl("/")
                        .failureUrl("/login?error"))
                .logout(logout -> logout
                        .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)); //로그아웃시 생성된 사용자 세션 삭제
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        //기본적으로 bcrypt 암호화 알고리즘의 BcryptPasswordEncoder 객체를 생성하고 사용
    }

    //AuthenticationManager는 스프링 시큐리티의 인증을 처리
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

 

<View>

레이아웃

layout.html

<!DOCTYPE html>
<html lang="ko" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>시큐어 코딩</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link th:href="@{/css/bootstrap.css}" rel="stylesheet">
    <link th:href="@{/css/style.css}" rel="stylesheet">
</head>
<body>
<!-- 네비게이션 바 -->
<nav th:replace="~{layout/navbar :: navbarFragment}"></nav>

<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->

<!-- 푸터 -->
<footer th:replace="~{layout/footer :: footerFragment}"></footer>

<!-- 자바스크립트 -->
<script th:src="@{/js/bootstrap.bundle.js}"></script>
</body>
</html>

 

navbar.html

<nav th:fragment="navbarFragment" class="navbar navbar-expand-md bg-body-tertiary border-bottom border-2 border-secondary" xmlns:sec="http://www.w3.org/1999/xhtml">
    <div class="container-fluid">
        <a class="navbar-brand" th:href="@{/}">Home</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
                    <li class="nav-item">
                        <div th:if="${loginMember == null}">
                            <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/login}"}>로그인</a>
                            <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/logout}"}>로그아웃</a>
                        </div>
                        <div th:if="${loginMember}" class="mt-1">
                            <p th:text="${loginMember} + '님 환영합니다.'" class="m-0 fw-bold"></p>
                        </div>
                    </li>
                    <li class="nav-item">
                        <div th:if="${loginMember == null}">
                            <a class="nav-link" href="/add">회원 가입</a>
                        </div>
                        <div th:if="${loginMember}">
                            <form th:action="@{/logout}" method="post" class="ms-2">
                                <button type="submit" class="btn btn-secondary btn-sm">로그아웃</button>
                            </form>
                        </div>
                    </li>
                </ul>
            </div>
        </div>
    </div>
</nav>

 

footer.html

<footer class="bg-body-tertiary text-center py-2 mt-5 border-top border-2 border-secondary" th:fragment="footerFragment">
    <p class="my-3">Copyright 2024. 이젠아카데미 Co. All rights reserved.</p>
</footer>

 

홈 화면

home.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>
    <div class="row">
        <div class="col">
            <button type="button" th:onclick="|location.href='@{/add}'|" class="w-100 btn btn-secondary btn-lg">회원 가입</button>
        </div>
        <div class="col">
            <button type="button" th:onclick="|location.href='@{/login}'|" class="w-100 btn btn-dark btn-lg">로그인</button>
        </div>
    </div>
</div>
</body>
</html>

 

loginHome.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>
    <div class="py-3 row">
        <p th:text="${loginMember} + '님, 환영합니다.'" class="mb-3 col-7 text-end"></p>
        <form th:action="@{/logout}" method="post" class="col-5 text-start">
            <button type="submit" class="btn btn-secondary btn-sm">로그아웃</button>
        </form>
    </div>
    <div class="row">
        <div class="col">
            <button type="button" th:onclick="|location.href='@{/members}'|" class="w-100 btn btn-dark btn-lg">회원 목록</button>
        </div>
    </div>
</div>
</body>
</html>

 

회원 가입

addMemberForm.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
    <div class="pb-3 text-center">
        <h2>회원 가입</h2>
    </div>
    <form th:action th:Object="${memberForm}" method="post">
        <div th:if="${#fields.hasErrors()}" class="alert alert-danger" role="alert">
            <div th:each="err : ${#fields.allErrors()}" th:text="${err}">
                전체 오류 메시지
            </div>
        </div>

        <div class="mb-3">
            <label th:for="name" class="form-label">이름</label>
            <input type="text" class="form-control" th:field="*{name}" placeholder="이름을 입력하세요">
        </div>

        <div class="mb-3">
            <label th:for="loginId" class="form-label">아이디</label>
            <input type="text" class="form-control" th:field="*{loginId}" placeholder="아이디를 입력하세요">
        </div>
        <div class="mb-3 pb-3">
            <label th:for="password" class="form-label">비밀번호</label>
            <input type="password" class="form-control" th:field="*{password}" placeholder="비밀번호를 입력하세요">
        </div>
        <div class="row">
            <div class="col">
                <button type="submit" class="w-100 btn btn-primary btn-lg">회원 가입</button>
            </div>
            <div class="col">
                <button type="button" th:onclick="|location.href='@{/}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
            </div>
        </div>
    </form>
</div>
</body>
</html>

 

회원 수정

updateMemberForm.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
    <h1 class="pb-3">회원 수정</h1>
    <form th:action th:Object="${memberForm}" method="post">
        <div th:if="${#fields.hasErrors()}" class="alert alert-danger" role="alert">
            <div th:each="err : ${#fields.allErrors()}" th:text="${err}">
                전체 오류 메시지
            </div>
        </div>

        <input type="hidden" th:field="*{id}">
        <div class="mb-3">
            <label th:for="name" class="form-label">이름</label>
            <input type="text" class="form-control" th:field="*{name}" placeholder="이름을 입력하세요">
        </div>
        <div class="mb-3">
            <label th:for="loginId" class="form-label">아이디</label>
            <input type="text" class="form-control" th:field="*{loginId}" placeholder="아이디를 입력하세요">
        </div>
        <div class="mb-3">
            <label th:for="password" class="form-label">비밀번호</label>
            <input type="password" class="form-control" th:field="*{password}" th:placeholder="'기존 비밀번호는 ' + *{password} + ' 입니다.'">
        </div>
        <div class="mb-3 pb-3">
            <label th:for="grade" class="form-label">등급</label>
            <select class="form-select" th:field="*{grade}">
                <option value="user" selected>user</option>
                <option value="admin">admin</option>
            </select>
        </div>
        <div class="row">
            <div class="col">
                <button type="submit" class="w-100 btn btn-primary btn-lg">수정</button>
            </div>
            <div class="col">
                <button type="button" th:onclick="|location.href='@{/members}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
            </div>
        </div>
    </form>
</div>
</body>
</html>

 

로그인

loginForm.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>
    <form th:action="@{/login}" method="post">
        <div th:if="${param.error}">
            <p class="alert alert-danger">로그인 ID와 패스워드를 확인하세요.</p>
        </div>

        <div class="mb-3">
            <label for="loginId" class="form-label">아이디</label>
            <input type="text" class="form-control" id="loginId" name="loginId" placeholder="아이디를 입력하세요">
        </div>
        <div class="mb-3 pb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" class="form-control" id="password" name="password" placeholder="비밀번호를 입력하세요">
        </div>
        <div class="row">
            <div class="col">
                <button type="submit" class="w-100 btn btn-primary btn-lg">로그인</button>
            </div>
            <div class="col">
                <button type="button" th:onclick="|location.href='@{/}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
            </div>
        </div>
    </form>
</div>
</body>
</html>

 

회원 목록

memberList.html

<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
    <div class="container my-5" layout:fragment="content">
        <h1 class="pb-4">회원 목록</h1>
        <div class="overflow-x-auto">
            <table class="table">
                <thead>
                <tr>
                    <th scope="col" style="min-width: 50px;">#</th>
                    <th scope="col" style="min-width: 100px;">아이디</th>
                    <th scope="col" style="min-width: 100px;">이름</th>
                    <th scope="col" style="min-width: 350px;">비밀번호</th>
                    <th scope="col" style="min-width: 80px;">등급</th>
                    <th scope="col" style="min-width: 80px;"></th>
                    <th scope="col" style="min-width: 80px;"></th>
                </tr>
                </thead>
                <tbody>
                <tr class="align-middle" th:each = "member : ${ezenMembers}">
                    <!-- 받은 변수를 각각 돌려 줄 지역변수 : ${Model로 받은 변수} -->
                    <th scope="row" th:text="${member.id}"></th>
                    <td th:text="${member.loginId}"></td>
                    <td th:text="${member.name}"></td>
                    <td th:text="${member.password}"></td>
                    <td th:text="${member.grade}"></td>
                    <td><a th:href="@{/members/edit/{id} (id=${member.id})}" class="btn btn-outline-primary" role="button">수정</a></td>
                    <!-- role : 웹브라우저에게 어떤 역할인지 알려줌 -->
                    <td><a th:href="@{/members/delete/{id} (id = ${member.id})}" class="btn btn-danger" role="button">삭제</a></td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
</body>
</html>

 

경로

 

'Framework > Spring' 카테고리의 다른 글

[Spring] 시큐리티 로그인 로직  (2) 2024.12.18
[Spring] 코드 프로젝트  (0) 2024.12.05
[Spring] 문의 페이지 테스트  (0) 2024.11.13
[Spring] 에러 페이지 예제  (0) 2024.11.12
[Spring] 검색 기능 예제  (0) 2024.11.11