들어가며
안녕하세요, 오늘은 제가 LG CNS AM Inspire Camp에서 진행한 Spring Boot 게시판 프로젝트를 JPA와 Spring Security로 고도화하는 과정을 공유하려고 합니다. 단순한 CRUD 기능을 넘어 생각해보지 못했던 데이터 영속성과 보안까지 고려한 애플리케이션으로 발전시켜보았습니다.
1. JPA를 활용한 게시판 전환
1.1 JPA 기본 설정
처음 MyBatis로 구현했던 게시판을 JPA로 전환하는 과정은 생각보다 큰 패러다임 전환이었습니다. SQL 중심 접근에서 객체 중심 접근으로 바뀌면서 코드가 훨씬 직관적이 되었습니다.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2' // 개발 환경용
implementation 'mysql:mysql-connector-java' // 배포 환경용
}
# application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/boarddb
spring.datasource.username=root
spring.datasource.password=1234
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
🔍 배운 점: JPA의 설정은 생각보다 간단했지만, 그 배경에는 수많은 최적화와 설계 패턴이 숨어있다는 것을 알았습니다. ddl-auto=update
를 사용하면 개발 중에는 편리하지만, 실 서비스에서는 validate
나 직접 마이그레이션 도구를 사용하는 것이 안전하다는 점을 배웠습니다.
1.2 엔티티 설계
엔티티 설계는 객체 모델링의 묘미를 느낄 수 있는 부분이었습니다.
@Entity
@Table(name = "board")
@Getter @Setter
@NoArgsConstructor
public class BoardEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
private String writer;
@CreationTimestamp
private LocalDateTime createdTime;
@UpdateTimestamp
private LocalDateTime updatedTime;
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<BoardFileEntity> files = new ArrayList<>();
// 파일 추가 메서드
public void addFile(BoardFileEntity file) {
files.add(file);
file.setBoard(this);
}
}
🔍 깨달음: 처음에는 테이블 간 관계 설정이 복잡하게 느껴졌지만, 실제로는 객체 지향적 사고방식과 일치한다는 것을 깨달았습니다. 특히 cascade
와 orphanRemoval
속성을 통해 파일 업로드와 삭제를 더 우아하게 처리할 수 있었습니다.
1.3 Repository 계층 구현
JPA의 Repository 인터페이스는 마법 같았습니다. 메소드 이름만으로 쿼리가 생성된다니!
public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
List<BoardEntity> findByTitleContaining(String keyword);
@Query("SELECT b FROM BoardEntity b WHERE b.writer = :writer")
List<BoardEntity> findByCustomWriter(@Param("writer") String writer);
@Query(value = "SELECT * FROM board WHERE created_time > DATE_SUB(NOW(), INTERVAL 7 DAY)",
nativeQuery = true)
List<BoardEntity> findRecentBoards();
}
🔍 배운 점: 복잡한 SQL을 직접 작성하지 않고도 데이터 조회가 가능하다는 것이 JPA의 큰 장점이었습니다. 특히 메소드 명명 규칙을 통한 쿼리 생성은 코드의 가독성과 유지보수성을 크게 높여주었습니다. 다만 복잡한 쿼리의 경우 @Query
를 활용하는 것이 더 명확할 수 있다는 것도 배웠습니다.
1.4 Service 계층 개선
서비스 계층에서는 트랜잭션 관리의 중요성을 배웠습니다.
@Service
@Transactional
public class BoardService {
private final BoardRepository boardRepository;
private final FileService fileService;
// 생성자 주입
public Long save(BoardDTO boardDTO, List<MultipartFile> files) {
BoardEntity board = convertToEntity(boardDTO);
// 파일 저장 및 연관관계 설정
if (files != null && !files.isEmpty()) {
files.forEach(file -> {
String savedPath = fileService.saveFile(file);
BoardFileEntity fileEntity = new BoardFileEntity();
fileEntity.setOriginalFileName(file.getOriginalFilename());
fileEntity.setStoredFileName(savedPath);
fileEntity.setFileSize(file.getSize());
board.addFile(fileEntity);
});
}
return boardRepository.save(board).getId();
}
// 다른 메서드들...
}
🔍 깨달음: @Transactional
어노테이션 하나로 트랜잭션 관리가 이루어진다는 점이 놀라웠습니다. 특히 예외 발생 시 자동 롤백되는 기능은 데이터 일관성을 유지하는 데 큰 도움이 되었습니다. 또한 영속성 컨텍스트의 변경 감지(Dirty Checking) 기능 덕분에 엔티티의 변경이 자동으로 데이터베이스에 반영되는 점도 매우 편리했습니다.
1.5 Controller 수정
컨트롤러는 크게 바뀌지 않았지만, 응답 처리가 더 세련되어졌습니다.
@Controller
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
// 생성자 주입
@GetMapping
public String list(Model model,
@RequestParam(required = false) String keyword,
@PageableDefault(size = 10) Pageable pageable) {
Page<BoardDTO> boardList;
if (keyword != null && !keyword.isEmpty()) {
boardList = boardService.searchBoards(keyword, pageable);
} else {
boardList = boardService.getList(pageable);
}
model.addAttribute("boards", boardList);
return "board/list";
}
// 다른 매핑 메소드들...
}
🔍 배운 점: JPA와 Spring의 통합은 페이징과 정렬 같은 복잡한 기능도 쉽게 구현할 수 있게 해준다는 것을 배웠습니다. 특히 Pageable
인터페이스를 활용한 페이징 처리는 이전 방식보다 훨씬 직관적이고 유연했습니다.
2. Spring Security 통합
2.1 기본 보안 설정
프로젝트에 보안을 추가하는 과정은 도전적이었습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/signup", "/css/**", "/js/**").permitAll()
.requestMatchers("/board/write", "/board/edit/**").authenticated()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
🔍 깨달음: 보안 설정은 복잡해 보이지만 체계적으로 접근하면 관리하기 쉽다는 것을 알았습니다. 특히 최신 Spring Security의 람다 기반 DSL은 가독성을 크게 향상시켰습니다. 보안은 애플리케이션의 핵심 요소이며, 시작부터 제대로 설계하는 것이 중요하다는 점을 깨달았습니다.
2.2 사용자 관리 시스템
사용자 관리 시스템은 엔티티 설계부터 시작했습니다.
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
private String name;
private String email;
@ElementCollection(fetch = FetchType.EAGER)
private Set<String> roles = new HashSet<>();
// 편의 메서드
public void addRole(String role) {
roles.add(role);
}
}
회원가입 기능 구현:
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// 생성자 주입
public void signup(UserDTO userDTO) {
// 중복 ID 체크
if (userRepository.findByUsername(userDTO.getUsername()).isPresent()) {
throw new IllegalArgumentException("이미 존재하는 사용자명입니다.");
}
// 비밀번호 확인
if (!userDTO.getPassword().equals(userDTO.getPasswordConfirm())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
UserEntity user = new UserEntity();
user.setUsername(userDTO.getUsername());
user.setPassword(passwordEncoder.encode(userDTO.getPassword()));
user.setName(userDTO.getName());
user.setEmail(userDTO.getEmail());
user.addRole("ROLE_USER");
userRepository.save(user);
}
}
🔍 배운 점: 사용자 관리는 보안의 기본이며, 특히 비밀번호 암호화는 반드시 필요하다는 것을 배웠습니다. @ElementCollection
을 활용한 역할 관리는 간단한 권한 시스템에 적합하다는 것도 알았습니다. 다만 실무에서는 더 복잡한 권한 모델이 필요할 수 있으므로 @ManyToMany
관계를 사용한 역할 엔티티를 별도로 두는 것도 고려해볼 수 있겠다는 생각이 들었습니다.
2.3 인증/인가 시스템
Spring Security의 핵심은 인증과 인가 시스템입니다.
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// 생성자 주입
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username));
}
private UserDetails createUserDetails(UserEntity user) {
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().stream()
.map(role -> role.replace("ROLE_", ""))
.toArray(String[]::new))
.build();
}
}
성공적인 로그인 후 처리:
@Component
public class CustomAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 마지막 로그인 시간 업데이트 등의 작업 수행
// 원래 요청했던 URL로 리다이렉트
super.onAuthenticationSuccess(request, response, authentication);
}
}
🔍 깨달음: Spring Security의 인증 시스템은 생각보다 유연하고 확장성이 높다는 것을 알았습니다. 특히 UserDetailsService
를 통한 커스텀 인증 로직 구현은 다양한 인증 방식(DB, OAuth, LDAP 등)을 통합할 수 있게 해줍니다. 보안은 사용자 경험과 상충될 수 있지만, 적절한 설계를 통해 둘 다 만족시킬 수 있다는 점을 배웠습니다.
2.4 게시판 기능과 연동
이제 기존 게시판 기능에 인증 정보를 연동합니다.
@Controller
@RequestMapping("/board")
public class BoardController {
// ...
@PostMapping("/write")
public String write(@Valid BoardDTO boardDTO,
BindingResult result,
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam("files") List<MultipartFile> files) {
if (result.hasErrors()) {
return "board/writeForm";
}
// 현재 로그인한 사용자 정보 설정
boardDTO.setWriter(userDetails.getUsername());
Long boardId = boardService.save(boardDTO, files);
return "redirect:/board/" + boardId;
}
@GetMapping("/edit/{id}")
public String editForm(@PathVariable Long id,
Model model,
@AuthenticationPrincipal UserDetails userDetails) {
BoardDTO board = boardService.findById(id);
// 작성자와 현재 사용자가 일치하는지 확인
if (!board.getWriter().equals(userDetails.getUsername())) {
throw new AccessDeniedException("수정 권한이 없습니다.");
}
model.addAttribute("board", board);
return "board/editForm";
}
}
HTML 템플릿에서의 권한 체크:
<div th:if="${#authorization.expression('isAuthenticated()')}">
<a th:if="${#authorization.expression('hasRole(''ADMIN'')')} or
(${board.writer} == ${#authentication.name})"
th:href="@{/board/edit/{id}(id=${board.id})}"
class="btn btn-primary">수정</a>
</div>
🔍 배운 점: @AuthenticationPrincipal
을 통해 컨트롤러에서 인증 정보를 쉽게 접근할 수 있다는 것이 편리했습니다. Thymeleaf의 Spring Security 통합 기능은 프론트엔드에서도 권한에 따른 UI 요소 제어를 간편하게 해준다는 점이 인상적이었습니다.
3. REST API 보안 강화
3.1 JPA 기반 REST API
기존 애플리케이션에 REST API를 추가하는 작업을 진행했습니다.
@RestController
@RequestMapping("/api/boards")
public class BoardApiController {
private final BoardService boardService;
// 생성자 주입
@GetMapping
public ResponseEntity<List<BoardDTO>> getAllBoards() {
List<BoardDTO> boards = boardService.findAll();
return ResponseEntity.ok(boards);
}
@GetMapping("/{id}")
public ResponseEntity<BoardDTO> getBoard(@PathVariable Long id) {
try {
BoardDTO board = boardService.findById(id);
return ResponseEntity.ok(board);
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
public ResponseEntity<BoardDTO> createBoard(@RequestBody BoardDTO boardDTO,
@AuthenticationPrincipal UserDetails userDetails) {
boardDTO.setWriter(userDetails.getUsername());
Long id = boardService.save(boardDTO, null);
BoardDTO saved = boardService.findById(id);
return ResponseEntity.created(URI.create("/api/boards/" + id)).body(saved);
}
// PUT, DELETE 메서드 구현...
}
🔍 깨달음: RESTful API 설계는 리소스 중심적인 사고가 필요하다는 것을 배웠습니다. 특히 HTTP 상태 코드와 메서드를 올바르게 사용하는 것이 중요합니다. ResponseEntity
를 활용하면 상태 코드, 헤더, 본문을 모두 제어할 수 있어 REST API 개발에 매우 유용했습니다.
3.2 API 보안 설정
API 보안은 추가적인 고려사항을 요구했습니다.
@Configuration
public class SecurityConfig {
// 기존 SecurityFilterChain 수정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 기존 설정...
// API 보안 설정 추가
.antMatcher("/api/**")
.authorizeHttpRequests()
.antMatchers(HttpMethod.GET, "/api/boards/**").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable() // API는 CSRF 보호 비활성화 (JWT 사용 시)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
}
🔍 배운 점: API 보안은 웹 애플리케이션 보안과 다른 접근 방식이 필요하다는 것을 배웠습니다. CSRF 보호는 일반 웹 애플리케이션에서 중요하지만, API에서는 JWT 같은 토큰 기반 인증을 사용할 때는 비활성화할 수 있습니다. 상황에 맞게 보안 전략을 수립하는 것이 중요하다는 점을 깨달았습니다.
4. 실제 적용 방안
4.1 성능 최적화
@EntityGraph(attributePaths = {"files"})
List<BoardEntity> findAll();
🔍 깨달음: N+1 문제는 JPA를 사용할 때 가장 흔하게 접하는 성능 이슈였습니다. @EntityGraph
, fetch join
, BatchSize
같은 다양한 해결책을 알게 되었고, 각각의 장단점을 이해하게 되었습니다. 성능 최적화는 결국 트레이드오프의 문제이며, 항상 측정을 통한 개선이 필요하다는 점을 배웠습니다.
4.2 보안 강화
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 기본 설정...
.sessionManagement(session -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
)
.rememberMe(remember -> remember
.tokenValiditySeconds(86400)
.key("secureKey")
);
return http.build();
}
🔍 배운 점: 보안은 여러 층위에서 적용되어야 한다는 것을 배웠습니다. 서버 측 검증, 적절한 세션 관리, 패스워드 정책 등 다양한 보안 조치를 적용함으로써 시스템을 더 안전하게 만들 수 있습니다. 특히 HTTPS는 모든 프로덕션 환경에서 기본이 되어야 한다는 점을 깨달았습니다.
4.3 테스트 전략
@SpringBootTest
@AutoConfigureMockMvc
public class BoardControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private BoardRepository boardRepository;
@WithMockUser(username = "testuser", roles = {"USER"})
@Test
public void testCreateBoard() throws Exception {
mockMvc.perform(post("/board/write")
.param("title", "Test Title")
.param("content", "Test Content"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("/board/*"));
assertThat(boardRepository.findByTitleContaining("Test Title")).hasSize(1);
}
}
🔍 깨달음: 테스트는 기능 개발만큼이나 중요하다는 것을 배웠습니다. 특히 Spring Security를 사용할 때는 @WithMockUser
와 같은 애노테이션을 활용하여 인증된 상태를 시뮬레이션할 수 있다는 점이 유용했습니다. 테스트를 통해 보안 설정의 오류도 미리 발견할 수 있었습니다.
5. 결론 & 향후 계획
배운 점 정리
프로젝트를 통해 JPA와 Spring Security를 통합하는 과정은 단순한 기술 습득을 넘어 웹 애플리케이션 아키텍처에 대한 더 깊은 이해를 가져다 주었습니다. SQL과 보안 코드에 집중하던 시간을 비즈니스 로직에 더 투자할 수 있게 되었고, 결과적으로 더 견고하고 유지보수하기 쉬운 애플리케이션을 개발할 수 있었습니다.
개인적인 깨달음
이 프로젝트를 통해 프레임워크나 라이브러리를 사용하는 것은 단순히 코드를 줄이는 것이 아니라, 더 높은 추상화 수준에서 문제를 해결할 수 있게 한다는 점을 깨달았습니다. JPA의 영속성 컨텍스트나 Spring Security의 필터 체인 같은 개념은 처음에는 이해하기 어려웠지만, 이를 통해 더 견고한 애플리케이션을 구축할 수 있었습니다.
또한 보안은 뒤늦게 추가할 수 있는 기능이 아니라, 처음부터 설계에 통합되어야 하는 핵심 요소라는 점도 중요한 배움이었습니다. Spring Security는 복잡하지만 제대로 활용하면 강력한 보안 체계를 쉽게 구축할 수 있게 해주었습니다.
향후 활용 방안
- OAuth2 통합: 소셜 로그인을 추가하여 사용자 편의성을 높이고 싶습니다.
- 마이크로서비스 아키텍처 탐색: 현재 모놀리식 구조를 일부 분리하여 마이크로서비스 아키텍처를 경험해보고 싶습니다.
- 실시간 기능 추가: WebSocket을 활용하여 실시간 알림이나 채팅 기능을 구현해볼 계획입니다.
- 클라우드 배포: AWS나 GCP에 배포하여 확장성 있는 애플리케이션으로 발전시키고 싶습니다.
참고 자료
'백엔드' 카테고리의 다른 글
웹 게시판 구현을 통해 배운 Spring 실전 개발 여정 (0) | 2025.03.17 |
---|---|
스프링 프레임워크 마스터하기: Bean 라이프사이클과 AOP (0) | 2025.03.17 |
스프링 프레임워크의 의존성 주입(DI) 마스터하기 (0) | 2025.03.16 |
Java 제네릭 메소드 완벽 가이드: 코드의 재사용성과 안전성을 한번에! (0) | 2025.03.13 |
Java 컬렉션 프레임워크 정리 (0) | 2025.03.06 |