안녕하세요 요즘 정신이 없어서 좀 늦게 작성합니다 ㅠㅠ
9월 10일부터 9월 23일까지 진행했던 프로젝트에서 맡았던 역할과
프로젝트를 진행하면서 고민했던 점, 어려웠던 점을 정리해보려고 합니다.
이번에 진행했던 프로젝트는 이상형 월드컵 사이트를 참고해서
현재 같이 수업을 듣고 있는 백엔드 비대면반 이상형 월드컵 사이트를 개발했습니다.
제 역할은
- 로그인 ( Spring Security + JWT )
- 관리자, 사용자 계정 설정 ( admin, user )
- 전체 ERD
이 부분을 맡았습니다.
1) ERD
전체 ERD 같은 경우는 초반에 다른 한 분과 공통 역할이어서 같이 만들었습니다.
제일 처음에 완성했던 ERD입니다.
이 뒤로는 모든 인원이 ERD에 대해 고민하면서 계속 수정했습니다.
이번 프로젝트는 ERD에 대해 효율성과 데이터 무결성 등 많은 고민을 하면서 강사님과 다른 조원에게 물어보며 오랜 시간에 걸쳐서 만들었습니다.
ERD 최종본입니다.
2) Spring Security + JWT
이제 제가 맡았던 로그인을 Spring Security + JWT로 구현해야 했습니다.
수업에서 엄청 짧게 배우기도 했고 Spring Security + JWT가 생각보다 많이 어려워서 초반에는 많이 헤맸습니다.
유튜브 강의를 찾아보면서 따리치고 인터넷 검색을 통해 겨우 완성했습니다.
( Spring Security + JWT 부분을 GPT에게 물어보면 자꾸 예전 버전을 알려주고 바보가 되는 버그가 걸렸습니다...)
이 영상 시리즈 덕분에 공부하면서 겨우 구현할 수 있었습니다.
고민 1
여기서 이제 고민했던 부분은
보통 서비스는 회원가입을 진행할 때 비밀번호를 암호화해서 데이터베이스에 저장합니다.
하지만 저희는 저희 반 사진을 다 미리 저장해야 되기도 했고 저희 반만 사용하는 서비스였기 때문에
회원가입 로직 없이 모든 회원을 각자 이름과 비밀번호는 1234로 데이터베이스에 미리 저장하기로 했습니다.
고민을 하다가 다른 분한테 물어보니 보통 실제 서비스에서도 개인 정보와 보안 등의 문제로 비밀번호 암호화는 상당히 중요한 부분이라고 말해줘서 그럼 다 찾아서 암호화시키고 다시 저장해야 되겠다고 생각하고 암호화 로직을 짰습니다.
@Service
public class PasswordEncryptionServiceV2 {
private final UsersRepositoryV2 usersRepositoryV2;
private final BCryptPasswordEncoder passwordEncoder;
public PasswordEncryptionServiceV2(UsersRepositoryV2 usersRepositoryV2, BCryptPasswordEncoder passwordEncoder) {
this.usersRepositoryV2 = usersRepositoryV2;
this.passwordEncoder = passwordEncoder;
}
public void encryptPasswords() {
List<Users> users = usersRepositoryV2.findAll();
for (Users user : users) {
String rawPassword = user.getPassword();
// 비밀번호가 암호화되어 있지 않다면 암호화
if (!rawPassword.startsWith("$2a$")) { // BCrypt 비밀번호는 $2a$로 시작
String encodedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(encodedPassword);
usersRepositoryV2.save(user);
}
}
}
}
이 코드를 통해 비밀번호가 암호화되어 있지 않다면 암호화 후 다시 user 테이블에 저장해 줬습니다.
고민 2
이제 권한을 허용해주는 부분에서 어려움을 겪었습니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder) throws Exception {
http
.httpBasic(httpBasic -> httpBasic.disable())
.formLogin(formLogin -> formLogin.disable())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers(
"/api/posts/**",
"/static/**",
"/image/**")
.permitAll()
.requestMatchers("manager1.html","/worldcup/management/**", "/worldcup/list/**", "/worldcup/user").hasAuthority("admin") // 관리자만 접근
.anyRequest().authenticated())
.addFilterBefore(new JWTFilterV2(jwtUtilV2), LoginFilterV2.class)
.addFilterAt(new LoginFilterV2(authenticationManager(authenticationConfiguration), jwtUtilV2), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
초반에는 hasAuthority("admin")을 통해 html도 관리자만 허용하도록
.requestMatchers("manager1.html","/worldcup/management/**","/worldcup/list/**""/worldcup/user").hasAuthority("admin") 이렇게 설정해 줬습니다.
Postman을 통해 잘 되는 것도 확인했고 됐다 싶었는데 프론트를 완성 후 웹에서 테스트할 때 문제가 생겼었습니다.
관리자로 로그인한 상태에서 Local storge에 토큰이 잘 저장되어 있는 것도 확인했는데 manager.html로 접속하면
이 화면이 떴고 Local storge에 토큰도 보이지 않았습니다.
며칠을 무슨 문제일까 고민하면서 제가 생각했을 때는 html을 전체 허용해주지 않으면 거부돼서 연결이 되지 않는 걸까??
라는 생각이 들었고 확실하진 않으니 GPT한테 물어봤습니다..
네, HTML 파일에 대한 요청을 permitAll로 허용하지 않으면 브라우저가 해당 파일을 가져올 수 없기 때문에
JavaScript 코드에서 localStorage에 접근하려고 해도 작동하지 않을 수 있습니다.
permitAll로 HTML 파일을 허용해야 브라우저가 해당 파일을 로드하고,
그 안에 있는 스크립트들이 실행되어 localStorage를 사용할 수 있습니다. 따라서, 만약 /manager1.html, /manager-register1.html, /userlist1.html 등을 관리자만 볼 수 있게 하고 싶다면 HTML 파일 자체에 접근 권한을 부여하는 것이 아니라, HTML 파일을 열고 난 후 해당 페이지에서 서버에 요청할 때 권한을 체크하는 방식을 사용하는 것이 좋습니다.
예를 들어, manager1.html을 로드할 때, JavaScript로 백엔드에 현재 사용자의 권한을 요청하고, 권한이 'admin'이 아닌 경우 해당 페이지에서 다른 페이지로 리다이렉션하거나 경고 메시지를 표시하는 방법을 사용할 수 있습니다.
이러한 답변을 들었고
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder) throws Exception {
http
.httpBasic(httpBasic -> httpBasic.disable())
.formLogin(formLogin -> formLogin.disable())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers(
"/*.html", // 모든 html 허용
"/api/posts/**",
"/static/**",
"/image/**")
.permitAll()
.requestMatchers("/worldcup/management/**", "/worldcup/list/**", "/worldcup/user").hasAuthority("admin") // 관리자만 접근
.anyRequest().authenticated())
.addFilterBefore(new JWTFilterV2(jwtUtilV2), LoginFilterV2.class)
.addFilterAt(new LoginFilterV2(authenticationManager(authenticationConfiguration), jwtUtilV2), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
모든 html은 허용을 해준 뒤 관리자 부분을 맡은 조원과 소통하면서
권한이 'admin'이 아닌 경우 해당 페이지에서 다른 페이지로 리다이렉션하거나 경고 메시지를 표시하는 방법을 사용할 수 있습니다.
이 방식을 활용하여 구현하였습니다.
추가적으로 테스트할 때 좀 바보같은 실수를 한 부분은
// 토큰 생성 (만료시간 일단 1시간으로 설정함)
String token = jwtUtilV2.createJwt(username, role, 60*60*10L*100);
토근을 생성할 때 처음에 60*60*10L이 1시간인줄 알고 60*60*10L로 설정했다가 알고보니 1분이였고
5일동안 만료시간을 1분으로 설정해서 테스트하는데 살짝 어려움을 겪었습니다.
(postman에서는 1분 밖에 테스트를 못하는구나 이런 바보같은 생각을...)
3) 자유게시판
그 뒤 자유게시판은 시간이 남는 사람이 하기로 했었는데
토큰을 활용해서 여러 가지 해보고 싶은 것도 있고 추석에 시간도 남아서
처음 제 역할은 아니었지만 자유게시판도 구현했습니다.
제가 해보고 싶었던 자유게시판 기능은
로그인 X
- 게시글 확인 가능
- 사용자 이름 게시글 검색 가능
로그인 O
- 토큰에 저장되어 있는 UserName 활용해서 게시글 등록
- 자신이 작성한 게시글만 수정, 삭제 가능
- 로그인이 되어있지 않으면 위 기능을 수행하려고 할 때 로그인 창으로 이동
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer postId;
private String content;
private String title;
private LocalDateTime postCreatedAt;
@ManyToOne
@JoinColumn(name = "user_id")
private Users user;
}
Post Entity입니다.
@Data
public class PostDtoV2 {
private Integer postId;
private String content;
private String title;
private String userName;
private LocalDateTime post_created_at;
}
PostDto를 만들어줬습니다.
import com.example.WorldCup.dto.PostDtoV2;
import com.example.WorldCup.entity.Post;
import java.util.List;
public interface PostServiceV2 {
List<PostDtoV2> listPost();
List<PostDtoV2> searchName(String userName);
PostDtoV2 insertPost(PostDtoV2 postDto, String userName);
PostDtoV2 updatePost(PostDtoV2 postDto, String userName);
void deletePost(int postId, String username);
}
PostService입니다.
@Service
@RequiredArgsConstructor
public class PostServiceImplV2 implements PostServiceV2 {
private final PostRepositoryV2 postRepository;
private final UsersRepositoryV2 userRepository;
@Override
public List<PostDtoV2> listPost() {
List<Post> posts = postRepository.findAllWithUser();
List<PostDtoV2> postDtolist = new ArrayList<>();
for (Post post : posts) {
PostDtoV2 postDto = new PostDtoV2();
postDto.setUserName(post.getUser().getName());
postDto.setPostId(post.getPostId());
postDto.setContent(post.getContent());
postDto.setTitle(post.getTitle());
postDto.setPost_created_at(post.getPostCreatedAt());
postDtolist.add(postDto);
}
return postDtolist;
}
@Override
public List<PostDtoV2> searchName(String userName) {
List<Post> posts = postRepository.findByUserName(userName);
List<PostDtoV2> postDtolist = new ArrayList<>();
for (Post post : posts) {
PostDtoV2 postDto = new PostDtoV2();
postDto.setUserName(post.getUser().getName());
postDto.setPostId(post.getPostId());
postDto.setContent(post.getContent());
postDto.setTitle(post.getTitle());
postDto.setPost_created_at(post.getPostCreatedAt());
postDtolist.add(postDto);
}
return postDtolist;
}
@Override
public PostDtoV2 insertPost(PostDtoV2 postDto, String userName) {
Users user = userRepository.findByName(userName);
Post post = new Post();
post.setTitle(postDto.getTitle());
post.setContent(postDto.getContent());
post.setPostCreatedAt(LocalDateTime.now()); // 저장할 때 현재 시간 입력
post.setUser(user);
Post savedPost = postRepository.save(post);
PostDtoV2 savedPostDto = new PostDtoV2();
savedPostDto.setPostId(savedPost.getPostId());
savedPostDto.setTitle(savedPost.getTitle());
savedPostDto.setContent(savedPost.getContent());
savedPostDto.setPost_created_at(savedPost.getPostCreatedAt());
return savedPostDto;
}
@Override
public PostDtoV2 updatePost(PostDtoV2 postDto, String userName) {
// postId를 이용해 기존 게시글을 가져옴
Post post = postRepository.findById(postDto.getPostId())
.orElseThrow(() -> new RuntimeException("게시물을 찾을 수 없습니다."));
// 토큰에 저장되어 있는 이름으로 비교해서 다른 사용자 수정 권한 X
if (!post.getUser().getName().equals(userName)) {
throw new RuntimeException("다른 사용자 수정 권한이 없습니다.");
}
// 게시글의 내용을 업데이트
post.setTitle(postDto.getTitle());
post.setContent(postDto.getContent());
// post.setPost_created_at(LocalDateTime.now()); // 업데이트 작성 날짜까지 최신으로?? 아님 처음 작성한 날짜 그대로
Post savedPost = postRepository.save(post);
PostDtoV2 savedPostDto = new PostDtoV2();
savedPostDto.setPostId(savedPost.getPostId());
savedPostDto.setTitle(savedPost.getTitle());
savedPostDto.setContent(savedPost.getContent());
savedPostDto.setPost_created_at(savedPost.getPostCreatedAt());
return savedPostDto;
}
@Override
public void deletePost(int postId, String userName) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new RuntimeException("게시물을 찾을 수 없습니다."));
// 토큰에 저장되어 있는 이름으로 비교해서 다른 사용자 삭제 권한 X
if (!post.getUser().getName().equals(userName)) {
throw new RuntimeException("다른 사용자 삭제 권한이 없습니다.");
}
postRepository.deleteById(postId);
}
}
PostServiceImpl코드입니다.
listPost 부분부터 보면
postRepository.findAllWithUser()
이 부분을 통해 모든 post를 가져옵니다.
N+1 문제
저는 게시글을 가져오면서 사용자 이름을 표시해주고 싶었고 처음엔
public interface PostRepositoryV2 extends JpaRepository<Post, Integer> {
List<Post> findByUserName(String userName);
// 리스트 조인으로 한꺼번에 가져오려고 사용했습니당
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
}
findByUserName()을 이용해서 구현했었는데 N+1 문제가 발생하여
JOIN FETCH를 활용해 N+1을 해결했습니다.
Hibernate: select p1_0.post_id,p1_0.content,p1_0.post_created_at,p1_0.title,u1_0.user_id,u1_0.image,u1_0.name,u1_0.password,u1_0.role_code from post p1_0 join users u1_0 on u1_0.user_id=p1_0.user_id
게시글 띄울 때 날아가는 쿼리문입니다.
searchName은 간단하게 findByUserName을 활용하여 구현했습니다.
이제 제가 토큰을 활용하여해보고 싶었던 등록, 수정, 삭제를 보면
매개 변수로 String Name을 받는 걸 보실 수 있습니다.
일단 PostController 코드를 보시면
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/posts")
public class PostControllerV2 {
private final PostServiceV2 postService;
// 게시글 전체 목록
@GetMapping("/list")
public List<PostDtoV2> listPost() {
return postService.listPost();
}
// 게시글 이름 검색
@GetMapping("/searchName")
public List<PostDtoV2> searchPosts(@RequestParam String userName) {
return postService.searchName(userName);
}
// 게시글 등록
@PostMapping("/insert")
public ResponseEntity<PostDtoV2> insertPost(@RequestBody PostDtoV2 postDto) {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
PostDtoV2 savedPostDto = postService.insertPost(postDto, name);
return new ResponseEntity<>(savedPostDto, HttpStatus.CREATED);
}
// 게시글 업데이트
@PutMapping("/{postId}")
public ResponseEntity<PostDtoV2> updatePost(PostDtoV2 postDto) {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
PostDtoV2 savedPostDto = postService.updatePost(postDto, name);
return new ResponseEntity<>(savedPostDto, HttpStatus.OK);
}
// 게시글 삭제
@DeleteMapping("/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable int postId) {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
postService.deletePost(postId, name);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
String name = SecurityContextHolder.getContext().getAuthentication().getName();
이 코드를 통해 토큰에서 Name을 가져와 넘겨줍니다.
고민 1
바로 잘 될 것이라 생각했는데 name을 계속 anonymous로 가져오는 문제가 생겼었습니다.
프론트에서 토큰이 넘어오지 않는 문제였고
fetch('/api/posts/insert', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
이러한 형식으로 헤더에 토큰도 같이 넘겨줘야 한다는 것을 알았습니다.
이걸 통해 로그인한 사용자로 게시글을 등록할 수 있게 해줬고
자신이 작성한 글만 수정, 삭제를 할 수 있도록 구현했습니다.
고민 2
현재는 컨트롤러에서
String name = SecurityContextHolder.getContext().getAuthentication().getName();
이 코드를 썼지만 serviceImpl에서 하는 게 맞는 거 같기도 하고...
SecurityContextHolder.getContext().getAuthentication().getName()을 컨트롤러에서 사용하는 것은 일반적으로 문제가 없지만, 코드의 유지보수성과 재사용성을 고려했을 때, 이 로직을 서비스 레이어로 옮기는 것도 좋은 선택이 될 수 있습니다.
GPT는 이렇다고 합니다.
4) 보완할 점
Git과 Github으로 협업하면서 충돌 나지 않으려고 규칙을 정했는데도 어려움을 좀 겪었습니다.
Git에 대해 더 열심히 공부해야겠습니다.
저랑 같은 역할을 맡은 분들의 JWT 구현을 보니 access token에서 끝나는 것이 아닌 refresh token도 구현해야 된다는 것을 알았습니다. 또한 보안상의 문제는 없는지 등 추가적으로 공부해서 구현해봐야 할 것 같습니다.
각자 맡은 역할을 구현에 성공하긴 했지만 각자 스타일대로 코드를 짜서 다음 프로젝트에선 그래도 좀 통일되게 짜야할 것 같고 패키지랑 폴더도 깔끔하게 정리해야 할 것 같습니다.
시간이 부족해서 이번엔 작성하지 못했지만 다음 프로젝트에선 테스트 코드도 다 작성하는 것이 좋아 보입니다.
정리
이 프로젝트를 진행하면서 공부도 많이 됐고 협업도 할 수 있어서 도움이 많이 됐습니다.
ERD는 정해진 답도 없고 엄청 어렵구나라는 걸 다시 한 번 느꼈습니다. ( Spring Security + JWT는 너무 어렵고..)
여러 명과 2주 동안 한 첫 협업 프로젝트인데 그래도 완성하고 발표까지 잘 마무리된 것 같아서 다행입니다.
앞으로 남은 종합 프로젝트와 융합 프로젝트에서는 꼭 상을 받아 더 기분 좋은 후기로 돌아오겠습니다.
'프로젝트' 카테고리의 다른 글
[Spring] 간단한 프로젝트를 하면서 고민했던 것들 (2) | 2024.09.08 |
---|