코딩 자동화
레포지토리를 선택하고 요청하면 AI가 작업 카드를 만들고, 승인 후 분석·설계·코딩을 거쳐 PR까지 생성합니다.
Repository
작업 카드
진행중
8완료
0- 카드 없음
새 작업 요청
진행중인 카드 — 54
전체적으로 화면이 허술하네 전체 ui 및 스타일 고도화해주고 회원가입 로그인도 jwt를 이용해서 고도화헤줘
sample-board · #54
전체적으로 화면이 허술하네 전체 ui 및 스타일 고도화해주고 회원가입 로그인도 jwt를 이용해서 고도화헤줘
분석
- 🚀 분석을 시작합니다...
- 🧭 먼저 어떻게 바꿀지 살펴볼게요...
- 📂 파일 43개를 확인했어요.
- 📄 pom.xml 파일을 확인했어요.
- 📄 src/main/resources/static/css/style.css 파일을 확인했어요.
- 🔍 "LoginController" 관련 코드를 확인했어요.
- 📄 src/main/resources/templates/users/login.html 파일을 확인했어요.
- ask_user 실행하고 있어요...
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;@Controllerpublic 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;">더보기 ></a>+ <h2>최근 게시글</h2>+ <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 ></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 생성 후 바로 머지
- 🚀 구현을 시작합니다...
- 🌿 브랜치 생성: 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;@Controllerpublic 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;">더보기 ></a>+ <h2>최근 게시글</h2>+ <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 ></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;@Controllerpublic 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;">더보기 ></a>+ <h2>최근 게시글</h2>+ <a th:href="@{/posts}" style="font-size: 14px; color: var(--primary); text-decoration: none; font-weight: 600;">더보기 ></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 생성 후 바로 머지
- 🚀 구현을 시작합니다...
- 🌿 브랜치 생성: 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;">더보기 ></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>