# 개요

팀 프로젝트 진행 중 User Entity 관련 담당을 맡았다. 인증/인가를 구현하는 과정에서 평소에 하던대로 세션과 쿠키를 이용해서 구현하려고 했는데 의문점이 생겼다.

  1. 클라이언트는 리액트를 이용해서 구현하고 서버는 API 서버로 사용할 예정인데 세션과 쿠키를 이용한 인증/인가가 유효할까?
  2. 세션/쿠키 대안으로 JWT를 이용한 인증 방식이 많이 사용 된다고 하는데, 실제로는 어떻게 구현할까?
  3. 두 방식의 차이는 무엇이고 왜 JWT를 활용한 인증방식이 많이 쓰일까?

1번이 궁금했던 이유는 기존에 JSP나 템플릿 엔진을 사용해서 서버 사이드 렌더링을 할 때는 세션 방식이 유효하지만 클라이언트 사이드 렌더링할때도 과연 그럴까? 라는 의문이 들었었다.

2,3번은 내가 정확히 아는 내용도 아니고 "그냥 그렇다고 하더라~"정도로만 인식하고 있었다.

글을 작성하면서 2,3번에 대한 내용을 따로 정리했다.

 

인증/인가 - Session vs Token

# 개요인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.내용만 정리하고 별도 코드는 작성하지 않는다.지적, 오타 등 환영합니다.. # 인증? 인가?영어로

jino-dev-diary.tistory.com

 

 

검색을 통해 알아낸 정보를 ChatGPT에 다시 질문함으로써 간단히 정리해보고자 한다.

더보기

 

 

결론은 클라이언트 사이드 렌더링 구조라고 하더라도 세션 방식 인증/인가로 구현할 수 있다고한다.

하지만 2번 3번 때문에 한 번 쯤은 구현해보고 싶었고, JWT를 이용한 인증방식이 어떻게 동작하는지 이해할 필요가 있다고 생각했다.

JWT 관련 내용은 세션에서 JWT으로 변경할 때 정리하고 이 글에서는 제목처럼 팀 프로젝트 할 당시의 User Entity와 간단한 SignUp API를 구현하고 테스트 하겠다.

테스트 코드를 추가하면 좋겠지만 그것도 이번 프로젝트를 하면서 공부한 내용이 있으므로 따로 포스팅 하겠다.

선요약

  1. H2 DB 추가 및 설정
  2. User Entity 추가
  3. SignUp API 추가
    • SignUpController 추가
    • UserService 추가
    • UserRepository 추가
  4. PasswordEncoder 추가
    • 라이브러리 추가
    • Bean 추가
    • 비밀번호 DB에 저장할 때 암호화
  5. DuplicateResourceException 추가
  6. Borough Entity, Repository 추가
    • 미리 DB에 25개의 Borough 레코드 추가
    • 회원 가입 시 User와 Borough 연관 관계 매핑

# DB 설정

실제 프로젝트 때는 AWS RDS Mysql을 사용했는데 여기서는 H2로 구현한다.

DB설정 추가

더보기

application-db.yml 추가

spring:
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:~/test
    username: sa
    password: q1w2e3r4
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true

 

application.properties 수정

spring.application.name=securityExample
spring.config.import=classpath:application-db.yml

 

# SignUp API 추가

Spring Security를 이용한 인증/인가에 관한 글이기 때문에 가장 먼저 User에 대한 Entity가 필요하다고 생각하여, User Entity와 그것을 DB에 레코드로 저장하는 SignUp API (Create)를 추가했다.

## User Entity 추가

프로젝트에 필요한 User Entity에 대한 설계는 다음과 같았다.

  • 이메일을 기반으로 회원가입 한다.
  • 이메일, 비밀번호, 닉네임, 주소, 연락처를 입력받는다.
  • 이메일, 비밀번호, 닉네임, 주소는 반드시 필요하다. → email, password, nickname, address not null
  • 이메일과 닉네임은 중복되어서는 안된다.  → email, nickname unique

User Entity 추가

더보기

User.java 추가

package com.example.securityexample.user.entity;

// 생략...

@Entity(name = "users") // H2에 user 테이블이 이미 있기 때문에 users로 수정했다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {

  @Id
  @Column(name = "USER_ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(unique = true, nullable = false)
  private String email;
  @Column(nullable = false)
  private String password;
  private String phone;
  @Column(unique = true, nullable = false)
  private String nickname;
  @Column(nullable = false)
  private String address;
}

 

## SignUp API 구현

회원가입을 위한 요구사항은 다음과 같았다.

  • 이메일, 비밀번호, 비밀번호 확인, 닉네임, 주소, 연락처를 입력받는다.
  • 각 입력은 각자 유효한 형식이 있다.
  • 이메일은 인증된 이메일만 가입할 수 있다.
  • 이메일과 닉네임은 중복되어서는 안된다.
  • 비밀번호는 암호화되어 DB에 저장되어야 한다.

여기서 메일 인증은 Security와는 관련 없으니 구현하지 않겠다. (별도 포스팅 예정)

