JWT + OAuth2.0을 활용하여 로그인, 회원가입 구현하기 (React, Spring) (프로젝트 소개 및 Member 엔티티)

Lord·2024년 7월 23일
post-thumbnail

풀스택 개발자 공부를 하고 난 이후, 첫 미니(?) 프로젝트를 완성하였다. 기본적인 회원가입과 로그인을 구현하였고 카카오로 로그인하기까지 넣어 소셜 로그인이 가능하도록 하였다.

개발 언어 및 프레임워크

  • 프론트엔드 : React
  • 백엔드 : Java Spring
  • 데이터베이스 : MySQL, Redis

구현한 기능

  1. 회원가입에는 일반 회원가입, 소셜 로그인을 통한 회원가입으로 나뉜다.
    • 일반 회원가입시 이메일, 비밀번호, 닉네임을 통해 진행한다.
    • 처음 소셜 로그인에 로그인하면 소셜 로그인 전용 회원가입 페이지로 이동하며, 닉네임을 입력하여 최종 회원가입을 완료할 수 있다.
  2. 로그인을 할 때마다 JWT Access Token, Refresh Token을 발급해주며, 토큰 여부로 로그인, 로그아웃을 판단한다. 또한, API에 요청할 때마다 Access Token을 사용하여 보안 성능을 향상한다. 이때, Refresh Token은 Redis에 저장한다.
  3. 멤버는 3가지의 유형으로 나눈다. (Guest, User, Admin)
    • Guest : OAuth 로그인을 처음 한 유저로, Guest 유저가 로그인하면 바로 OAuth 전용 회원가입 페이지로 리다이렉트한다.
    • User : 일반 유저이다.
    • Admin : 관리자 권한을 얻는다.
  4. 로그인을 하게 되면 내 정보 확인하는 버튼이 생기고, 해당 버튼 클릭을 하여 현재 로그인한 사용자의 이메일과 닉네임을 확인할 수 있다.
  5. 회원 탈퇴 버튼을 통해 탈퇴를 할 수 있다.

추가로 구현할 기능

  1. 관리자 역할 구현 : 관리자 페이지를 제작하여, 기존 유저들의 계정 잠금, 권한 등 여러 서비스를 구현한다.
  2. 글쓰기 기능 추가 : 로그인을 한 이후, 새롭게 유저가 할 수 있는 기능들을 추가한다.
  3. 닉네임, 비밀번호 변경 : 닉네임과 비밀번호를 변경하는 서비스를 추가한다.
  4. 회원가입, 탈퇴 시 이메일 전송 : 회원가입과 회원탈퇴를 할 경우 사용자에게 이메일을 보내는 과정을 추가한다.
  5. 이메일 인증 구현 : 회원가입 또는 비밀번호 변경시, 이메일 인증번호를 통해 사용자를 한번 더 확인하는 기능을 추가한다.

프로젝트 구성

백엔드와 프론트엔드 모두 구현을 해야하기에 처음에 어떻게 프로젝트를 구성할지 고민이 많았다. 처음에는 하나의 저장소에 함께 구현을 해볼까 하다가, 요즘은 백엔드와 프론트엔드 서버를 아예 분리하여 많이 개발을 한다는 글을 보고 나누어 진행을 하였다.


백엔드 구현 과정 (주요 소스코드)

멤버 엔티티 작성 (Member.java)

@Table(name = "member")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;
    private String password;

    @Column(unique = true)
    private String nickname;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Enumerated(EnumType.STRING)
    private SocialType socialType;
    private String socialId;

    public void authorizeUser() {
        this.role = Role.USER;
    }

    public void authorizeAdmin() {
        this.role = Role.ADMIN;
    }

    public void updateNickname(String nickname) {
        this.nickname = nickname;
    }

    public void encodePassword(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(password);
    }

    public boolean matchPassword(PasswordEncoder passwordEncoder, String checkPassword) {
        return passwordEncoder.matches(checkPassword, getPassword());
    }
}

