Woori AI HubPC 화면

코딩 자동화

레포지토리를 선택하고 요청하면 AI가 작업 카드를 만들고, 승인 후 분석·설계·코딩을 거쳐 PR까지 생성합니다.

Repository

작업 카드

진행중

8

완료

0
  • 카드 없음

새 작업 요청

진행중인 카드 — 54

전체적으로 화면이 허술하네 전체 ui 및 스타일 고도화해주고 회원가입 로그인도 jwt를 이용해서 고도화헤줘

sample-board · #54

전체적으로 화면이 허술하네 전체 ui 및 스타일 고도화해주고 회원가입 로그인도 jwt를 이용해서 고도화헤줘

✓분석
✓설계
✓코딩/테스트
✓PR 생성

분석

  • 🚀 분석을 시작합니다...
  • 🧭 먼저 어떻게 바꿀지 살펴볼게요...
  • 📂 파일 43개를 확인했어요.
  • 📄 pom.xml 파일을 확인했어요.
  • 📄 src/main/resources/static/css/style.css 파일을 확인했어요.
  • 🔍 "LoginController" 관련 코드를 확인했어요.
  • 📄 src/main/resources/templates/users/login.html 파일을 확인했어요.
  • ask_user 실행하고 있어요...
Spring Security + JWT 기반의 정석적인 인증/인가 시스템 도입

Spring Security + JWT 기반의 정석적인 인증/인가 시스템 도입

  • 🚀 구현을 시작합니다...
  • 🔍 "UserService" 관련 코드를 확인했어요.
  • ✅ 분석 완료: 사용자의 요청에 따라 (1) pom.xml에 Spring Security 및 JJWT 의존성을 추가하고, (2) Spring Security 설정 및 JWT 토큰 발행/검증 필터(JwtTokenProvider, JwtAuthenticationFilter, SecurityConfig)를 구현하며, (3) UserService와 LoginController를 수정하여 비밀번호 암호화 및 JWT 기반 인증을 적용합니다. 또한 (4) style.css 및 각 Thymeleaf 템플릿(home, list, view, form, login, signup 등)의 UI와 스타일을 전면 고도화합니다.

구현 계획

사용자의 요청에 따라 (1) pom.xml에 Spring Security 및 JJWT 의존성을 추가하고, (2) Spring Security 설정 및 JWT 토큰 발행/검증 필터(JwtTokenProvider, JwtAuthenticationFilter, SecurityConfig)를 구현하며, (3) UserService와 LoginController를 수정하여 비밀번호 암호화 및 JWT 기반 인증을 적용합니다. 또한 (4) style.css 및 각 Thymeleaf 템플릿(home, list, view, form, login, signup 등)의 UI와 스타일을 전면 고도화합니다.

  • 수정pom.xml

    Spring Security 및 JJWT(JWT) 라이브러리 의존성 추가

  • 생성src/main/java/com/example/board/security/JwtTokenProvider.java

    JWT 토큰 생성, 유효성 검증 및 인증 정보 추출 유틸리티 클래스 생성

  • 생성src/main/java/com/example/board/security/JwtAuthenticationFilter.java

    HTTP 요청에서 JWT 토큰을 추출하고 SecurityContext에 인증 정보를 설정하는 필터 생성

  • 생성src/main/java/com/example/board/security/SecurityConfig.java

    Spring Security 설정, PasswordEncoder 빈 등록 및 HTTP 보안 필터 체인 설정

  • 수정src/main/java/com/example/board/service/UserService.java

    BCryptPasswordEncoder를 이용한 회원가입 비밀번호 암호화 및 로그인 로직 JWT 연동 고도화

  • 수정src/main/java/com/example/board/controller/LoginController.java

    로그인 시 JWT 토큰 발급 및 쿠키/인증 처리 로직 추가

  • 수정src/main/resources/static/css/style.css

    전체 UI 및 스타일 테마(라이트/다크/네온)와 반응형 디자인 고도화

  • 수정src/main/resources/templates/users/login.html

    로그인 화면 UI 모던화 및 JWT 연동 반영

  • 수정src/main/resources/templates/users/signup.html

    회원가입 화면 UI 모던화 반영

  • 수정src/main/resources/templates/home.html

    홈 화면 UI 고도화

  • 수정src/main/resources/templates/list.html

    게시판 목록 화면 UI 고도화

  • 수정src/main/resources/templates/view.html

    게시판 상세 화면 UI 고도화

  • 수정src/main/resources/templates/form.html

    게시판 작성/수정 폼 화면 UI 고도화

모바일에서 봐도 반응형으로 되게끔 개편해줘

직접 입력

  • 🚀 구현을 시작합니다...
  • ✅ 분석 완료: 사용자의 요청에 따라 전체 화면 UI 및 스타일을 모바일 반응형으로 전면 고도화하고, Spring Security와 JJWT를 도입하여 회원가입 및 로그인을 JWT 기반 인증 시스템으로 고도화합니다.

구현 계획

사용자의 요청에 따라 전체 화면 UI 및 스타일을 모바일 반응형으로 전면 고도화하고, Spring Security와 JJWT를 도입하여 회원가입 및 로그인을 JWT 기반 인증 시스템으로 고도화합니다.

  • 수정pom.xml

    Spring Security 및 JJWT(JWT) 의존성 추가

  • 생성src/main/java/com/example/board/security/JwtTokenProvider.java

    JWT 토큰 생성, 유효성 검증 및 인증 정보 추출 유틸리티 클래스 생성

  • 생성src/main/java/com/example/board/security/JwtAuthenticationFilter.java

    HTTP 요청의 JWT 토큰을 검증하고 SecurityContext에 인증 정보를 설정하는 필터 생성

  • 생성src/main/java/com/example/board/security/SecurityConfig.java

    Spring Security 설정, 비밀번호 암호화(BCrypt) 및 필터 등록을 위한 설정 클래스 생성

  • 수정src/main/java/com/example/board/service/UserService.java

    비밀번호 BCrypt 암호화 및 Spring Security UserDetailsService 연동 고도화

  • 수정src/main/java/com/example/board/controller/LoginController.java

    JWT 발급 및 쿠키/인증 처리를 반영한 로그인/회원가입 컨트롤러 고도화

  • 수정src/main/resources/static/css/style.css

    전체 UI/스타일 고도화 및 모바일 반응형(@media) 레이아웃 전면 개편

  • 수정src/main/resources/templates/fragments/header.html

    모바일 반응형 GNB 메뉴 및 JWT 인증 상태 연동 UI 개편

  • 수정src/main/resources/templates/home.html

    모바일 반응형 메인 화면 UI 고도화

  • 수정src/main/resources/templates/list.html

    모바일 반응형 게시글 목록 테이블 및 페이지네이션 UI 고도화

  • 수정src/main/resources/templates/view.html

    모바일 반응형 게시글 상세 및 댓글 영역 UI 고도화

  • 수정src/main/resources/templates/form.html

    모바일 반응형 게시글 작성/수정 폼 UI 고도화

  • 수정src/main/resources/templates/users/login.html

    모바일 반응형 로그인 화면 UI 고도화

  • 수정src/main/resources/templates/users/signup.html

    모바일 반응형 회원가입 화면 UI 고도화

이 계획대로 구현해줘

이 계획대로 구현해줘

  • 🚀 구현을 시작합니다...
  • 📄 pom.xml 파일을 확인했어요.
  • 📄 src/main/java/com/example/board/service/UserService.java 파일을 확인했어요.
  • 📄 src/main/java/com/example/board/controller/LoginController.java 파일을 확인했어요.
  • 📄 src/main/resources/templates/fragments/header.html 파일을 확인했어요.
  • 📄 src/main/resources/templates/home.html 파일을 확인했어요.
  • 📄 src/main/resources/templates/list.html 파일을 확인했어요.
  • 📄 src/main/resources/templates/view.html 파일을 확인했어요.
  • 📄 src/main/resources/templates/form.html 파일을 확인했어요.
  • 📄 src/main/resources/templates/users/signup.html 파일을 확인했어요.
  • ✏️ pom.xml 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/java/com/example/board/security/JwtTokenProvider.java 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/java/com/example/board/security/JwtAuthenticationFilter.java 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/java/com/example/board/security/SecurityConfig.java 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/java/com/example/board/service/UserService.java 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/java/com/example/board/controller/LoginController.java 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/static/css/style.css 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/fragments/header.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/home.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/list.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/view.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/form.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/users/login.html 파일 작성 완료 (커밋 대기 중).
  • ✏️ src/main/resources/templates/users/signup.html 파일 작성 완료 (커밋 대기 중).