TDD 느낌을 내기 위해 API 테스트 후 코드 작성하는 흐름으로 작성하겠다.

API 테스트

Request Body는 생략

코드를 작성하지 않았으니 당연히 실패한다. 요청 형식과 url 맞게 Controller를 구현하고 그에 대한 비즈니스 로직을 Service 계층에 구현했다.

 

RestController 추가

Rest API 서버를 구현하기 때문에 @RestController로 Controller 클래스를 추가하고, 클라이언트 요청에 대한 SignUpRequestDto를 추가했다.

더보기

SignUpController.java 추가

package com.example.securityexample.user.controller;

// 생략...

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/signup")
public class SignUpController {

  @PostMapping(value = "", produces = "application/json;charset=UTF-8")
  public ResponseEntity<Map<String, String>> signUp(@RequestBody @Valid SignUpRequestDto signUpRequestDto) {

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(Map.of("message", Message.SIGNUP_SUCCESS));
  }
}

Service 계층 없이 응답으로 Status와 message만 반환한다. (Message는 생략)

 

SignUpDto.java 추가

package com.example.securityexample.user.dto;

// 생략...

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignUpDto {

  @Pattern(regexp = Regexp.EMAIL, message = Message.INVALID_EMAIL)
  private String email;
  @Pattern(regexp = Regexp.PASSWORD, message = Message.INVALID_PASSWORD)
  private String password;
  private String passwordConfirm;
  @Pattern(regexp = Regexp.NICKNAME, message = Message.INVALID_NICKNAME)
  private String nickname;
  @Pattern(regexp = Regexp.ADDRESS, message = Message.INVALID_ADDRESS)
  private String address;
  @Pattern(regexp = Regexp.PHONE, message = Message.INVALID_PHONE)
  private String phone;

  @AssertTrue(message = Message.INVALID_PASSWORD_CONFIRM)
  public boolean isPasswordConfirm() {
    return this.password.equals(this.passwordConfirm);
  }
}

SignUp API에 필요한 입력들 각자 유효한 형식이 있기 때문에 Spring Validation을 이용했다. (Message, Regexp는 생략)

 

Service 추가

SignUp API에 비즈니스 로직을 Service 계층에 작성하고, Controller 계층에서는 Service의 메서드를 호출하도록 했다. 

더보기

UserService.java 추가

package com.example.securityexample.user.service;

// 생략...

@Service
@RequiredArgsConstructor
public class UserService {

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    User user = signUpRequestDto.toEntity();
  }
}

Repository계층이 없으므로 RequestDTO → Entity 클래스로 변환하는 로직만 추가했다.

 

SignUpRequestDto.java에 toEntity() 메서드 추가

package com.kh.bookfinder.user.dto;

// 생략 ...

public class SignUpDto {
	
  // 생략 ...
	
  public User toEntity() {
    return User.builder()
        .email(this.email)
        .nickname(this.nickname)
        .password(this.password)
        .address(this.address)
        .phone(this.phone)
        .build();
  }
}

 

SignUpController.java에서 UserService의 메서드 호출

package com.example.securityexample.user.controller;

// 생략...
public class SignUpController {

  private final UserService userService;

  @PostMapping(value = "", produces = "application/json;charset=UTF-8")
  public ResponseEntity<Map<String, String>> signUp(@RequestBody @Valid SignUpRequestDto signUpRequestDto) {
    userService.createNewUser(signUpRequestDto);

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(Map.of("message", Message.SIGNUP_SUCCESS));
  }
}

 

Repository 추가

SignUp API를 통해 User Entity를 DB에 저장하기 위해서 다음과 같은 로직이 필요하다.

  • SignUpRequestDto의 email이 중복되는지 체크
  • SignUpRequestDto의 nickname이 중복되는지 체크

Repository 계층에 email, nickname을 조회하는 메서드를 작성하고 Service 계층에서 호출하도록 했다.

더보기

UserRepository.java 추가

package com.example.securityexample.user.repository;

// 생략...

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

  Optional<User> findByEmail(String email);

  Optional<User> findByNickname(String nickname);
}

JPA를 사용했다.

 

UserService.java에 UserRepository의 메서드 호출

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    if (alreadyExistEmail(signUpRequestDto.getEmail())) {
      throw new RuntimeException(Message.DUPLICATE_EMAIL);
    }
    if (alreadyExistNickname(signUpRequestDto.getNickname())) {
      throw new RuntimeException(Message.DUPLICATE_NICKNAME);
    }

    User user = signUpRequestDto.toEntity();
    this.userRepository.save(user);
  }

  private boolean alreadyExistEmail(String email) {
    return userRepository.findByEmail(email).isPresent();
  }

  private boolean alreadyExistNickname(String nickname) {
    return userRepository.findByNickname(nickname).isPresent();
  }
}

추상화를 위해 메서드로 추출했다.

 

PasswordEncoder 추가

DB에 User Entity를 저장할때 패스워드를 암호화 하기 위해 PasswordEncoder를 추가했다.

