본문 바로가기
Web/Spring

[Spring/Java] 편의성 빼고 로그인 기능 구현하기 (JDBC, H2)

by char_lie 2024. 4. 18.
반응형

Spring 과제 테스트를 준비하기 위해서 Spring 공부를 다시 하면서, 기초되는 부분을 익힐겸 로그인 기능 구현을 목표로 삼았다. 

무려 편의성 최고인 Lombok, JPA를 사용하지 않고 구현하려고하니 정말 고통스러웠지만, 어찌저찌 생각한 형태로 구현에 성공했다. 기존과 다르게 InteliJ를 사용하지 않고 VScode로 구현한 경험도 신선한 경험이었다.

기존 프로젝트를 진행하면서 사용했던 코드를 재활용한 것이지만 굉장히 오래걸렸고, 이 과정을 통해 어느 정도 기초 스프링 이해도 증가에 도움이 됐다.

 

기본 개발 환경

- Java 17

- Spring Boot 2.7.4

- Maven (그냥 Gradle 쓰면 되는데 너무 늦게 깨달았다)

- VScode (프로그래머스 내부 IDE 환경이 VScode여서 VScode로 진행)

- H2 Database

- Postman (실제 테스트에선 curl 명령어를 통해 테스트 예정)

폴더 구조

 

의존성

- Spring boot starter

- h2database

- spring boot starter jdbc

- java jwt

 

코드 및 설명

1.  Member DB 접근하기

시작에 앞서 로그인 기능을 구현하기 위해서는 유저 가입 Member 테이블이 필요했다. 그래서 데이터에 접근 할 수 있는 형태로 Member DB를 구성해줄 필요가 있었다.

그렇기에 제일 먼저 SQL 문을 통해 데이터베이스를 하나 만들었다.

CREATE TABLE Member (
    member_id INT AUTO_INCREMENT PRIMARY KEY,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    modified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    email VARCHAR(255) UNIQUE NOT NULL,
    is_deleted BOOLEAN DEFAULT FALSE,
    password VARCHAR(255) NOT NULL,
    name VARCHAR(100) NOT NULL
);

 

그 후 Spring에서 Member를 접근하기 위해서 원래라면 JPA에서 간단하게 했겠지만 Lombok도 쓰지 않았기에, 일일이 Getter, Setter를 만들어줬다. 

import java.util.Date;

public class Member {
    private Long id;
    private Date createdAt;
    private Date modifiedAt;
    private String email;
    private boolean deleted;
    private String password;
    private String name;

    public Member() {
    }

    public Member(Long id, Date createdAt, Date modifiedAt, String email, boolean deleted, String password, String name) {
        this.id = id;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
        this.email = email;
        this.deleted = deleted;
        this.password = password;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    // ... 이하 생략
}

이렇게 만들고 나서, JDBC용으로 save, update, delete를 위해 다음과 같이 설정했고, 로그인 기능 구현을 위해서 이메일을 기준으로 DB를 검색할 필요가 있으므로 findbyEmail을 하나 만들어줬다. 이때 null일 경우를 가정해서 Optional을 이용했다.

반응형
import java.util.Optional;

public interface MemberRepository {
    Optional<Member> findbyEmail(String email);
    void save(Member member);
    void update(Member member);
    void delete(Long id);
}

 

이후 각각의 항목에 대해 Repository를 추가로 만들어서 각각의 기능에 대해 쿼리를 날릴 수 있도록 각각 구성했다. 이 과정에서 findbyEmail이 함수 사용법이 바뀌어서 다르게 써야한대서 한참을 찾아보다가 래퍼런스가 내장이란걸 뒤늦게 까먹고 래퍼런스를 확인해서 함수를 적용했다.

import java.util.List;
import java.util.Optional;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

@Repository
public class JdbcMemberRepository implements MemberRepository {

    private final JdbcTemplate jdbcTemplate;

    public JdbcMemberRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Optional<Member> findbyEmail(String email) {
        String sql = "SELECT * FROM Member WHERE email = ?";

        RowMapper<Member> rowMapper = (rs, rowNum) -> new Member(
                rs.getLong("member_id"),
                rs.getDate("created_at"),
                rs.getDate("modified_at"),
                rs.getString("email"),
                rs.getBoolean("is_deleted"),
                rs.getString("password"),
                rs.getString("name")
        );

        List<Member> members = jdbcTemplate.query(sql, rowMapper, email);
        return members.isEmpty() ? Optional.empty() : Optional.of(members.get(0));
    }