변경된 코드

  • 수정pom.xml
    --- a/pom.xml
    +++ b/pom.xml
    @@ -15,15 +15,17 @@
    <artifactId>board</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>board</name>
    - <description>Simple board sample with menu (Spring Boot + MyBatis)</description>
    + <description>Simple board sample with menu (Spring Boot + MyBatis + Security + JWT)</description>
    <properties>
    <java.version>1.8</java.version>
    <mybatis.version>2.3.1</mybatis.version>
    + <jjwt.version>0.11.5</jjwt.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    -<dependencies>
    +
    + <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    @@ -31,6 +33,27 @@
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>org.springframework.boot</groupId>
    + <artifactId>spring-boot-starter-security</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-api</artifactId>
    + <version>${jjwt.version}</version>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-impl</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-jackson</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    </dependency>
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    @@ -48,6 +71,11 @@
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    + <dependency>
    + <groupId>org.springframework.security</groupId>
    + <artifactId>spring-security-test</artifactId>
    + <scope>test</scope>
    + </dependency>
    </dependencies>
    <build>
     
  • 수정src/main/java/com/example/board/controller/LoginController.java
    --- a/src/main/java/com/example/board/controller/LoginController.java
    +++ b/src/main/java/com/example/board/controller/LoginController.java
    @@ -1,6 +1,7 @@
    package com.example.board.controller;
    import com.example.board.domain.User;
    +import com.example.board.security.JwtTokenProvider;
    import com.example.board.service.UserService;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    @@ -8,16 +9,20 @@
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    +import javax.servlet.http.Cookie;
    import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    @Controller
    public class LoginController {
    private final UserService userService;
    + private final JwtTokenProvider jwtTokenProvider;
    - public LoginController(UserService userService) {
    + public LoginController(UserService userService, JwtTokenProvider jwtTokenProvider) {
    this.userService = userService;
    + this.jwtTokenProvider = jwtTokenProvider;
    }
    @GetMapping("/login")
    @@ -29,6 +34,7 @@
    public String login(@RequestParam("username") String username,
    @RequestParam("password") String password,
    HttpServletRequest request,
    + HttpServletResponse response,
    Model model) {
    User user = userService.login(username, password);
    @@ -36,6 +42,14 @@
    model.addAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
    return "users/login";
    }
    +
    + // JWT 토큰 생성 및 쿠키 저장
    + String token = jwtTokenProvider.createToken(username);
    + Cookie cookie = new Cookie("JWT-TOKEN", token);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(3600);
    + response.addCookie(cookie);
    HttpSession session = request.getSession();
    session.setAttribute("loginUser", user);
    @@ -60,12 +74,20 @@
    }
    return "redirect:/login?registered=true";
    }
    -@GetMapping("/logout")
    - public String logout(HttpServletRequest request) {
    +
    + @GetMapping("/logout")
    + public String logout(HttpServletRequest request, HttpServletResponse response) {
    HttpSession session = request.getSession(false);
    if (session != null) {
    session.invalidate();
    }
    +
    + Cookie cookie = new Cookie("JWT-TOKEN", null);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(0);
    + response.addCookie(cookie);
    +
    return "redirect:/";
    }
    }
     
  • 생성src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    --- a/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    +++ b/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    @@ -0,0 +1,33 @@
    +package com.example.board.security;
    +
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.context.SecurityContextHolder;
    +import org.springframework.web.filter.OncePerRequestFilter;
    +
    +import javax.servlet.FilterChain;
    +import javax.servlet.ServletException;
    +import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    +import java.io.IOException;
    +
    +public class JwtAuthenticationFilter extends OncePerRequestFilter {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Override
    + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    + throws ServletException, IOException {
    + String token = jwtTokenProvider.resolveToken(request);
    + if (token != null && jwtTokenProvider.validateToken(token)) {
    + Authentication auth = jwtTokenProvider.getAuthentication(token);
    + if (auth != null) {
    + SecurityContextHolder.getContext().setAuthentication(auth);
    + }
    + }
    + filterChain.doFilter(request, response);
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/JwtTokenProvider.java
    --- a/src/main/java/com/example/board/security/JwtTokenProvider.java
    +++ b/src/main/java/com/example/board/security/JwtTokenProvider.java
    @@ -0,0 +1,75 @@
    +package com.example.board.security;
    +
    +import io.jsonwebtoken.*;
    +import io.jsonwebtoken.security.Keys;
    +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.userdetails.User;
    +import org.springframework.security.core.userdetails.UserDetails;
    +import org.springframework.stereotype.Component;
    +
    +import javax.annotation.PostConstruct;
    +import javax.servlet.http.HttpServletRequest;
    +import java.security.Key;
    +import java.util.ArrayList;
    +import java.util.Date;
    +
    +@Component
    +public class JwtTokenProvider {
    +
    + private static final String SECRET_KEY_STRING = "mySecretKeyForJwtAuthenticationWithSpringSecurityAndBoardApplication1234567890";
    + private Key key;
    + private final long validityInMilliseconds = 3600000; // 1 hour
    +
    + @PostConstruct
    + public void init() {
    + this.key = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
    + }
    +
    + public String createToken(String username) {
    + Claims claims = Jwts.claims().setSubject(username);
    + Date now = new Date();
    + Date validity = new Date(now.getTime() + validityInMilliseconds);
    +
    + return Jwts.builder()
    + .setClaims(claims)
    + .setIssuedAt(now)
    + .setExpiration(validity)
    + .signWith(key, SignatureAlgorithm.HS256)
    + .compact();
    + }
    +
    + public Authentication getAuthentication(String token) {
    + UserDetails userDetails = new User(getUsername(token), "", new ArrayList<>());
    + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    + }
    +
    + public String getUsername(String token) {
    + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    + }
    +
    + public boolean validateToken(String token) {
    + try {
    + Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    + return !claims.getBody().getExpiration().before(new Date());
    + } catch (JwtException | IllegalArgumentException e) {
    + return false;
    + }
    + }
    +
    + public String resolveToken(HttpServletRequest request) {
    + javax.servlet.http.Cookie[] cookies = request.getCookies();
    + if (cookies != null) {
    + for (javax.servlet.http.Cookie cookie : cookies) {
    + if ("JWT-TOKEN".equals(cookie.getName())) {
    + return cookie.getValue();
    + }
    + }
    + }
    + String bearerToken = request.getHeader("Authorization");
    + if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
    + return bearerToken.substring(7);
    + }
    + return null;
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/SecurityConfig.java
    --- a/src/main/java/com/example/board/security/SecurityConfig.java
    +++ b/src/main/java/com/example/board/security/SecurityConfig.java
    @@ -0,0 +1,51 @@
    +package com.example.board.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.config.http.SessionCreationPolicy;
    +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    +import org.springframework.security.web.SecurityFilterChain;
    +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    +
    +@Configuration
    +@EnableWebSecurity
    +public class SecurityConfig {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Bean
    + public PasswordEncoder passwordEncoder() {
    + return new BCryptPasswordEncoder();
    + }
    +
    + @Bean
    + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    + return authenticationConfiguration.getAuthenticationManager();
    + }
    +
    + @Bean
    + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    + http
    + .csrf().disable()
    + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    + .and()
    + .authorizeRequests()
    + .antMatchers("/", "/login", "/signup", "/css/**", "/js/**", "/posts", "/posts/{id}", "/settlements", "/settlements/{id}", "/h2-console/**").permitAll()
    + .anyRequest().authenticated()
    + .and()
    + .headers().frameOptions().disable()
    + .and()
    + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    +
    + return http.build();
    + }
    +}
     
  • 수정src/main/java/com/example/board/service/UserService.java
    --- a/src/main/java/com/example/board/service/UserService.java
    +++ b/src/main/java/com/example/board/service/UserService.java
    @@ -2,6 +2,7 @@
    import com.example.board.domain.User;
    import com.example.board.mapper.UserMapper;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    @@ -9,14 +10,16 @@
    public class UserService {
    private final UserMapper userMapper;
    + private final PasswordEncoder passwordEncoder;
    - public UserService(UserMapper userMapper) {
    + public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
    this.userMapper = userMapper;
    + this.passwordEncoder = passwordEncoder;
    }
    public User login(String username, String password) {
    User user = userMapper.findByUsername(username);
    - if (user != null && user.getPassword().equals(password)) {
    + if (user != null && passwordEncoder.matches(password, user.getPassword())) {
    return user;
    }
    return null;
    @@ -31,10 +34,14 @@
    User user = new User();
    user.setUsername(username);
    - user.setPassword(password); // 실무에서는 암호화 필요
    + user.setPassword(passwordEncoder.encode(password));
    user.setNickname(nickname);
    userMapper.insertUser(user);
    return true;
    }
    +
    + public User findByUsername(String username) {
    + return userMapper.findByUsername(username);
    + }
    }
     
  • 수정src/main/resources/static/css/style.css
    --- a/src/main/resources/static/css/style.css
    +++ b/src/main/resources/static/css/style.css
    @@ -1,5 +1,5 @@
    /* ==========================================
    - Multi-Theme System (Light, Dark, Neon)
    + Multi-Theme System & Mobile Responsive Design
    ========================================== */
    :root {
    --bg-color: #f8fafc;
    @@ -49,6 +49,7 @@
    --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
    --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
    }
    +
    [data-theme="neon"] {
    --bg-color: #05050a;
    --card-bg: #0f0f17;
    @@ -83,6 +84,7 @@
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background: var(--bg-color);
    color: var(--text-main);
    + line-height: 1.5;
    }
    /* ==========================================
    @@ -94,20 +96,24 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
    - height: 64px;
    + height: 70px;
    box-shadow: var(--shadow);
    border-bottom: 1px solid var(--border-color);
    -}
    + position: sticky;
    + top: 0;
    + z-index: 1000;
    +}
    +
    .nav-left {
    display: flex;
    align-items: center;
    - gap: 28px;
    + gap: 32px;
    }
    .nav-right {
    display: flex;
    align-items: center;
    - gap: 12px;
    + gap: 16px;
    }
    nav.gnb .brand {
    @@ -172,24 +178,14 @@
    .user-greeting {
    color: var(--text-main);
    font-size: 14px;
    - font-weight: 500;
    + font-weight: 600;
    }
    .auth-btn {
    - padding: 6px 12px !important;
    + padding: 6px 14px !important;
    border-radius: 6px;
    font-size: 13px !important;
    -}
    -.user-greeting {
    - color: var(--text-main);
    - font-size: 14px;
    - font-weight: 500;
    -}
    -
    -.auth-btn {
    - padding: 6px 12px !important;
    - border-radius: 6px;
    - font-size: 13px !important;
    + font-weight: 600 !important;
    }
    .auth-btn.login {
    @@ -215,19 +211,20 @@
    main {
    max-width: 1040px;
    margin: 40px auto;
    - padding: 0 20px;
    + padding: 0 24px;
    }
    h1 {
    - font-size: 24px;
    + font-size: 26px;
    font-weight: 700;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    color: var(--text-main);
    letter-spacing: -0.5px;
    }
    h2 {
    color: var(--text-main);
    + font-size: 20px;
    }
    p {
    @@ -245,7 +242,7 @@
    box-shadow: var(--shadow);
    border: 1px solid var(--border-color);
    background: var(--card-bg);
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    }
    table.post-list {
    @@ -253,14 +250,6 @@
    border-collapse: separate;
    border-spacing: 0;
    background: transparent;
    - white-space: nowrap;
    -}
    -table.post-list {
    - width: 100%;
    - border-collapse: separate;
    - border-spacing: 0;
    - background: transparent;
    - white-space: nowrap;
    }
    table.post-list th, table.post-list td {
    @@ -290,13 +279,13 @@
    }
    table.post-list td.num, table.post-list th.num {
    - width: 70px;
    + width: 80px;
    text-align: center;
    color: var(--text-muted);
    }
    table.post-list td.views, table.post-list th.views {
    - width: 90px;
    + width: 100px;
    color: var(--text-sub);
    text-align: center;
    }
    @@ -312,11 +301,12 @@
    text-align: center;
    color: var(--text-sub);
    }
    +
    /* Badges */
    .badge {
    font-weight: 600;
    font-size: 12px;
    - padding: 4px 8px;
    + padding: 4px 10px;
    border-radius: 6px;
    display: inline-block;
    }
    @@ -350,7 +340,7 @@
    .toolbar {
    display: flex;
    justify-content: flex-end;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    }
    .btn {
    @@ -372,7 +362,7 @@
    .btn:hover {
    background: var(--primary-hover);
    - transform: translateY(-2px);
    + transform: translateY(-1px);
    box-shadow: 0 4px 8px rgba(0,0,0,0.15);
    }
    @@ -382,12 +372,6 @@
    color: var(--text-main);
    box-shadow: none;
    }
    -.btn.secondary {
    - background: var(--card-bg);
    - border: 1px solid var(--border-color);
    - color: var(--text-main);
    - box-shadow: none;
    -}
    .btn.secondary:hover {
    background: var(--border-color);
    @@ -406,8 +390,8 @@
    ========================================== */
    .card {
    background: var(--card-bg);
    - padding: 32px;
    - border-radius: 14px;
    + padding: 36px;
    + border-radius: 16px;
    box-shadow: var(--shadow-lg);
    border: 1px solid var(--border-color);
    margin-bottom: 24px;
    @@ -417,7 +401,7 @@
    .post-meta {
    color: var(--text-sub);
    font-size: 14px;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    padding-bottom: 16px;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    @@ -427,21 +411,15 @@
    }
    .post-content {
    - min-height: 180px;
    - line-height: 1.7;
    + min-height: 200px;
    + line-height: 1.8;
    white-space: pre-wrap;
    - margin-bottom: 32px;
    - color: var(--text-main);
    - font-size: 15px;
    + margin-bottom: 36px;
    + color: var(--text-main);
    + font-size: 16px;
    word-break: break-all;
    }
    -.actions {
    - display: flex;
    - gap: 12px;
    - justify-content: flex-end;
    - flex-wrap: wrap;
    -}
    .actions {
    display: flex;
    gap: 12px;
    @@ -458,7 +436,7 @@
    form.post-form select {
    width: 100%;
    padding: 12px 16px;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -486,7 +464,7 @@
    }
    form.post-form textarea {
    - min-height: 220px;
    + min-height: 240px;
    resize: vertical;
    }
    @@ -494,8 +472,8 @@
    Comments & Replies
    ========================================== */
    .comment-section {
    - margin-top: 40px;
    - padding-top: 28px;
    + margin-top: 48px;
    + padding-top: 32px;
    border-top: 1px solid var(--border-color);
    }
    @@ -504,18 +482,13 @@
    padding: 0;
    margin: 0 0 24px 0;
    }
    -.comment-list {
    - list-style: none;
    - padding: 0;
    - margin: 0 0 24px 0;
    -}
    .comment-item {
    - padding: 16px 20px;
    + padding: 20px;
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    - border-radius: 10px;
    - margin-bottom: 12px;
    + border-radius: 12px;
    + margin-bottom: 16px;
    box-shadow: var(--shadow);
    }
    @@ -532,7 +505,7 @@
    display: flex;
    justify-content: space-between;
    align-items: center;
    - margin-bottom: 8px;
    + margin-bottom: 10px;
    flex-wrap: wrap;
    gap: 8px;
    }
    @@ -540,7 +513,7 @@
    .comment-content {
    font-size: 14px;
    white-space: pre-wrap;
    - margin: 0;
    + margin: 0 0 12px 0;
    color: var(--text-main);
    line-height: 1.6;
    word-break: break-all;
    @@ -561,17 +534,13 @@
    }
    .comment-form {
    - margin-top: 12px;
    - padding: 16px;
    + margin-top: 16px;
    + padding: 20px;
    background: var(--primary-light);
    - border-radius: 10px;
    - border: 1px solid var(--border-color);
    -}
    -
    -.comment-form.reply-form {
    - display: none;
    - margin-top: 12px;
    -}
    + border-radius: 12px;
    + border: 1px solid var(--border-color);
    +}
    +
    .comment-form.reply-form {
    display: none;
    margin-top: 12px;
    @@ -581,7 +550,7 @@
    .comment-form textarea {
    width: 100%;
    padding: 10px 14px;
    - margin-bottom: 10px;
    + margin-bottom: 12px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -591,7 +560,7 @@
    }
    .comment-form textarea {
    - min-height: 80px;
    + min-height: 90px;
    resize: vertical;
    }
    @@ -602,62 +571,52 @@
    nav.gnb {
    padding: 0 16px;
    height: auto;
    - min-height: 60px;
    + min-height: 64px;
    flex-direction: column;
    align-items: stretch;
    - padding-bottom: 10px;
    - padding-top: 10px;
    - gap: 10px;
    + padding-bottom: 12px;
    + padding-top: 12px;
    + gap: 12px;
    }
    .nav-left {
    flex-direction: column;
    align-items: flex-start;
    - gap: 8px;
    + gap: 10px;
    width: 100%;
    }
    .nav-links {
    display: flex;
    flex-wrap: wrap;
    - gap: 12px;
    + gap: 14px;
    width: 100%;
    }
    nav.gnb a.menu-item {
    - margin-right: 0;
    font-size: 14px;
    }
    .nav-right {
    justify-content: space-between;
    width: 100%;
    - border-top: 1px solid rgba(255, 255, 255, 0.15);
    - padding-top: 8px;
    + border-top: 1px solid var(--border-color);
    + padding-top: 10px;
    }
    main {
    - margin: 16px 12px;
    + margin: 20px 16px;
    padding: 0;
    }
    .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    - }
    -main {
    - margin: 16px 12px;
    - padding: 0;
    - }
    -
    - .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    + padding: 24px 18px;
    + border-radius: 12px;
    }
    h1 {
    - font-size: 20px;
    - margin-bottom: 16px;
    + font-size: 22px;
    + margin-bottom: 18px;
    }
    table.post-list th, table.post-list td {
    @@ -667,7 +626,7 @@
    .actions {
    flex-direction: column;
    - gap: 8px;
    + gap: 10px;
    }
    .actions .btn {
     
  • 수정src/main/resources/templates/form.html
    --- a/src/main/resources/templates/form.html
    +++ b/src/main/resources/templates/form.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.id} != null ? '게시글 수정 - Sample Board' : '게시글 작성 - Sample Board'">게시글 작성</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
     
  • 수정src/main/resources/templates/fragments/header.html
    --- a/src/main/resources/templates/fragments/header.html
    +++ b/src/main/resources/templates/fragments/header.html
    @@ -14,14 +14,14 @@
    <div class="nav-right">
    <!-- 테마 전환 스위처 -->
    -<div class="theme-switcher">
    + <div class="theme-switcher">
    <button type="button" class="theme-btn" data-theme="light" title="라이트 모드">☀️</button>
    <button type="button" class="theme-btn" data-theme="dark" title="다크 모드">🌙</button>
    <button type="button" class="theme-btn" data-theme="neon" title="네온 모드">⚡</button>
    </div>
    <th:block th:if="${session.loginUser != null}">
    -<span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    + <span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    <a class="menu-item auth-btn logout" th:href="@{/logout}">로그아웃</a>
    </th:block>
    <th:block th:unless="${session.loginUser != null}">
     
  • 수정src/main/resources/templates/home.html
    --- a/src/main/resources/templates/home.html
    +++ b/src/main/resources/templates/home.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    @@ -9,40 +10,41 @@
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    <div class="card" style="margin-bottom: 24px;">
    - <h1 style="color: #0284c7; margin-bottom: 8px;">Spring Boot + MyBatis 샘플 페이지</h1>
    - <p style="color: #64748b; margin-bottom: 16px;">상단 메뉴의 <strong>게시판</strong>에서 목록을 확인하고, <strong>글쓰기</strong>로 새 글을 등록해 보세요.</p>
    + <h1 style="color: var(--primary); margin-bottom: 12px;">Spring Boot + MyBatis + JWT 샘플 게시판</h1>
    + <p style="margin-bottom: 20px;">모던한 디자인 시스템과 다크/라이트/네온 테마, JWT 기반 보안 인증 시스템이 적용된 고도화된 게시판입니다.</p>
    <a class="btn" th:href="@{/posts}">게시판 바로가기</a>
    </div>
    <div class="card">
    -<div class="card">
    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
    - <h2 style="font-size: 18px; margin: 0; color: #1e293b;">최근 게시글</h2>
    - <a th:href="@{/posts}" style="font-size: 14px; color: #0284c7; text-decoration: none;">더보기 &gt;</a>
    + <h2>최근 게시글</h2>
    + <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 &gt;</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th class="num" style="width: 100px;">작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align: center; color: #94a3b8; padding: 24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    - </td>
    - <td th:text="${post.writer}" style="color: #64748b;">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align: center; color: var(--text-muted); padding: 32px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </div>
    </main>
    </body>
     
  • 수정src/main/resources/templates/list.html
    --- a/src/main/resources/templates/list.html
    +++ b/src/main/resources/templates/list.html
    @@ -2,39 +2,42 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>게시판 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    - <h1>게시판</h1>
    + <h1>자유 게시판</h1>
    <div class="toolbar">
    <a class="btn" th:href="@{/posts/new}">글쓰기</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th>작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align:center; color:#888; padding:24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    - </td>
    - <td th:text="${post.writer}">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align:center; color:var(--text-muted); padding:36px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/login.html
    --- a/src/main/resources/templates/users/login.html
    +++ b/src/main/resources/templates/users/login.html
    @@ -2,33 +2,40 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    - <title>로그인</title>
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    + <title>로그인 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    -<div class="container" style="max-width: 400px; margin-top: 100px;">
    - <h2>로그인</h2>
    - <div th:if="${error}" style="color: red; margin-bottom: 15px;" th:text="${error}"></div>
    -
    - <form th:action="@{/login}" method="post">
    - <div class="form-group" style="margin-bottom: 15px;">
    - <label for="username">아이디</label>
    - <input type="text" id="username" name="username" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    +<div th:replace="~{fragments/header :: menu}"></div>
    +<main>
    + <div class="card" style="max-width: 440px; margin: 40px auto;">
    + <h1 style="text-align: center; margin-bottom: 24px;">로그인</h1>
    +
    + <div th:if="${error}" style="background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;" th:text="${error}"></div>
    + <div th:if="${param.registered}" style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; color: #10b981; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;">회원가입이 완료되었습니다. 로그인해주세요.</div>
    +
    + <form class="post-form" th:action="@{/login}" method="post">
    + <div>
    + <label for="username">아이디</label>
    + <input type="text" id="username" name="username" required placeholder="아이디를 입력하세요">
    + </div>
    + <div>
    + <label for="password">비밀번호</label>
    + <input type="password" id="password" name="password" required placeholder="비밀번호를 입력하세요">
    + </div>
    + <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">로그인</button>
    + </form>
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <span style="color: var(--text-sub);">계정이 없으신가요?</span>
    + <a th:href="@{/signup}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">회원가입</a>
    </div>
    - <div class="form-group" style="margin-bottom: 20px;">
    - <label for="password">비밀번호</label>
    - <input type="password" id="password" name="password" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    + <div style="margin-top: 30px; padding: 16px; background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 8px; font-size: 13px; color: var(--text-sub);">
    + <strong>테스트 계정:</strong><br>
    + - 관리자: admin / 1234<br>
    + - 일반회원: user1 / 1234
    </div>
    - <button type="submit" class="btn btn-primary" style="width: 100%; padding: 10px;">로그인</button>
    - </form>
    - <div style="margin-top: 15px; text-align: center;">
    - <a th:href="@{/}">홈으로 돌아가기</a>
    </div>
    - <div style="margin-top: 25px; padding: 10px; background: #f8f9fa; font-size: 13px; color: #666;">
    - <strong>테스트 계정:</strong><br>
    - - 관리자: admin / 1234<br>
    - - 일반회원: user1 / 1234
    - </div>
    -</div>
    +</main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/signup.html
    --- a/src/main/resources/templates/users/signup.html
    +++ b/src/main/resources/templates/users/signup.html
    @@ -29,7 +29,7 @@
    </div>
    <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">가입하기</button>
    </form>
    -<div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    <span style="color: var(--text-sub);">이미 계정이 있으신가요?</span>
    <a th:href="@{/login}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">로그인</a>
    </div>
     
  • 수정src/main/resources/templates/view.html
    --- a/src/main/resources/templates/view.html
    +++ b/src/main/resources/templates/view.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.title} + ' - Sample Board'">게시글 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    <script>
    @@ -41,51 +42,49 @@
    <div class="comment-section">
    <h3>댓글</h3>
    <ul class="comment-list">
    - <li th:if="${#lists.isEmpty(comments)}" style="color: #888; font-size: 14px; padding: 8px 0;">
    + <li th:if="${#lists.isEmpty(comments)}" style="color: var(--text-muted); font-size: 14px; padding: 12px 0;">
    등록된 댓글이 없습니다.
    </li>
    <!-- 부모 댓글 반복 -->
    <li class="comment-item" th:each="comment : ${comments}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${comment.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${comment.id})}" method="post"
    onsubmit="return confirm('댓글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${comment.content}">댓글 내용</p>
    -<div class="comment-actions">
    + <div class="comment-actions">
    <button type="button" class="btn-reply-toggle" th:onclick="'toggleReplyForm(' + ${comment.id} + ')'">답글쓰기</button>
    </div>
    <!-- 대댓글 작성 폼 (토글) -->
    <form class="comment-form reply-form" th:id="'reply-form-' + ${comment.id}" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    <input type="hidden" name="parentId" th:value="${comment.id}"/>
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="답글 내용을 입력하세요" required></textarea>
    <div style="display: flex; justify-content: flex-end;">
    - <button type="submit" class="btn" style="padding: 4px 10px; font-size: 13px;">답글 등록</button>
    + <button type="submit" class="btn" style="padding: 6px 14px; font-size: 13px;">답글 등록</button>
    </div>
    </form>
    <!-- 대댓글 목록 -->
    - <ul class="comment-list" style="margin-top: 10px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    + <ul class="comment-list" style="margin-top: 12px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    <li class="comment-item reply" th:each="reply : ${comment.children}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${reply.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${reply.id})}" method="post"
    onsubmit="return confirm('답글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${reply.content}">답글 내용</p>
    @@ -96,9 +95,9 @@
    <!-- 최상위 댓글 작성 폼 -->
    <form class="comment-form" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="댓글 내용을 입력하세요" required></textarea>
    -<div style="display: flex; justify-content: flex-end;">
    + <div style="display: flex; justify-content: flex-end;">
    <button type="submit" class="btn">댓글 등록</button>
    </div>
    </form>
     
PR 생성 후 바로 머지

PR 생성 후 바로 머지

  • 🚀 구현을 시작합니다...
  • 🌿 브랜치 생성: coding-agent/sample-board-73abfa64
  • 📦 커밋 완료 (bd14f74)
  • ✅ PR 생성 완료: https://github.com/woorifis-9297/sample-board/pull/20
  • 🎉 머지 완료 (66a241b)

변경된 코드

  • 수정pom.xml
    --- a/pom.xml
    +++ b/pom.xml
    @@ -15,15 +15,17 @@
    <artifactId>board</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>board</name>
    - <description>Simple board sample with menu (Spring Boot + MyBatis)</description>
    + <description>Simple board sample with menu (Spring Boot + MyBatis + Security + JWT)</description>
    <properties>
    <java.version>1.8</java.version>
    <mybatis.version>2.3.1</mybatis.version>
    + <jjwt.version>0.11.5</jjwt.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    -<dependencies>
    +
    + <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    @@ -31,6 +33,27 @@
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>org.springframework.boot</groupId>
    + <artifactId>spring-boot-starter-security</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-api</artifactId>
    + <version>${jjwt.version}</version>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-impl</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-jackson</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    </dependency>
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    @@ -48,6 +71,11 @@
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    + <dependency>
    + <groupId>org.springframework.security</groupId>
    + <artifactId>spring-security-test</artifactId>
    + <scope>test</scope>
    + </dependency>
    </dependencies>
    <build>
     
  • 수정src/main/java/com/example/board/controller/LoginController.java
    --- a/src/main/java/com/example/board/controller/LoginController.java
    +++ b/src/main/java/com/example/board/controller/LoginController.java
    @@ -1,6 +1,7 @@
    package com.example.board.controller;
    import com.example.board.domain.User;
    +import com.example.board.security.JwtTokenProvider;
    import com.example.board.service.UserService;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    @@ -8,16 +9,20 @@
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    +import javax.servlet.http.Cookie;
    import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    @Controller
    public class LoginController {
    private final UserService userService;
    + private final JwtTokenProvider jwtTokenProvider;
    - public LoginController(UserService userService) {
    + public LoginController(UserService userService, JwtTokenProvider jwtTokenProvider) {
    this.userService = userService;
    + this.jwtTokenProvider = jwtTokenProvider;
    }
    @GetMapping("/login")
    @@ -29,6 +34,7 @@
    public String login(@RequestParam("username") String username,
    @RequestParam("password") String password,
    HttpServletRequest request,
    + HttpServletResponse response,
    Model model) {
    User user = userService.login(username, password);
    @@ -36,6 +42,14 @@
    model.addAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
    return "users/login";
    }
    +
    + // JWT 토큰 생성 및 쿠키 저장
    + String token = jwtTokenProvider.createToken(username);
    + Cookie cookie = new Cookie("JWT-TOKEN", token);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(3600);
    + response.addCookie(cookie);
    HttpSession session = request.getSession();
    session.setAttribute("loginUser", user);
    @@ -60,12 +74,20 @@
    }
    return "redirect:/login?registered=true";
    }
    -@GetMapping("/logout")
    - public String logout(HttpServletRequest request) {
    +
    + @GetMapping("/logout")
    + public String logout(HttpServletRequest request, HttpServletResponse response) {
    HttpSession session = request.getSession(false);
    if (session != null) {
    session.invalidate();
    }
    +
    + Cookie cookie = new Cookie("JWT-TOKEN", null);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(0);
    + response.addCookie(cookie);
    +
    return "redirect:/";
    }
    }
     
  • 생성src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    --- a/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    +++ b/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    @@ -0,0 +1,33 @@
    +package com.example.board.security;
    +
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.context.SecurityContextHolder;
    +import org.springframework.web.filter.OncePerRequestFilter;
    +
    +import javax.servlet.FilterChain;
    +import javax.servlet.ServletException;
    +import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    +import java.io.IOException;
    +
    +public class JwtAuthenticationFilter extends OncePerRequestFilter {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Override
    + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    + throws ServletException, IOException {
    + String token = jwtTokenProvider.resolveToken(request);
    + if (token != null && jwtTokenProvider.validateToken(token)) {
    + Authentication auth = jwtTokenProvider.getAuthentication(token);
    + if (auth != null) {
    + SecurityContextHolder.getContext().setAuthentication(auth);
    + }
    + }
    + filterChain.doFilter(request, response);
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/JwtTokenProvider.java
    --- a/src/main/java/com/example/board/security/JwtTokenProvider.java
    +++ b/src/main/java/com/example/board/security/JwtTokenProvider.java
    @@ -0,0 +1,75 @@
    +package com.example.board.security;
    +
    +import io.jsonwebtoken.*;
    +import io.jsonwebtoken.security.Keys;
    +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.userdetails.User;
    +import org.springframework.security.core.userdetails.UserDetails;
    +import org.springframework.stereotype.Component;
    +
    +import javax.annotation.PostConstruct;
    +import javax.servlet.http.HttpServletRequest;
    +import java.security.Key;
    +import java.util.ArrayList;
    +import java.util.Date;
    +
    +@Component
    +public class JwtTokenProvider {
    +
    + private static final String SECRET_KEY_STRING = "mySecretKeyForJwtAuthenticationWithSpringSecurityAndBoardApplication1234567890";
    + private Key key;
    + private final long validityInMilliseconds = 3600000; // 1 hour
    +
    + @PostConstruct
    + public void init() {
    + this.key = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
    + }
    +
    + public String createToken(String username) {
    + Claims claims = Jwts.claims().setSubject(username);
    + Date now = new Date();
    + Date validity = new Date(now.getTime() + validityInMilliseconds);
    +
    + return Jwts.builder()
    + .setClaims(claims)
    + .setIssuedAt(now)
    + .setExpiration(validity)
    + .signWith(key, SignatureAlgorithm.HS256)
    + .compact();
    + }
    +
    + public Authentication getAuthentication(String token) {
    + UserDetails userDetails = new User(getUsername(token), "", new ArrayList<>());
    + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    + }
    +
    + public String getUsername(String token) {
    + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    + }
    +
    + public boolean validateToken(String token) {
    + try {
    + Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    + return !claims.getBody().getExpiration().before(new Date());
    + } catch (JwtException | IllegalArgumentException e) {
    + return false;
    + }
    + }
    +
    + public String resolveToken(HttpServletRequest request) {
    + javax.servlet.http.Cookie[] cookies = request.getCookies();
    + if (cookies != null) {
    + for (javax.servlet.http.Cookie cookie : cookies) {
    + if ("JWT-TOKEN".equals(cookie.getName())) {
    + return cookie.getValue();
    + }
    + }
    + }
    + String bearerToken = request.getHeader("Authorization");
    + if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
    + return bearerToken.substring(7);
    + }
    + return null;
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/SecurityConfig.java
    --- a/src/main/java/com/example/board/security/SecurityConfig.java
    +++ b/src/main/java/com/example/board/security/SecurityConfig.java
    @@ -0,0 +1,51 @@
    +package com.example.board.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.config.http.SessionCreationPolicy;
    +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    +import org.springframework.security.web.SecurityFilterChain;
    +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    +
    +@Configuration
    +@EnableWebSecurity
    +public class SecurityConfig {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Bean
    + public PasswordEncoder passwordEncoder() {
    + return new BCryptPasswordEncoder();
    + }
    +
    + @Bean
    + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    + return authenticationConfiguration.getAuthenticationManager();
    + }
    +
    + @Bean
    + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    + http
    + .csrf().disable()
    + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    + .and()
    + .authorizeRequests()
    + .antMatchers("/", "/login", "/signup", "/css/**", "/js/**", "/posts", "/posts/{id}", "/settlements", "/settlements/{id}", "/h2-console/**").permitAll()
    + .anyRequest().authenticated()
    + .and()
    + .headers().frameOptions().disable()
    + .and()
    + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    +
    + return http.build();
    + }
    +}
     
  • 수정src/main/java/com/example/board/service/UserService.java
    --- a/src/main/java/com/example/board/service/UserService.java
    +++ b/src/main/java/com/example/board/service/UserService.java
    @@ -2,6 +2,7 @@
    import com.example.board.domain.User;
    import com.example.board.mapper.UserMapper;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    @@ -9,14 +10,16 @@
    public class UserService {
    private final UserMapper userMapper;
    + private final PasswordEncoder passwordEncoder;
    - public UserService(UserMapper userMapper) {
    + public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
    this.userMapper = userMapper;
    + this.passwordEncoder = passwordEncoder;
    }
    public User login(String username, String password) {
    User user = userMapper.findByUsername(username);
    - if (user != null && user.getPassword().equals(password)) {
    + if (user != null && passwordEncoder.matches(password, user.getPassword())) {
    return user;
    }
    return null;
    @@ -31,10 +34,14 @@
    User user = new User();
    user.setUsername(username);
    - user.setPassword(password); // 실무에서는 암호화 필요
    + user.setPassword(passwordEncoder.encode(password));
    user.setNickname(nickname);
    userMapper.insertUser(user);
    return true;
    }
    +
    + public User findByUsername(String username) {
    + return userMapper.findByUsername(username);
    + }
    }
     
  • 수정src/main/resources/static/css/style.css
    --- a/src/main/resources/static/css/style.css
    +++ b/src/main/resources/static/css/style.css
    @@ -1,5 +1,5 @@
    /* ==========================================
    - Multi-Theme System (Light, Dark, Neon)
    + Multi-Theme System & Mobile Responsive Design
    ========================================== */
    :root {
    --bg-color: #f8fafc;
    @@ -49,6 +49,7 @@
    --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
    --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
    }
    +
    [data-theme="neon"] {
    --bg-color: #05050a;
    --card-bg: #0f0f17;
    @@ -83,6 +84,7 @@
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background: var(--bg-color);
    color: var(--text-main);
    + line-height: 1.5;
    }
    /* ==========================================
    @@ -94,20 +96,24 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
    - height: 64px;
    + height: 70px;
    box-shadow: var(--shadow);
    border-bottom: 1px solid var(--border-color);
    -}
    + position: sticky;
    + top: 0;
    + z-index: 1000;
    +}
    +
    .nav-left {
    display: flex;
    align-items: center;
    - gap: 28px;
    + gap: 32px;
    }
    .nav-right {
    display: flex;
    align-items: center;
    - gap: 12px;
    + gap: 16px;
    }
    nav.gnb .brand {
    @@ -172,24 +178,14 @@
    .user-greeting {
    color: var(--text-main);
    font-size: 14px;
    - font-weight: 500;
    + font-weight: 600;
    }
    .auth-btn {
    - padding: 6px 12px !important;
    + padding: 6px 14px !important;
    border-radius: 6px;
    font-size: 13px !important;
    -}
    -.user-greeting {
    - color: var(--text-main);
    - font-size: 14px;
    - font-weight: 500;
    -}
    -
    -.auth-btn {
    - padding: 6px 12px !important;
    - border-radius: 6px;
    - font-size: 13px !important;
    + font-weight: 600 !important;
    }
    .auth-btn.login {
    @@ -215,19 +211,20 @@
    main {
    max-width: 1040px;
    margin: 40px auto;
    - padding: 0 20px;
    + padding: 0 24px;
    }
    h1 {
    - font-size: 24px;
    + font-size: 26px;
    font-weight: 700;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    color: var(--text-main);
    letter-spacing: -0.5px;
    }
    h2 {
    color: var(--text-main);
    + font-size: 20px;
    }
    p {
    @@ -245,7 +242,7 @@
    box-shadow: var(--shadow);
    border: 1px solid var(--border-color);
    background: var(--card-bg);
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    }
    table.post-list {
    @@ -253,14 +250,6 @@
    border-collapse: separate;
    border-spacing: 0;
    background: transparent;
    - white-space: nowrap;
    -}
    -table.post-list {
    - width: 100%;
    - border-collapse: separate;
    - border-spacing: 0;
    - background: transparent;
    - white-space: nowrap;
    }
    table.post-list th, table.post-list td {
    @@ -290,13 +279,13 @@
    }
    table.post-list td.num, table.post-list th.num {
    - width: 70px;
    + width: 80px;
    text-align: center;
    color: var(--text-muted);
    }
    table.post-list td.views, table.post-list th.views {
    - width: 90px;
    + width: 100px;
    color: var(--text-sub);
    text-align: center;
    }
    @@ -312,11 +301,12 @@
    text-align: center;
    color: var(--text-sub);
    }
    +
    /* Badges */
    .badge {
    font-weight: 600;
    font-size: 12px;
    - padding: 4px 8px;
    + padding: 4px 10px;
    border-radius: 6px;
    display: inline-block;
    }
    @@ -350,7 +340,7 @@
    .toolbar {
    display: flex;
    justify-content: flex-end;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    }
    .btn {
    @@ -372,7 +362,7 @@
    .btn:hover {
    background: var(--primary-hover);
    - transform: translateY(-2px);
    + transform: translateY(-1px);
    box-shadow: 0 4px 8px rgba(0,0,0,0.15);
    }
    @@ -382,12 +372,6 @@
    color: var(--text-main);
    box-shadow: none;
    }
    -.btn.secondary {
    - background: var(--card-bg);
    - border: 1px solid var(--border-color);
    - color: var(--text-main);
    - box-shadow: none;
    -}
    .btn.secondary:hover {
    background: var(--border-color);
    @@ -406,8 +390,8 @@
    ========================================== */
    .card {
    background: var(--card-bg);
    - padding: 32px;
    - border-radius: 14px;
    + padding: 36px;
    + border-radius: 16px;
    box-shadow: var(--shadow-lg);
    border: 1px solid var(--border-color);
    margin-bottom: 24px;
    @@ -417,7 +401,7 @@
    .post-meta {
    color: var(--text-sub);
    font-size: 14px;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    padding-bottom: 16px;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    @@ -427,21 +411,15 @@
    }
    .post-content {
    - min-height: 180px;
    - line-height: 1.7;
    + min-height: 200px;
    + line-height: 1.8;
    white-space: pre-wrap;
    - margin-bottom: 32px;
    - color: var(--text-main);
    - font-size: 15px;
    + margin-bottom: 36px;
    + color: var(--text-main);
    + font-size: 16px;
    word-break: break-all;
    }
    -.actions {
    - display: flex;
    - gap: 12px;
    - justify-content: flex-end;
    - flex-wrap: wrap;
    -}
    .actions {
    display: flex;
    gap: 12px;
    @@ -458,7 +436,7 @@
    form.post-form select {
    width: 100%;
    padding: 12px 16px;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -486,7 +464,7 @@
    }
    form.post-form textarea {
    - min-height: 220px;
    + min-height: 240px;
    resize: vertical;
    }
    @@ -494,8 +472,8 @@
    Comments & Replies
    ========================================== */
    .comment-section {
    - margin-top: 40px;
    - padding-top: 28px;
    + margin-top: 48px;
    + padding-top: 32px;
    border-top: 1px solid var(--border-color);
    }
    @@ -504,18 +482,13 @@
    padding: 0;
    margin: 0 0 24px 0;
    }
    -.comment-list {
    - list-style: none;
    - padding: 0;
    - margin: 0 0 24px 0;
    -}
    .comment-item {
    - padding: 16px 20px;
    + padding: 20px;
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    - border-radius: 10px;
    - margin-bottom: 12px;
    + border-radius: 12px;
    + margin-bottom: 16px;
    box-shadow: var(--shadow);
    }
    @@ -532,7 +505,7 @@
    display: flex;
    justify-content: space-between;
    align-items: center;
    - margin-bottom: 8px;
    + margin-bottom: 10px;
    flex-wrap: wrap;
    gap: 8px;
    }
    @@ -540,7 +513,7 @@
    .comment-content {
    font-size: 14px;
    white-space: pre-wrap;
    - margin: 0;
    + margin: 0 0 12px 0;
    color: var(--text-main);
    line-height: 1.6;
    word-break: break-all;
    @@ -561,17 +534,13 @@
    }
    .comment-form {
    - margin-top: 12px;
    - padding: 16px;
    + margin-top: 16px;
    + padding: 20px;
    background: var(--primary-light);
    - border-radius: 10px;
    - border: 1px solid var(--border-color);
    -}
    -
    -.comment-form.reply-form {
    - display: none;
    - margin-top: 12px;
    -}
    + border-radius: 12px;
    + border: 1px solid var(--border-color);
    +}
    +
    .comment-form.reply-form {
    display: none;
    margin-top: 12px;
    @@ -581,7 +550,7 @@
    .comment-form textarea {
    width: 100%;
    padding: 10px 14px;
    - margin-bottom: 10px;
    + margin-bottom: 12px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -591,7 +560,7 @@
    }
    .comment-form textarea {
    - min-height: 80px;
    + min-height: 90px;
    resize: vertical;
    }
    @@ -602,62 +571,52 @@
    nav.gnb {
    padding: 0 16px;
    height: auto;
    - min-height: 60px;
    + min-height: 64px;
    flex-direction: column;
    align-items: stretch;
    - padding-bottom: 10px;
    - padding-top: 10px;
    - gap: 10px;
    + padding-bottom: 12px;
    + padding-top: 12px;
    + gap: 12px;
    }
    .nav-left {
    flex-direction: column;
    align-items: flex-start;
    - gap: 8px;
    + gap: 10px;
    width: 100%;
    }
    .nav-links {
    display: flex;
    flex-wrap: wrap;
    - gap: 12px;
    + gap: 14px;
    width: 100%;
    }
    nav.gnb a.menu-item {
    - margin-right: 0;
    font-size: 14px;
    }
    .nav-right {
    justify-content: space-between;
    width: 100%;
    - border-top: 1px solid rgba(255, 255, 255, 0.15);
    - padding-top: 8px;
    + border-top: 1px solid var(--border-color);
    + padding-top: 10px;
    }
    main {
    - margin: 16px 12px;
    + margin: 20px 16px;
    padding: 0;
    }
    .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    - }
    -main {
    - margin: 16px 12px;
    - padding: 0;
    - }
    -
    - .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    + padding: 24px 18px;
    + border-radius: 12px;
    }
    h1 {
    - font-size: 20px;
    - margin-bottom: 16px;
    + font-size: 22px;
    + margin-bottom: 18px;
    }
    table.post-list th, table.post-list td {
    @@ -667,7 +626,7 @@
    .actions {
    flex-direction: column;
    - gap: 8px;
    + gap: 10px;
    }
    .actions .btn {
     
  • 수정src/main/resources/templates/form.html
    --- a/src/main/resources/templates/form.html
    +++ b/src/main/resources/templates/form.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.id} != null ? '게시글 수정 - Sample Board' : '게시글 작성 - Sample Board'">게시글 작성</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
     
  • 수정src/main/resources/templates/fragments/header.html
    --- a/src/main/resources/templates/fragments/header.html
    +++ b/src/main/resources/templates/fragments/header.html
    @@ -14,14 +14,14 @@
    <div class="nav-right">
    <!-- 테마 전환 스위처 -->
    -<div class="theme-switcher">
    + <div class="theme-switcher">
    <button type="button" class="theme-btn" data-theme="light" title="라이트 모드">☀️</button>
    <button type="button" class="theme-btn" data-theme="dark" title="다크 모드">🌙</button>
    <button type="button" class="theme-btn" data-theme="neon" title="네온 모드">⚡</button>
    </div>
    <th:block th:if="${session.loginUser != null}">
    -<span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    + <span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    <a class="menu-item auth-btn logout" th:href="@{/logout}">로그아웃</a>
    </th:block>
    <th:block th:unless="${session.loginUser != null}">
     
  • 수정src/main/resources/templates/home.html
    --- a/src/main/resources/templates/home.html
    +++ b/src/main/resources/templates/home.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    @@ -9,40 +10,41 @@
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    <div class="card" style="margin-bottom: 24px;">
    - <h1 style="color: #0284c7; margin-bottom: 8px;">Spring Boot + MyBatis 샘플 페이지</h1>
    - <p style="color: #64748b; margin-bottom: 16px;">상단 메뉴의 <strong>게시판</strong>에서 목록을 확인하고, <strong>글쓰기</strong>로 새 글을 등록해 보세요.</p>
    + <h1 style="color: var(--primary); margin-bottom: 12px;">Spring Boot + MyBatis + JWT 샘플 게시판</h1>
    + <p style="margin-bottom: 20px;">모던한 디자인 시스템과 다크/라이트/네온 테마, JWT 기반 보안 인증 시스템이 적용된 고도화된 게시판입니다.</p>
    <a class="btn" th:href="@{/posts}">게시판 바로가기</a>
    </div>
    <div class="card">
    -<div class="card">
    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
    - <h2 style="font-size: 18px; margin: 0; color: #1e293b;">최근 게시글</h2>
    - <a th:href="@{/posts}" style="font-size: 14px; color: #0284c7; text-decoration: none;">더보기 &gt;</a>
    + <h2>최근 게시글</h2>
    + <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 &gt;</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th class="num" style="width: 100px;">작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align: center; color: #94a3b8; padding: 24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    - </td>
    - <td th:text="${post.writer}" style="color: #64748b;">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align: center; color: var(--text-muted); padding: 32px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </div>
    </main>
    </body>
     
  • 수정src/main/resources/templates/list.html
    --- a/src/main/resources/templates/list.html
    +++ b/src/main/resources/templates/list.html
    @@ -2,39 +2,42 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>게시판 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    - <h1>게시판</h1>
    + <h1>자유 게시판</h1>
    <div class="toolbar">
    <a class="btn" th:href="@{/posts/new}">글쓰기</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th>작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align:center; color:#888; padding:24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    - </td>
    - <td th:text="${post.writer}">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align:center; color:var(--text-muted); padding:36px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/login.html
    --- a/src/main/resources/templates/users/login.html
    +++ b/src/main/resources/templates/users/login.html
    @@ -2,33 +2,40 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    - <title>로그인</title>
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    + <title>로그인 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    -<div class="container" style="max-width: 400px; margin-top: 100px;">
    - <h2>로그인</h2>
    - <div th:if="${error}" style="color: red; margin-bottom: 15px;" th:text="${error}"></div>
    -
    - <form th:action="@{/login}" method="post">
    - <div class="form-group" style="margin-bottom: 15px;">
    - <label for="username">아이디</label>
    - <input type="text" id="username" name="username" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    +<div th:replace="~{fragments/header :: menu}"></div>
    +<main>
    + <div class="card" style="max-width: 440px; margin: 40px auto;">
    + <h1 style="text-align: center; margin-bottom: 24px;">로그인</h1>
    +
    + <div th:if="${error}" style="background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;" th:text="${error}"></div>
    + <div th:if="${param.registered}" style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; color: #10b981; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;">회원가입이 완료되었습니다. 로그인해주세요.</div>
    +
    + <form class="post-form" th:action="@{/login}" method="post">
    + <div>
    + <label for="username">아이디</label>
    + <input type="text" id="username" name="username" required placeholder="아이디를 입력하세요">
    + </div>
    + <div>
    + <label for="password">비밀번호</label>
    + <input type="password" id="password" name="password" required placeholder="비밀번호를 입력하세요">
    + </div>
    + <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">로그인</button>
    + </form>
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <span style="color: var(--text-sub);">계정이 없으신가요?</span>
    + <a th:href="@{/signup}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">회원가입</a>
    </div>
    - <div class="form-group" style="margin-bottom: 20px;">
    - <label for="password">비밀번호</label>
    - <input type="password" id="password" name="password" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    + <div style="margin-top: 30px; padding: 16px; background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 8px; font-size: 13px; color: var(--text-sub);">
    + <strong>테스트 계정:</strong><br>
    + - 관리자: admin / 1234<br>
    + - 일반회원: user1 / 1234
    </div>
    - <button type="submit" class="btn btn-primary" style="width: 100%; padding: 10px;">로그인</button>
    - </form>
    - <div style="margin-top: 15px; text-align: center;">
    - <a th:href="@{/}">홈으로 돌아가기</a>
    </div>
    - <div style="margin-top: 25px; padding: 10px; background: #f8f9fa; font-size: 13px; color: #666;">
    - <strong>테스트 계정:</strong><br>
    - - 관리자: admin / 1234<br>
    - - 일반회원: user1 / 1234
    - </div>
    -</div>
    +</main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/signup.html
    --- a/src/main/resources/templates/users/signup.html
    +++ b/src/main/resources/templates/users/signup.html
    @@ -29,7 +29,7 @@
    </div>
    <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">가입하기</button>
    </form>
    -<div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    <span style="color: var(--text-sub);">이미 계정이 있으신가요?</span>
    <a th:href="@{/login}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">로그인</a>
    </div>
     
  • 수정src/main/resources/templates/view.html
    --- a/src/main/resources/templates/view.html
    +++ b/src/main/resources/templates/view.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.title} + ' - Sample Board'">게시글 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    <script>
    @@ -41,51 +42,49 @@
    <div class="comment-section">
    <h3>댓글</h3>
    <ul class="comment-list">
    - <li th:if="${#lists.isEmpty(comments)}" style="color: #888; font-size: 14px; padding: 8px 0;">
    + <li th:if="${#lists.isEmpty(comments)}" style="color: var(--text-muted); font-size: 14px; padding: 12px 0;">
    등록된 댓글이 없습니다.
    </li>
    <!-- 부모 댓글 반복 -->
    <li class="comment-item" th:each="comment : ${comments}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${comment.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${comment.id})}" method="post"
    onsubmit="return confirm('댓글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${comment.content}">댓글 내용</p>
    -<div class="comment-actions">
    + <div class="comment-actions">
    <button type="button" class="btn-reply-toggle" th:onclick="'toggleReplyForm(' + ${comment.id} + ')'">답글쓰기</button>
    </div>
    <!-- 대댓글 작성 폼 (토글) -->
    <form class="comment-form reply-form" th:id="'reply-form-' + ${comment.id}" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    <input type="hidden" name="parentId" th:value="${comment.id}"/>
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="답글 내용을 입력하세요" required></textarea>
    <div style="display: flex; justify-content: flex-end;">
    - <button type="submit" class="btn" style="padding: 4px 10px; font-size: 13px;">답글 등록</button>
    + <button type="submit" class="btn" style="padding: 6px 14px; font-size: 13px;">답글 등록</button>
    </div>
    </form>
    <!-- 대댓글 목록 -->
    - <ul class="comment-list" style="margin-top: 10px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    + <ul class="comment-list" style="margin-top: 12px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    <li class="comment-item reply" th:each="reply : ${comment.children}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${reply.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${reply.id})}" method="post"
    onsubmit="return confirm('답글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${reply.content}">답글 내용</p>
    @@ -96,9 +95,9 @@
    <!-- 최상위 댓글 작성 폼 -->
    <form class="comment-form" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="댓글 내용을 입력하세요" required></textarea>
    -<div style="display: flex; justify-content: flex-end;">
    + <div style="display: flex; justify-content: flex-end;">
    <button type="submit" class="btn">댓글 등록</button>
    </div>
    </form>
     
전체적인 디자인을 하늘색 흰색으로 해줘

댓글: 전체적인 디자인을 하늘색 흰색으로 해줘

  • 🚀 분석을 시작합니다...
  • 🧭 먼저 어떻게 바꿀지 살펴볼게요...
  • 📄 src/main/resources/static/css/style.css 파일을 확인했어요.
  • ✅ 분석 완료: 사용자의 요청에 따라 전체적인 디자인 컬러 시스템을 산뜻한 하늘색(Sky Blue)과 흰색(White) 테마로 고도화합니다. CSS 파일의 변수 및 스타일을 수정하여 시원하고 깔끔한 UI를 제공합니다.

구현 계획

사용자의 요청에 따라 전체적인 디자인 컬러 시스템을 산뜻한 하늘색(Sky Blue)과 흰색(White) 테마로 고도화합니다. CSS 파일의 변수 및 스타일을 수정하여 시원하고 깔끔한 UI를 제공합니다.

  • 수정src/main/resources/static/css/style.css

    전체 디자인을 하늘색과 흰색 기반의 테마(배경: 연한 하늘색, 카드: 순백색, 포인트: 스카이 블루)로 전면 개편

이 계획대로 구현해줘

이 계획대로 구현해줘

  • 🚀 구현을 시작합니다...
  • ✏️ src/main/resources/static/css/style.css 파일 작성 완료 (커밋 대기 중).

변경된 코드

  • 수정pom.xml
    --- a/pom.xml
    +++ b/pom.xml
    @@ -15,15 +15,17 @@
    <artifactId>board</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>board</name>
    - <description>Simple board sample with menu (Spring Boot + MyBatis)</description>
    + <description>Simple board sample with menu (Spring Boot + MyBatis + Security + JWT)</description>
    <properties>
    <java.version>1.8</java.version>
    <mybatis.version>2.3.1</mybatis.version>
    + <jjwt.version>0.11.5</jjwt.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    -<dependencies>
    +
    + <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    @@ -31,6 +33,27 @@
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>org.springframework.boot</groupId>
    + <artifactId>spring-boot-starter-security</artifactId>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-api</artifactId>
    + <version>${jjwt.version}</version>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-impl</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    + </dependency>
    + <dependency>
    + <groupId>io.jsonwebtoken</groupId>
    + <artifactId>jjwt-jackson</artifactId>
    + <version>${jjwt.version}</version>
    + <scope>runtime</scope>
    </dependency>
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    @@ -48,6 +71,11 @@
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    + <dependency>
    + <groupId>org.springframework.security</groupId>
    + <artifactId>spring-security-test</artifactId>
    + <scope>test</scope>
    + </dependency>
    </dependencies>
    <build>
     
  • 수정src/main/java/com/example/board/controller/LoginController.java
    --- a/src/main/java/com/example/board/controller/LoginController.java
    +++ b/src/main/java/com/example/board/controller/LoginController.java
    @@ -1,6 +1,7 @@
    package com.example.board.controller;
    import com.example.board.domain.User;
    +import com.example.board.security.JwtTokenProvider;
    import com.example.board.service.UserService;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    @@ -8,16 +9,20 @@
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    +import javax.servlet.http.Cookie;
    import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    @Controller
    public class LoginController {
    private final UserService userService;
    + private final JwtTokenProvider jwtTokenProvider;
    - public LoginController(UserService userService) {
    + public LoginController(UserService userService, JwtTokenProvider jwtTokenProvider) {
    this.userService = userService;
    + this.jwtTokenProvider = jwtTokenProvider;
    }
    @GetMapping("/login")
    @@ -29,6 +34,7 @@
    public String login(@RequestParam("username") String username,
    @RequestParam("password") String password,
    HttpServletRequest request,
    + HttpServletResponse response,
    Model model) {
    User user = userService.login(username, password);
    @@ -36,6 +42,14 @@
    model.addAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
    return "users/login";
    }
    +
    + // JWT 토큰 생성 및 쿠키 저장
    + String token = jwtTokenProvider.createToken(username);
    + Cookie cookie = new Cookie("JWT-TOKEN", token);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(3600);
    + response.addCookie(cookie);
    HttpSession session = request.getSession();
    session.setAttribute("loginUser", user);
    @@ -60,12 +74,20 @@
    }
    return "redirect:/login?registered=true";
    }
    -@GetMapping("/logout")
    - public String logout(HttpServletRequest request) {
    +
    + @GetMapping("/logout")
    + public String logout(HttpServletRequest request, HttpServletResponse response) {
    HttpSession session = request.getSession(false);
    if (session != null) {
    session.invalidate();
    }
    +
    + Cookie cookie = new Cookie("JWT-TOKEN", null);
    + cookie.setHttpOnly(true);
    + cookie.setPath("/");
    + cookie.setMaxAge(0);
    + response.addCookie(cookie);
    +
    return "redirect:/";
    }
    }
     
  • 생성src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    --- a/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    +++ b/src/main/java/com/example/board/security/JwtAuthenticationFilter.java
    @@ -0,0 +1,33 @@
    +package com.example.board.security;
    +
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.context.SecurityContextHolder;
    +import org.springframework.web.filter.OncePerRequestFilter;
    +
    +import javax.servlet.FilterChain;
    +import javax.servlet.ServletException;
    +import javax.servlet.http.HttpServletRequest;
    +import javax.servlet.http.HttpServletResponse;
    +import java.io.IOException;
    +
    +public class JwtAuthenticationFilter extends OncePerRequestFilter {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Override
    + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    + throws ServletException, IOException {
    + String token = jwtTokenProvider.resolveToken(request);
    + if (token != null && jwtTokenProvider.validateToken(token)) {
    + Authentication auth = jwtTokenProvider.getAuthentication(token);
    + if (auth != null) {
    + SecurityContextHolder.getContext().setAuthentication(auth);
    + }
    + }
    + filterChain.doFilter(request, response);
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/JwtTokenProvider.java
    --- a/src/main/java/com/example/board/security/JwtTokenProvider.java
    +++ b/src/main/java/com/example/board/security/JwtTokenProvider.java
    @@ -0,0 +1,75 @@
    +package com.example.board.security;
    +
    +import io.jsonwebtoken.*;
    +import io.jsonwebtoken.security.Keys;
    +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    +import org.springframework.security.core.Authentication;
    +import org.springframework.security.core.userdetails.User;
    +import org.springframework.security.core.userdetails.UserDetails;
    +import org.springframework.stereotype.Component;
    +
    +import javax.annotation.PostConstruct;
    +import javax.servlet.http.HttpServletRequest;
    +import java.security.Key;
    +import java.util.ArrayList;
    +import java.util.Date;
    +
    +@Component
    +public class JwtTokenProvider {
    +
    + private static final String SECRET_KEY_STRING = "mySecretKeyForJwtAuthenticationWithSpringSecurityAndBoardApplication1234567890";
    + private Key key;
    + private final long validityInMilliseconds = 3600000; // 1 hour
    +
    + @PostConstruct
    + public void init() {
    + this.key = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
    + }
    +
    + public String createToken(String username) {
    + Claims claims = Jwts.claims().setSubject(username);
    + Date now = new Date();
    + Date validity = new Date(now.getTime() + validityInMilliseconds);
    +
    + return Jwts.builder()
    + .setClaims(claims)
    + .setIssuedAt(now)
    + .setExpiration(validity)
    + .signWith(key, SignatureAlgorithm.HS256)
    + .compact();
    + }
    +
    + public Authentication getAuthentication(String token) {
    + UserDetails userDetails = new User(getUsername(token), "", new ArrayList<>());
    + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    + }
    +
    + public String getUsername(String token) {
    + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    + }
    +
    + public boolean validateToken(String token) {
    + try {
    + Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    + return !claims.getBody().getExpiration().before(new Date());
    + } catch (JwtException | IllegalArgumentException e) {
    + return false;
    + }
    + }
    +
    + public String resolveToken(HttpServletRequest request) {
    + javax.servlet.http.Cookie[] cookies = request.getCookies();
    + if (cookies != null) {
    + for (javax.servlet.http.Cookie cookie : cookies) {
    + if ("JWT-TOKEN".equals(cookie.getName())) {
    + return cookie.getValue();
    + }
    + }
    + }
    + String bearerToken = request.getHeader("Authorization");
    + if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
    + return bearerToken.substring(7);
    + }
    + return null;
    + }
    +}
     
  • 생성src/main/java/com/example/board/security/SecurityConfig.java
    --- a/src/main/java/com/example/board/security/SecurityConfig.java
    +++ b/src/main/java/com/example/board/security/SecurityConfig.java
    @@ -0,0 +1,51 @@
    +package com.example.board.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.config.http.SessionCreationPolicy;
    +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    +import org.springframework.security.web.SecurityFilterChain;
    +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    +
    +@Configuration
    +@EnableWebSecurity
    +public class SecurityConfig {
    +
    + private final JwtTokenProvider jwtTokenProvider;
    +
    + public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
    + this.jwtTokenProvider = jwtTokenProvider;
    + }
    +
    + @Bean
    + public PasswordEncoder passwordEncoder() {
    + return new BCryptPasswordEncoder();
    + }
    +
    + @Bean
    + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    + return authenticationConfiguration.getAuthenticationManager();
    + }
    +
    + @Bean
    + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    + http
    + .csrf().disable()
    + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    + .and()
    + .authorizeRequests()
    + .antMatchers("/", "/login", "/signup", "/css/**", "/js/**", "/posts", "/posts/{id}", "/settlements", "/settlements/{id}", "/h2-console/**").permitAll()
    + .anyRequest().authenticated()
    + .and()
    + .headers().frameOptions().disable()
    + .and()
    + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    +
    + return http.build();
    + }
    +}
     
  • 수정src/main/java/com/example/board/service/UserService.java
    --- a/src/main/java/com/example/board/service/UserService.java
    +++ b/src/main/java/com/example/board/service/UserService.java
    @@ -2,6 +2,7 @@
    import com.example.board.domain.User;
    import com.example.board.mapper.UserMapper;
    +import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    @@ -9,14 +10,16 @@
    public class UserService {
    private final UserMapper userMapper;
    + private final PasswordEncoder passwordEncoder;
    - public UserService(UserMapper userMapper) {
    + public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
    this.userMapper = userMapper;
    + this.passwordEncoder = passwordEncoder;
    }
    public User login(String username, String password) {
    User user = userMapper.findByUsername(username);
    - if (user != null && user.getPassword().equals(password)) {
    + if (user != null && passwordEncoder.matches(password, user.getPassword())) {
    return user;
    }
    return null;
    @@ -31,10 +34,14 @@
    User user = new User();
    user.setUsername(username);
    - user.setPassword(password); // 실무에서는 암호화 필요
    + user.setPassword(passwordEncoder.encode(password));
    user.setNickname(nickname);
    userMapper.insertUser(user);
    return true;
    }
    +
    + public User findByUsername(String username) {
    + return userMapper.findByUsername(username);
    + }
    }
     
  • 수정src/main/resources/static/css/style.css
    --- a/src/main/resources/static/css/style.css
    +++ b/src/main/resources/static/css/style.css
    @@ -1,76 +1,77 @@
    /* ==========================================
    - Multi-Theme System (Light, Dark, Neon)
    + Sky Blue & White Design System (Responsive)
    ========================================== */
    :root {
    - --bg-color: #f8fafc;
    + --bg-color: #f0f9ff;
    --card-bg: #ffffff;
    - --text-main: #1e293b;
    - --text-sub: #64748b;
    + --text-main: #0f172a;
    + --text-sub: #475569;
    --text-muted: #94a3b8;
    - --border-color: #e2e8f0;
    + --border-color: #bae6fd;
    --input-bg: #ffffff;
    - --input-border: #cbd5e1;
    - --primary: #2563eb;
    - --primary-hover: #1d4ed8;
    - --primary-light: #eff6ff;
    + --input-border: #7dd3fc;
    + --primary: #0284c7;
    + --primary-hover: #0369a1;
    + --primary-light: #e0f2fe;
    --primary-text: #ffffff;
    - --nav-bg: #2563eb;
    + --nav-bg: #0284c7;
    --nav-brand: #ffffff;
    - --nav-item: #bfdbfe;
    + --nav-item: #e0f2fe;
    --nav-item-hover: #ffffff;
    - --badge-completed: #2563eb;
    + --badge-completed: #0284c7;
    --badge-pending: #d97706;
    --badge-cancelled: #dc2626;
    - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
    - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
    + --shadow: 0 4px 6px -1px rgba(2, 132, 199, 0.08), 0 2px 4px -1px rgba(2, 132, 199, 0.04);
    + --shadow-lg: 0 10px 15px -3px rgba(2, 132, 199, 0.12);
    --transition-speed: 0.3s ease;
    }
    [data-theme="dark"] {
    - --bg-color: #0f172a;
    - --card-bg: #1e293b;
    - --text-main: #f1f5f9;
    - --text-sub: #94a3b8;
    + --bg-color: #0b1329;
    + --card-bg: #111e38;
    + --text-main: #f0f9ff;
    + --text-sub: #93c5fd;
    --text-muted: #64748b;
    - --border-color: #334155;
    - --input-bg: #0f172a;
    - --input-border: #475569;
    - --primary: #3b82f6;
    - --primary-hover: #2563eb;
    - --primary-light: #1e3a8a;
    - --primary-text: #ffffff;
    - --nav-bg: #1e293b;
    - --nav-brand: #f1f5f9;
    - --nav-item: #94a3b8;
    + --border-color: #1e3a8a;
    + --input-bg: #0b1329;
    + --input-border: #1d4ed8;
    + --primary: #38bdf8;
    + --primary-hover: #0ea5e9;
    + --primary-light: #172554;
    + --primary-text: #0f172a;
    + --nav-bg: #111e38;
    + --nav-brand: #38bdf8;
    + --nav-item: #93c5fd;
    --nav-item-hover: #ffffff;
    - --badge-completed: #3b82f6;
    + --badge-completed: #38bdf8;
    --badge-pending: #f59e0b;
    --badge-cancelled: #ef4444;
    - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
    - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
    -}
    + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
    + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
    +}
    +
    [data-theme="neon"] {
    - --bg-color: #05050a;
    - --card-bg: #0f0f17;
    - --text-main: #00f0ff;
    - --text-sub: #a78bfa;
    + --bg-color: #030712;
    + --card-bg: #0b132b;
    + --text-main: #38bdf8;
    + --text-sub: #c084fc;
    --text-muted: #6b7280;
    - --border-color: #1f1f38;
    - --input-bg: #05050a;
    - --input-border: #3b0764;
    - --primary: #ff007f;
    - --primary-hover: #db2777;
    - --primary-light: #2e1065;
    - --primary-text: #ffffff;
    - --nav-bg: #0b0b14;
    + --border-color: #1e1b4b;
    + --input-bg: #030712;
    + --input-border: #4c1d95;
    + --primary: #00f0ff;
    + --primary-hover: #0284c7;
    + --primary-light: #1e1b4b;
    + --primary-text: #030712;
    + --nav-bg: #030712;
    --nav-brand: #00f0ff;
    - --nav-item: #c084fc;
    + --nav-item: #38bdf8;
    --nav-item-hover: #00f0ff;
    --badge-completed: #00f0ff;
    --badge-pending: #facc15;
    --badge-cancelled: #ff0055;
    - --shadow: 0 0 15px rgba(255, 0, 127, 0.2), inset 0 0 10px rgba(0, 240, 255, 0.05);
    - --shadow-lg: 0 0 25px rgba(0, 240, 255, 0.25);
    + --shadow: 0 0 15px rgba(0, 240, 255, 0.2);
    + --shadow-lg: 0 0 25px rgba(0, 240, 255, 0.3);
    }
    * {
    @@ -83,6 +84,7 @@
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background: var(--bg-color);
    color: var(--text-main);
    + line-height: 1.5;
    }
    /* ==========================================
    @@ -94,20 +96,24 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
    - height: 64px;
    + height: 70px;
    box-shadow: var(--shadow);
    border-bottom: 1px solid var(--border-color);
    -}
    + position: sticky;
    + top: 0;
    + z-index: 1000;
    +}
    +
    .nav-left {
    display: flex;
    align-items: center;
    - gap: 28px;
    + gap: 32px;
    }
    .nav-right {
    display: flex;
    align-items: center;
    - gap: 12px;
    + gap: 16px;
    }
    nav.gnb .brand {
    @@ -172,24 +178,14 @@
    .user-greeting {
    color: var(--text-main);
    font-size: 14px;
    - font-weight: 500;
    + font-weight: 600;
    }
    .auth-btn {
    - padding: 6px 12px !important;
    + padding: 6px 14px !important;
    border-radius: 6px;
    font-size: 13px !important;
    -}
    -.user-greeting {
    - color: var(--text-main);
    - font-size: 14px;
    - font-weight: 500;
    -}
    -
    -.auth-btn {
    - padding: 6px 12px !important;
    - border-radius: 6px;
    - font-size: 13px !important;
    + font-weight: 600 !important;
    }
    .auth-btn.login {
    @@ -215,19 +211,20 @@
    main {
    max-width: 1040px;
    margin: 40px auto;
    - padding: 0 20px;
    + padding: 0 24px;
    }
    h1 {
    - font-size: 24px;
    + font-size: 26px;
    font-weight: 700;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    color: var(--text-main);
    letter-spacing: -0.5px;
    }
    h2 {
    color: var(--text-main);
    + font-size: 20px;
    }
    p {
    @@ -245,7 +242,7 @@
    box-shadow: var(--shadow);
    border: 1px solid var(--border-color);
    background: var(--card-bg);
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    }
    table.post-list {
    @@ -253,14 +250,6 @@
    border-collapse: separate;
    border-spacing: 0;
    background: transparent;
    - white-space: nowrap;
    -}
    -table.post-list {
    - width: 100%;
    - border-collapse: separate;
    - border-spacing: 0;
    - background: transparent;
    - white-space: nowrap;
    }
    table.post-list th, table.post-list td {
    @@ -290,13 +279,13 @@
    }
    table.post-list td.num, table.post-list th.num {
    - width: 70px;
    + width: 80px;
    text-align: center;
    color: var(--text-muted);
    }
    table.post-list td.views, table.post-list th.views {
    - width: 90px;
    + width: 100px;
    color: var(--text-sub);
    text-align: center;
    }
    @@ -312,11 +301,12 @@
    text-align: center;
    color: var(--text-sub);
    }
    +
    /* Badges */
    .badge {
    font-weight: 600;
    font-size: 12px;
    - padding: 4px 8px;
    + padding: 4px 10px;
    border-radius: 6px;
    display: inline-block;
    }
    @@ -350,7 +340,7 @@
    .toolbar {
    display: flex;
    justify-content: flex-end;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    }
    .btn {
    @@ -366,14 +356,14 @@
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    + box-shadow: 0 2px 4px rgba(2, 132, 199, 0.2);
    transition: all 0.2s ease;
    }
    .btn:hover {
    background: var(--primary-hover);
    - transform: translateY(-2px);
    - box-shadow: 0 4px 8px rgba(0,0,0,0.15);
    + transform: translateY(-1px);
    + box-shadow: 0 4px 8px rgba(2, 132, 199, 0.3);
    }
    .btn.secondary {
    @@ -382,15 +372,9 @@
    color: var(--text-main);
    box-shadow: none;
    }
    -.btn.secondary {
    - background: var(--card-bg);
    - border: 1px solid var(--border-color);
    - color: var(--text-main);
    - box-shadow: none;
    -}
    .btn.secondary:hover {
    - background: var(--border-color);
    + background: var(--primary-light);
    }
    .btn.danger {
    @@ -406,8 +390,8 @@
    ========================================== */
    .card {
    background: var(--card-bg);
    - padding: 32px;
    - border-radius: 14px;
    + padding: 36px;
    + border-radius: 16px;
    box-shadow: var(--shadow-lg);
    border: 1px solid var(--border-color);
    margin-bottom: 24px;
    @@ -417,7 +401,7 @@
    .post-meta {
    color: var(--text-sub);
    font-size: 14px;
    - margin-bottom: 20px;
    + margin-bottom: 24px;
    padding-bottom: 16px;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    @@ -427,21 +411,15 @@
    }
    .post-content {
    - min-height: 180px;
    - line-height: 1.7;
    + min-height: 200px;
    + line-height: 1.8;
    white-space: pre-wrap;
    - margin-bottom: 32px;
    - color: var(--text-main);
    - font-size: 15px;
    + margin-bottom: 36px;
    + color: var(--text-main);
    + font-size: 16px;
    word-break: break-all;
    }
    -.actions {
    - display: flex;
    - gap: 12px;
    - justify-content: flex-end;
    - flex-wrap: wrap;
    -}
    .actions {
    display: flex;
    gap: 12px;
    @@ -458,7 +436,7 @@
    form.post-form select {
    width: 100%;
    padding: 12px 16px;
    - margin-bottom: 16px;
    + margin-bottom: 20px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -486,7 +464,7 @@
    }
    form.post-form textarea {
    - min-height: 220px;
    + min-height: 240px;
    resize: vertical;
    }
    @@ -494,8 +472,8 @@
    Comments & Replies
    ========================================== */
    .comment-section {
    - margin-top: 40px;
    - padding-top: 28px;
    + margin-top: 48px;
    + padding-top: 32px;
    border-top: 1px solid var(--border-color);
    }
    @@ -504,18 +482,13 @@
    padding: 0;
    margin: 0 0 24px 0;
    }
    -.comment-list {
    - list-style: none;
    - padding: 0;
    - margin: 0 0 24px 0;
    -}
    .comment-item {
    - padding: 16px 20px;
    + padding: 20px;
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    - border-radius: 10px;
    - margin-bottom: 12px;
    + border-radius: 12px;
    + margin-bottom: 16px;
    box-shadow: var(--shadow);
    }
    @@ -532,7 +505,7 @@
    display: flex;
    justify-content: space-between;
    align-items: center;
    - margin-bottom: 8px;
    + margin-bottom: 10px;
    flex-wrap: wrap;
    gap: 8px;
    }
    @@ -540,7 +513,7 @@
    .comment-content {
    font-size: 14px;
    white-space: pre-wrap;
    - margin: 0;
    + margin: 0 0 12px 0;
    color: var(--text-main);
    line-height: 1.6;
    word-break: break-all;
    @@ -561,17 +534,13 @@
    }
    .comment-form {
    - margin-top: 12px;
    - padding: 16px;
    + margin-top: 16px;
    + padding: 20px;
    background: var(--primary-light);
    - border-radius: 10px;
    - border: 1px solid var(--border-color);
    -}
    -
    -.comment-form.reply-form {
    - display: none;
    - margin-top: 12px;
    -}
    + border-radius: 12px;
    + border: 1px solid var(--border-color);
    +}
    +
    .comment-form.reply-form {
    display: none;
    margin-top: 12px;
    @@ -581,7 +550,7 @@
    .comment-form textarea {
    width: 100%;
    padding: 10px 14px;
    - margin-bottom: 10px;
    + margin-bottom: 12px;
    border: 1px solid var(--input-border);
    border-radius: 8px;
    font-size: 14px;
    @@ -591,7 +560,7 @@
    }
    .comment-form textarea {
    - min-height: 80px;
    + min-height: 90px;
    resize: vertical;
    }
    @@ -602,62 +571,52 @@
    nav.gnb {
    padding: 0 16px;
    height: auto;
    - min-height: 60px;
    + min-height: 64px;
    flex-direction: column;
    align-items: stretch;
    - padding-bottom: 10px;
    - padding-top: 10px;
    - gap: 10px;
    + padding-bottom: 12px;
    + padding-top: 12px;
    + gap: 12px;
    }
    .nav-left {
    flex-direction: column;
    align-items: flex-start;
    - gap: 8px;
    + gap: 10px;
    width: 100%;
    }
    .nav-links {
    display: flex;
    flex-wrap: wrap;
    - gap: 12px;
    + gap: 14px;
    width: 100%;
    }
    nav.gnb a.menu-item {
    - margin-right: 0;
    font-size: 14px;
    }
    .nav-right {
    justify-content: space-between;
    width: 100%;
    - border-top: 1px solid rgba(255, 255, 255, 0.15);
    - padding-top: 8px;
    + border-top: 1px solid var(--border-color);
    + padding-top: 10px;
    }
    main {
    - margin: 16px 12px;
    + margin: 20px 16px;
    padding: 0;
    }
    .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    - }
    -main {
    - margin: 16px 12px;
    - padding: 0;
    - }
    -
    - .card {
    - padding: 20px 16px;
    - border-radius: 10px;
    + padding: 24px 18px;
    + border-radius: 12px;
    }
    h1 {
    - font-size: 20px;
    - margin-bottom: 16px;
    + font-size: 22px;
    + margin-bottom: 18px;
    }
    table.post-list th, table.post-list td {
    @@ -667,7 +626,7 @@
    .actions {
    flex-direction: column;
    - gap: 8px;
    + gap: 10px;
    }
    .actions .btn {
     
  • 수정src/main/resources/templates/form.html
    --- a/src/main/resources/templates/form.html
    +++ b/src/main/resources/templates/form.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.id} != null ? '게시글 수정 - Sample Board' : '게시글 작성 - Sample Board'">게시글 작성</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
     
  • 수정src/main/resources/templates/fragments/header.html
    --- a/src/main/resources/templates/fragments/header.html
    +++ b/src/main/resources/templates/fragments/header.html
    @@ -14,14 +14,14 @@
    <div class="nav-right">
    <!-- 테마 전환 스위처 -->
    -<div class="theme-switcher">
    + <div class="theme-switcher">
    <button type="button" class="theme-btn" data-theme="light" title="라이트 모드">☀️</button>
    <button type="button" class="theme-btn" data-theme="dark" title="다크 모드">🌙</button>
    <button type="button" class="theme-btn" data-theme="neon" title="네온 모드">⚡</button>
    </div>
    <th:block th:if="${session.loginUser != null}">
    -<span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    + <span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    <a class="menu-item auth-btn logout" th:href="@{/logout}">로그아웃</a>
    </th:block>
    <th:block th:unless="${session.loginUser != null}">
     
  • 수정src/main/resources/templates/home.html
    --- a/src/main/resources/templates/home.html
    +++ b/src/main/resources/templates/home.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    @@ -9,40 +10,41 @@
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    <div class="card" style="margin-bottom: 24px;">
    - <h1 style="color: #0284c7; margin-bottom: 8px;">Spring Boot + MyBatis 샘플 페이지</h1>
    - <p style="color: #64748b; margin-bottom: 16px;">상단 메뉴의 <strong>게시판</strong>에서 목록을 확인하고, <strong>글쓰기</strong>로 새 글을 등록해 보세요.</p>
    + <h1 style="color: var(--primary); margin-bottom: 12px;">Spring Boot + MyBatis + JWT 샘플 게시판</h1>
    + <p style="margin-bottom: 20px;">모던한 디자인 시스템과 다크/라이트/네온 테마, JWT 기반 보안 인증 시스템이 적용된 고도화된 게시판입니다.</p>
    <a class="btn" th:href="@{/posts}">게시판 바로가기</a>
    </div>
    <div class="card">
    -<div class="card">
    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
    - <h2 style="font-size: 18px; margin: 0; color: #1e293b;">최근 게시글</h2>
    - <a th:href="@{/posts}" style="font-size: 14px; color: #0284c7; text-decoration: none;">더보기 &gt;</a>
    + <h2>최근 게시글</h2>
    + <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 &gt;</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th class="num" style="width: 100px;">작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align: center; color: #94a3b8; padding: 24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    - </td>
    - <td th:text="${post.writer}" style="color: #64748b;">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align: center; color: var(--text-muted); padding: 32px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목입니다</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </div>
    </main>
    </body>
     
  • 수정src/main/resources/templates/list.html
    --- a/src/main/resources/templates/list.html
    +++ b/src/main/resources/templates/list.html
    @@ -2,39 +2,42 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>게시판 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    <div th:replace="~{fragments/header :: menu}"></div>
    <main>
    - <h1>게시판</h1>
    + <h1>자유 게시판</h1>
    <div class="toolbar">
    <a class="btn" th:href="@{/posts/new}">글쓰기</a>
    </div>
    - <table class="post-list">
    - <thead>
    - <tr>
    - <th class="num">번호</th>
    - <th>제목</th>
    - <th>작성자</th>
    - <th class="views">조회수</th>
    - </tr>
    - </thead>
    - <tbody>
    - <tr th:if="${#lists.isEmpty(posts)}">
    - <td colspan="4" style="text-align:center; color:#888; padding:24px;">등록된 게시글이 없습니다.</td>
    - </tr>
    - <tr th:each="post : ${posts}">
    - <td class="num" th:text="${post.id}">1</td>
    - <td>
    - <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    - </td>
    - <td th:text="${post.writer}">작성자</td>
    - <td class="views" th:text="${post.viewCount}">0</td>
    - </tr>
    - </tbody>
    - </table>
    + <div class="table-responsive">
    + <table class="post-list">
    + <thead>
    + <tr>
    + <th class="num">번호</th>
    + <th>제목</th>
    + <th class="writer">작성자</th>
    + <th class="views">조회수</th>
    + </tr>
    + </thead>
    + <tbody>
    + <tr th:if="${#lists.isEmpty(posts)}">
    + <td colspan="4" style="text-align:center; color:var(--text-muted); padding:36px;">등록된 게시글이 없습니다.</td>
    + </tr>
    + <tr th:each="post : ${posts}">
    + <td class="num" th:text="${post.id}">1</td>
    + <td>
    + <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">제목</a>
    + </td>
    + <td class="writer" th:text="${post.writer}">작성자</td>
    + <td class="views" th:text="${post.viewCount}">0</td>
    + </tr>
    + </tbody>
    + </table>
    + </div>
    </main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/login.html
    --- a/src/main/resources/templates/users/login.html
    +++ b/src/main/resources/templates/users/login.html
    @@ -2,33 +2,40 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    - <title>로그인</title>
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    + <title>로그인 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
    -<div class="container" style="max-width: 400px; margin-top: 100px;">
    - <h2>로그인</h2>
    - <div th:if="${error}" style="color: red; margin-bottom: 15px;" th:text="${error}"></div>
    -
    - <form th:action="@{/login}" method="post">
    - <div class="form-group" style="margin-bottom: 15px;">
    - <label for="username">아이디</label>
    - <input type="text" id="username" name="username" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    +<div th:replace="~{fragments/header :: menu}"></div>
    +<main>
    + <div class="card" style="max-width: 440px; margin: 40px auto;">
    + <h1 style="text-align: center; margin-bottom: 24px;">로그인</h1>
    +
    + <div th:if="${error}" style="background: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: #ef4444; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;" th:text="${error}"></div>
    + <div th:if="${param.registered}" style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; color: #10b981; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; text-align: center;">회원가입이 완료되었습니다. 로그인해주세요.</div>
    +
    + <form class="post-form" th:action="@{/login}" method="post">
    + <div>
    + <label for="username">아이디</label>
    + <input type="text" id="username" name="username" required placeholder="아이디를 입력하세요">
    + </div>
    + <div>
    + <label for="password">비밀번호</label>
    + <input type="password" id="password" name="password" required placeholder="비밀번호를 입력하세요">
    + </div>
    + <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">로그인</button>
    + </form>
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <span style="color: var(--text-sub);">계정이 없으신가요?</span>
    + <a th:href="@{/signup}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">회원가입</a>
    </div>
    - <div class="form-group" style="margin-bottom: 20px;">
    - <label for="password">비밀번호</label>
    - <input type="password" id="password" name="password" class="form-control" required style="width: 100%; padding: 8px; box-sizing: border-box;">
    + <div style="margin-top: 30px; padding: 16px; background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 8px; font-size: 13px; color: var(--text-sub);">
    + <strong>테스트 계정:</strong><br>
    + - 관리자: admin / 1234<br>
    + - 일반회원: user1 / 1234
    </div>
    - <button type="submit" class="btn btn-primary" style="width: 100%; padding: 10px;">로그인</button>
    - </form>
    - <div style="margin-top: 15px; text-align: center;">
    - <a th:href="@{/}">홈으로 돌아가기</a>
    </div>
    - <div style="margin-top: 25px; padding: 10px; background: #f8f9fa; font-size: 13px; color: #666;">
    - <strong>테스트 계정:</strong><br>
    - - 관리자: admin / 1234<br>
    - - 일반회원: user1 / 1234
    - </div>
    -</div>
    +</main>
    </body>
    </html>
     
  • 수정src/main/resources/templates/users/signup.html
    --- a/src/main/resources/templates/users/signup.html
    +++ b/src/main/resources/templates/users/signup.html
    @@ -29,7 +29,7 @@
    </div>
    <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">가입하기</button>
    </form>
    -<div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    <span style="color: var(--text-sub);">이미 계정이 있으신가요?</span>
    <a th:href="@{/login}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">로그인</a>
    </div>
     
  • 수정src/main/resources/templates/view.html
    --- a/src/main/resources/templates/view.html
    +++ b/src/main/resources/templates/view.html
    @@ -2,6 +2,7 @@
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
    <meta charset="UTF-8">
    + <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${post.title} + ' - Sample Board'">게시글 - Sample Board</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    <script>
    @@ -41,51 +42,49 @@
    <div class="comment-section">
    <h3>댓글</h3>
    <ul class="comment-list">
    - <li th:if="${#lists.isEmpty(comments)}" style="color: #888; font-size: 14px; padding: 8px 0;">
    + <li th:if="${#lists.isEmpty(comments)}" style="color: var(--text-muted); font-size: 14px; padding: 12px 0;">
    등록된 댓글이 없습니다.
    </li>
    <!-- 부모 댓글 반복 -->
    <li class="comment-item" th:each="comment : ${comments}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${comment.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${comment.id})}" method="post"
    onsubmit="return confirm('댓글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${comment.content}">댓글 내용</p>
    -<div class="comment-actions">
    + <div class="comment-actions">
    <button type="button" class="btn-reply-toggle" th:onclick="'toggleReplyForm(' + ${comment.id} + ')'">답글쓰기</button>
    </div>
    <!-- 대댓글 작성 폼 (토글) -->
    <form class="comment-form reply-form" th:id="'reply-form-' + ${comment.id}" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    <input type="hidden" name="parentId" th:value="${comment.id}"/>
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="답글 내용을 입력하세요" required></textarea>
    <div style="display: flex; justify-content: flex-end;">
    - <button type="submit" class="btn" style="padding: 4px 10px; font-size: 13px;">답글 등록</button>
    + <button type="submit" class="btn" style="padding: 6px 14px; font-size: 13px;">답글 등록</button>
    </div>
    </form>
    <!-- 대댓글 목록 -->
    - <ul class="comment-list" style="margin-top: 10px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    + <ul class="comment-list" style="margin-top: 12px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    <li class="comment-item reply" th:each="reply : ${comment.children}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${reply.writer}">작성자</strong>
    - <span style="margin-left: 8px; color: #999;" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    + <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    </div>
    <form th:action="@{/posts/comments/{id}/delete(id=${reply.id})}" method="post"
    onsubmit="return confirm('답글을 삭제하시겠습니까?');" style="display:inline;">
    <input type="hidden" name="postId" th:value="${post.id}"/>
    - <button type="submit" class="btn danger" style="padding: 2px 6px; font-size: 12px;">삭제</button>
    + <button type="submit" class="btn danger" style="padding: 4px 8px; font-size: 12px;">삭제</button>
    </form>
    </div>
    <p class="comment-content" th:text="${reply.content}">답글 내용</p>
    @@ -96,9 +95,9 @@
    <!-- 최상위 댓글 작성 폼 -->
    <form class="comment-form" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    - <input type="text" name="writer" placeholder="작성자" style="max-width: 200px;" required>
    + <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="댓글 내용을 입력하세요" required></textarea>
    -<div style="display: flex; justify-content: flex-end;">
    + <div style="display: flex; justify-content: flex-end;">
    <button type="submit" class="btn">댓글 등록</button>
    </div>
    </form>
     
PR 생성 후 바로 머지

PR 생성 후 바로 머지

  • 🚀 구현을 시작합니다...
  • 🌿 브랜치 생성: coding-agent/sample-board-b75bcabe
  • 📦 커밋 완료 (0a97701)
  • ✅ PR 생성 완료: https://github.com/woorifis-9297/sample-board/pull/21
  • 🎉 머지 완료 (96b1cde)

변경된 코드

  • 수정pom.xml
    --- a/pom.xml
    +++ b/pom.xml
    @@ -24,7 +24,8 @@
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    -<dependencies>
    +
    + <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    @@ -63,13 +64,9 @@
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
    -</dependency>
    + </dependency>
    +
    <dependency>
    - <groupId>com.h2database</groupId>
    - <artifactId>h2</artifactId>
    - <scope>runtime</scope>
    - </dependency>
    -<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
     
  • 수정src/main/java/com/example/board/controller/LoginController.java
    --- a/src/main/java/com/example/board/controller/LoginController.java
    +++ b/src/main/java/com/example/board/controller/LoginController.java
    @@ -61,13 +61,6 @@
    public String signupForm() {
    return "users/signup";
    }
    -return "redirect:/";
    - }
    -
    - @GetMapping("/signup")
    - public String signupForm() {
    - return "users/signup";
    - }
    @PostMapping("/signup")
    public String signup(@RequestParam("username") String username,
     
  • 수정src/main/java/com/example/board/security/JwtAuthenticationFilter.java
     
  • 수정src/main/java/com/example/board/security/JwtTokenProvider.java
    --- a/src/main/java/com/example/board/security/JwtTokenProvider.java
    +++ b/src/main/java/com/example/board/security/JwtTokenProvider.java
    @@ -47,9 +47,6 @@
    public String getUsername(String token) {
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }
    -public String getUsername(String token) {
    - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    - }
    public boolean validateToken(String token) {
    try {
     
  • 수정src/main/java/com/example/board/security/SecurityConfig.java
     
  • 수정src/main/java/com/example/board/service/UserService.java
     
  • 수정src/main/resources/static/css/style.css
    --- a/src/main/resources/static/css/style.css
    +++ b/src/main/resources/static/css/style.css
    @@ -1,77 +1,79 @@
    /* ==========================================
    - Multi-Theme System & Mobile Responsive Design
    + Sky Blue & White Design System (Responsive)
    ========================================== */
    :root {
    - --bg-color: #f8fafc;
    + --bg-color: #f0f9ff;
    --card-bg: #ffffff;
    - --text-main: #1e293b;
    - --text-sub: #64748b;
    + --text-main: #0f172a;
    + --text-sub: #475569;
    --text-muted: #94a3b8;
    - --border-color: #e2e8f0;
    + --border-color: #bae6fd;
    --input-bg: #ffffff;
    - --input-border: #cbd5e1;
    - --primary: #2563eb;
    - --primary-hover: #1d4ed8;
    - --primary-light: #eff6ff;
    + --input-border: #7dd3fc;
    + --primary: #0284c7;
    + --primary-hover: #0369a1;
    + --primary-light: #e0f2fe;
    --primary-text: #ffffff;
    - --nav-bg: #2563eb;
    + --nav-bg: #0284c7;
    --nav-brand: #ffffff;
    - --nav-item: #bfdbfe;
    + --nav-item: #e0f2fe;
    --nav-item-hover: #ffffff;
    - --badge-completed: #2563eb;
    + --badge-completed: #0284c7;
    --badge-pending: #d97706;
    --badge-cancelled: #dc2626;
    - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
    - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
    + --shadow: 0 4px 6px -1px rgba(2, 132, 199, 0.08), 0 2px 4px -1px rgba(2, 132, 199, 0.04);
    + --shadow-lg: 0 10px 15px -3px rgba(2, 132, 199, 0.12);
    --transition-speed: 0.3s ease;
    }
    +
    [data-theme="dark"] {
    - --bg-color: #0f172a;
    - --card-bg: #1e293b;
    - --text-main: #f1f5f9;
    - --text-sub: #94a3b8;
    + --bg-color: #0b1329;
    + --card-bg: #111e38;
    + --text-main: #f0f9ff;
    + --text-sub: #93c5fd;
    --text-muted: #64748b;
    - --border-color: #334155;
    - --input-bg: #0f172a;
    - --input-border: #475569;
    - --primary: #3b82f6;
    - --primary-hover: #2563eb;
    - --primary-light: #1e3a8a;
    - --primary-text: #ffffff;
    - --nav-bg: #1e293b;
    - --nav-brand: #f1f5f9;
    - --nav-item: #94a3b8;
    + --border-color: #1e3a8a;
    + --input-bg: #0b1329;
    + --input-border: #1d4ed8;
    + --primary: #38bdf8;
    + --primary-hover: #0ea5e9;
    + --primary-light: #172554;
    + --primary-text: #0f172a;
    + --nav-bg: #111e38;
    + --nav-brand: #38bdf8;
    + --nav-item: #93c5fd;
    --nav-item-hover: #ffffff;
    - --badge-completed: #3b82f6;
    + --badge-completed: #38bdf8;
    --badge-pending: #f59e0b;
    --badge-cancelled: #ef4444;
    - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
    - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
    + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
    + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
    }
    [data-theme="neon"] {
    - --bg-color: #05050a;
    - --card-bg: #0f0f17;
    - --text-main: #00f0ff;
    - --text-sub: #a78bfa;
    + --bg-color: #030712;
    + --card-bg: #0b132b;
    + --text-main: #38bdf8;
    + --text-sub: #c084fc;
    --text-muted: #6b7280;
    - --border-color: #1f1f38;
    - --input-bg: #05050a;
    - --input-border: #3b0764;
    - --primary: #ff007f;
    - --primary-hover: #db2777;
    - --primary-light: #2e1065;
    - --primary-text: #ffffff;
    - --nav-bg: #0b0b14;
    + --border-color: #1e1b4b;
    + --input-bg: #030712;
    + --input-border: #4c1d95;
    + --primary: #00f0ff;
    + --primary-hover: #0284c7;
    + --primary-light: #1e1b4b;
    + --primary-text: #030712;
    + --nav-bg: #030712;
    --nav-brand: #00f0ff;
    - --nav-item: #c084fc;
    + --nav-item: #38bdf8;
    --nav-item-hover: #00f0ff;
    --badge-completed: #00f0ff;
    --badge-pending: #facc15;
    --badge-cancelled: #ff0055;
    - --shadow: 0 0 15px rgba(255, 0, 127, 0.2), inset 0 0 10px rgba(0, 240, 255, 0.05);
    - --shadow-lg: 0 0 25px rgba(0, 240, 255, 0.25);
    -}
    + --shadow: 0 0 15px rgba(0, 240, 255, 0.2);
    + --shadow-lg: 0 0 25px rgba(0, 240, 255, 0.3);
    +}
    +
    * {
    box-sizing: border-box;
    transition: background-color var(--transition-speed), color var(--transition-speed), border-color var(--transition-speed), box-shadow var(--transition-speed);
    @@ -140,10 +142,6 @@
    color: var(--nav-item-hover);
    transform: translateY(-1px);
    }
    -nav.gnb a.menu-item:hover {
    - color: var(--nav-item-hover);
    - transform: translateY(-1px);
    -}
    /* Theme Switcher UI */
    .theme-switcher {
    @@ -201,11 +199,6 @@
    color: var(--text-main) !important;
    }
    -.auth-btn.logout {
    - background: var(--card-bg);
    - border: 1px solid var(--border-color);
    - color: var(--text-main) !important;
    -}
    .auth-btn.logout {
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    @@ -280,9 +273,6 @@
    table.post-list tbody tr {
    transition: background-color 0.2s ease;
    }
    -table.post-list tbody tr {
    - transition: background-color 0.2s ease;
    -}
    table.post-list tbody tr:hover {
    background: var(--primary-light);
    @@ -333,16 +323,6 @@
    background: var(--badge-cancelled);
    }
    -table.post-list a {
    - color: var(--text-main);
    - text-decoration: none;
    - font-weight: 600;
    - transition: color 0.2s;
    -}
    -
    -table.post-list a:hover {
    - color: var(--primary);
    -}
    table.post-list a {
    color: var(--text-main);
    text-decoration: none;
    @@ -376,14 +356,14 @@
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    - box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    + box-shadow: 0 2px 4px rgba(2, 132, 199, 0.2);
    transition: all 0.2s ease;
    }
    .btn:hover {
    background: var(--primary-hover);
    transform: translateY(-1px);
    - box-shadow: 0 4px 8px rgba(0,0,0,0.15);
    + box-shadow: 0 4px 8px rgba(2, 132, 199, 0.3);
    }
    .btn.secondary {
    @@ -394,18 +374,7 @@
    }
    .btn.secondary:hover {
    - background: var(--border-color);
    -}
    -
    -.btn.danger {
    - background: #ef4444;
    -}
    -
    -.btn.danger:hover {
    - background: #dc2626;
    -}
    -.btn.secondary:hover {
    - background: var(--border-color);
    + background: var(--primary-light);
    }
    .btn.danger {
    @@ -451,12 +420,6 @@
    word-break: break-all;
    }
    -.actions {
    - display: flex;
    - gap: 12px;
    - justify-content: flex-end;
    - flex-wrap: wrap;
    -}
    .actions {
    display: flex;
    gap: 12px;
    @@ -519,11 +482,6 @@
    padding: 0;
    margin: 0 0 24px 0;
    }
    -.comment-list {
    - list-style: none;
    - padding: 0;
    - margin: 0 0 24px 0;
    -}
    .comment-item {
    padding: 20px;
    @@ -583,10 +541,6 @@
    border: 1px solid var(--border-color);
    }
    -.comment-form.reply-form {
    - display: none;
    - margin-top: 12px;
    -}
    .comment-form.reply-form {
    display: none;
    margin-top: 12px;
    @@ -659,15 +613,6 @@
    padding: 24px 18px;
    border-radius: 12px;
    }
    -main {
    - margin: 20px 16px;
    - padding: 0;
    - }
    -
    - .card {
    - padding: 24px 18px;
    - border-radius: 12px;
    - }
    h1 {
    font-size: 22px;
     
  • 수정src/main/resources/templates/form.html
     
  • 수정src/main/resources/templates/fragments/header.html
    --- a/src/main/resources/templates/fragments/header.html
    +++ b/src/main/resources/templates/fragments/header.html
    @@ -14,14 +14,14 @@
    <div class="nav-right">
    <!-- 테마 전환 스위처 -->
    -<div class="theme-switcher">
    + <div class="theme-switcher">
    <button type="button" class="theme-btn" data-theme="light" title="라이트 모드">☀️</button>
    <button type="button" class="theme-btn" data-theme="dark" title="다크 모드">🌙</button>
    <button type="button" class="theme-btn" data-theme="neon" title="네온 모드">⚡</button>
    </div>
    <th:block th:if="${session.loginUser != null}">
    -<span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    + <span class="user-greeting" th:text="${session.loginUser.nickname + '님'}">사용자님</span>
    <a class="menu-item auth-btn logout" th:href="@{/logout}">로그아웃</a>
    </th:block>
    <th:block th:unless="${session.loginUser != null}">
     
  • 수정src/main/resources/templates/home.html
    --- a/src/main/resources/templates/home.html
    +++ b/src/main/resources/templates/home.html
    @@ -20,7 +20,7 @@
    <h2>최근 게시글</h2>
    <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 &gt;</a>
    </div>
    -<div class="table-responsive">
    + <div class="table-responsive">
    <table class="post-list">
    <thead>
    <tr>
     
  • 수정src/main/resources/templates/list.html
     
  • 수정src/main/resources/templates/users/login.html
    --- a/src/main/resources/templates/users/login.html
    +++ b/src/main/resources/templates/users/login.html
    @@ -26,7 +26,7 @@
    </div>
    <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">로그인</button>
    </form>
    -<div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    <span style="color: var(--text-sub);">계정이 없으신가요?</span>
    <a th:href="@{/signup}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">회원가입</a>
    </div>
     
  • 수정src/main/resources/templates/users/signup.html
    --- a/src/main/resources/templates/users/signup.html
    +++ b/src/main/resources/templates/users/signup.html
    @@ -29,7 +29,7 @@
    </div>
    <button type="submit" class="btn" style="width: 100%; margin-top: 12px; padding: 12px;">가입하기</button>
    </form>
    -<div style="margin-top: 24px; text-align: center; font-size: 14px;">
    + <div style="margin-top: 24px; text-align: center; font-size: 14px;">
    <span style="color: var(--text-sub);">이미 계정이 있으신가요?</span>
    <a th:href="@{/login}" style="color: var(--primary); font-weight: 600; text-decoration: none; margin-left: 6px;">로그인</a>
    </div>
     
  • 수정src/main/resources/templates/view.html
    --- a/src/main/resources/templates/view.html
    +++ b/src/main/resources/templates/view.html
    @@ -48,7 +48,6 @@
    <!-- 부모 댓글 반복 -->
    <li class="comment-item" th:each="comment : ${comments}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${comment.writer}">작성자</strong>
    <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    @@ -60,7 +59,7 @@
    </form>
    </div>
    <p class="comment-content" th:text="${comment.content}">댓글 내용</p>
    -<div class="comment-actions">
    + <div class="comment-actions">
    <button type="button" class="btn-reply-toggle" th:onclick="'toggleReplyForm(' + ${comment.id} + ')'">답글쓰기</button>
    </div>
    @@ -78,7 +77,6 @@
    <ul class="comment-list" style="margin-top: 12px; margin-bottom: 0;" th:if="${not #lists.isEmpty(comment.children)}">
    <li class="comment-item reply" th:each="reply : ${comment.children}">
    <div class="comment-meta">
    -<div class="comment-meta">
    <div>
    <strong th:text="${reply.writer}">작성자</strong>
    <span style="margin-left: 8px; color: var(--text-muted);" th:text="${#temporals.format(reply.createdAt, 'yyyy-MM-dd HH:mm')}">작성일</span>
    @@ -99,7 +97,7 @@
    <form class="comment-form" method="post" th:action="@{/posts/{id}/comments(id=${post.id})}">
    <input type="text" name="writer" placeholder="작성자" style="max-width: 220px;" required>
    <textarea name="content" placeholder="댓글 내용을 입력하세요" required></textarea>
    -<div style="display: flex; justify-content: flex-end;">
    + <div style="display: flex; justify-content: flex-end;">
    <button type="submit" class="btn">댓글 등록</button>
    </div>
    </form>