Member 클래스는 데이터베이스의 member 테이블과 매핑되는 엔티티 클래스이다. 이 클래스는 회원 정보와 관련된 필드를 가지고 있으며, 각 필드는 다음과 같이 정의된다.

  1. id: 회원 고유의 식별자(ID)로, @Id@GeneratedValue 어노테이션을 통해 자동으로 생성된다.
  2. email: 회원의 이메일 주소로, @Column 어노테이션을 통해 유니크하고 널이 될 수 없도록 설정하였다.
  3. password: 회원의 비밀번호로, 암호화된 형태로 저장된다.
  4. nickname: 회원의 닉네임으로, 유니크하도록 설정하였다.
  5. role: 회원의 역할(Role)로, @Enumerated 어노테이션을 사용하여 문자열 형태로 저장된다.
  6. socialType: 회원이 가입한 소셜 타입(SocialType)으로, @Enumerated 어노테이션을 사용하여 문자열 형태로 저장된다.
  7. socialId: 소셜 로그인 시 사용하는 소셜 ID이다.
주요 메소드
  • authorizeUser: 회원의 역할을 일반 사용자(USER)로 설정한다.
  • authorizeAdmin: 회원의 역할을 관리자(ADMIN)로 설정한다.
  • updateNickname: 회원의 닉네임을 업데이트한다.
  • encodePassword: 비밀번호를 암호화하여 저장한다.
  • matchPassword: 입력된 비밀번호와 저장된 비밀번호가 일치하는지 확인한다.

이 클래스는 @Getter, @NoArgsConstructor, @AllArgsConstructor, @Builder 어노테이션을 사용하여 Lombok을 통해 자동으로 필요한 메소드와 생성자를 생성한다. 이를 통해 코드의 간결함과 유지하고, 유지 보수성을 높인다.

멤버 서비스 구현 (MemberServiceImpl.java)

@Service
@Transactional
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public void join(MemberJoinDto memberJoinDto) throws BaseException {
        Member member = memberJoinDto.toEntity();
        member.authorizeUser();
        member.encodePassword(passwordEncoder);

        if (memberRepository.findByEmail(memberJoinDto.email()).isPresent()) {
            throw new MemberException(MemberExceptionType.ALREADY_EXIST_EMAIL);
        }

        if (memberRepository.findByNickname(memberJoinDto.nickname()).isPresent()) {
            throw new MemberException(MemberExceptionType.ALREADY_EXIST_NICKNAME);
        }

        memberRepository.save(member);
    }

    @Override
    public void update(MemberUpdateDto memberUpdateDto, String email) throws BaseException {
        Member member = memberRepository.findByEmail(email).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
        if (memberRepository.findByNickname(memberUpdateDto.nickname()).isPresent()) {
            throw new MemberException(MemberExceptionType.ALREADY_EXIST_NICKNAME);
        }
        member.updateNickname(memberUpdateDto.nickname());
    }

    @Override
    public MemberDto getMyInfo() throws BaseException {
        Member member = memberRepository.findByEmail(GetLoginMember.getLoginMemberEmail()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
        return new MemberDto(member);
    }

    @Override
    public void oauthJoin(OauthJoinRequest oauthJoinRequest) throws BaseException {
        Member member = memberRepository.findByEmail(oauthJoinRequest.email()).orElseThrow(() -> new MemberException(MemberExceptionType.ALREADY_EXIST_EMAIL));
        if (memberRepository.findByNickname(oauthJoinRequest.nickname()).isPresent()) {
            throw new MemberException(MemberExceptionType.ALREADY_EXIST_NICKNAME);
        }
        member.updateNickname(oauthJoinRequest.nickname());
    }

    @Override
    public void authorizeUser(OauthJoinRequest oauthJoinRequest) throws BaseException {
        Member member = memberRepository.findByEmail(oauthJoinRequest.email()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
        member.authorizeUser();
    }

    @Override
    public void withdraw(String email) throws BaseException {
        Member member = memberRepository.findByEmail(email).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));

        memberRepository.delete(member);
    }
}

