[Spring Boot] Spring Security, MySQL, JWT를 이용한 회원 서비스 만들기

고리·2023년 2월 6일
0

Server

목록 보기
5/12
post-thumbnail

기업에 제공하는 서비스에 회원 인증 기능이 필요하게 되었다.

요구사항은 아래와 같다.

  • 일반 직원은 데이터의 검색만 가능할 것
  • 데이터 저장은 관리자만 가능할 것
  • 사원 번호를 ID로 사용할 것

spring security와 JWT는 인프런 강의를 참고해 진행했다. 하지만 옛날 영상이라 클래스, 메서드 등 deprecate된 코드는 spring에서 권고하는 방식으로 바꿔서 작성하였다.

프로젝트 구조

  • java version: 17
  • spring boot version: 3.0.2
  • spring security version: 6.0.1
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.cad</groupId>
    <artifactId>searh_service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- JPA 종속성 추가 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-jpa</artifactId>
        </dependency>

        <!-- lombok 종속성 추가 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- MySQL 종속성 추가 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

        <!-- jwt 종속성 추가 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
        </dependency>
    </dependencies>
    <name>searh_service</name>
    <description>searh_service</description>
    <properties>
        <java.version>17</java.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Controller

우선 사용할 Controller를 만들어주자

package com.cad.searh_service.controller;

import com.cad.searh_service.domain.dto.*;
import com.cad.searh_service.service.MemberService;
import lombok.RequiredArgsConstructor;
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 org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemeberController {
    private final MemberService memberService;

    @PostMapping("/register")
    public ResponseEntity<MemberRegisterResponse> register(@RequestBody MemberRegisterRequest memeberRegisterRequest) {
        MemberDto memberDto = memberService.register(memeberRegisterRequest);
        return new ResponseEntity<>(new MemberRegisterResponse(memberDto.getEmployName()), HttpStatus.OK);
    }

    @PostMapping("/login")
    public ResponseEntity<MemberLoginResponse> login(@RequestBody MemberLoginRequest memberLoginRequest) {
        String token = memberService.login(memberLoginRequest.getEmployNumber(), memberLoginRequest.getPassword());
        return new ResponseEntity<>(new MemberLoginResponse(token), HttpStatus.OK);
    }
}package com.cad.searh_service.controller;

