공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
시큐리티 설정과 자바 대응 코드 구현
지난 시간까지 게시글과 댓글 기능을 구현하였다. 이번 시간에는 이 기능들을 더욱 안전하게 보호하고, 사용자별로 권한을 관리할 수 있도록 인증 기능을 추가로 개발한다.
1. 설정 및 보안구성
먼저, 프로젝트의 전반적인 설정을 다루는 JpaConfig.java와 보안 설정을 담당하는 SecurityConfig.java를 구성한다. 이를 통해 JPA 감사(Auditing)를 활성화하고, Spring Security를 통해 애플리케이션의 보안을 강화한다.
추가로, 테스트 환경에서 보안 설정을 동일하게 적용하기 위해 TestSecurityConfig.java를 구성한다. 이 클래스는 테스트 시에 인증된 사용자 데이터를 모의(Mock)하여 사용할 수 있도록 돕는다.
1.1. JPA Config 수정 - AuditorAware 설정
package org.example.projectboard.config;
import org.example.projectboard.dto.security.BoardPrincipal;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication) // 현재 인증된 사용자의 정보를 가져온다.
.filter(Authentication::isAuthenticated) // 인증된 사용자인지 확인한다.
.map(Authentication::getPrincipal) // 사용자 정보를 포함한 객체를 가져온다.
.map(BoardPrincipal.class::cast) // 이 객체를 정의한 사용자 정보 클래스(BoardPrincipal)로 변환한다.
.map(BoardPrincipal::getUsername); // 사용자 정보에서 사용자 이름을 추출한다.
}
}
- 수정 전
- 하드코딩된 사용자 이름 "eunchan"을 반환하도록 설정되어 있었다.
- 이는 Spring Security와의 통합이 이루어지지 않았던 상태이다.
- 수정 후
- Spring Security를 사용하여 현재 인증된 사용자의 이름을 동적으로 반환하도록 코드가 변경되었다.
- SecurityContextHolder를 통해 현재 인증된 사용자의 정보를 가져오고, 이 정보를 BoardPrincipal 객체로 캐스팅한 후, 해당 객체에서 사용자 이름을 추출하여 반환하도록 했다.
- 이는 인증된 사용자의 정보를 정확히 반영하기 위함이다.
1.2. SecurityConfig 수정 - 인증 및 권한 설정
이제 모든 요청에 대해 인증을 필요로 하며, 특정 경로에 대해서만 예외적으로 인증 없이 접근할 수 있도록 설정했다.
package org.example.projectboard.config;
import org.example.projectboard.dto.UserAccountDto;
import org.example.projectboard.dto.security.BoardPrincipal;
import org.example.projectboard.repository.UserAccountRepository;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/search-hashtag"
).permitAll()
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.logout(logout -> logout
.logoutSuccessUrl("/")
)
.build();
}
@Bean
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository) {
return username -> userAccountRepository
.findById(username) // 데이터베이스에서 사용자 ID(username)로 사용자 정보를 찾음
.map(UserAccountDto::from) // 찾은 정보를 UserAccountDto로 변환
.map(BoardPrincipal::from) // UserAccountDto를 BoardPrincipal로 변환 (Spring Security가 사용하는 사용자 객체)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username)); // 사용자가 없으면 예외 발생
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
- 수정 전
- 모든 요청을 허용하는 설정(anyRequest().permitAll())만 존재했다. 이는 보안이 전혀 적용되지 않은 상태였다.
- 수정 후
- 정적 리소스와 특정 URL(GET 요청)에 대해서는 인증 없이 접근을 허용하고, 나머지 요청은 인증된 사용자만 접근할 수 있도록 설정을 변경했다.
- 또한, UserDetailsService를 설정하여 데이터베이스에서 사용자 정보를 조회하고, 해당 정보를 BoardPrincipal로 변환하여 Spring Security가 이를 인식하도록 했다.
- 로그아웃 시에는 홈 페이지로 리다이렉션 되도록 설정했다.
- 마지막으로, 비밀번호 인코딩을 위한 PasswordEncoder를 추가하여 보안을 강화했다.
1.3. TestSecurityConfig.java
테스트 환경에서 Spring Security를 설정하기 위한 TestSecurityConfig 클래스이다. 이 클래스는 테스트 시에 필요한 인증된 사용자 정보를 설정하는 역할을 한다. 이 클래스는 UserAccountRepository를 Mock으로 주입하여 테스트에서 인증된 사용자 데이터를 미리 설정할 수 있도록 돕는다.
package org.example.projectboard.config;
import org.example.projectboard.domain.UserAccount;
import org.example.projectboard.repository.UserAccountRepository;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
@Import(SecurityConfig.class)
public class TestSecurityConfig {
@MockBean private UserAccountRepository userAccountRepository;
@BeforeTestMethod
public void securitySetUp() {
given(userAccountRepository.findById(anyString())).willReturn(Optional.of(UserAccount.of(
"ecTest",
"pw",
"ecTest@email.com",
"ec-test",
"test memo"
)));
}
}
- UserAccountRepository를 모의(Mock) 객체로 설정하여, findById 메서드가 호출될 때마다 특정 사용자 데이터를 반환하도록 한다.
- 이 예시에서는 "ecTest"라는 사용자 ID를 가진 사용자 계정을 반환하도록 설정되어 있다.
2. 컨트롤러 클래스
게시글과 댓글을 등록, 수정, 삭제할 때 인증된 사용자의 정보를 활용하도록 코드를 수정했다. @AuthenticationPrincipal 어노테이션을 사용해 현재 인증된 사용자의 정보를 BoardPrincipal 객체로 받아와 서비스 레이어에 전달한다.
2.1. ArticleCommentController.java
// ArticleCommentController.java
@PostMapping("/new")
public String postNewArticleComment(
@AuthenticationPrincipal BoardPrincipal boardPrincipal,
ArticleCommentRequest articleCommentRequest
) {
articleCommentService.saveArticleComment(articleCommentRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles/" + articleCommentRequest.articleId();
}
@PostMapping("/{commentId}/delete")
public String deleteArticleComment(
@PathVariable Long commentId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal,
Long articleId
) {
articleCommentService.deleteArticleComment(commentId, boardPrincipal.getUsername());
return "redirect:/articles/" + articleId;
}
- 수정 전
- 기존 ArticleCommentController에서는 댓글 작성과 삭제 시 하드코딩된 사용자 정보를 사용했다.
- 이는 실제 환경에서 다양한 사용자의 인증을 처리할 수 없는 한계가 있었으며, 보안 측면에서도 바람직하지 않았다.
- 수정 후
- @AuthenticationPrincipal 어노테이션을 사용하여 현재 인증된 사용자의 정보를 BoardPrincipal 객체로 받아오도록 수정되었다.
- 이를 통해 댓글 작성 시 실제 로그인된 사용자의 정보를 기반으로 댓글이 작성되며, 댓글 삭제 시에도 해당 사용자의 권한을 활용하게 된다.
2.2. ArticleController.java
// ArticleController.java
@PostMapping("/form")
public String postNewArticle(
@AuthenticationPrincipal BoardPrincipal boardPrincipal,
ArticleRequest articleRequest
) {
articleService.saveArticle(articleRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles";
}
@PostMapping("/{articleId}/form")
public String updateArticle(
@PathVariable Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal,
ArticleRequest articleRequest
) {
// TODO: 인증 정보를 넣어줘야 한다.
articleService.updateArticle(articleId, articleRequest.toDto(boardPrincipal.toDto()));
return "redirect:/articles/" + articleId;
}
@PostMapping("/{articleId}/delete")
public String deleteArticle(
@PathVariable Long articleId,
@AuthenticationPrincipal BoardPrincipal boardPrincipal
) {
articleService.deleteArticle(articleId, boardPrincipal.getUsername());
return "redirect:/articles";
}
- 수정 전
- ArticleController 역시 게시글 작성, 수정, 삭제 시 하드코딩된 사용자 정보를 사용하고 있었다.
- 이는 다수 사용자가 동시에 사용하거나 권한에 따라 접근을 제한하는 기능을 구현하는 데 어려움을 초래할 수 있었다.
- 수정 후
- @AuthenticationPrincipal을 통해 현재 인증된 사용자의 정보를 받아와 게시글 작업을 수행하도록 변경되었다.
- 이로 인해 모든 게시글 작업이 실제 로그인된 사용자의 정보를 기반으로 이루어지며, 이를 통해 각 사용자의 권한을 정확하게 반영할 수 있다.
3. 서비스 클래스
컨트롤러에서 받은 요청을 처리하는 로직은 서비스 계층에서 구현된다. ArticleService.java와 ArticleCommentService.java에서는 게시글과 댓글에 대한 비즈니스 로직을 담당하며, 인증된 사용자의 정보를 이용하여 안전한 데이터 처리를 수행한다.
3.1. ArticleCommentService.java
댓글 삭제 기능을 담당하는 ArticleCommentService에도 유사한 방식으로 기능이 확장되었다. 이전에는 단순히 댓글의 ID만으로 댓글을 삭제했지만, 이제는 댓글 작성자의 userId를 추가로 확인하여, 해당 사용자의 댓글만 삭제할 수 있도록 하였다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
private final UserAccountRepository userAccountRepository;
...
public void deleteArticleComment(Long articleCommentId, String userId) {
articleCommentRepository.deleteByAndUserAccount_UserId(articleCommentId, userId);
}
}
- 이 메서드는 articleCommentId와 userId를 받아, ArticleCommentRepository의 deleteByAndUserAccount_UserId 메서드를 호출하여 해당 조건에 맞는 댓글을 삭제한다.
- 댓글 작성자와 삭제 요청자가 동일한지 확인하여, 다른 사용자의 댓글이 실수로 삭제되는 것을 방지한다.
- 사용자의 userId를 추가로 확인함으로써, 악의적인 사용자가 다른 사용자의 댓글을 삭제하지 못하도록 한다.
3.2. ArticleService.java
게시글과 관련된 비즈니스 로직을 처리하는 ArticleService 클래스이다. 게시글의 저장, 수정, 삭제, 조회 등의 로직을 담당하며, 이번 구현에서는 게시글 수정 및 삭제 시 인증된 사용자만 자신의 게시글을 수정 또는 삭제할 수 있도록 로직을 추가했다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
...
public void updateArticle(Long articleId, ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(articleId);
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
if (article.getUserAccount().equals(userAccount)){
if (dto.title() != null) { article.setTitle(dto.title()); }
if (dto.content() != null) { article.setContent(dto.content()); }
article.setHashtag(dto.hashtag());
}
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 수정하는데 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
}
public void deleteArticle(long articleId, String userId){
articleRepository.deleteByIdAndUserAccount_UserId(articleId, userId);
}
...
}
updateArticle 메서드
- articleRepository.getReferenceById(articleId)를 호출하여 수정하려는 게시글의 Article 객체를 가져온다.
- userAccountRepository.getReferenceById(dto.userAccountDto().userId())를 호출하여 현재 인증된 사용자의 UserAccount 객체를 가져온다.
- article.getUserAccount().equals(userAccount)를 통해 게시글의 작성자와 현재 인증된 사용자가 동일한지 확인한다.
- 작성자와 현재 사용자가 동일한 경우에만 게시글의 수정 작업을 진행한다.
deleteArticle 메서드
- articleRepository.deleteByIdAndUserAccount_UserId(articleId, userId)를 호출할 때, 게시글 ID와 현재 사용자의 ID를 함께 전달한다.
- 이 메서드는 주어진 ID와 작성자가 일치하는 경우에만 게시글을 삭제한다.
4. 보안 관련 클래스
다음으로, 보안 기능의 주요 핵심을 이루는 사용자 인증 정보를 처리할 수 있도록 BoardPrincipal.java 클래스를 구현한다. 이 클래스는 Spring Security에서 인증된 사용자의 정보를 관리하는 역할을 한다.
4.1. BoardPrincipal.java
사용자 인증 정보를 관리하기 위해 새로운 클래스를 추가했다. BoardPrincipal.java는 UserDetails 인터페이스를 구현하며, Spring Security와 연동하여 인증된 사용자 정보를 관리하는 역할을 한다. 이 클래스는 사용자의 ID, 비밀번호, 권한 정보 등을 포함하며, UserAccountDto와 상호 변환할 수 있는 기능도 제공한다.
package org.example.projectboard.dto.security;
import lombok.Getter;
import org.example.projectboard.dto.UserAccountDto;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
public record BoardPrincipal (
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo
) implements UserDetails{
public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
Set<RoleType> roleTypes = Set.of(RoleType.USER);
return new BoardPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet( ))
,
email,
nickname,
memo
);
}
public static BoardPrincipal from(UserAccountDto dto) {
return BoardPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.email(),
dto.nickname(),
dto.memo()
);
}
public UserAccountDto toDto() {
return UserAccountDto.of(
username,
password,
email,
nickname,
memo,
null,
null,
null,
null
);
}
@Override public String getUsername() {return username;}
@Override public String getPassword() {return password;}
@Override public Collection<? extends GrantedAuthority> getAuthorities() {return authorities;}
public enum RoleType{
USER("ROLE_USER");
@Getter
private final String name;
RoleType(String name) {
this.name = name;
}
}
}
5. 리포지토리 클래스
게시글과 댓글에 대한 데이터를 데이터베이스에서 효과적으로 관리하기 위해 ArticleRepository.java, ArticleCommentRepository.java를 구성한다. 이 레포지토리 클래스들은 JPA를 활용하여 데이터의 CRUD 작업을 지원하며, 인증된 사용자 정보를 기반으로 데이터를 안전하게 관리한다.
5.1. ArticleCommentRepository.java
댓글 데이터의 저장소 역할을 하는 ArticleCommentRepository 인터페이스이다. Spring Data JPA를 활용하여 기본적인 CRUD 기능을 제공하며, 이번 구현에서는 deleteByAndUserAccount_UserId 메서드를 추가하여 사용자 ID에 따른 댓글 삭제 기능을 구현했다. 이를 통해 인증된 사용자가 자신이 작성한 댓글만 삭제할 수 있도록 했다.
@RepositoryRestResource
public interface ArticleCommentRepository extends
JpaRepository<ArticleComment, Long>,
QuerydslPredicateExecutor<ArticleComment>,
QuerydslBinderCustomizer<QArticleComment>{
...
void deleteByAndUserAccount_UserId(Long articleCommentId, String userId);
...
}
- 수정 전
- 기존 ArticleCommentRepository 인터페이스는 게시글 ID를 기반으로 댓글을 조회하는 메서드와, Querydsl을 이용한 동적 쿼리 바인딩 설정을 포함하고 있었다.
- findByArticleId(Long articleId) 메서드를 통해 게시글 ID로 댓글을 조회할 수 있다.
- customize 메서드는 댓글 내용, 작성 시간, 작성자를 기준으로 검색 기능을 지원하도록 QuerydslBindings를 설정했다.
- 기존 ArticleCommentRepository 인터페이스는 게시글 ID를 기반으로 댓글을 조회하는 메서드와, Querydsl을 이용한 동적 쿼리 바인딩 설정을 포함하고 있었다.
- 수정 후
- 수정된 ArticleCommentRepository에서는 deleteByAndUserAccount_UserId 메서드가 추가되었다.
- 이 메서드는 댓글 ID와 사용자 ID를 기반으로 특정 사용자가 작성한 댓글을 삭제할 수 있도록 한다.
5.2. ArticleRepository.java
게시글 데이터의 저장소 역할을 하는 ArticleRepository 인터페이스이다. 이 인터페이스도 Spring Data JPA를 활용하여 기본적인 CRUD 기능을 제공한다. 이번 구현에서는 deleteByIdAndUserAccount_UserId 메서드를 추가하여 사용자 ID에 따른 게시글 삭제 기능을 구현했다. 이를 통해 인증된 사용자가 자신이 작성한 게시글만 삭제할 수 있도록 했다.
@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
ArticleRepositoryCustom,
QuerydslPredicateExecutor<Article>,
QuerydslBinderCustomizer<QArticle>{
...
void deleteByIdAndUserAccount_UserId(Long articleId, String userId);
...
}
- 특정 사용자가 자신의 게시글을 삭제할 때, 다른 사용자의 게시글이 실수로 삭제되는 것을 방지한다.
- 즉, 게시글의 작성자(userId)와 삭제 요청자의 userId가 일치할 때만 해당 게시글을 삭제할 수 있다.
- 사용자 ID를 추가로 확인함으로써, 사용자가 자신의 데이터에만 접근하고 삭제할 수 있도록 보장한다.
6. 테스트 클래스
마지막으로, ArticleServiceTest.java, ArticleCommentServiceTest.java 등 각 서비스와 컨트롤러의 기능을 검증하는 테스트 클래스를 작성한다. 이 테스트들은 서비스와 컨트롤러가 예상대로 동작하는지, 그리고 예외 상황에서도 안정적으로 작동하는지 확인하는 데 초점을 맞춘다.
6.1. JpaRepositoryTest.java
JPA와 관련된 데이터베이스 연동 테스트를 담당하는 JpaRepositoryTest 클래스이다. 데이터베이스에 게시글과 댓글이 정상적으로 저장, 수정, 삭제되는지 테스트한다. 이번 구현에서는 사용자 인증과 관련된 기능이 데이터베이스에 제대로 반영되는지 확인하는 테스트를 포함했다.
@Import(JpaRepositoryTest.TestJpaConfig.class)
@DataJpaTest
class JpaRepositoryTest {
// 코드 생략
@EnableJpaAuditing
@TestConfiguration
public static class TestJpaConfig{
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.of("eunchan");
}
}
}
- TestJpaConfig 클래스
- 테스트 환경에서만 사용할 JPA 감사(Auditing) 설정을 정의한다. 이를 통해 본래 애플리케이션 설정과는 분리된, 테스트에 최적화된 환경을 구성할 수 있다.
- @TestConfiguration
- 이 클래스가 테스트에만 사용되는 구성 클래스임을 명시한다. 이는 @Configuration과 유사하지만, 테스트 환경에서만 적용된다.
- @EnableJpaAuditing
- JPA 감사 기능을 활성화하는 어노테이션이다. 이 설정은 JPA 엔터티가 생성되거나 수정될 때 createdBy, lastModifiedBy 등의 필드에 자동으로 값을 채워준다.
- auditorAware 메서드
- 이 메서드는 현재 인증된 사용자를 감사 필드에 기록하기 위한 정보를 제공한다. 테스트에서는 "eunchan"이라는 문자열을 반환하도록 설정되어 있다.
- @Import(JpaRepositoryTest.TestJpaConfig.class)
- 이 어노테이션은 JpaRepositoryTest 클래스가 TestJpaConfig 설정을 가져와 사용할 수 있도록 한다. 이를 통해, 테스트 클래스는 TestJpaConfig에서 정의한 감사 설정을 적용받게 된다.
6.2. ArticleCommentControllerTest.java
댓글 관련 기능의 컨트롤러 테스트를 담당하는 ArticleCommentControllerTest 클래스이다. 댓글 작성 및 삭제와 같은 기능이 정상적으로 작동하는지 확인하는 테스트를 포함하고 있다. 이번 구현에서는 Spring Security를 통해 인증된 사용자가 자신의 댓글을 작성 및 삭제할 수 있는지 테스트를 진행했다.
@DisplayName("View 컨트롤러 - 댓글")
@Import({TestSecurityConfig.class, FormDataEncoder.class})
@WebMvcTest(ArticleCommentController.class)
class ArticleCommentControllerTest {
...
@WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 댓글 등록 - 정상 호출")
@Test
void givenArticleCommentInfo_whenRequesting_thenSavesNewArticleComment() throws Exception {
// Given
long articleId = 1L;
ArticleCommentRequest request = ArticleCommentRequest.of(articleId, "test comment");
willDoNothing().given(articleCommentService).saveArticleComment(any(ArticleCommentDto.class));
// When & Then
mvc.perform(
post("/comments/new")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(request))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleCommentService).should().saveArticleComment(any(ArticleCommentDto.class));
}
@WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][GET] 댓글 삭제 - 정상 호출")
@Test
void givenArticleCommentIdToDelete_whenRequesting_thenDeletesArticleComment() throws Exception {
// Given
long articleId = 1L;
long articleCommentId = 1L;
String userId = "ecTest";
willDoNothing().given(articleCommentService).deleteArticleComment(articleCommentId, userId);
// When & Then
mvc.perform(
post("/comments/" + articleCommentId + "/delete")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(Map.of("articleId", articleId)))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleCommentService).should().deleteArticleComment(articleCommentId, userId);
}
- @WithUserDetails 어노테이션 추가:
- @WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION) 어노테이션이 추가되어, 테스트가 실행되기 전에 ecTest라는 사용자로 인증된 상태가 된다.
- 이를 통해 테스트가 실제 인증된 사용자가 댓글을 추가하거나 삭제할 때의 시나리오를 모의할 수 있게 된다.
- deleteArticleComment 메서드에서 사용자 ID를 전달하는 방식 변경:
- 이전 코드에서는 articleCommentService.deleteArticleComment(articleCommentId); 메서드가 호출되었지만, 수정된 코드에서는 articleCommentService.deleteArticleComment(articleCommentId, userId);로 변경되었다.
- 이 변경으로 인해 댓글 삭제 시, userId가 함께 전달되어 인증된 사용자만이 자신의 댓글을 삭제할 수 있도록 제어된다.
- TestSecurityConfig 클래스의 사용:
- 이 테스트 클래스는 TestSecurityConfig를 통해 테스트 환경에서의 인증된 사용자 설정을 모의하고 있다. 실제 Spring Security 설정을 불러오는 대신, 모의된 사용자(ecTest)를 사용하여 테스트를 진행한다.
6.3. ArticleControllerTest.java
게시글 관련 기능의 컨트롤러 테스트를 담당하는 ArticleControllerTest 클래스이다. 게시글 작성, 수정, 삭제와 같은 기능이 정상적으로 작동하는지 확인하는 테스트를 포함하고 있다. 이번 구현에서는 인증된 사용자가 자신의 게시글을 작성, 수정, 삭제할 수 있는지 테스트를 진행했다.
@WithMockUser
@DisplayName("[view][GET] 게시글 페이지 - 정상 호출, 인증된 사용자")
@Test
public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// Given
Long articleId = 1L;
long totalCount = 1L;
given(articleService.getArticleWithComments(articleId)).willReturn(createArticleWithCommentsDto());
given(articleService.getArticleCount()).willReturn(totalCount);
// When & Then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/detail"))
.andExpect(model().attributeExists("article"))
.andExpect(model().attributeExists("articleComments"))
.andExpect(model().attribute("totalCount", totalCount));
then(articleService).should().getArticleWithComments(articleId);
then(articleService).should().getArticleCount();
}
@DisplayName("[view][GET] 게시글 페이지 - 인증 없을 땐 로그인 페이지로 이동")
@Test
void givenNothing_whenRequestingArticlePage_thenRedirectsToLoginPage() throws Exception {
// Given
long articleId = 1L;
// When & Then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
then(articleService).shouldHaveNoInteractions();
then(articleService).shouldHaveNoInteractions();
}
@WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 새 게시글 등록 - 정상 호출")
@Test
void givenNewArticleInfo_whenRequesting_thenSavesNewArticle() throws Exception {
// Given
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new");
willDoNothing().given(articleService).saveArticle(any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles"))
.andExpect(redirectedUrl("/articles"));
then(articleService).should().saveArticle(any(ArticleDto.class));
}
@DisplayName("[view][GET] 게시글 수정 페이지 - 인증 없을 땐 로그인 페이지로 이동")
@Test
void givenNothing_whenRequesting_thenRedirectsToLoginPage() throws Exception {
// Given
long articleId = 1L;
// When & Then
mvc.perform(get("/articles/" + articleId + "/form"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
then(articleService).shouldHaveNoInteractions();
}
@WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 게시글 수정 - 정상 호출")
@Test
void givenUpdatedArticleInfo_whenRequesting_thenUpdatesNewArticle() throws Exception {
// Given
long articleId = 1L;
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new");
willDoNothing().given(articleService).updateArticle(eq(articleId), any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/" + articleId + "/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleService).should().updateArticle(eq(articleId), any(ArticleDto.class));
}
@WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 게시글 삭제 - 정상 호출")
@Test
void givenArticleIdToDelete_whenRequesting_thenDeletesArticle() throws Exception {
// Given
long articleId = 1L;
String userId = "ecTest";
willDoNothing().given(articleService).deleteArticle(articleId, userId);
// When & Then
mvc.perform(
post("/articles/" + articleId + "/delete")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles"))
.andExpect(redirectedUrl("/articles"));
then(articleService).should().deleteArticle(articleId, userId);
}
@WithUserDetails 어노테이션 추가
- @WithUserDetails(value = "ecTest", setupBefore = TestExecutionEvent.TEST_EXECUTION) 어노테이션이 각 테스트에 추가되었다. 이 어노테이션은 테스트가 실행되기 전에 ecTest라는 사용자 계정으로 인증된 상태를 미리 설정해 준다.
- 이를 통해 테스트가 실제 인증된 사용자로 동작하는 것처럼 실행되며, 인증된 사용자만이 게시글 작성, 수정, 삭제 기능을 사용할 수 있도록 테스트된다.
인증이 없는 경우
- @WithMockUser 어노테이션이 추가된 시나리오와 대비하여, 인증이 없는 경우(로그인되지 않은 경우)에는 로그인 페이지로 리디렉션 되는지 확인하기 위한 테스트 코드가 존재한다. 여기에서는 @WithMockUser를 사용하지 않고, 사용자 인증이 없는 상태에서 접근할 때 로그인 페이지로 리디렉션 되는지 확인한다.
- 이 테스트는 인증된 사용자가 아닌 경우의 시나리오를 명확하게 테스트하며, 예상대로 동작하는지 검증한다.
게시글 등록 및 수정 테스트에서 인증된 사용자 시나리오 추가
- 게시글 등록 및 수정 테스트에서도 @WithUserDetails를 활용하여 인증된 사용자가 실제로 데이터를 저장하거나 업데이트하는 시나리오를 테스트할 수 있게 되었다.
- 이는 인증된 사용자의 역할을 명확히 하며, Spring Security와 연동된 기능이 제대로 작동하는지 확인하는 데 중점을 둔다.
deleteArticle 메서드에서 사용자 ID 전달:
- 게시글 삭제 테스트에서는 이제 articleService.deleteArticle(articleId, userId);와 같이 사용자 ID가 함께 전달된다. 이는 인증된 사용자만이 자신이 작성한 게시글을 삭제할 수 있도록 보장하기 위한 것이다.
6.4. ArticleCommentServiceTest.java
댓글 서비스의 비즈니스 로직을 테스트하는 ArticleCommentServiceTest 클래스이다. 댓글 저장, 수정, 삭제 기능이 정상적으로 동작하는지 확인하는 테스트를 진행한다. 이번 구현에서는 인증된 사용자가 자신의 댓글을 삭제할 수 있는지 테스트했다.
@DisplayName("댓글 ID를 입력하면, 댓글을 삭제한다.")
@Test
void givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment() {
// Given
Long articleCommentId = 1L;
String userId = "eunchan";
willDoNothing().given(articleCommentRepository).deleteByAndUserAccount_UserId(articleCommentId, userId);
// When
sut.deleteArticleComment(articleCommentId, userId);
// Then
then(articleCommentRepository).should().deleteByAndUserAccount_UserId(articleCommentId, userId);
}
- willDoNothing().given(...)
- articleCommentRepository의 deleteByAndUserAccount_UserId 메서드가 호출될 때 아무런 동작도 하지 않도록 설정했다.
- 이는 메서드의 실행 자체를 테스트하기 위해 사용된다.
- deleteByAndUserAccount_UserId 메서드 호출
- 댓글 삭제 시, 해당 댓글이 지정된 userId와 연결되어 있는지 확인하며 삭제한다.
- 이를 통해 권한이 없는 사용자가 다른 사용자의 댓글을 삭제하는 것을 방지한다.
- sut.deleteArticleComment(articleCommentId, userId)
- sut 객체는 CommentService의 인스턴스이다.
- deleteArticleComment 메서드를 호출하면서 articleCommentId와 userId를 인자로 넘겨주어 댓글을 삭제한다.
- then(articleCommentRepository).should().deleteByAndUserAccount_UserId(...)
- 해당 메서드가 articleCommentRepository에서 호출되었는지 확인하는 부분이다.
- 이를 통해 실제로 메서드가 예상대로 동작했음을 검증한다.
6.5. ArticleServiceTest.java
게시글 서비스의 비즈니스 로직을 테스트하는 ArticleServiceTest 클래스이다. 게시글 저장, 수정, 삭제 기능이 정상적으로 동작하는지 확인하는 테스트를 진행한다. 이번 구현에서는 인증된 사용자가 자신의 게시글을 수정 및 삭제할 수 있는지 테스트했다.
@DisplayName("게시글의 수정 정보를 입력하면, 게시글을 수정한다.")
@Test
void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
// given
Article article = createArticle();
ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");
given(articleRepository.getReferenceById(dto.id())).willReturn(article);
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(dto.userAccountDto().toEntity());
// when
sut.updateArticle(dto.id(), dto);
// then
assertThat(article)
.hasFieldOrPropertyWithValue("title", dto.title())
.hasFieldOrPropertyWithValue("content", dto.content())
.hasFieldOrPropertyWithValue("hashtag", dto.hashtag());
then(articleRepository).should().getReferenceById(dto.id());
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
}
@DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다.")
@Test
void givenArticleId_whenDeletingArticle_thenDeletesArticle() {
// given
Long articleId = 1L;
String userId = "eunchan";
willDoNothing().given(articleRepository).deleteByIdAndUserAccount_UserId(articleId, userId);
// when
sut.deleteArticle(articleId, userId);
// then
then(articleRepository).should().deleteByIdAndUserAccount_UserId(articleId, userId);
}
수정
- given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(dto.userAccountDto().toEntity());
- 이 부분은 userId를 통해 사용자의 참조를 가져와 UserAccount 엔티티를 반환하도록 설정한다. 이는 게시글 수정 시 수정 권한이 있는 사용자인지 확인하기 위함이다.
- then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
- UserAccountRepository의 getReferenceById 메서드가 실제로 호출되었는지 확인하여, 수정 시 올바른 사용자 정보가 조회되었음을 검증한다.
삭제
- willDoNothing().given(articleRepository).deleteByIdAndUserAccount_UserId(articleId, userId);
- ArticleRepository에서 deleteByIdAndUserAccount_UserId 메서드가 호출될 때 실제로 삭제 동작을 하지 않도록 설정한다. 이 메서드는 게시글이 특정 사용자와 연관되어 있는지를 확인한 후 삭제하기 위한 테스트를 위해 추가되었다.
- then(articleRepository).should().deleteByIdAndUserAccount_UserId(articleId, userId);
- deleteByIdAndUserAccount_UserId 메서드가 실제로 호출되었는지를 확인하여, 특정 사용자와 연관된 게시글이 올바르게 삭제되었는지 검증한다.
이번 시간에는 게시글과 댓글 기능에 대한 인증 기능을 추가로 개발하고, 관련된 설정과 보안 구성, 그리고 각종 서비스 및 리포지토리에서 필요한 부분들을 수정하고 테스트했다. 이로써 기본적인 인증 및 권한 관리가 적용된 게시판 기능이 완성되었다.
다음 시간에는 뷰(View) 레이어를 구현하여 사용자가 웹 페이지에서 이러한 기능들을 직접적으로 사용할 수 있도록 마무리할 예정이다.
'BackEnd > Project' 카테고리의 다른 글
[Adv. Board] Ch01. 프로젝트 기획 (0) | 2024.08.13 |
---|---|
[Board] Ch03. 인증 기능 구현(2) (0) | 2024.08.13 |
[Board] Ch03. 게시글 댓글 구현 (0) | 2024.08.12 |
[Board] Ch03. 게시글 뷰 기능 구현 (0) | 2024.08.12 |
[Board] Ch03. 게시판 검색 구현 - 해시태그 (0) | 2024.08.11 |