MemberServiceImpl 클래스는 MemberService 인터페이스를 구현하는 서비스 클래스이다. 이 클래스는 회원 관련 비즈니스 로직을 처리하며, @Service 어노테이션을 통해 Spring의 서비스 컴포넌트로 등록된다. 또한, @Transactional 어노테이션을 통해 트랜잭션 관리가 적용된다. 주요 메소드는 다음과 같다.

  1. 회원 가입 (join 메소드)

    • 설명: MemberJoinDto 객체를 통해 전달된 회원 정보를 기반으로 새로운 회원을 등록한다. 회원의 이메일과 닉네임이 이미 존재하는지 확인한 후, 회원 정보를 저장한다. 회원의 비밀번호는 암호화되어 저장된다.
    • 예외 처리: 이메일 또는 닉네임이 이미 존재하는 경우 MemberException 예외를 발생시킨다.
  2. 회원 정보 수정 (update 메소드)

    • 설명: 현재 로그인한 회원의 이메일을 통해 해당 회원의 정보를 조회하고, MemberUpdateDto 객체를 통해 전달된 닉네임으로 회원 정보를 수정한다. 닉네임이 이미 존재하는지 확인한 후, 수정된 닉네임을 저장한다.
    • 예외 처리: 닉네임이 이미 존재하는 경우 MemberException 예외를 발생시킨다.
  3. 회원 정보 조회 (getMyInfo 메소드)

    • 설명: 현재 로그인한 회원의 이메일을 통해 해당 회원의 정보를 조회하고, MemberDto 객체로 반환한다.
    • 예외 처리: 회원 정보가 존재하지 않는 경우 MemberException 예외를 발생시킨다.
  4. OAuth2 회원 정보 수정 (oauthJoin 메소드)

    • 설명: OAuth2를 통해 가입한 회원의 정보를 수정하고, 권한을 부여한다. 이메일과 닉네임이 이미 존재하는지 확인한 후, 수정된 정보를 저장한다.
    • 예외 처리: 이메일 또는 닉네임이 이미 존재하는 경우 MemberException 예외를 발생시킨다.
  5. 사용자 권한 부여 (authorizeUser 메소드)

    • 설명: OAuth2를 통해 가입한 회원에게 사용자 권한을 부여한다.
    • 예외 처리: 회원 정보가 존재하지 않는 경우 MemberException 예외를 발생시킨다.
  6. 회원 탈퇴 (withdraw 메소드)

    • 설명: 현재 로그인한 회원의 이메일을 통해 해당 회원의 정보를 조회하고, 회원 정보를 삭제한다.
    • 예외 처리: 회원 정보가 존재하지 않는 경우 MemberException 예외를 발생시킨다.

이와 같이, MemberServiceImpl 클래스는 회원 가입, 정보 수정, 조회, 권한 부여, 탈퇴 등의 다양한 회원 관련 기능을 제공하며, 각 기능은 명시된 예외 처리 로직을 통해 안정성을 보장한다.

멤버 컨트롤러 구현 (MemberController.java)

@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;
    private final RedisService redisService;

    @PostMapping("/join")
    @ResponseStatus(HttpStatus.OK)
    public void join(@Valid @RequestBody MemberJoinDto memberJoinDto) throws Exception {
        memberService.join(memberJoinDto);
    }

    @PutMapping("/update")
    @PreAuthorize("hasRole('USER'||'ADMIN')")
    @ResponseStatus(HttpStatus.OK)
    public void updateMemberInfo(@Valid @RequestBody MemberUpdateDto memberUpdateDto) throws Exception {
        String email = GetLoginMember.getLoginMemberEmail();
        memberService.update(memberUpdateDto, email);
    }

    @PutMapping("/oauth2/update")
    @ResponseStatus(HttpStatus.OK)
    public ResponseEntity<Void> oauthJoin(@Valid @RequestBody OauthJoinRequest oauthJoinRequest) throws Exception {
        memberService.oauthJoin(oauthJoinRequest);
        memberService.authorizeUser(oauthJoinRequest);
        return ResponseEntity.ok().build();
    }

    @DeleteMapping
    @PreAuthorize("hasRole('USER'||'ADMIN')")
    @ResponseStatus(HttpStatus.OK)
    public void withdraw() throws Exception {
        String email = GetLoginMember.getLoginMemberEmail();
        memberService.withdraw(email);
    }

    @GetMapping("/myInfo")
    @PreAuthorize("hasRole('USER'||'ADMIN')")
    public ResponseEntity<MemberDto> getMyInfo() throws Exception {
        MemberDto dto = memberService.getMyInfo();
        return ResponseEntity.ok(dto);
    }

    @GetMapping("/logout")
    @PreAuthorize("hasRole('USER'||'ADMIN' || 'MANAGER')")
    public void logout(HttpServletRequest request, HttpServletResponse response) {
        new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
    }

    @GetMapping("/access-denied")
    public String accessDenied() {
        return ("/error");
    }
}