그냥 사용하면 되는줄 알았는데 Bean으로 등록해서 사용해야 한다고 한다.

더보기

build.gradle에 dependency 추가

// build.gradle
implementation 'org.springframework.security:spring-security-crypto`

 

PasswordEncoderConfig.java 추가

package com.example.securityexample.global.config;

// 생략...

@Configuration
public class PasswordEncoderConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

 BCryptPasswordEncoder 외에도 다양한 Encoder가 있다고 하는데, Spring Security가 기본적으로 BCrypt를 사용하기도 하고 가장 안전하다고 해서 사용했다.

 

SignUpRequestDto의 toEntity() 메서드 수정

package com.kh.bookfinder.user.dto;

// 생략 ...

public class SignUpDto {
	
  // 생략 ...
	
  public User toEntity(PasswordEncoder passwordEncoder) {
    return User.builder()
        .email(this.email)
        .nickname(this.nickname)
        .password(passwordEncoder.encode(this.password))
        .address(this.address)
        .phone(this.phone)
        .build();
  }
}

 

UserService에 PasswordEncoder 필드 추가

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    // 생략...

    User user = signUpRequestDto.toEntity(passwordEncoder);
    this.userRepository.save(user);
  }
  // 생략...
}

 

DuplicateResourceException 추가

물론 중복된다는 메시지가 있긴 하지만 RuntimeException으로 모호하게 처리하는 것 보다는 명확한 이름을 가진 Exception을 사용하는 것이 낫다고 생각해서 DuplicateResourceException을 추가했다

더보기

DuplicateResourceException.java 추가

package com.example.securityexample.global.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.CONFLICT)
public class DuplicateResourceException extends RuntimeException {

  public DuplicateResourceException(String message) {
    super(message);
  }
}

 

UserService.java의 createNewUser() 메서드 수정

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    if (alreadyExistEmail(signUpRequestDto.getEmail())) {
      throw new DuplicateResourceException(Message.DUPLICATE_EMAIL);
    }
    if (alreadyExistNickname(signUpRequestDto.getNickname())) {
      throw new DuplicateResourceException(Message.DUPLICATE_NICKNAME);
    }

    User user = signUpRequestDto.toEntity(passwordEncoder);
    this.userRepository.save(user);
  }
  // 생략...
}

 

Borough 도메인 추가

API를 계속 작성하는 중에 설계가 조금 추가 되었다.

회원이 등록한 자치구(강남구, 관악구 등)의 도서관 목록을 보여주게 하고 싶어요

그래서 Borough라는 Entity를 만들어 Borough 테이블에 서울 내 모든 자치구를 미리 추가했다.

그리고 회원가입 시 User 테이블과 Borough 테이블의 연관관계를 매핑해주었다.

더보기

Borough.java 추가

package com.example.securityexample.borough.entity;

// 생략...

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Borough {

  @Id
  @Column(name = "BOROUGH_ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(unique = true, nullable = false)
  private String name;
}

 

BoroughRepository.java 추가

package com.example.securityexample.borough.repository;

// 생략...

@Repository
public interface BoroughRepository extends JpaRepository<Borough, Long> {

  Optional<Borough> findById(Long id);

  Optional<Borough> findByName(String name);
}

 

User.java 수정

package com.example.securityexample.user.entity;

// 생략...
public class User {

  // 생략...
  @ManyToOne
  @JoinColumn(name = "BOROUGH_ID")
  private Borough borough;

  public String extractBoroughName() {
    return this.address.split(" ")[1];
  }

}

Borough 필드와, 주소에서 자치구를 추출하는 메서드를 추가했다. 


UserService.java 수정

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final BoroughRepository boroughRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    // 생략...

    User user = signUpRequestDto.toEntity(passwordEncoder);
    Borough borough = this.boroughRepository
        .findByName(user.extractBoroughName())
        .orElseThrow(() -> new ResourceNotFoundException(Message.INVALID_ADDRESS));
    user.setBorough(borough);
    this.userRepository.save(user);
  }
  // 생략...

}

User Entity의 주소 필드에서 자치구를 추출하고 해당하는 Borough Entity를 가져온다. 그리고 User Entity와 매핑한다.

 

Spring data REST 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-data-rest'

ResourceNotFoundException을 사용하기 위해 추가했다.

 

API 테스트


 

# 결론

이번 글에서 추가 한 것은 다음과 같다.

  1. H2 DB 추가 및 설정
  2. User Entity 추가
  3. SignUp API 추가
    • SignUpController 추가
    • UserService 추가
    • UserRepository 추가
  4. PasswordEncoder 추가
    • 라이브러리 추가
    • Bean 추가
    • 비밀번호 DB에 저장할 때 암호화
  5. DuplicateResourceException 추가
  6. Borough Entity, Repository 추가
    • 미리 DB에 25개의 Borough 레코드 추가
    • 회원 가입 시 User와 Borough 연관 관계 매핑

 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

+ Recent posts