import com.cad.searh_service.domain.dto.*;
import com.cad.searh_service.service.MemberService;
import lombok.RequiredArgsConstructor;
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 org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemeberController {
    private final MemberService memberService;

    @PostMapping("/register")
    public ResponseEntity<MemberRegisterResponse> register(@RequestBody MemberRegisterRequest memeberRegisterRequest) {
        try {
            MemberDto memberDto = memberService.register(memeberRegisterRequest);
            return new ResponseEntity<>(new MemberRegisterResponse(memberDto.getEmployName()), HttpStatus.OK);
        } catch (Exception e) {
            e.printStackTrace();
            return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    @PostMapping("/login")
    public ResponseEntity<MemberLoginResponse> login(@RequestBody MemberLoginRequest memberLoginRequest) {
        try {
            String token = memberService.login(memberLoginRequest.getEmployNumber(), memberLoginRequest.getPassword());
            return new ResponseEntity<>(new MemberLoginResponse(token), HttpStatus.OK);
        } catch (Exception e) {
            e.printStackTrace();
            return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

constructor injection을 사용하기 위해 @RequiredArgsConstructor 어노테이션을 추가했다. 생성자 주입 방식에 대해서는 이 포스트에 정리해 두었다.

RsponseEntity

http response, 즉 body, status code, header를 설정한다.

HttpHeader, HttpBody를 포함하는 클래스인 HttpEntity를 상속받아 구현되어 있으며 generic type이기 때문에 reponse body에 어떤 타입이라도 넣을 수 있다.

public class ResponseEntity<T> extends HttpEntity<T>

Controller에서는 사용하지 않았지만 header 추가 역시 가능하다.

public class ResponseEntity<T> extends HttpEntity<T> {

	public ResponseEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers, HttpStatus status) {
		super(body, headers);
		Assert.notNull(status, "HttpStatus must not be null");
		this.status = status;
	}
}

Config

위의 컨트롤러를 만들고 요청을 보내면 401 unauthorized 에러가 발생한다. 이를 해결하기 위해 security 설정을 해보자

package com.cad.searh_service.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig{
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable().headers().frameOptions().disable()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests(auth -> auth.requestMatchers("/member/*").permitAll().anyRequest().authenticated());


        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

영상에서는 webSecurityConfigurerAdapter를 extends했지만 공식 홈페이지를 보면 5.7 버전부터 deprecate되었다고 한다.

이제는 SecurityFilterChain Bean을 등록해 사용하자

  • httpBasic().disable()
    기본인증 사용 x

  • sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    인증 정보를 서버에 기록 x (JWT 사용시 명시 필요)

  • authorizeHttpRequests
    이는 HttpServletRequest를 사용하는 요청들에 대한 접근 제한을 설정하겠다는 의미이다. HttpServletRequest는 클라이언트가 서버에 보내는 요청정보를 처리하는 객체다.

  • auth.requestMatchers("url").permitAll()
    이는 지정한 url로 들어오는 모든 요청은 인증 없이 접근을 허용하겠다는 의미이다.

  • anyRequest().authenticated()
    이는 위를 제외한 모든 요청은 인증되어야 한다는 의미이다.


Entity

멤버(유저) 엔티티를 작성해보자

엔티티는 실제 DB 테이블과 매핑되는 클래스로 DB Table에 존재하는 Column을 필드로 가진다. 때문에 반드시 필요한 Column만을 명시해야 한다. 또한 DB 영속성을 위한 객체이므로 다른 계층, 컴포넌트로 값을 전달하는 것은 지양해야한다.

package com.cad.searh_service.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "employnumber", unique = true)
    private String employNumber;
    
    @Column(name = "employname")
    private String employName;
    
    @Column(name = "phonenumber")
    private String phoneNumber;
    
    private String password;
    
    @Column(name = "adminkey")
    private String adminKey;
}

코드에서는 롬복 어노테이션을 무분별하게 사용했지만 생각보다 고려해야 할 사항이 많다. 영상에서도 실무에서는 신중하게 사용해야 한다고 지적한다. 왜 그럴까?

대표적인 단점으로, 만약 @Setter 어노테이션을 사용했다면 setter 메서드가 필요없는 필드에 대해서도 setter 메서드를 강제로 생성하기 때문에 필드값이 변경될 위험이 생긴다. 이런 부분들이 전부 리펙토링의 대상이기 때문에 무분별하게 사용할수록 리펙토링이 힘들어진다.

Constructor

@NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor에 대한 공식 문서를 살펴보자

이 어노테이션들은 객체 내부에 선언되어 있는 특정 field마다 1개의 파라미터를 허용하는 constructor를 생성하고 이 파라미터를 field에 할당한다.
하나씩 예를 들어보면

  • NoArgsConstructor
@NoArgsConstructor
public class Member {
	private long id
    private String password;
}

Member member = new Member(); // argument 전달 x

파라미터가 없는 기본 constructor를 생성한다. 만약 클래스 내부에 final이 붙은 필드가 있다면 컴파일 에러가 발생한다. final이 붙은 필드는 항상 초기화가 필요하기 때문이다.

@NoArgsConstructor(force = true)

이렇게 force 옵션을 주면 위 문제를 해결할 수 있지만 @NonNull 어노테이션이 붙은 필드의 경우는 해결할 수 없기 때문에 해당 필드는 직접 할당해줄 필요가 있다.

  • RequiredArgsConstructor
@RequiredArgsConstructor
public class Member {
	private final long id
    private String password;
}

Member member = new Member(1);

특별한 처리가 필요한 각 필드에 대해서 한개의 파라미터를 갖는 constructor를 생성한다. 위의 각 필드는 final이 붙거나 @NonNull 어노테이션이 붙은 필드를 뜻한다. 특히 @NonNull이 붙은 필드가 null 값을 갖는다면 NullPointerException이 발생하기 때문에 주의해야 한다.

  • AllArgsConstructor
@AllArgsConstructor
public class Member {
	private final long id
    private String password;
}

Member member = new Member(1, "pw");

클래스 내부에 선언된 모든 필드에 대해서 한개의 파라미터를 갖는 constructor를 생성한다. 그외 특징은 RequiredArgsConstructor와 동일하다.

잠깐 Constructor에 대해서 이야기를 했는데 @AllArgsConstructor, @RequiredArgsConstructor는 사용할 때 특히 신중해야 한다. 그 이유를 여기서 알 수 있었는데

@AllArgsConstructor
public class Member {
	private final long id
    private String password;
    private String nickname;
}

Member member = new Member(1, "pw", "power");

위 코드는 문제가 없다 하지만 클래스 필드 중 password와 nickname의 위치를 아래처럼 바꾼다면 비즈니스 로직에 문제가 발생하지만 컴파일, 런타임 모두 에러가 발생하지 않는다.

@AllArgsConstructor
public class Member {
	private final long id
    private String nickname;
    private String password;
}

Member member = new Member(1, "pw", "power");

결국 객체의 불변성, 일관성, 안정성을 해친다. 같은 이유로 Entity class에는 setter를 사용하지 않는다. 따라서 @AllArgsConstructor, @RequiredArgsConstructor를 사용할 때는 신중해야한다.

Builder

@Builder 어노테이션을 붙여 파라미터를 활용해 빌더 패턴을 자동으로 생성하자. 빌더 패턴을 아름답게 설명한 블로그가 있길래 소개하려고한다.

빌더 패턴이란 객체 생성을 깔끔하고 유연하게 하기 위한 기법이다. 블로그를 통해 내가 이해한 바를 적어보면 아래의 세단계를 거쳐 빌더패턴이 등장했다.

  1. 점층적 생성자 패턴

필수인자를 받는 필수 생성자를 하나 만들고 1개 ~ 모든 선택적 인자를 다 받는 생성자를 추가한다.

이 방법은 필수 인자의 개수가 몇개든 그에 맞는 생성자가 존재해 불필요한 인자를 전달할 필요가 없다는 장점이 있다. 하지만 호출 코드만봐서는 각 인자의 의미를 알기 어렵고 코드의 길이가 급격하게 늘어난다.

Member i = new Member(54, 26, 1234);
Member you = new Member(64, 26, 4567);
member he new Member(24, 35, 9876);

  1. 자바빈 패턴

점층적 생성자 패턴에 대한 대안으로 setter 메서드를 이용해 생성 코드를 읽기 좋게 만든다.

이 방법은 각인자의 의미를 파악하기 쉽고 여러개의 생성자를 만들어 코드 길이를 늘릴 필요가 없다. 하지만 객체 일관성이 무너지고 setter 메서드가 있기 때문에 immutable class를 만들 수 없다.

Member i = new Member();
i.setMemberId(54);
i.setMemeberAge(26);
i.setMemberPassword(1234);

  1. 빌더 패턴

이 방법은 자바 빈 패턴의 장점을 그대로 가져오고 단점을 해결한다. 한번에 객체를 생성하기 때문에 객체 일관성이 깨지지 않고 setter 메서드가 없이 때문에 immutable class를 만들 수 있다.

public class Member {
	private final int id;
    private final int age;
    private final int password;
   
    public static class Builder {

    	// 필수 인자
    	private final int id;
        private final int password

        // 선택 인자(기본 값으로 초기화)
        private int age;
        public Builder(int id, int password) {
        	this.id = id;
            this.password = password;
        }

        public Builder age(int val) {
        	age = val;
            return this; // 이렇게 하면 .을 사용해 체인을 이어갈 수 있다.
        }

        public MmeberInfo build() {
        	return new MemberInfo(this):
        }
    }

    private MemberInfo(Builder builder) {
    	id = builder.id;
        age = builder.age;
        password = builder.password;
    }
}

/* ------Use Case 1----- */
MemberInfo.Builder builder = new MemberInfo.Builder(77, 2435);
builder.age(26);

MemberInfo david = builder.build();

/* ------Use Case 2----- */
MemberInfo david = new MemberInfo
                      .Builder(77, 2435)
                      .age(26)
                      .build();

위의 작업을 @Builder 어노테이션을 통해 간편하게 해결할 수 있다.

추가로 @column(name = "...") 어노테이션을 사용해 컬럼의 이름을 바꿔주었는데 실행 환경이 Windows라 소문자 혹은 _만을 사용해야 에러가 발생하지 않는다.

DTO

Member DTO를 작성하기 전에 왜 Entity와 DTO(Data Transfer Object)를 분리했을지 고민해보자, 이 블로그를 참고했다.

실무에서는 API response, request를 처리할 때 계층간에 전달해야 할 파라미터가 너무 많은 시점에 별도의 DTO 생성을 고민한다고 한다.

1. 관심사의 분리

특정한 관심사에 따라 기능을 나누고, 각 기능을 독립적으로 개발한 뒤 이를 조합하는 방식으로 복잡한 소프트웨어를 구성해보자는 아이디어를 관심사의 분리(Separation of concerns, SoC)라고 한다.

DTO와 Entity는 관심사가 다르다.

DTO는 데이터를 담아 다른 계층 또는 다른 컴포넌트에게 전달하기 위한 자료구조다. 그러므로 어떤 기능 및 동작이 없어야 한다. 목적 자체가 전달이기 때문에 읽고, 쓰는 것이 모두 가능하며 일회성의 성격이 강하다.

반면 Entity는 핵심 비즈니스 로직을 담는 비즈니스 도메인 영역의 일부다. 물론 엔티티는 비즈니스 로직을 포함하는 도메인 엔티티와 DB관련 처리를 위한 영속성 엔티티로 나뉘지만 설명의 편의를 위해 2개를 합하여 설명한다.
결국 Entity는 다른 계층 또는 컴포넌트에게 데이터 전달을 위해 사용되지 않으며 어떤 기능 및 동작, 즉 비즈니스 로직이 주로 추가된다.

이번 프로젝트 처럼 JPA를 이용하면 Entity는 실제 DB와 매핑되며 내부적으로는 Entity Manager에 의해 관리되는 객체이다. 여기서 관리란 생명 주기(Life Cycle)을 말한다. 주로 일회성으로 사용되는 DTO와 별도의 생명 주기를 갖는 Entity가 다른 점이기도 하다.

2. DB와 VIEW의 분리

위의 Member Entity를 보면 Id 컬럼을 확인할 수 있다. 이 Id 정보를 사용자에게 보여줄 필요가 있을까? 만약 사용자가 개인정보를 열람하고 싶다면 employNumber, employName, phoneNumber만 제공해도 문제가 없을 것이다. 이처럼 3개의 컬럼만을 제공하기 위해서는 @JsonIgnore등의 어노테이션을 추가로 붙여주어야 한다. 이는 비즈니스 로직이 아닌 유효성 검사, 요청 응답을 위한 값이 추가 되어 Entity를 무겁게 만들어 확장 및 유지보수가 어렵게 된다.

또한 회원 가입과 로그인 요청, 응답에 필요한 정보들이 모두 다르기 때문에 각 요청, 응답에 대해 Entity를 수정하지 않고 DTO를 사용하면 객체의 독립성을 높일 수 있다.

이제 Dto를 작성해 보자
차례대로 MemberDto, MemberLoginRequest, MemberLoginResponse, MemberRegisterRequest, MemberRegisterResponse다.

package com.cad.searh_service.entity.memberDto;

import com.cad.searh_service.entity.Member;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

@AllArgsConstructor
@Getter
@Builder
public class MemberDto {
    private Long id;
    private String employNumber;
    private String employName;
    private String phoneNumber;
    private String password;
    private String adminKey;

    public static MemberDto fromEntity(Member member)  {
        MemberDto memberDto = MemberDto.builder()
                .id(member.getId())
                .employNumber(member.getEmployNumber())
                .employName(member.getEmployName())
                .phoneNumber(member.getPhoneNumber())
                .adminKey(member.getAdminKey())
                .build();
        return memberDto;
    }
}

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class MemberLoginRequest {
    private String employNumber;
    private String password;
}

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class MemberLoginResponse {
    private String token;
}

import com.cad.searh_service.entity.Member;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class MemberRegisterRequest {
    private String employNumber;
    private String employName;
    private String phoneNumber;
    private String password;
    private String adminKey;

    public Member toEntity(String password) {
        return Member.builder()
                .employNumber(this.employNumber)
                .employName(this.employName)
                .phoneNumber(this.phoneNumber)
                .password(password)
                .adminKey(this.adminKey)
                .build();
    }
}

password는 암호화가 필요해 파라미터로 전달 받았다.


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class MemberRegisterResponse {
    private String employName;
}

Repository

이번 프로젝트에서는 JpaRepository를 사용했다. 그렇다면 왜 JpaRepository를 사용할까?

위의 사진을 보면 인터페이스간의 상속 관계를 알 수 있다.

그런데 Repository는 왜 interface로 사용될까? 그 이유는 객체지향 5원칙(SOLID)의 OCP를 따르기 위함이다.

OCP (Open Closed Principle)
확장에 대해서는 개방적(open)이고, 수정에 대해서는 폐쇄적(closed)인, 즉 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계되어야 한다.

Repository의 구현체에서 변경이 발생해도 Repository를 사용하는 부분에서는 영향이 미치지 않는다.

CrudRepository는 Repository를 상속하고 PagingAndSortingRepository는 CrudRepository를 상속하고 JpaRepository는 PagingAndSortingRepository를 상속한다.

  • CrudRepository는 CRUD 기능을 제공한다.
  • PagingAndSortingRepository는 paging과 record 정렬 방법을 제공한다.

JpaRepository는 영속 컨텍스트 플러시처럼 JPA와 관련된 기능을 제공할 뿐만 아니라 Crud, PagingAndSorting Repository의 모든 기능을 제공하기 때문에 개발 편의성이 높아진다.

즉 JpaRepository를 사용하지 않으면 Repository의 구현체를 직접 구현해야 한다는 것이다. 이 과정에서 더 많은 시간이 들고 코드가 난잡해진다. JpaRepository를 상속 받아서 사용하면 네이밍 규칙만 맞춰도 알아서 쿼리문을 생성해준다.

JpaRepository를 사용한다는 것은 JPA를 따른다는 것이기에 위에서 설명한것 외에도 다양한 이유가 있다. 본 포스팅의 회원 기능 구현을 위해서는 CrudRepository로도 충분하지만 후에 sorting이 필요한 기능이 추가되기 때문에 JpaRepository를 사용했다.

Persistence Layer
CRUD, 데이터 베이스에접근하는 계층

package com.cad.searh_service.repository;

import com.cad.searh_service.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmployNumber(String employNumber);
}

Optional

public final class Optional<T> extends Object

Null Point Exception을 방지하기 위해 사용한다. Optinal은 null이 올 수 있는 값을 감싸는 Wrapper 클래스다.

Service

이제 Service class를 정의해보자 Service는 현재 두개의 동작을한다.

package com.cad.searh_service.service;

import com.cad.searh_service.entity.Member;
import com.cad.searh_service.entity.memberDto.MemberDto;
import com.cad.searh_service.entity.memberDto.MemberRegisterRequest;
import com.cad.searh_service.repository.MemberRepository;
import com.cad.searh_service.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    @Value("${jwt.token.secret}")
    private String secretkey;
    private final long expireTimeMs = 1000 * 60 * 60 * 24 * 7; // 토큰 7일

    public MemberDto register(MemberRegisterRequest request) {
        memberRepository.findByEmployNumber(request.getEmployNumber())
                .ifPresent(member -> {
                    throw new RuntimeException();
                });

        Member saveMember = memberRepository.save(request.toEntity(bCryptPasswordEncoder.encode(request.getPassword())));
        return MemberDto.fromEntity(saveMember);
    }

    public String login(String employNumber, String password) {
        Member member = memberRepository.findByEmployNumber(employNumber)
                .orElseThrow(() -> new RuntimeException("가입되지 않은 사원입니다."));

        if (!bCryptPasswordEncoder.matches(password, member.getPassword())) {
            throw new RuntimeException("비밀번호가 일치하지 않습니다.");
        }

        return JwtUtil.createToken(employNumber, expireTimeMs, secretkey);
    }
}

코드를 보면 가장 먼저 눈에 띄는게 예외처리 일것이다. 예외처리는 다른 포스팅에서 다룰 예정이다.

BCryptPasswordEncoder

스프링 시큐리티에서 제공하는 클래스로 비밀번호 암호화를 위해 사용했다.

랜덤하게 생성된 salt를 사용하기 때문에 같은 비밀번호를 encode 메서드를 통해 인코딩 하더라도 매번 다른 문자열을 반환한다.

salt란 중복되지 않은 랜덤한 특정 문자열을 의미한다.

JWT(JwtUtil)

package com.cad.searh_service.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;

public class JwtUtil {
    public static String createToken(String employNumber, long expireTimeMs, String key) {
        Claims claims = Jwts.claims();
        claims.put("employNumber", employNumber);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }
}

JWT는 Claim기반 토큰이다. Claim은 사용자에 대한 정보를 뜻하는데 JWT는 이 Claim을 JSON을 이용해서 정의한다. 이 프로젝트에서는 put 메서드를 사용해 Claim을 정의했다.

이 토큰은 토큰 자체로 사용자에대한 정보를 갖고 있다. 그러므로 어떤 서비스를 호출했을 때 서버는 사용자에대한 정보를 다른 곳에서 가져올 필요가 없다. 그렇기 때문에 구글에 JWT를 검색하면 보이는 기본적인 그림이 가능한 것이다.

  • @Value("${jwt.token.secret}")
    application.properties에 jwt.token.secret="글자수가 256개 이상인 문자열"을 적어주자. 암호화 알고리즘으로 HS256을 사용했기 때문에 256개 이상은 필수적이다.

JWT 인증 방식에서 사용자로부터 로그인 요청이 들어오면 서버는 secret key를 통해 서명을 해 access token을 발급해 준다.

이제 MySQL과 연결해보자 우선 MySQL workbench를 설치하자

왼쪽에 보이는 + 버튼을 눌러 커넥션을 하나 만들어주고 전부 디폴트로 만들면 된다.

왼쪽의 SCHEMAS 부분을 우클릭해 새로운 스키마를 만든다. 현재 프로젝트에서는 search-service로 만들었다.

왼쪽 위의 테이블 생성 버튼을 눌러 다음과 같이 만들어주고 Apply 한다.

CREATE TABLE `search-service`.`new_table` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `employNumber` VARCHAR(45) NOT NULL,
  `employName` VARCHAR(45) NULL,
  `phoneNumber` VARCHAR(45) NULL,
  `password` VARCHAR(200) NULL,
  `adminKey` VARCHAR(45) NULL,
  PRIMARY KEY (`employNumber`),
  UNIQUE INDEX `employNumber_UNIQUE` (`employNumber` ASC) VISIBLE,
  UNIQUE INDEX `id_UNIQUE` (`id` ASC) VISIBLE);

아니면 간단하게 위의 SQL문을 입력하고 번개모양 버튼을 클릭해 실행해준다.

jwt.token.secret=TESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTINGTESTING

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/search-service?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.show_sql=true

위의 내용을 application.properties에 붙여 넣어주고 실행하자!

api 요청을 보내자. 여기서는 insomnia를 사용했는데 postman이나 다른 테스팅 도구를 사용해도 무방하다.

요청에 맞는 반환값을 잘 받아왔는지 확인하고 데이터베이스에 잘 저장되었는지도 확인해보자

select * from member

끝!

profile
Back-End Developer

1개의 댓글

comment-user-thumbnail
2023년 9월 16일

선생님 글을 보며 따라해보았는데, 401 에러로 인해 진행이 안돼고 있습니다 ㅠㅠ 어떻게하면 해결 할 수 있을까요..?

답글 달기