MemberController 클래스는 회원 관련 RESTful 웹 서비스의 엔드포인트를 제공하는 컨트롤러 클래스이다. 이 클래스는 다양한 회원 관련 요청을 처리하며, @RestController@RequestMapping 어노테이션을 통해 컨트롤러로 설정되고, 기본 경로는 /members로 설정된다. 주요 메소드는 다음과 같다.

  1. 회원 가입 (join 메소드)

    • URL: /members/join
    • HTTP 메소드: POST
    • 요청 본문: MemberJoinDto 객체
    • 응답: 없음 (HTTP 상태 코드 200)
    • 설명: 새로운 회원을 가입시키는 메소드이다. 요청 본문으로 전달된 MemberJoinDto 객체를 통해 회원 정보를 받아와 저장한다.
  2. 회원 정보 수정 (updateMemberInfo 메소드)

    • URL: /members/update
    • HTTP 메소드: PUT
    • 요청 본문: MemberUpdateDto 객체
    • 응답: 없음 (HTTP 상태 코드 200)
    • 설명: 기존 회원의 정보를 수정하는 메소드이다. 현재 로그인한 회원의 이메일 정보를 통해 해당 회원의 정보를 수정한다. 이 메소드는 USER 또는 ADMIN 권한을 가진 사용자만 호출할 수 있다.
  3. OAuth2 회원 정보 수정 (oauthJoin 메소드)

    • URL: /members/oauth2/update
    • HTTP 메소드: PUT
    • 요청 본문: OauthJoinRequest 객체
    • 응답: ResponseEntity<Void>
    • 설명: OAuth2를 통해 가입한 회원의 정보를 수정하고 권한을 부여하는 메소드이다. 요청 본문으로 전달된 OauthJoinRequest 객체를 통해 회원 정보를 받아와 처리한다.
  4. 회원 탈퇴 (withdraw 메소드)

    • URL: /members
    • HTTP 메소드: DELETE
    • 응답: 없음 (HTTP 상태 코드 200)
    • 설명: 현재 로그인한 회원의 이메일 정보를 통해 해당 회원을 탈퇴시키는 메소드이다. 이 메소드는 USER 또는 ADMIN 권한을 가진 사용자만 호출할 수 있다.
  5. 회원 정보 조회 (getMyInfo 메소드)

    • URL: /members/myInfo
    • HTTP 메소드: GET
    • 응답: ResponseEntity<MemberDto>
    • 설명: 현재 로그인한 회원의 정보를 조회하는 메소드이다. 이 메소드는 USER 또는 ADMIN 권한을 가진 사용자만 호출할 수 있다.
  6. 로그아웃 (logout 메소드)

    • URL: /members/logout
    • HTTP 메소드: GET
    • 응답: 없음
    • 설명: 현재 로그인한 사용자를 로그아웃시키는 메소드이다. 이 메소드는 USER, ADMIN 또는 MANAGER 권한을 가진 사용자만 호출할 수 있다.
  7. 접근 거부 (accessDenied 메소드)

    • URL: /members/access-denied
    • HTTP 메소드: GET
    • 응답: String
    • 설명: 접근이 거부되었을 때 호출되는 메소드로, 에러 페이지로 리다이렉션한다.

