Backend
home

2025-6-25 (수)

생성일
2025/06/24 15:32
태그
JPA
Spring Security

Spring Security & JWT

build.gradle 설정

plugins { id 'java' id 'org.springframework.boot' version '3.5.0' id 'io.spring.dependency-management' version '1.1.7' } group = 'org.example' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' ... 중략 // 20250625 - JWT 추가 implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-security' ... } tasks.named('test') { useJUnitPlatform() }
Java
복사
... 중략 ... jwt.accessTokenExpirationTime=1000000 jwt.refreshTokenExpirationTime=86400000 jwt.secretKey=ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ
Java
복사

JWT 패키지 구성

JwtKey (jwt > JwtKey)

package org.example.backendproject.security.jwt; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class JwtKey { @Value("${jwt.secretKey}") private String secretKey; // 서명키를 만들어서 반환하는 메서드 @Bean public SecretKey secretKey() { byte[] keyBytes = secretKey.getBytes(); // 설정파일에서 불러온 키 값을 바이트 배열로 변환 return new SecretKeySpec(keyBytes, "HmacSHA256"); // 바이트 배열을 HmacSHA256용 Security 객체로 변환 } }
Java
복사

Role (core > Role)

package org.example.backendproject.security.core; // 회원가입시에 사용자의 권한을 정의 public enum Role { ROLE_USER("USER"), ROLE_ADMIN("ADMIN"); private String role; Role(String role) { this.role = role; } public String getRole() { return this.role; } }
Java
복사

CustomUserDetails (core 패키지)

package org.example.backendproject.security.core; import java.util.Collection; import java.util.Collections; import java.util.List; import lombok.RequiredArgsConstructor; import org.example.backendproject.user.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @RequiredArgsConstructor public class CustomUserDetails implements UserDetails { // UserDetails <- 사용자 정보를 담는 인터페이스 // 로그인한 사용자의 정보를 담아두는 역할을 함 private final User user; @Override public Collection<? extends GrantedAuthority> getAuthorities() { // User의 권한을 반환하는 메서드 // Collections.singleton <- 이 사용자는 한 가지 권한만 갖는다는 의미 return Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())); } // 토큰에서 추출한 사용자 정보의 Id를 반환 (테이블의 pk 값) // User 엔티티에서 Id 추출 public Long getId() { return user.getId(); } @Override public String getPassword() { return user.getPassword(); // User 엔티티에서 password 반환 } @Override public String getUsername() { // Username이라고 해서 username이라고 하기 보단 유니크한 값을 적용해야 함 return user.getUserid(); // User 엔티티와 참조되어 있는 UserProfile의 username 반환, 로그인 식별할 수 있는 값 } /** 아래는 현재 계정 상태를 확인하는 메서드 **/ @Override // 현재 계정 상태가 활성화인지 여부 확인 public boolean isEnabled() { return true; } @Override // 이 계정이 만료되었는지 public boolean isAccountNonExpired() { return true; } @Override // 이 계정이 잠겨있는지 public boolean isAccountNonLocked() { return true; } @Override // 자격증명이 만료되지 않았는지 public boolean isCredentialsNonExpired() { return true; } }
Java
복사

User - Role 추가

... 생략 ... @Enumerated(EnumType.STRING) // 이 필드를 DB에 문자열로 저장 private Role role;
Java
복사

CustomUserDetailsService

package org.example.backendproject.security.core; import lombok.RequiredArgsConstructor; import org.example.backendproject.user.entity.User; import org.example.backendproject.user.repository.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; // 로그인할 때 스프링에서 DB에 현재 로그인 하는 사용자가 존재하는지 확인 @Override public UserDetails loadUserByUsername(String userid) throws UsernameNotFoundException { User user = userRepository.findByUserid(userid).orElseThrow( () -> new IllegalArgumentException("해당 유저가 존재하지 않습니다 -> " + userid)); return new CustomUserDetails(user); } public UserDetails loadUserById(Long id) throws UsernameNotFoundException { User user = userRepository.findById(id).orElseThrow( () -> new IllegalArgumentException("해당 유저가 존재하지 않습니다 -> " + id)); return new CustomUserDetails(user); } }
Java
복사

JwtTokenProvider

package org.example.backendproject.security.jwt; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.UnsupportedJwtException; import java.util.Date; import javax.crypto.SecretKey; import lombok.RequiredArgsConstructor; import org.example.backendproject.security.core.CustomUserDetails; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class JwtTokenProvider { /** JWT 토큰 생성 및 추출 검증하는 클래스 **/ private final SecretKey secretKey; // 토큰을 만들 때 서명하는 키 // 현재 인증된 사용자 정보를 기반으로 access, refresh token 발급 public String generateToken(Authentication authentication, Long expirationMillis) { // 현재 로그인한 사용자의 정보를 꺼냄 CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); Date expiryDate = new Date(new Date().getTime() + expirationMillis); // 토큰 만료시간 생성 (밀리초 단위까지) Claims claims = Jwts.claims(); claims.put("user-id", customUserDetails.getId()); claims.put("username", customUserDetails.getUsername()); return Jwts.builder() .setSubject(customUserDetails.getUsername()) // 이 JWT 토큰의 주체를 지정 .setClaims(claims) // payload .setIssuedAt(new Date()) // 토큰 발급시간 .setExpiration(expiryDate) // 토큰 만료시간 .signWith(secretKey, SignatureAlgorithm.HS512) // secret 키와 알고리즘을 이용해서 암호화하여 서명 .compact(); // <- 위에서 저장한 정보들을 최종적으로 문자열로 만들어주는 메서드 } // JWT 토큰에서 사용자 ID를 추출하는 메서드 public Long getUserIdFromToken(String token) { return Jwts .parserBuilder() // JWT 토큰을 해석하겠다고 선언 .setSigningKey(secretKey) // 토큰을 검증하기 위해 비밀키 사용 .build() // 해석할 준비완료 .parseClaimsJws(token) // 전달받은 토큰을 파싱 .getBody() // 파싱한 토큰의 payload 부분을 꺼내서 .get("user-id", Long.class); // user-id 를 반환 } public Boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token); return true; } catch (MalformedJwtException e) { // 토큰 형식이 잘못되었을 때 return false; } catch (ExpiredJwtException e) { // 토큰이 만료가 되었을 때 return false; } catch (UnsupportedJwtException e) { // 지원하지 않는 토큰일 때 return false; } catch (IllegalArgumentException e) { // 토큰 문자열이 비어있거나 이상할 때 return false; } catch (JwtException e) { // 기타 예외 return false; } } }
Java
복사

JwtTokenFilter

package org.example.backendproject.security.jwt; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.backendproject.security.core.CustomUserDetailsService; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @Component @RequiredArgsConstructor @Slf4j public class JwtTokenFilter extends OncePerRequestFilter { // JwtTokenFilter 모든 HTTP 요청을 가로채서 JWT 토큰을 검사하는 필터 역할 // OncePerRequestFilter는 한 요청당 딱 한 번만 실행되는 필터 역할 private final JwtTokenProvider jwtTokenProvider; private final CustomUserDetailsService customUserDetailsService; // HTTP 매 요청마다 호출 @Override protected void doFilterInternal(HttpServletRequest request, // http 요청 HttpServletResponse response, // http 응답 FilterChain filterChain ) throws ServletException, IOException { String accessToken = getTokenFromRequest(request); // 요청 헤더에서 토큰 추출 if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) { UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(accessToken); // 토큰에서 사용자를 꺼내서 담은 사용자 인증 객체 authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // http 요청으로부터 부가 정보(ip, 세션 등)를 추출해서 사용자 인증 객체에 넣어줌 SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 토큰에서 사용자 인증정보를 조회해서 인증정보를 현재 스레드에 인증된 사용자로 등록 String url = request.getRequestURI().toString(); log.info("현재 들어온 HTTP 요청 = " + url); String method = request.getMethod(); // GET, POST, PUT ... log.info("HTTP 메소드 + method = " + method); } /** * CharacterEncodingFilter: 문자 인코딩 처리 * CorsFilter: CORS 정책 처리 * CsrfFilter: CSRF 보안 처리 * JWTTokenFilter: JWT 토큰 처리(핵심) * SecurityContextFilter: 인증/인가 정보 저장 * ExceptionFilter: 예외 처리 */ filterChain.doFilter(request, response); // JwtTokenFilter를 거치고 다음 필터로 넘어감 (이동...이동...이동) } // HTTP 요청 헤더에서 토큰을 추출하는 메서드 public String getTokenFromRequest(HttpServletRequest request) { String token = null; String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { token = bearerToken.substring(7); } return token; } // http 요청에서 사용자 인증 정보를 담는 객체 private UsernamePasswordAuthenticationToken getAuthentication(String token) { // JWT 토큰에서 사용자 userId 추출 Long userId = jwtTokenProvider.getUserIdFromToken(token); // 위 추출한 id를 DB에서 사용자 정보 조회 UserDetails userDetails = customUserDetailsService.loadUserById(userId); return new UsernamePasswordAuthenticationToken( userDetails, // 사용자 정보 null, userDetails.getAuthorities()); // 사용자의 권한 } }
Java
복사

SecurityConfig

package org.example.backendproject.security.config; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.example.backendproject.security.jwt.JwtTokenFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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 // 스프링 시큐리티 활성화 @RequiredArgsConstructor // 생성자 자동 생성 public class SecurityConfig { private final JwtTokenFilter jwtTokenFilter; // 스프링 시큐리티에서 어떤 순서로 어떤 보안 규칙의 필터를 거칠지를 정의하는 클래스 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // CSRF 보호 기능 비활성화 .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests((auth) -> auth .requestMatchers("/", "/index.html", "/*.html", "/favicon.ico", "/css/**", "fetchWithAuth.js", "/js/**", "/images/**", "/.well-know/**" ).permitAll() // 인증 필요없이 모두 허용 .requestMatchers("/boards/**", "/api/auth/**").permitAll() .requestMatchers("/api/comments/**").permitAll() .requestMatchers( "/api/user/**").authenticated() // 인증이 필요한 경로 ) // 인증 실패시 예외 처리 .exceptionHandling(e -> e // 인증 안 된 사용자가 접근하려고 할 때 .authenticationEntryPoint((request, response, authException) -> { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); }) // 인증은 되었지만 권한이 없을 때 .authenticationEntryPoint((request, response, authException) -> { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); }) ) // 스프링 시큐리티에서 세션관리정책을 설정하는 부분 // 기본적으로 스프링 시큐리티는 세션을 생성함 // 하지만 JWT 기반 인증은 세션상태를 저장하지 않는 무상태(stateless) 방식 // 인증 정보를 세션에 저장하지 않고, 매 요청마다 토큰으로 인증 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) .build(); // 위 명시한 설정들을 적용 } // 회원가입시 비밀번호를 암호화해주는 메서드 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Java
복사

Auth

LoginResponseDTO 생성 - Request가 있으면 Response도 있어야 함
package org.example.backendproject.Auth.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.example.backendproject.Auth.entity.Auth; @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class LoginResponseDTO { private String tokenType; private String accessToken; private String refreshToken; private Long userId; @Builder public LoginResponseDTO(Auth auth) { this.tokenType = auth.getTokenType(); this.accessToken = auth.getAccessToken(); this.refreshToken = auth.getRefreshToken(); this.userId = auth.getId(); } }
Java
복사
업데이트 메서드 + Auth 생성자 추가
package org.example.backendproject.Auth.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.example.backendproject.user.entity.User; @NoArgsConstructor @AllArgsConstructor @Getter @Setter @Entity public class Auth { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String tokenType; @Column(nullable = false) private String accessToken; @Column(nullable = false) private String refreshToken; @OneToOne(fetch = FetchType.LAZY) // 1:1 관계, 지연로딩 적용 -> Auth 엔티티 조회할 때 user 객체는 불러오지 않음 @JoinColumn(name = "user_id") // auth.getUser()에 실제로 접근할 때 User 쿼리 발생!! private User user; // updateAccessToken 메서드 추가 //토큰값을 업데이트 해주는 메서드 public void updateAccessToken(String newAccessToken) { this.accessToken = newAccessToken; } // updateRefreshToken 메서드 추가 public void updateRefreshToken(String newRefreshToken) { this.refreshToken = newRefreshToken; } public Auth(User user, String refreshToken, String accessToken, String tokenType) { this.user = user; this.refreshToken = refreshToken; this.accessToken = accessToken; this.tokenType = tokenType; } }
Java
복사
AuthRepositoy 에서 RefreshToken을 찾는 메서드와 사용자 존재 여부 확인 메서드 생성
package org.example.backendproject.Auth.repository; import java.util.Optional; import org.example.backendproject.Auth.entity.Auth; import org.example.backendproject.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface AuthRepository extends JpaRepository<Auth, Long> { Optional<Auth> findByRefreshToken(String refreshToken); boolean existsByUser(User user); }
Java
복사
AuthService, Jwt 토큰 반영하여 서비스 로직 수정
package org.example.backendproject.Auth.service; import lombok.RequiredArgsConstructor; import org.example.backendproject.Auth.dto.LoginRequestDTO; import org.example.backendproject.Auth.dto.LoginResponseDTO; import org.example.backendproject.Auth.dto.SignUpRequestDTO; import org.example.backendproject.Auth.entity.Auth; import org.example.backendproject.Auth.repository.AuthRepository; import org.example.backendproject.security.core.CustomUserDetails; import org.example.backendproject.security.core.Role; import org.example.backendproject.security.jwt.JwtTokenProvider; import org.example.backendproject.user.entity.User; import org.example.backendproject.user.entity.UserProfile; import org.example.backendproject.user.repository.UserRepository; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.beans.factory.annotation.Value; @RequiredArgsConstructor @Service public class AuthService { private final AuthRepository authRepository; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @Value("${jwt.accessTokenExpirationTime}") private Long jwtAccessTokenExpirationTime; @Value("${jwt.refreshTokenExpirationTime}") private Long jwtRefreshTokenExpirationTime; // 회원가입 @Transactional // 해당 어노테이션 선언해야 저장이 된다. public void signUp(SignUpRequestDTO dto) { // 사용자 조회 여부 확인, null값 체크 if (userRepository.findByUserid(dto.getUserid()).isPresent()) { throw new RuntimeException("사용자가 이미 존재합니다."); } User user = new User(); user.setUserid(dto.getUserid()); user.setPassword(passwordEncoder.encode(dto.getPassword())); // 비밀번호 암호화하여 저장 user.setRole(Role.ROLE_USER); // 일반 사용자로 회원가입 UserProfile profile = new UserProfile(); profile.setUsername(dto.getUsername()); profile.setEmail(dto.getEmail()); profile.setPhone(dto.getPhone()); profile.setAddress(dto.getAddress()); /** 연관관계 설정 **/ profile.setUser(user); user.setUserProfile(profile); userRepository.save(user); } // 로그인 public LoginResponseDTO login(LoginRequestDTO loginRequestDTO) { User user = userRepository.findByUserid(loginRequestDTO.getUserid()) .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); // 입력한 비밀번호가 암호화된 비밀번호와 일치하는지 확인 if (!passwordEncoder.matches(loginRequestDTO.getPassword(), user.getPassword())) { throw new BadCredentialsException("비밀번호가 일치하지 않습니다."); // 시큐리티 로그인 과정에서 비밀번호가 일치하지 않으면 던져주는 예외 } // 위 비밀번호가 일치하면 기존 토큰 정보를 비교하고 토큰이 있으면 업데이트, 없으면 새로 발급 String accessToken = jwtTokenProvider.generateToken( new UsernamePasswordAuthenticationToken(new CustomUserDetails(user), user.getPassword()), jwtAccessTokenExpirationTime); // 리프레시 토큰 String refreshToken = jwtTokenProvider.generateToken( new UsernamePasswordAuthenticationToken(new CustomUserDetails(user), user.getPassword()), jwtRefreshTokenExpirationTime); // 현재 로그인 한 사람이 DB에 있는지 확인하고 있으면 토큰을 DB에 저장하고 로그인 처리 if (authRepository.existsByUser(user)) { Auth auth = user.getAuth(); auth.setRefreshToken(refreshToken); auth.setAccessToken(accessToken); authRepository.save(auth); return new LoginResponseDTO(auth); } // 위에서 DB에 사용자 정보가 없으면 아래 새로 생성해서 로그인 처리 Auth auth = new Auth(user, refreshToken, accessToken, "Bearer"); authRepository.save(auth); return new LoginResponseDTO(auth); } // 리프레시 토큰을 받아서 새로운 액세스 토큰을 발급해주는 서비스 @Transactional public String refreshToken(String refreshToken) { // 리프레시 토큰 유효성 검사 if (jwtTokenProvider.validateToken(refreshToken)) { // DB에서 리프레시 토큰을 가진 사용자가 있는지 확인 Auth auth = authRepository.findByRefreshToken(refreshToken).orElseThrow( () -> new IllegalArgumentException("해당 REFRESH_TOKEN 을 찾을 수 없습니다.\nREFRESH_TOKEN: " + refreshToken)); // 있으면 인증 객체를 만들어서 새로운 토큰 발급 String newAccessToken = jwtTokenProvider.generateToken( new UsernamePasswordAuthenticationToken( new CustomUserDetails(auth.getUser()), auth.getUser().getPassword()), jwtAccessTokenExpirationTime); // 액세스 토큰 auth.updateAccessToken(newAccessToken); // 토큰 업데이트 authRepository.save(auth); // DB에 반영 return newAccessToken; } else { throw new IllegalArgumentException("토큰이 유효하지 않습니다."); } } }
Java
복사

새로 회원가입 후 로그인

DB에 등록된 access_token, refresh_token 확인
accessToken payload, refreshToken payload 확인
jwt.io 접속해서 확인

내 정보 수정

UserController 수정
/** 내 정보 보기 **/ @GetMapping("/me/") public ResponseEntity<UserDTO> getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails){ Long id = userDetails.getId(); return ResponseEntity.ok(userService.getMyInfo(id)); } // @AuthenticationPrincipal - 스프링 시큐리티에서 인증한 사용자 정보를 자동으로 주입받는 어노테이션 // 요청 헤더 안에 있는 JWT 토큰에서 사용자 정보를 읽어옴 /** 유저 정보 수정 **/ @PutMapping("/me") public ResponseEntity<UserDTO> updateUser(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody UserDTO dto) { Long id = userDetails.getId(); UserDTO updated = userService.updateUser(id, dto); return ResponseEntity.ok(updated); }
Java
복사
내 정보 수정 후 DB쪽에서 결과 확인 (기존 username: jonathan → kkk 로 변경)
수정된 후 내 정보 창에서 확인

게시판 테스트

게시판 CRUD 체크 + 댓글, 대댓글 체크

게시판 클릭~
게시판 접속 확인 후 글쓰기 클릭~
내가 쓴 글 클릭
수정 클릭
수정 내용 확인
댓글 + 대댓글 내용 확인
삭제 버튼 클릭
게시판에서 실종된 모습 확인

오늘 푸시한 커밋 리스트

날짜
커밋 메시지
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25
2025-06-25