    @Override
    public void save(Member member) {
        String sql = "INSERT INTO Member (created_at, modified_at, email, is_deleted, password, name) VALUES (?, ?, ?, ?, ?, ?)";
        jdbcTemplate.update(sql, member.getCreatedAt(), member.getModifiedAt(), member.getEmail(), member.isDeleted(), member.getPassword(), member.getName());
    }

    @Override
    public void update(Member member) {
        String sql = "UPDATE Member SET modified_at = ?, email = ?, is_deleted = ?, password = ?, name = ? WHERE member_id = ?";
        jdbcTemplate.update(sql, member.getModifiedAt(), member.getEmail(), member.isDeleted(), member.getPassword(), member.getName(), member.getId());
    }

    @Override
    public void delete(Long id) {
        String sql = "DELETE FROM Member WHERE member_id = ?";
        jdbcTemplate.update(sql, id);
    }
}

이렇게 해서 Member에 접근할 수 있게 만들었다.

 

2. JWT 구현하기

JWT기능을 구현하는 과정에서 이전에 프로젝트에 사용한 코드를 최대한 재활용하면서 안쓰는 부분을 지워 맞춤혐으로 수정했다.

import com.example.demo.global.exception.*;
import com.example.demo.Member.Db.*;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Date;

import static com.example.demo.global.status.FailCode.*;

@Service
public class JwtProvider {

    private final MemberRepository memberRepository;
    private final String secretKey;
    private final Long accessTokenExpirationPeriod;
    private final Long refreshTokenExpirationPeriod;
    private final String accessHeader;
    private final String refreshHeader;

    private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
    private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
    private static final String ID_CLAIM = "id";
    private static final String BEARER = "Bearer ";

    public JwtProvider(MemberRepository memberRepository,
                       @Value("${jwt.secretKey}") String secretKey,
                       @Value("${jwt.access.expiration}") Long accessTokenExpirationPeriod,
                       @Value("${jwt.refresh.expiration}") Long refreshTokenExpirationPeriod,
                       @Value("${jwt.access.header}") String accessHeader,
                       @Value("${jwt.refresh.header}") String refreshHeader) {
        this.memberRepository = memberRepository;
        this.secretKey = secretKey;
        this.accessTokenExpirationPeriod = accessTokenExpirationPeriod;
        this.refreshTokenExpirationPeriod = refreshTokenExpirationPeriod;
        this.accessHeader = accessHeader;
        this.refreshHeader = refreshHeader;
    }

    public TokenMapping createToken(Long id, String name) {
        return TokenMapping.builder()
                .accessToken(createAccessToken(id))
                .refreshToken(createRefreshToken())
                .userName(name)
                .build();
    }

