일반 로그인/회원가입 기능 구현 회고입니다.
<회고하며>
Rq와 RsData 에 대해 정리할 수 있었고,
DTO 객체 를 사용하여 데이터 흐름을 구조화하여 처리하는 과정을 회고하며 사용 이유에 대해 체감할 수 있었습니다.
<참고>
memberDTO 는 로그인/회원가입 로직과 관련이 없고, 나중에 회원 목록을 보여야할 일이 많아서 DB에서 가져온 엔티티 객체(Member)의 일부 정보만 사용하기 위해 미리 작성하였는데 회원 목록 조회 시 수정이 필요할 것 같습니다.
코드 내부에서 설명을 위한 주석처리 참고해주세요
***별표 주석은 로그인/회원가입 시 사용한 코드 설명
// 그냥 주석은 로그인/회원가입 로직과 관련 없고, 나중을 위해 미리 작성한 코드 설명
MemberController
• 로그인/회원가입 관련 요청을 처리하고, JoinFormDto 객체를 통해 회원 정보를 받아와 가입을 시도합니다.
@PreAuthorize("isAnonymous()")
@PostMapping("/join")
public String join(@Valid JoinFormDto joinFormDto) {
RsData<Member> joinRs = memberService.join(joinFormDto.getUsername(), joinFormDto.getPassword(), joinFormDto.getEmail(), "일반회원");
if (joinRs.isFail()) {
return rq.historyBack(joinRs);
}
// 아래 링크로 리다이렉트(302, 이동) 하고 그 페이지에서 메세지 보여줌
return rq.redirectWithMsg("/members/login", joinRs);
}
JoinFormDto
• 회원가입 폼에서 사용되는 데이터 전송 객체(DTO)로, username, password, email 필드가 있습니다. 클라이언트에서 서버로 데이터를 전송할 때, JoinFormDto 객체에 사용자 입력 데이터를 담아서 POST 요청으로 서버에 전송하여, 데이터 흐름을 구조화하여 쉽게 처리할 수 있습니다.
• @NotBlank 및 @Size와 같은 유효성 검사 어노테이션을 사용하여 입력된 데이터의 유효성을 검사합니다.
@AllArgsConstructor
@Getter
public class JoinFormDto {
@NotBlank
@Size(min = 4, max = 30)
private final String username;
@NotBlank
@Size(min = 4, max = 30)
private final String password;
@NotBlank
private String email;
}
MemberDto
• 회원 정보를 전달하는 데이터 전송 객체(DTO) 클래스로, 회원의 ID, 이름, 생성일 및 수정일을 포함합니다. -> 현재 회원가입/로그인 로직과는 별개
Member
• 실제 회원 정보를 저장하는 엔티티 클래스로, 사용자의 아이디, 비밀번호, 이메일, 역할(role) 등의 정보를 저장합니다.
• 권한 정보를 가져오는 메서드와, 관리자 여부를 확인하는 메서드를 추가로 작성하였습니다. → 관리자 회원가입/로그인 구현 시 사용할 예정
MemberRepository
• 사용자 이름(username) 및 이메일(email)로 회원을 검색할 수 있는 메서드가 있습니다.
public interface MemberRepository extends JpaRepository<Member, Long>{
Optional<Member> findByUsername(String username);
Optional<Member> findByEmail(String email);
}
→ 회원가입 시, 중복된 사용자인지 체크할 때 사용됩니다.
MemberService
• 비즈니스 로직으로, join() 메서드를 통해 회원 가입을 처리하고, 중복된 아이디나 이메일을 체크합니다.
• public Optional<Member> findByUsername(String username)
: 주어진 username으로 회원을 조회하는 메서드로, 사용자명이 일치하는 회원을 Optional로 반환합니다.
• join 메서드를 호출하면 아래에 정의된 join 메서드를 호출하고, 회원 가입 결과에 따른 RsData 객체를 반환합니다.
public RsData<Member> join(String providerTypeCode, String username, String password, String email, String role) {
*** 실제 회원 가입을 처리하는 메서드입니다. 회원 가입에 필요한 정보(아이디, 비밀번호, 이메일, 역할 등)를 받아와서 다음 작업을 수행합니다.**
*** 주어진 사용자명(username)이 이미 데이터베이스에 존재하는지 확인하고, 이미 존재하면 중복 오류를 나타내는 RsData 객체를 반환합니다.**
if (findByUsername(username).isPresent()) {
return RsData.of("F-1", "해당 아이디(%s)는 이미 사용중입니다.".formatted(username));
}
*** 비밀번호를 암호화합니다.**
if (StringUtils.hasText(password)) {
password = passwordEncoder.encode(password);
}
*** 주어진 이메일(email)이 이미 데이터베이스에 존재하는지 확인하고, 이미 존재하면 중복 오류를 나타내는 RsData 객체를 반환합니다.**
if (findByEmail(email).isPresent()) {
return RsData.of("F-2", "해당 이메일(%s)은 이미 사용중입니다.".formatted(email));
}
*** member 객체를 생성하고 DB에 저장**
Member member = Member
.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.build();
memberRepository.save(member);
*** 회원 가입이 성공했음을 나타내는 RsData 객체를 반환합니다.**
return RsData.of("S-1", "회원가입이 완료되었습니다.", member);
}
• RsData 객체는 회원 가입 결과와 관련된 정보를 포함하는 데이터 전송 객체로, 아래에서 더 자세히 설명할 것입니다. 간단히 요약해서, RsData 클래스는 결과 코드(resultCode), 메시지(msg), 그리고 회원 객체(data)를 포함하고, 결과를 나타내는 여러 메서드(isSuccess(), isFail())도 제공합니다.
Rq (Request 객체)
• 사용자 인증(현재 로그인한 사용자 정보 및 권한), 로그인 상태, 메시지 처리(메세지 출력 및 페이지 리디렉션하는 메서드), 세션 관리 등을 확인할 수 있는 메서드를 제공하여 요청 관련해서 작업을 쉽게 처리할 수 있도록 도와주는 클래스입니다.
@Component
@RequestScope
public class Rq {
*** 회원 관련 비즈니스 로직을 처리하기 위한 서비스 객체**
private final MemberService memberService;
// **다국어 지원 및 메시지를 가져오는 데 사용되는 객체**
private final MessageSource messageSource;
// **언어 및 지역 설정을 가져오는 데 사용되는 객체**
private final LocaleResolver localeResolver;
*** 로케일 정보를 저장하는 변수**
private Locale locale;
*** 현재 HTTP 요청과 관련된 정보를 제공하는 객체**
private final HttpServletRequest req;
*** HTTP 응답과 관련된 정보를 제공하는 객체**
private final HttpServletResponse resp;
// **세션 객체**
private final HttpSession session;
// **현재 로그인한 사용자 정보를 저장하는 변수**
private final User user;
*** 회원 정보를 저장하기 위한 변수로, 레이지 로딩을 사용하여 처음부터 넣지 않고, 요청이 들어올 때 넣음**
private Member member = null;
public Rq(MemberService memberService, MessageSource messageSource, LocaleResolver localeResolver, HttpServletRequest req, HttpServletResponse resp, HttpSession session) {
this.memberService = memberService;
this.messageSource = messageSource;
this.localeResolver = localeResolver;
this.req = req;
this.resp = resp;
this.session = session;
*** 현재 로그인한 회원의 인증정보를 가져옴**
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getPrincipal() instanceof User) {
this.user = (User) authentication.getPrincipal();
} else {
this.user = null;
}
}
public boolean isAdmin() {
if (isLogout()) return false;
return getMember().isAdmin();
}
*** HTTP 요청의 Referer 헤더를 확인하여 관리자 페이지로부터의 요청인지 여부를 확인**
public boolean isRefererAdminPage() {
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if (savedRequest == null) return false;
String referer = savedRequest.getRedirectUrl();
return referer != null && referer.contains("/adm");
}
*** 로그인 되어 있는지 체크**
public boolean isLogin() {
return user != null;
}
*** 로그아웃 되어 있는지 체크**
public boolean isLogout() {
return !isLogin();
}
*** 로그인 된 회원의 객체**
public Member getMember() {
if (isLogout()) return null;
// 데이터가 없는지 체크
if (member == null) {
member = memberService.findByUsername(user.getUsername())
.orElseThrow(() -> {
// 예외 처리 람다 내부에서 예외를 생성하여 던짐
return new UserNotFoundException("User not found");
});
}
return member;
}
*** 뒤로가기 + 메세지
: 뒤로 가기 버튼을 클릭하면 이전 페이지로 이동하면서 메시지를 전달하는 기능을 수행**
public String historyBack(String msg) {
String referer = req.getHeader("referer");
String key = "historyBackErrorMsg___" + referer;
req.setAttribute("localStorageKeyAboutHistoryBackErrorMsg", key);
req.setAttribute("historyBackErrorMsg", msg);
// 200 이 아니라 400 으로 응답코드가 지정되도록
resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return "common/js";
}
*** 뒤로가기 + 메세지
: RsData 객체에 포함된 메시지를 사용하여 뒤로 가기를 수행하는 메서드**
public String historyBack(RsData rsData) {
return historyBack(rsData.getMsg());
}
*** 302 + 메세지**
public String redirectWithMsg(String url, RsData rsData) {
return redirectWithMsg(url, rsData.getMsg());
}
*** 302 + 메세지**
public String redirectWithMsg(String url, String msg) {
return "redirect:" + urlWithMsg(url, msg);
}
private String urlWithMsg(String url, String msg) {
// 기존 URL에 혹시 msg 파라미터가 있다면 그것을 지우고 새로 넣는다.
return Ut.url.modifyQueryParam(url, "msg", msgWithTtl(msg));
}
*** 메세지에 ttl 적용**
private String msgWithTtl(String msg) {
return Ut.url.encode(msg) + ";ttl=" + new Date().getTime();
}
// 세션 속성을 설정
public void setSessionAttr(String name, String value) {
session.setAttribute(name, value);
}
// 세션에서 속성 값을 가져옴
public <T> T getSessionAttr(String name, T defaultValue) {
try {
return (T) session.getAttribute(name);
} catch (Exception ignored) {
}
return defaultValue;
}
// 세션에서 속성을 제거
public void removeSessionAttr(String name) {
session.removeAttribute(name);
}
// 다국어 메시지를 가져오는 메서드
public String getCText(String code, String... args) {
return messageSource.getMessage(code, args, getLocale());
}
*** 현재 요청의 로케일을 가져옴**
private Locale getLocale() {
if (locale == null) locale = localeResolver.resolveLocale(req);
return locale;
}
// **현재 요청의 파라미터를 JSON 형식의 문자열로 반환**
public String getParamsJsonStr() {
Map<String, String[]> parameterMap = req.getParameterMap();
return Ut.json.toStr(parameterMap);
}
}
• 참고로, Ut 클래스는 Java 객체를 JSON 형식의 문자열로 변환할 수 있고, URL 문자열을 수정하고 쿼리 매개변수를 추가, 수정 또는 삭제하는 데 사용되었습니다. 직접적으로 회원가입/로그인 로직과는 별개의 유틸리티 메서드여서 자세한 설명은 생략합니다.
RsData
• 응답 데이터를 담는 클래스로, 다양한 HTTP 응답에 대한 정보를 포함하는 데 사용됩니다.
• 결과 코드(resultCode), 메세지(msg), 데이터(data)를 포함하고, 성공 여부를 확인하는 메서드를 통해 상태를 나타내며, 필요한 데이터와 메시지를 함께 제공합니다. 따라서, 클라이언트와 서버 간의 통신을 효율적으로 관리하고 오류 처리를 간편하게 할 수 있습니다.
@Getter
@Setter
@AllArgsConstructor
public class RsData<T> {
*** 응답 결과 코드를 나타내는 문자열
: 성공(S-1) 또는 실패(F-1)와 같은 상태를 나타냄**
private String resultCode;
*** 응답 메시지를 나타내는 문자열
: 주로 요청 처리 결과 또는 오류 메시지를 설명하는 데 사용**
private String msg;
*** 응답 데이터를 포함하는 제네릭 타입의 필드
: 주로 응답 결과 데이터가 저장**
private T data;
*** RsData 객체를 생성하고 결과 코드, 메시지 및 데이터를 지정**
public static <T> RsData<T> of(String resultCode, String msg, T data) {
return new RsData<>(resultCode, msg, data);
}
*** 데이터를 포함하지 않는 RsData 객체를 생성하고 결과 코드와 메시지를 지정**
public static <T> RsData<T> of(String resultCode, String msg) {
return of(resultCode, msg, null);
}
*** 성공 상태를 나타내는 RsData 객체를 생성하고 성공 메시지와 데이터를 지정**
public static <T> RsData<T> successOf(T data) {
return of("S-1", "성공", data);
}
*** 실패 상태를 나타내는 RsData 객체를 생성하고 실패 메시지와 데이터를 지정**
public static <T> RsData<T> failOf(T data) {
return of("F-1", "실패", data);
}
***결과 코드가 "S-"로 시작하는 경우를 성공으로 간주**
public boolean isSuccess() {
return resultCode.startsWith("S-");
}
*** 결과 코드가 "S-"로 시작하지 않는 경우를 실패로 간주**
public boolean isFail() {
return !isSuccess();
}
}
Rq와 RsData - 예외처리 및 데이터 전달
즉, Rq와 RsData 클래스를 사용함으로써 에러 처리 및 예외 처리를 일관된 방식으로 수행할 수 있었습니다.
• Rq 클래스를 사용하여 현재 로그인한 사용자의 정보를 쉽게 가져오는데 사용하여 로그인 여부, 사용자 권한, 세션 정보 등을 처리하기 용이했습니다. 또한 URL 관련 작업 및 페이지 리다이렉션을 단순화하여, 메시지와 함께 리디렉트할 때 사용되었습니다.
• RsData 클래스 사용하여 일관된 형식의 응답 데이터를 생성하여 클라이언트에서 처리하기 쉬운 형태의 응답을 보냈고, 예외 또는 오류 메시지를 제공하기 용이했습니다. 또한, 성공한 작업의 결과나 요청에 따른 데이터를 포함하여 응답을 생성해서 데이터를 반환하여 전달하는 데 사용되었습니다.
SecurityConfig
• Spring Security를 사용하여 웹 보안을 구성하는 Java 설정 클래스로, 로그인 페이지, 로그아웃 처리 및 비밀번호 인코딩과 같은 보안 설정을 포함합니다.
• 즉, SecurityConfig 클래스는 Spring Security를 통해 웹 애플리케이션의 인증과 권한 부여를 구성하여, 사용자가 로그인하고 로그아웃할 때의 동작 및 비밀번호 인코딩을 관리합니다. 이러한 구성을 통해 웹 애플리케이션의 보안을 강화할 수 있습니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(
formLogin -> formLogin
.loginPage("/members/login")
.defaultSuccessUrl("/members/home")
)
.logout(
logout -> logout
.logoutUrl("/members/logout")
.logoutSuccessUrl("/")
);
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
CustomUserDetailsService
• Spring Security가 로그인 인증 요청을 처리할 때 사용자 정보를 로드하는 데 사용됩니다. Spring Security는 이 정보를 활용하여 사용자가 제공한 인증 정보(사용자명과 비밀번호)를 검증하고 인증을 수행합니다.
• loadUserByUsername() 메서드를 오버라이드하여 사용자 정보를 DB에서 검색하고 사용자 이름(username)을 통해 회원 정보를 가져옵니다
@Service
@RequiredArgsConstructor
@Transactional(readOnly = false)
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("username(%s) not found".formatted(username)));
return new User(member.getUsername(), member.getPassword(), member.getGrantedAuthorities());
}
}