일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 자바 중급1편 - 날짜와 시간
- 자바 고급2편 - 네트워크 프로그램
- 스프링 입문(무료)
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch6
- 자바로 계산기 만들기
- 자바의 정석 기초편 ch9
- 2024 정보처리기사 수제비 실기
- 자바 중급2편 - 컬렉션 프레임워크
- 자바로 키오스크 만들기
- 자바의 정석 기초편 ch5
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch7
- 스프링 mvc2 - 로그인 처리
- @Aspect
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch2
- 람다
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch13
- 자바 기초
- 스프링 트랜잭션
- 자바의 정석 기초편 ch4
- 데이터 접근 기술
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch14
- 스프링 mvc2 - 검증
- 스프링 mvc1 - 스프링 mvc
- 자바 고급2편 - io
- 자바의 정석 기초편 ch1
- Today
- Total
개발공부기록
스케줄러(일정관리) API 서버 만들기 - JPA 적용, 일정/유저/댓글 CRUD, 서블릿 필터를 활용한 로그인 및 회원가입 구현(예외처리, 암호화, 페이징 적용 포함) 본문
스케줄러(일정관리) API 서버 만들기 - JPA 적용, 일정/유저/댓글 CRUD, 서블릿 필터를 활용한 로그인 및 회원가입 구현(예외처리, 암호화, 페이징 적용 포함)
소소한나구리 2025. 4. 1. 16:12GitHub
- main 브랜치: https://github.com/nagul2/scheduler-api-jpa
- 다른 브랜치는 기록용
JdbcTemplate으로 일정 관리 API 만들기 회고
프로젝트 정보
스프링 부트 프로젝트 생성
프로젝트 정보
- Java: JDK 17
- Group: spring.advanced
- artifact: scheduler-jpa
- Packaging: Jar
Dependencies
- Spring Web
- Spring Data JPA
- MySQL Driver
- Lombok
- Validation
사용 스킬
- Java
- Spring
- Spring Boot
- JPA
- Spring Data
- MySQL
- validation
- bcrypt: 암호화 라이브러리
패키지 구조
패키지 구조는 각각의 도메인 별로 요청과 비즈니스 로직이 존재하기 때문에 각각 user, schedule, comment 패키지로 구분하여 하위에 엔티티와 dto를 관리하는 domain 패키지, api 통신을 하는 컨트롤러 패키지, 비즈니스 로직이 있는 service 패키지, DB와 통신하는 repository 패키지를 분리했다
추가적으로 로그인, 로그아웃처럼 인증 관련된 패키지를 auth패키지로 별도로 분리하고, 공통으로 사용하는 예외정보나 설정 정보, 상수들을 관리하는 common 패키지로 관리하였다.
일정, 유저, 댓글 Entity 개발
ERD
직전에 JDBC로 일정관리 API 서버를 만드는 프로젝트에서는 작성자 테이블과 일정 테이블을 분리했지만 로그인 개념이 아니라 작성자 글 하나에 유저가 비밀번호를 통해 일정을 등록하는 형식이였다
이번에는 유저 테이블과 일정 테이블, 그리고 일정의 댓글 테이블을 JPA의 연관관계 매핑을 통해 외래키를 지정하고, 서블릿 필터와 세션으로 로그인, 로그아웃 기능을 구현하여 애플리케이션에 로그인한 사용자가 일정, 댓글, 유저를 CRUD를 하는 로직을 구현해보았다.
BaseEntity
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createAt;
@LastModifiedDate
private LocalDateTime modifyAt;
}
공통적으로 모든 Entity에서 사용하는 생성일, 수정일 필드 정보를 Auditing 기능을 통해 관리하도록 했다.
여러 곳에서 공통으로 사용할 것이기에 common.entity 패키지에서 관리하도록 했고 별도로 생성될 필요가 없으므로 추상 클래스로 생성했다.
@MappedSuperclass를 적용하여 공통 필드를 여러 엔티티에서 상속 받아 사용할 수 있도록 하고 스프링 데이터 JPA에서 제공하는 @CreatedDate와 @LastModifiedDate를 적용해주어 자동으로 생성일과 수정일이 DB에 반영되도록 했다.
정상적으로 동작하도록 BaseEntity 클래스에 @EntityListeners(AuditingEntityLIstener.class)와 애플리케이션을 스프링 부트 설정 클래스에 @EnableJpaAuditing을 설정해주었다.
@EnableJpaAuditing
@SpringBootApplication
public class SchedulerJpaApplication {
// ... 생략
}
Schedule
package spring.advanced.schedulerjpa.schedule.domain.entity;
@Table(name = "schedule")
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Schedule extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false)
private String title;
@Lob
private String content;
public void updateSchedule(String title, String content) {
if (title != null) {
this.title = title;
}
if (content != null) {
this.content = content;
}
}
}
schedule 테이블과 매핑되는 Schedule 클래스이며 PK인 id는 DB에서 자동으로 값이 증가되도록 GenerationType.IDENTITY 전략을 사용했다.
제목은 null이 들어가지 않도록 nullable = false 옵션을 지정해주었고, 내용은 제목에 비해 더 많은 글을 입력할 수 있도록 @Lob을 사용하여 매핑해주었다.
데이터베이스의 BLOB, CLOB 타입과 매핑이 되는데 길이 제한이 없어 대용량 데이터를 저장할 수 있다는 특징이 있고 필드 타입이 문자면 CLOB, 나머지는 BLOB(Byte)타입으로 매핑이 된다.
DB마다 매핑되는 타입이 다를 수 있으며 현재 사용한 JPA,MySQL에서는 longtext로 매핑이 되었다.
BaseEntity를 상속받아 공통 필드를 적용시키고, @Builder패턴을 사용하기 위해 기본 생성자와 모든 필드 생성자를 적용해주는 롬복의 애노테이션을 사용했다.
그리고 필드의 데이터를 update하기위한 메서드를 Entity에 작성하여 나름대로 최대한 Service 계층의 로직을 줄이고 객체지향적인 코드를 작성해보고자 했다.
일정은 한 유저당 여러개를 생성할 수 있으므로 다대일 관계를 가지고 있어 @ManyToOne을 통해 매핑해주었고, 지연로딩 옵션을 적용하여 실제 객체가 사용될 때 데이터를 불러오도록 최적화를 진행했으며 조인 컬럼은 유저 테이블의 PK인 user_id 필드를 외래키로 지정했다
User
package spring.advanced.schedulerjpa.user.domain.entity;
@Table(name = "users")
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(unique = true)
private String email;
private String password; // password 추가
public void updateUser(String username, String email, String password) {
if (username != null) {
this.username = username;
}
if (email != null) {
this.email = email;
}
if (password != null) {
this.password = password;
}
}
}
users 테이블과 매핑되는 User 클래스이며 DB에서 user가 예약어이므로 @Table 애노테이션으로 users 테이블로 매핑 되도록 지정했다.
다른 엔티티 클래스와 마찬가지로 공통 필드를 적용하기 위해 BaseEntity를 적용하고 @Builder 패턴을 사용하기 위한 애노테이션들을 지정해주었고 필드 수정을 위한 수정 메서드를 User 클래스에서 제공하고 있다.
PK값인 id와 로그인을 하기 위한 사용자 아이디인 username, 비밀번호인 password가 있고, email 정보인 email 필드를 가지고 있다.
여기서 username과 email은 비즈니스 로직상 유일한 값이 들어와야 한다고 가정하여 unique 제약 조건을 지정해주었고, 사용자 아이디는 꼭 필요하다고 보고 not null 제약 조건을 적용 했다
사실 비밀번호도 무조건 값이 있어야 하는 필드라고 볼 수 있는데 패스워드가 필요없는 슈퍼 계정이 있을 수 있다고 가정하고 패스워드는 null이 가능하도록 해두었다.
Comment
@Table(name = "comment")
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Comment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "schedule_id")
private Schedule schedule;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false)
private String content;
public void updateContent(String content) {
if (content != null) {
this.content = content;
}
}
}
일정의 댓글인 comment 테이블과 매핑되는 Comment 클래스이다.
PK인 id와 댓글 내용인 content 필드를 가지고 있으며 실질적으로 사용자가 작성해야할 필드는 content가 유일하므로 무조건 작성할 수 있도록 not null 제약 조건을 적용했다
다른 Entity와 마찬가지로 BaseEntity를 상속받아 공통 필드들을 적용해주고 content를 수정하기 위한 updateContent 메서드도 가지고 있으며 @Builder 패턴을 사용하기 위한 애노테이션들도 적용해주었다.
Comment 클래스는 User와 Schedule 모두 연관관계를 맺어야하는데 유저가 여러 댓글도 달 수 있고 일정도 여러 댓글이 달려있을 수 있기 때문에 모두 다대일 연관관계를 맺었으며 유저와 일정이 있어야만 댓글이 생성되는 식별 관계이기때문에 각 테이블의 PK를 외래키로 가지도록 설정했다.
서블릿 필터와 세션을 활용한 인증 구현
구조 변경
이전에 JDBC를 활용하여 일정 API를 구현했을 때에는 비밀번호 각 일정마다 사용자가 입력한 비밀번호를 검증하여 접근하는 구조였다.
JPA를 활용한 이번 토이 프로젝트에서는 애초에 일정에 글을 등록하거나 댓글을 작성하는 등의 기능을 사용하려면 사용자가 아이디와 암호를 입력하여 로그인을 성공해야만 여러 api에 접근할 수 있도록 하고, 로그아웃으로 인증을 빠져나가는 구조를 도입해보았다.
LoginCheckFilter
package spring.advanced.schedulerjpa.auth.filter;
@Slf4j
public class LoginCheckFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
// Filter를 통과하지 못하면 401 응답 작성
if (loginCheckPath(requestURI)) {
log.error("[login Failed] {}", requestURI);
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(AuthConst.LOGIN_MEMBER) == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 응답코드 401
response.setContentType("application/json"); // 콘텐츠 타입 설정
response.setCharacterEncoding("UTF-8"); // 인코딩 설정
ErrorDto errorDto = new ErrorDto(
ErrorCode.UNAUTHORIZED_ACCESS.getCode(),
ErrorCode.UNAUTHORIZED_ACCESS,
ErrorCode.UNAUTHORIZED_ACCESS.getMessage(),
LocalDateTime.now()
);
String json = new ObjectMapper()
.registerModule(new JavaTimeModule()) // LocalDateTime 직렬화를 위한 모듈 등록
.disable(WRITE_DATES_AS_TIMESTAMPS) // timestamp -> 문자열로 처리
.writeValueAsString(errorDto); // 객체 -> JSON 문자열로 변경
response.getWriter().write(json); // 클라이언트에 JSON 응답을 전송
return;
}
}
filterChain.doFilter(request, response);
}
private boolean loginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(AuthConst.WHITE_LIST, requestURI);
}
}
인증관련 코드들을 모아놓은 auth 패키지 하위에 filter 패키지를 만들어서 로그인을 체크하는 필터인 LoginCheckFilter를 생성하고 OncePerRequestFilter를 상속받아 Servlet필터를 사용하기 위한 메서드를 구현해주었다.
보통 과거에는 필터를 구현할 때 Filter 인터페이스를 구현했는데 이 Filter 인터페이스는 중복 호출이 발생할 수 있는데 대표적인 예시로 forwad 방식으로 api가 요청이 들어오게 되면 응답할 때 필터를 한번 더 호출하게 되는 현상이 있다.
이러한 구조를 개선하여 한번만 호출되도록 보장해주는 추상 클래스가 OncePerRequestFilter이다
보통 JWT 토큰을 생성하는 유틸 클래스를 만들 때 해당 클래스를 상속받아 메서드를 구현한다.
** OncePerRequestFilter 설명 출처: https://beaniejoy.tistory.com/96
OncePerRequestFilter를 상속 받으면 doFilterInternal()메서드를 구현하는데 여기서 필터에서 적용하고자할 로직을 작성해주면 된다.
여기에서는 request.getRequestURI()를 통해 요청받은 URI를 가져와서, 필터를 통과하면 다음 필터인 doFilter를 호출하도록 로직이 작성되어있다.
조건문은 필터를 통과하지 못했을 때의 로직을 적어둔 것인데, loginCheckPath() 메서드는 미리 지정해둔 화이트 리스트(로그인 없이도 동작가능한 URI 경로)가 아니면 true를 반환하여 Filter를 통과하지 못하도록 메서드화 해두었다.
이어서 필터의 로직을 설명해보면, Filter를 통과하지 못했으므로 request.getSession(false)로 세션이 없으면 null을 반환하도록하는 세션을 생성하고, 해당 세션이 null이거나 로그인 정보가 없으면 인증실패 처리를 하여 인증이 실패되어 401 상태코드와 미리 지정해둔 예외를 생성하여 응답해준다.
여기서 발생된 예외를 직렬화를 위해서 ObjectMapper()를 사용하였으며 오류 정보를 담은 ErrorDto에서 LocalDateTime.now()를 활용하여 오류가 발생한 시간 정보를 담고 있는데, 이를 직렬화 하기 위해선 JavaTimeModule 이라는 것이 필요하여 .registerModule(new JavaTimeModule())을 통해 모듈을 등록해주고 .disable(WRITE_DATES_AS_TIMESTAMPS)를 통해 timestamp를 문자열로 처리되도록 해주었다.
이후 직렬화된 json을 response.getWriter().write()로 응답해주도록 로그인 검증 필터를 완성해두었다.
AuthConst
package spring.advanced.schedulerjpa.common.constant;
public abstract class AuthConst {
public static final String LOGIN_MEMBER = "loginMember";
public static final String[] WHITE_LIST = {"/api/users/signup", "/api/login", "/api/logout"};
}
여기서 사용할 상수는 별도로 상수만 모아두는 common.constant 패키지에 두었으며, 애플리케이션이 확장되어 다른 용도의 상수가 생길 수 있으므로 공통 패키지인 common의 하위로 두었다.
현재는 로그인 인증 없이 통과해야할 URL인 WHITE_LIST와 필터를 통과하면 로그인 유저라고 세션에 등록할 상수가 등록되어있다.
WHITE_LIST에는 회원가입, 로그인, 로그아웃 API가 등록되어있다.
WebConfig
package spring.advanced.schedulerjpa.common.config;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<Filter> loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/api/*");
return filterRegistrationBean;
}
}
다양한 웹 관련 설정을 등록하기 위한 클래스로 로그인인증 필터를 스프링 프레임워크에서 동작하도록 하려면 스프링 빈으로 등록해야 하는데 이를 수동등록 하기 위한 설정 클래스이다.(물론 자동 등록도 가능하다)
스프링 빈으로 수동으로 등록하고 싱글톤으로 관리되도록 하기 위해 @Configuration, @Bean을 활용했다.
이런 설정 클래스는 용도마다 모아두어 관리하면 유지보수에 좋기 때문에 common 패키지 하위에 config 패키지를 만들어서 관리할 수 있도록 작성해두었으며 용도마다 적용해야할 설정이 많아진다면 config 패키지 안에서 분리를 할 수 있도록 구조를 만들었다.
new FilterRegistrationBean()을 사용하여 필터를 등록할 수 있으며 setFilter()로 등록할 필터를 생성해주고, serOrder()로 등록한 필터가 동작할 순서를 지정할 수 있으며 순서가 낮을 수록 먼저 동작한다.
이후 필터를 적용할 URL 패턴을 정곡해주면 되는데 해당 애플리케이션은 api 통신을 하므로 /api/* 하위의 모든 패턴에 필터가 적용되도록 지정해주어 로그인 구현을 위한 준비가 완료 되었다.
AuthController
package spring.advanced.schedulerjpa.auth.controller;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {
private final LoginService loginService;
@PostMapping("/login")
public ResponseEntity<AuthLoginResponseDto> login(@RequestBody AuthLoginRequestDto requestDto, HttpServletRequest servletRequest) {
AuthLoginResponseDto loginUserDto = loginService.login(requestDto.username(), requestDto.password());
HttpSession session = servletRequest.getSession();
session.setAttribute(AuthConst.LOGIN_MEMBER, loginUserDto);
return new ResponseEntity<>(loginUserDto, HttpStatus.OK);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest servletRequest) {
HttpSession session = servletRequest.getSession(false);
if (session != null) {
session.invalidate();
}
return new ResponseEntity<>(HttpStatus.OK);
}
}
인증 요청을 처리하고 응답하는 AuthController으로 로그인을 처리하는 login()과 로그아웃을 처리하는 logout()이 등록되어있다.
로그인 아이디와, 비밀번호 정보가 담긴 로그인 요청을 보내면 실제 로그인을 처리하는 서비스 계층으로 정보들을 보내고 로그인이 정성공하면 세션을 가져와서 상수인 AuthConst.LOGIN_MEMBER를 key값으로 로그인 정보를 저장하고 클라이언트에 200 상태코드와 함께 정보를 전송한다.
로그아웃은 별도의 정보없이 해당 api를 호출하면 getSession(false)로 세션을 가져오고 세션에 정보가 있다면 invalidate()로 세션을 제거하여 200 상태코드를 응답해주도록 작성했다.
LoginService
package spring.advanced.schedulerjpa.auth.service;
@Service
@RequiredArgsConstructor
public class LoginService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthLoginResponseDto login(String username, String password) {
User findUser = userRepository.findByUsername(username)
.orElseThrow(() -> new AuthFailedException(ErrorCode.LOGIN_FAILED.getMessage())
);
if (!passwordEncoder.matches(password, findUser.getPassword())) {
throw new AuthFailedException(ErrorCode.LOGIN_FAILED_PASSWORD.getMessage());
}
return new AuthLoginResponseDto(findUser.getId(), findUser.getUsername());
}
}
컨트롤러에 로그인 요청이 들어오면 실제 로그인을 처리하는 로직으로 userRepository 에서 findByUsername() 메서드를 통해 요청값으로 들어온 username을 검색하여 조회한다.
findByUsername의 반환값을 Optional로 작성했기 때문에 만약 찾지 못하면 잘못된 요청이므로 인증 실패 예외를 터트리도록 orElseThrow()를 사용해주었다.
조회된 유저에 담겨있는 암호화된 비밀번호와 요청값으로 넘어온 비밀번호를 해시값으로 비교하여 다르다면 로그인인증 실패 예외를 터트리고 문제없이 통과되면 로그인 성공 DTO에 필요한 정보를 담아서 반환해주도록 로그인 관련 로직을 완성해주었다.
여기서 비밀번호 비교를 위한 메서드가 사용되었는데, 회원가입시에 비밀번호 암호화를 위하여 at.favre.lib:bcrypt 라이브러리를 사용하여 비밀번호를 암호화 했기 때문에 검증이 필요하기 때문이다.
암호화 라이브러리 적용
암호화 라이브러리 적용
기존의 JDBC를 활용한 일정관리 프로그램에서는 암호화 로직을 사용하기 위해서 강제로 스프링 시큐리티가 제공하는 Password Encoder를 사용하기 위해 스프링 시큐리티를 사용했다
그러나 여기선 서블릿 필터로 로그인, 로그아웃을 구현하기 때문에 스프링 시큐리티를 한번더 붙이는 것 보단 단순히 암호화 하는 라이브러리만 필요하기 때문에 BCrypt 알고리즘을 구현한 경령화 라이브러리인 at.favre.lib:bcrypt를 사용했다.
설정, PasswordEncoder
implementation 'at.favre.lib:bcrypt:0.10.2'
package spring.advanced.schedulerjpa.common.config;
@Component
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
}
public boolean matches(String rawPassword, String encodedPassword) {
BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
}
위 처럼 gradle에 라이브러리를 등록해주고 스프링 시큐리티가 제공하는 PasswordEncoder와 동일한 기능을 하기 때문에 PasswordEncoder라는 이름으로 클래스를 만들어서 비밀번호를 암호화하는 encode()메서드와 해시값을 비교하여 검증하는 matches()메서드를 작성해주고, @Component 애노테이션을 적용하여 의존관계를 자동으로 주입받을 수 있도록 설정해두었다.
encode(String rawPassword) - 암호화
BCrypt.withDefaults()를 사용하여 BCrypt 라이브러리가 내부적으로 사용하는 기본 설정을 적용시키고 hashToString을 통해 비밀번호를 암호화하여 해시 문자열을 생성하여 반환한다.
hashToString()의 인자 값으로 BCrypt.MIN_COST, rawPassword.toCharArray()를 넘겨주는데, BCrypt가 보안상 문자 배열을 선호하기 때문에 입력되는 비밀번호를 문자 배열로 변환하여 넘여주었고, 간단한 프로젝트이므로 빠르게 해시값을 생성하기 위해 최소 보안 수준을 적용했다.
만약 실제로 사용한다면 10 ~ 12를 권장한다고하며, 실제 서비스에선 아마 대부분 스프링 시큐리티를 사용할 것이기 때문에 스프링 시큐리티에서 적용되는 기본 설정이면 보안에 충분이 문제가 없을 것이다.
matches(String RawPassword, String encodedPassword) - 검증
암호화된 비밀번호를 로그인시 검증하기 위한 matches()메서드도 생성해주었는데 BCrypt.verifyer(),verify(원본 암호, 암호화된 암호)를 적용해주면 암호화된 비밀번호를 salt값 등을 암호문에서 추출하여 검증하며 일치 여부를 판단하여 반환하는데, 결과를 Result 객체로 반환한다.
Result 객체에는 비밀번호 일치 여부, 해시 포맷 정보, 암호화 결과 바이트 배열 등 여러 정보가 담겨져있는데, verified 필드의 값에 검증 일치 여부가 boolean 값으로 저장되어 있으므로 이를 메서드의 반환값으로 적용하여 비밀번호 일치 여부를 검증하는 메서드를 작성했다.
이렇게 적용된 PasswordEncoder의 메서드를 로그인을 위한 login() 메서드에서 matches()를 사용하여 비밀번호를 검증하여 로그인 처리를 완료하고, 회원가입 메서드인 saveUser() 메서드에서는 encode()를 사용하여 비밀번호를 암호화하여 저장한다.
회원 CRUD API (회원 가입만 소개)
UserController - addUser()
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<UserCreateResponseDto> addUser(@Valid @RequestBody UserCreateRequestDto requestDto) {
String username = requestDto.username();
String email = requestDto.email();
String password = requestDto.password();
return new ResponseEntity<>(userService.saveUser(username, email, password), HttpStatus.CREATED);
}
// 사용자 전체조회, 단건 조회, 수정, 삭제 API 생략
}
회원 테이블의 정보를 CRUD하는 API는 모두 만들었지만, 검증하고 조회하고 예외를 반환하는 로직은 기존과 비슷하기 때문에 위의 비밀번호 암호화로직이 적용되고 Bean Validation이 적용된 회원가입 로직만 추가적으로 회고에 작성하고자 한다.
회원 가입에서는 클라이언트가 회원가입을 위해 작성된 정보를 바디에 담아 /api/users/signup 로 요청을 보내면 컨트롤러에서는 DTO에 담긴 값들을 파싱하여 실제 User를 저장하는 로직인 서비스 계층으로 분리하여 넘기고 유저가 성공적으로 저장하면 201 상태코드와 함께 응답 전용 DTO를 ResponseEntity에 담아 응답을 전송한다.
원래 평소의 코딩 스타일은 DTO를 컨트롤러에서 파싱하지 않고 바로 바로 서비스 계층으로 넘기고 컨트롤러에서는 웹뷰와 관련된 역할이나 API 응답과 관련된 역할만 하는 것을 선호하는데, JDBC를 사용하는 버전에서 DTO를 서비스로 넘기는 방식을 사용하였으므로 여기에서는 해당 방식을 적용해보았다.
UserCreateRequestDto - 레코드 적용
package spring.advanced.schedulerjpa.user.domain.dto;
public record UserCreateRequestDto(
@NotNull
@Pattern(regexp = "^[a-zA-Z0-9]+$",
message = "{validation.username}"
)
String username,
@Email
@Pattern(
regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$",
message = "{validation.email}"
)
String email,
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[~!@#$%^*_+|<>?:{}])[A-Za-z\\d~!@#$%^*_+|<>?:{}]{4,}$",
message = "{validation.password}")
String password
) {
}
해당 프로젝트에서 수많은 요청값을 전송해주는 DTO중 회원가입 데이터를 담고 있는 회원가입 전용 요청 DTO 객체이다.
이전 JDBC를 사용하는 프로젝트에서는 class로 만들어서 강제로 불변으로 만들기 위해 필드들에 private final을 적용하고 @Getter, @AllArgsConstructor를 사용하여 필드들을 조회하고 초기화 해주었는데, 이번 프로젝트에는 DTO를 모두 매우 편리하게 불변객체를 만들 수 있는 record를 사용했다.
record는 Java14에 도입되었고 16부터 정식 기능으로 채택 되었는데 확실히 사용해보면 관습적으로 불변객체를 만들기위해 적어주었던 접근제어자와 롬복 애노테이션들을 생략할 수 있어 매우 깔끔하게 DTO객체들을 생성할 수 있게 해준다.
해당 DTO에 Bean Validation을 적용하여 각 필드마다 검증을 해주었는데, @NotNull, @Email 등 Bean Validation이 제공하는 기본 기능 외에 @Pattern()으로 정규식을 적용하여 원하는값만 입력될 수 있도록 적용해주었다.
사용자 아이디인 username에서는 영어 대소문자와 숫자만 적용할 수 있도록 적용했다
email은 기본적인 이메일 패턴은 유지하면서 이메일 Id에는 영어 대소문자와 숫자, 그리고 @ 뒤에 도메인에는 영어 대소문자와 숫자, 그리고 .com 처럼 가장 마지막에는 영어만 올 수 있도록 적용했다.
@Email만 사용해도 기본적인 이메일 패턴은 검증이 가능하지만 ㅁㄴㅇㄹ@ㅁㄴㅇㄹ.ㅇㅁㅇㄴ 처럼 한글 이메일도 가입이 되기 때문에 이를 막고자 영어만 입력되도록 @Pattern을 통해 검증을 강화 시켰다
비밀번호는 영어 대소문자와 숫자, 특수문자를 적용될 수 있도록 하였고 추가적으로 영어+숫자+특수문자 조합이 꼭 이루어지고 4글자 이상 입력되야하도록 강제 해주었다.
정규식은 직접 작성하기위해서 깊게 공부하기보다는 적용하고자할 정규식을 AI툴을 통해 요청하거나 검색하면 이미 대부분의 원하는 정규식은 만들어져있으므로 이를 가져다 쓰는것이 빠르다.
적당히 정규식이 어떻게 돌아가는지 파악할 수 있는 정도면 충분하다고 생각한다.
UserServiceImpl - saveUser()
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public UserCreateResponseDto saveUser(String username, String email, String password) {
if (userRepository.existsByUsername(username)) {
throw new DuplicatedUsernameException(ErrorCode.DUPLICATED_USERNAME.getMessage());
}
User user = User.builder()
.username(username)
.email(email)
.password(passwordEncoder.encode(password))
.build();
try {
User savedUser = userRepository.save(user);
return new UserCreateResponseDto(savedUser.getId(), savedUser.getUsername());
} catch (DataIntegrityViolationException e) {
log.error("[강제 저장 시도 발생]");
throw new DuplicatedUserException(ErrorCode.DUPLICATED_USER.getMessage());
}
}
// 유저 전체조회, 단건 조회, 수정, 삭제 로직 생략
}
기본적으로 JPA를 사용하므로 영속성 관리를 위해 Service계층 전체를 @Transactional(readOnly = true)로 적용시켜 영속성은 관리하면서 성능을 최적화 시키도록 클래스 레벨에 적용 시켰다.
이후 회원 가입, 수정, 삭제 처럼 쓰기가 필요한 트랜잭션에는 @Transactional을 따로 적용시켜서 쓰기전용 트랜잭션으로 시작하도록 해주었다.
컨트롤러에서 요청값을 파싱하여 넘겨받은 username 정보를 userRepository.existsByUsername()을 통해 DB에 이미 존재하는 회원인지 확인하고 존재한다면 중복 회원이 있으므로 예외를 발생시킨다.
검증이 통과하면 User을 생성해주는데 이때 위에서 적용했던 암호화 클래스를 의존관계 주입하고 encode() 메서드를 통해 입력받은 비밀번호를 암호화하여 생성한다.
이후 userRepository.save()메서드를 통해 생성한 유저를 저장하고 성공하면 응답 전용 DTO 필요 정보만 담아서 반환시킨다.
만약 검증로직을 무시하고 저장을 시도하여 DB의 NotNul이나 Unique 제약조건에의해 예외가 발생하게 되면 중복 유저가 이미 있으므로 회원등록이 실패했다는 예외를 발생시키고 회원 등록은 트랜잭션 롤백한다.
UserRepository의 로직은 스프링 데이터 JPA의 메서드 이름으로 쿼리를 생성하는 기능을 사용하여 필요한 메서드들을 생성하는 로직이 적용되었으므로 생략하겠다.
일정 전체조회 - 페이징과 연관관계 테이블 조회
일정 전체조회 요구사항
유저 CRUD와 마찬가지로 일반적인 일정 CRUD와 댓글 CRUD는 일반적인 검증과 추가적인 기능을 적용하지 않았으므로 생략하고 이번 JPA를 적용하면서 연관관계가 적용된 테이블을 조회할 때 페이징을 적용하는 것을 어떻게하면 좋을지 연습해보는 것이 목적이므로 일정 전체 조회 API만 회고에 기록하고자 한다.
일정 전체 조회에는 일정 Entity의 모든 필드정보와 더불어서 해당 일정에 달려있는 댓글의 개수정보를 포함해서 출력해야 한다.
즉, 일정을 전체적으로 표시해주는데 화면에 해당일정에 댓글이 몇개 있다고 사용자에게 알려주기 위함이다.
이를 위해서는 서로 다른 Entity를 조회해야 하기 때문에 테이블을 조회하는 네이티브 SQL을 사용해야 하는데, 우리는 JPA 연관관계 매핑을 맺었기 때문에 JPA가 제공하는 JPQL이라는 Entity를 직접 조회하고 탐색할 수 있는 쿼리를 사용하였으며 이를 사용하기 위해 @Query애노테이션을 사용했다.
더 복잡한 쿼리나 타입 오류 방지 등을 위해선 Querydsl이라는 별도의 라이브러리를 적용해주는 것이 좋으나 실전이 아니라 단순히 연습이고 엄청 복잡한 쿼리가 필요하진 않기 때문에 여기서는 @Query를 사용하여 JPQL을 작성했다.
ScheduleController - findAllSchedules()
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@GetMapping
public ResponseEntity<Page<ScheduleFindAllPagingResponseDto>> findAllSchedules(
@PageableDefault(size = 10, sort = "modifyAt", direction = Sort.Direction.DESC) Pageable pageable) {
return new ResponseEntity<>(scheduleService.findAllSchedules(pageable), HttpStatus.OK);
}
// 기타 일정 저장, 단건 조회, 수정, 삭제 메서드 생략
}
일정 전체조회 API에서는 Pageable 인터페이스를 매개변수로 하여 page, size 정보를 클라이언트에서 요청 파라미터로 보내주면 해당 페이징 정보를 사용할 수 있도록 했다.
여기서 기본값을 적용해주기 위해 @PageableDefault 애노테이션을 사용했는데, size = 10을 적용하면 1페이지당 10개씩 데이터가 조회되고 sort = "modifyAt", direction = Sort.Direction.DESC로 수정일을 내림차순 적용하도록 정렬 정보를 기본으로 설정해주었다.
여기서 size = 10은 생략이 가능한데, 기본값이 10으로 설정 되어있다.
이처럼 Pageable은 페이징 정보 뿐 아니라 정렬 정보도 설정할 수 있는데 반환타입인 Page 인터페이스에 이런 정보를 모두 담아서 응답할 수 있다.
ScheduleFindAllPagingResponseDto - 레코드
package spring.advanced.schedulerjpa.schedule.domain.dto;
public record ScheduleFindAllPagingResponseDto(
Long id,
String username,
String title,
String content,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime createAt,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime modifyAt,
Long commentCount
) {
}
일정 전체 조회 응답 전용 DTO이며 역시 레코드로 작성해주어 매우 가독성 좋게 불변 객체를 생성했다.
여기서 생성일과 수정일을 직렬화하여 원하는 패턴을 적용해주기 위해 @JsonFormat을 적용해주었고으며 각 일정의 댓글 개수 정보인 commentCount를 Long타입으로 가지고 있다.
여기서 Long 타입으로 해당 필드를 작성한 이유는 이후 ScheduleRepository에서 JPQL을 통해 직접 Comment 객체에서 count()를 통해 조회하는데 하이버네이트가 6버전으로 올라가면서 더욱 엄격하게 검증하게 되면서 무조건 Long 타입만 반환하도록 하기 때문이다.
여기서 Long타입으로 선언하면 @Query에서 count() 쿼리부분에서 IDE에서 컴파일 오류처럼 보여지는데 실제로는 잘 동작하므로 무시해줘도 된다.
ScheduleServiceImpl - findAllSchedules()
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ScheduleServiceImpl implements ScheduleService {
private final ScheduleRepository scheduleRepository;
private final UserRepository userRepository;
@Override
public Page<ScheduleFindAllPagingResponseDto> findAllSchedules(Pageable pageable) {
Page<ScheduleFindAllPagingResponseDto> findAllWithCommentCount = scheduleRepository.findAllWithCommentCount(pageable);
if (findAllWithCommentCount.isEmpty()) {
throw new NotFoundScheduleException(ErrorCode.SCHEDULE_NOT_FOUND.getMessage());
}
return findAllWithCommentCount;
}
// 일정 생성, 단건 조회, 수정, 삭제 메서드들 생략
}
일정 에서도 Service 계층에서는 읽기 전용 Transactional을 클래스 레벨에 적용하여 모두 JPA 영속성 컨텍스트에서 관리되도록 적용해주었으며 조회 메서드인 findAllSchedules()는 쓰기 전용이 트랜잭션이 필요 없으므로 @Transactional을 별도로 달아주지 않았다.
여기서 scheduleRepository.findAllWithCommentCount(pageable)을 통해 조회된 정보를 Page<ScheduleFindAllPagingResonseDto>에 담아서 반환하고, 만약 조회된 일정이 없으면 예외를 발생시키도록 방어 코드를 작성했다.
ScheduleRepository
package spring.advanced.schedulerjpa.schedule.repository;
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
@Query(value = """
select new spring.advanced.schedulerjpa.schedule.domain.dto.ScheduleFindAllPagingResponseDto(
s.id,
u.username,
s.title,
s.content,
s.createAt,
s.modifyAt,
(select count(c) from Comment c where c.schedule.id = s.id)
)
from Schedule s
join s.user u
""",
countQuery = "select count(s) from Schedule s")
Page<ScheduleFindAllPagingResponseDto> findAllWithCommentCount(Pageable pageable);
}
일정을 조회할 때 각 일정마다 달린 댓글의 갯수를 함께 조회하여 반환하는 쿼리를 @Query 애노테이션과 JPQL을 통해 직접 작성했다.
댓글의 갯수는 서브 쿼리를 통하여 Comment가 연관관계로 맺고 있는 Schedule의 id값이 실제 조회하고자할 Schedule의 id와 같다면 그 갯수를 세도록 작성했고, 일정의 작성자 정보는 Schedule에서 연관관계를 맺고 있는 User를 join하여 해당 User의 username을 가져오도록 작성해두었다.
Pageable을 사용하면 페이지 정보를 위해 자동으로 countQuery를 날리는데, 작성한 코드가 복잡하다면 직접 countQuery 옵션으로 조쿼리를 날려주는게 오히려 성능이 좋을 수 있다.
물론 지금의 코드에서는 JPA가 직접 작성해주는 쿼리도 최적화가 잘 적용될 수 있지만 join이 여러번 되거나 group by가 적용이 된다면 지금처럼 간단한 countQuery 옵션을 적어주는 것만으로도 성능을 최적화할 수 있다
회고 정리
지금처럼 작성한 기능 말고도 회고에서 넘긴 일정 생성 API에서 사용된 세션값에서 유저정보를 활용한 부분과 여러 기본적인 CRUD 부분은너무 회고가 길어져서 생략했다.
이번 프로젝트를 하면서 JPA의 변경감지를 활용한다든가 패키지 구조를 나누는 부분은 이미 알고있거나 이전에 JDBC 프로젝트에서 연습해본 부분이기 때문에 이번에 새롭게 시도해보고 조금 고민하면서 코드를 작성한 부분들 위주로 회고를 작성했다.
특히 서블릿 필터로 인증관련 API를 구현하는 연습은 김영한님 강의를 다시한번 복습하는 계기가 되었고 앞으로 더 자주 사용할 스프링 시큐리티도 서블릿 필터로 만들어 졌기 때문에 오히려 근본 기술을 연습하는 계기가 된 것 같다.
내가 만든 코드를 검증하기 위해 단위 테스트나 통합 테스트 코드를 작성하는 부분도 같이 연습하면 좋을 것 같다는 생각이 들어 앞으로 팀프로젝트에서는 테스트 검증도 함께 추가해보는 연습을 할 예정이다.