    public String createAccessToken(Long id) {
        Date now = new Date();
        return JWT.create()
                .withSubject(ACCESS_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
                .withClaim(ID_CLAIM, id)
                .sign(Algorithm.HMAC512(secretKey));
    }

    public String createRefreshToken() {
        Date now = new Date();
        return JWT.create()
                .withSubject(REFRESH_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
                .sign(Algorithm.HMAC512(secretKey));
    }

    public Long getExpiration(String accessToken) {
        Date expiration = JWT.require(Algorithm.HMAC512(secretKey)).build().verify(accessToken).getExpiresAt();
        long now = new Date().getTime();
        return expiration.getTime() - now;
    }

    public Long AccessTokenDecoder(String accessToken) {
        accessToken = accessToken.replace("Bearer ", "");
        DecodedJWT jwt = JWT.decode(accessToken);
        Claim IDCLAIM = jwt.getClaim(ID_CLAIM);
        if (!IDCLAIM.isNull()) {
            return IDCLAIM.asLong();
        } else {
            throw new GlobalRuntimeException(UNMATCHED_CODE);
        }
    }

}

JWT 발급 과정에서 토큰과 이름정보를 같이 보내주기 위해서 따로 맵핑하여 저장한 형태로 구성했다.

package com.example.demo.auth.util;


public class TokenMapping {
    private final String accessToken;
    private final String refreshToken;
    private final String userName;

    public TokenMapping(String accessToken, String refreshToken, String userName){
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.userName = userName;
    }

    public String getAccessToken(){
        return accessToken;
    }

    public String getRefreshToken(){
        return refreshToken;
    }
    public String getUserName(){
        return userName;
    }

    public static class Builder {
        private String accessToken;
        private String refreshToken;
        private String userName;

        public Builder accessToken(String accessToken) {
            this.accessToken = accessToken;
            return this;
        }

        public Builder refreshToken(String refreshToken) {
            this.refreshToken = refreshToken;
            return this;
        }

        public Builder userName(String userName) {
            this.userName = userName;
            return this;
        }

        public TokenMapping build() {
            return new TokenMapping(accessToken, refreshToken, userName);
        }
    }

    public static Builder builder() {
        return new Builder();
    }
}

3. 에러 & 성공 코드 및 Response 형태 만들기

성공과 실패시에 해당하는 코드와, 메시지를 날려주기 위해서 따로 상태 코드를 정의했다.

import org.springframework.http.HttpStatus;

public enum FailCode {

    GENERAL_ERROR(HttpStatus.BAD_REQUEST, "데이터 처리 실패"),

    // 인증
    DIFFERENT_PASSWORD(HttpStatus.FORBIDDEN, "비밀번호가 틀렸습니다."),
    DELETED_USER(HttpStatus.BAD_REQUEST, "회원 탈퇴된 계정입니다."),
    UNSIGNED_USER(HttpStatus.BAD_REQUEST, "가입되지 않은 아이디입니다"),
    BAD_TOKEN(HttpStatus.BAD_REQUEST, "사용할 수 없는 토큰입니다."),
    UNMATCHED_TOKEN(HttpStatus.BAD_REQUEST, "토큰에 일치하는 회원이 없습니다."),
    
    // 검증
    EMAIL_EXIST(HttpStatus.BAD_REQUEST, "중복된 이메일입니다."),
    FIND_IMPOSSIBLE(HttpStatus.BAD_REQUEST, "해당 회원을 찾을 수 없습니다."),
    UNMATCHED_CODE(HttpStatus.BAD_REQUEST, "인증 번호가 일치하지 않습니다.");


    private final HttpStatus status;
    private final String message;

    FailCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }
}

일부는 아직 만들어지지 않았지만, 이전에 정보를 몇개 가져와서 써서 그대로 남아있다. 나중에 실패코드가 더 필요하면 추가하면 된다.

import org.springframework.http.HttpStatus;

public enum SuccessCode {
    GENERAL_SUCCESS(HttpStatus.OK, "데이터 처리 성공"),
    CREATE_SUCCESS(HttpStatus.CREATED, "데이터 생성 성공"),
    DELETE_SUCCESS(HttpStatus.NO_CONTENT, "데이터 삭제 성공");

    private final HttpStatus status;
    private final String message;

    SuccessCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }
}

성공 코드도 실패코드와 마찬가지로 나중에 추가해주면 된다.

 

이제 기본적인 반환 형태를 만들어 주자. 이때 내가 원한 반환 형태는

{
    "message" : 상태에 대한 메시지,
    "code" : 코드번호,
    "data" :
        {
        "데이터1" : 데이터1,
        "데이터2" : 데이터2,
        }
}

이와 같은 형태로 보내기 위해서 기본적인 Response 형태를 잡아줬다.

 

import com.example.demo.global.status.SuccessCode;

public class EnvelopeResponse<T> {

    private String message;
    private int code;
    private T data;

    public EnvelopeResponse(){
        this.message = "";
    }

    public EnvelopeResponse(String message, int code, T data){
        this.message = message;
        this.code = code;
        this.data = data;
    }

    public EnvelopeResponse(SuccessCode successCode, T data){
        this.message = successCode.getMessage();
        this.code = successCode.getStatus().value();
        this.data = data;
    }


    public String getMessage() {
        return message;
    }

    public void setMessage(String message){
        this.message = message;
    }

    public int getCode(){
        return code;
    }

    public void setCode(int code){
        this.code = code;
    }

    public T getData(){
        return data;
    }