예외 처리 (MemberExceptionType.java)

public enum MemberExceptionType implements BaseExceptionType {
    ALREADY_EXIST_EMAIL(600, HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."),
    ALREADY_EXIST_NICKNAME(601, HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."),
    WRONG_PASSWORD(602, HttpStatus.BAD_REQUEST, "아이디 또는 비밀번호를 잘못 입력했습니다."),
    NOT_FOUND_MEMBER(603, HttpStatus.NOT_FOUND, "일치하는 회원이 존재하지 않습니다."),
    WRONG_ROLE(604, HttpStatus.BAD_REQUEST, "잘못된 역할 권한입니다.");

    private final int errorCode;
    private final HttpStatus httpStatus;
    private final String errorMessage;

    MemberExceptionType(int errorCode, HttpStatus httpStatus, String errorMessage) {
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errorMessage = errorMessage;
    }

    @Override
    public int getErrorCode() {
        return this.errorCode;
    }

    @Override
    public HttpStatus getHttpStatus() {
        return this.httpStatus;
    }

    @Override
    public String getErrorMessage() {
        return this.errorMessage;
    }
}

MemberExceptionType 열거형(enum)은 회원 관련 예외 상황을 정의하고 관리하기 위해 사용되는 클래스이다. 이 클래스는 BaseExceptionType 인터페이스를 구현하여 공통적인 예외 속성을 제공하며, 다음과 같은 예외 타입을 정의한다.

  1. ALREADY_EXIST_EMAIL

    • 설명: 이미 존재하는 이메일로 회원가입을 시도할 때 발생하는 예외이다.
    • HTTP 상태 코드: 409(CONFLICT)
    • 에러 메시지: "이미 존재하는 이메일입니다."
  2. ALREADY_EXIST_NICKNAME

    • 설명: 이미 존재하는 닉네임으로 회원가입을 시도할 때 발생하는 예외이다.
    • HTTP 상태 코드: 409(CONFLICT)
    • 에러 메시지: "이미 존재하는 닉네임입니다."
  3. WRONG_PASSWORD

    • 설명: 아이디 또는 비밀번호를 잘못 입력했을 때 발생하는 예외이다.
    • HTTP 상태 코드: 400(BAD REQUEST)
    • 에러 메시지: "아이디 또는 비밀번호를 잘못 입력했습니다."
  4. NOT_FOUND_MEMBER

    • 설명: 요청한 회원 정보를 찾을 수 없을 때 발생하는 예외이다.
    • HTTP 상태 코드: 404(NOT FOUND)
    • 에러 메시지: "일치하는 회원이 존재하지 않습니다."
  5. WRONG_ROLE

    • 설명: 잘못된 역할 권한을 설정하려고 할 때 발생하는 예외이다.
    • HTTP 상태 코드: 400(BAD REQUEST)
    • 에러 메시지: "잘못된 역할 권한입니다."

각 예외 타입은 다음과 같은 속성을 가진다:

  • errorCode: 예외의 고유 코드로, 정수형 값을 가진다.
  • httpStatus: 예외에 대응하는 HTTP 상태 코드이다.
  • errorMessage: 예외 발생 시 클라이언트에게 전달될 에러 메시지이다.

이 클래스는 생성자에서 예외 코드, HTTP 상태 코드, 에러 메시지를 초기화하며, getErrorCode, getHttpStatus, getErrorMessage 메소드를 통해 각 속성에 접근할 수 있다.

이와 같이 MemberExceptionType 클래스는 다양한 회원 관련 예외 상황을 일관된 방식으로 정의하고 관리할 수 있도록 해준다.

내용이 길어져서 다음 포스팅 내용부터는 Auth 관련 코드에 대한 설명을 할 예정이다. 그럼 두번째 글에서 이어서 작성하자.

profile
다재다능한 Backend 개발자에 도전하는 개발자

0개의 댓글