    public void setData(T data){
        this.data = data;
    }
}

이제, 각각의 런타임에러와, 성공했을 시 형태를 잡아주기 위한 ControllerAdvice와 RuntimeException을 구성했다.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalControllerAdvice {

    @ExceptionHandler(GlobalRuntimeException.class)
    public ResponseEntity<Response> handleGlobalRuntimeException(GlobalRuntimeException exception) {
        return Response.fail(exception.getFailCode());
    }
}
import com.example.demo.global.status.FailCode;

public class GlobalRuntimeException extends RuntimeException{

    private final FailCode failCode;

    public GlobalRuntimeException(FailCode failCode){
        super(failCode.getMessage());
        this.failCode = failCode;
    }

    public FailCode getFailCode(){
        return failCode;
    }
}

이때, ContollerAdvice를 사용하기 위한 Response의 형태는 아래와 같다.

import com.example.demo.global.status.FailCode;
import org.springframework.http.ResponseEntity;

public class Response {
    private int code;
    private String message;

    public Response(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public static ResponseEntity<Response> fail(FailCode code) {
        return ResponseEntity.status(code.getStatus()).body(new Response(code.getStatus().value(), code.getMessage()));
    }
}

이를 통해 상태 처리를 해줄 수 있었다.

4. API를 위한 Service와 Controller 만들기

이제 세팅이 모두 끝났으니 이를 활요해서 Service와 Controller를 구성하면 된다.

import com.example.demo.auth.util.TokenMapping;

public interface AuthRepository {
    TokenMapping login(String email, String password);
}
import org.springframework.stereotype.Service;

import com.example.demo.Member.Db.Member;
import com.example.demo.Member.Db.MemberRepository;
import com.example.demo.auth.util.JwtProvider;
import com.example.demo.auth.util.TokenMapping;
import com.example.demo.global.exception.GlobalRuntimeException;

import static com.example.demo.global.status.FailCode.*;

@Service
public class AuthServiceImpl implements AuthRepository {
    private final MemberRepository memberRepository;
    private final JwtProvider JwtProvider;

    public AuthServiceImpl(MemberRepository memberRepository, JwtProvider JwtProvider){
        this.memberRepository = memberRepository;
        this.JwtProvider = JwtProvider;
    }

    @Override
    public TokenMapping login(String email, String password){
        Member member = memberRepository.findbyEmail(email)
        .orElseThrow(() -> new GlobalRuntimeException(UNSIGNED_USER));

        if(!password.equals(member.getPassword())){
            throw new GlobalRuntimeException(DIFFERENT_PASSWORD);
        }

        if(member.isDeleted()){
            throw new GlobalRuntimeException(DELETED_USER);
        }

        return JwtProvider.createToken(member.getId(), member.getName());
    }
}

Service를 다음과 같이 구성해서, 로그인시에 성공하면 토큰을 생성하고, 실패 조건들에 걸릴 경우 그에 맞는 에러들을 표시하도록 구성해주자.

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.auth.api.LoginDto;
import com.example.demo.auth.service.AuthServiceImpl;
import com.example.demo.auth.util.TokenMapping;
import com.example.demo.global.response.EnvelopeResponse;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import javax.servlet.http.HttpServletRequest;

import static com.example.demo.global.status.SuccessCode.*;

@RequestMapping
@RestController
public class AuthController {
    private final AuthServiceImpl authService;
    
    public AuthController(AuthServiceImpl authService){
        this.authService = authService;
    }

    @PostMapping("api/auth/login")
    public ResponseEntity<EnvelopeResponse<TokenMapping>> login(@RequestBody LoginDto loginRequest, HttpServletRequest request) {
        TokenMapping token = authService.login(loginRequest.getEmail(), loginRequest.getPassword());
        
        return new ResponseEntity<EnvelopeResponse<TokenMapping>>(new EnvelopeResponse<>(GENERAL_SUCCESS, token), HttpStatus.OK);
        }
    
}

마지막으로 Contoller를 통해 API를 post로 맵핑해주면 된다.

5. 실행 결과 상태

포스트맨에서 시도한 테스트

1. 로그인 성공

2. 로그인 실패 (틀린 비밀번호)

3. 로그인 실패 (없는 아이디)

4. 로그인 실패 (탈퇴한 계정)

Curl 명령어를 통한 테스트

반응형

댓글