관리 메뉴

한다 공부

[Spring] Spring Security로 회원가입 구현하기 본문

Dev/Java

[Spring] Spring Security로 회원가입 구현하기

사과당근 2023. 5. 18. 00:54

이번 글의 키워드 : Spring Security, 회원가입, Form에서 Controller로 데이터 전달이 안됨, Chat GPT

 

스프링 시큐리티를 사용해서 회원가입 기능을 구현해봅시다.

우선 완성된 디렉토리 구조는 다음과 같습니다.


우선 회원가입을 진행하기 위해, 어떠한 정보가 필요한지 생각해봅시다.

제가 필요한 정보는 사용자 아이디, 비밀번호, 이름, 전화번호, 등급 (vvip, vip .. ), 주소, 관리자인지 확인하는 정보 입니다.


이러한 사용자 정보를 Member라는 엔티티를 만들어 저장해봅시다

Member.java는 다음과 같습니다.

package store.unibly.web.member;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

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

    @Column(unique = true)
    private String loginId;

    private String password;

    private String name;

    @Column(unique = true)
    private String phone;

    @Enumerated(EnumType.STRING)
    private MemberLevel level;

    private String address;

    @Enumerated(EnumType.STRING)
    private MemberRole role;

    @Builder
    public Member(String loginId, String password, String name, String phone, String address, MemberRole role){
        this.loginId=loginId;
        this.password=password;
        this.name=name;
        this.phone=phone;
        this.address=address;
        this.role=role;
        this.level=MemberLevel.WHITE;
    }
}

그리고 등급을 구분하기 위한 MemberLevel.java 입니다

package store.unibly.web.member;

public enum MemberLevel {
    VVIP,
    VIP,
    RED,
    ORANGE,
    YELLOW,
    WHITE
}

role은 USER와 MANAGER로 구분하고 싶었기 때문에 MemberRole.java를 생성했습니다.

package store.unibly.web.member;

public enum MemberRole {
    USER,
    MANAGER
}

Spring Data Jpa를 사용하고 있습니다.

필요한 Repository를 만들었습니다.

package store.unibly.web.member;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Integer> {

}

이제, 스프링 시큐리티를 사용하기 위해 설정을 해야합니다.

build.gradle의 dependencies에 스프링 시큐리티와 타임리프 라이브러리를 사용할 수 있게 코드를 추가합시다.

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

그리고 config 패키지를 생성하여 SecurityConfig.java를 만들었습니다.

저는 url이 /back-office 로 시작하면, role이 MANAGER인 사용자만 접근하도록 추후 변경할 예정입니다.

 

CSRF는 일종의 공격인데, 이를 방지하기 위해 스프링 시큐리티는 CSRF 토큰 사용합니다. 토큰을 통해 해당 요청일 올바른 요청인지 확인하는 것이죠. CSRF 토큰이 없으면 스프링 시큐리티는 에러를 반환을 합니다. (이 과정에 대해 찾아보시는 것을 추천드립니다)

그리고 저는 데이터베이스로 H2를 사용합니다. 이 H2는 CSRF 토큰을 보내지 않습니다. 따라서 H2 콘솔이 켜지지 않으므로, CSRF 토큰 확인을 H2콘솔만 예외적으로 확인하지 않게 했습니다.

 

마지막으로, password를 암호화하기 위해 BCryptPasswordEncoder 객체를 사용하는데, 빈으로 등록하여 사용하기 위해 코드 마지막 부분에 빈을 등록했습니다. 여기서 객체지향 개발의 장점이 드러나는데요, 이렇게 빈을 사용하여 등록한 결과, 암호화 방식을 BCryptPasswordEncoder 이 아닌, 다른 방식으로 변경할 경우 모든 암호화 부분을 하나하나 수정할 필요 없이 빈 내부만 수정하면 되기 때문에 아주 편리합니다.

package store.unibly.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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity // 모든 요청이 스프링 시큐리티 제어를 받도록
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http.authorizeHttpRequests().requestMatchers(
                new AntPathRequestMatcher("/**")).permitAll() // back-office 권한 부여 추후 추가하기
            .and()
                .csrf().ignoringRequestMatchers(
                        new AntPathRequestMatcher("/h2/**")). // h2 콘솔만 CSRF 검증을 안하고 예외 처리 (h2 콘솔은 CSRF 토큰을 발행하는 기능이 없으므로)
            and()
                .headers()
                .addHeaderWriter(new XFrameOptionsHeaderWriter(
                        XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)); // h2 콘솔 frame 띄우기 위함
        return http.build();
    }

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

이게 회원가입 로직을 구현해봅시다.

로직은 대략적으로

1. 웹으로부터 요청

2. 컨트롤러에서 요청받을 경우, 서비스의 메소드 호출

3. 서비스에서 회원가입 로직 구현

4. DB에 저장

5. 모든 과정을 수행하고 컨트롤러에서 마지막에 html 반환

6. html 등장

으로 흘러갑니다.


우선 웹에서 데이터를 받아오기 위해 Form을 사용합니다.

SignUpForm.java는 다음과 같습니다.

package store.unibly.web.member.dto;

import lombok.Getter;
import lombok.Setter;
import store.unibly.web.member.MemberRole;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

@Getter
@Setter
public class SignUpForm {
    @Size(min=5, max=20)
    @NotEmpty(message = "ID를 입력해 주세요.")
    private String loginId;

    @NotEmpty(message = "비밀번호를 입력해 주세요.")
    private String password1;

    @NotEmpty(message = "비밀번호를 다시 한번 입력해 주세요.")
    private String password2;

    @Size(min=1, max=20)
    @NotEmpty(message = "이름을 입력해 주세요.")
    private String name;

    @Size(min=9, max=11)
    @NotEmpty(message = "전화번호를 입력해 주세요.")
    private String phone;

    private String address;

    private MemberRole role;
}

사실 여기서 저는 살짝 혼란이 왔습니다.

 

우선, Entity를 바로 사용하여 데이터를 노출시키면 보안상 등의 문제가 생길 수도 있어서 보통 DTO를 사용합니다.

DTO는 쉽게 말해 데이터 구조의 수정, 삭제 등을 쉽게 할 수 없게 하도록 한번 데이터를 감싸는 것입니다.

또한 DTO를 사용하면 엔티티의 데이터 중 원하는 데이터만을 전달할 수 있게 합니다.

 

챗 GPT에게 물어보았습니다. "DTO를 쓰는 이유가 무엇이더냐?"

지피티씨의 의견을 들어보겠습니다.

스프링(Spring) 프레임워크에서 DTO(Data Transfer Object)를 사용하는 이유는 다음과 같습니다:

1. 데이터 전달 객체: DTO는 데이터 전달을 위한 객체로 사용됩니다. 서비스 계층과 클라이언트(주로 웹 또는 API) 간에 데이터를 전달할 때 사용됩니다. DTO는 여러 필드를 포함하며, 이 필드들은 데이터를 저장하고 전달하기 위한 목적으로 사용됩니다.

2. 데이터 변환: DTO는 엔티티(Entity)나 도메인 객체(Domain Object)와 같은 내부 도메인 모델과 외부 클라이언트 사이의 데이터 변환에 사용됩니다. 엔티티는 데이터베이스나 영속성 계층에서 사용되는 객체이지만, DTO는 클라이언트에게 노출되는 데이터 객체입니다. DTO를 사용하여 엔티티의 필드 중 일부만 클라이언트로 전달하거나, 필요한 데이터를 가공하여 클라이언트에게 제공할 수 있습니다.

3. 성능 개선: DTO를 사용하여 필요한 데이터만 전송하므로, 불필요한 데이터를 제거하고 네트워크 트래픽을 줄일 수 있습니다. 특히, 대량의 데이터를 다루는 경우에는 성능상의 이점이 있을 수 있습니다.

4. 보안: DTO를 사용하여 민감한 정보를 필터링하거나 숨길 수 있습니다. 클라이언트에게 노출되어서는 안 되는 정보가 있는 경우 DTO를 통해 해당 정보를 제외하고 전달할 수 있습니다.

5. 유연성: DTO를 사용하면 클라이언트와 서버 간의 의존성을 낮출 수 있습니다. 클라이언트의 요구사항이 변경되어도 DTO의 구조를 조정하여 필요한 데이터를 제공할 수 있습니다. 이를 통해 시스템의 유연성과 확장성을 향상시킬 수 있습니다.

DTO는 주로 데이터 전달을 위한 목적으로 사용되며, 비즈니스 로직이나 데이터 조작을 위한 메서드를 포함하지 않습니다. 그 대신, 데이터를 저장하고 전달하기 위한 필드와 그에 따른 Getter/Setter 메서드를 포함합니다.

지피티씨, 좋은 정리 감사합니다.

 

그렇다면 Form은 왜 쓰는 걸까요?

웹으로 부터 받은 데이터를 받기 위해 사용합니다.

 

혼란이 온 부분은 여기입니다.

아니 데이터를 Form으로 감싸고 DTO도 사용해야하는거여?

또, 지피티씨에게 질문을 해보았습니다. "스프링에서 Form과 DTO의 차이를 알려줘."

스프링에서 Form과 DTO는 둘 다 데이터 전송 객체(Data Transfer Object)의 일종입니다.
그러나 각각은 다른 목적과 역할을 가지고 있습니다.

1. Form: Form은 웹 애플리케이션에서 사용자로부터 데이터를 입력받는 데 사용되는 객체입니다. 주로 HTML 폼을 통해 전송된 데이터를 받아 처리하는 데 사용됩니다. Form 객체는 입력 필드의 값을 담고 있는 프로퍼티를 가지며, 이를 통해 사용자로부터 입력받은 데이터를 서버로 전송할 수 있습니다. 스프링은 Form 데이터를 바인딩하고 유효성 검사를 수행하기 위해 Form 객체와 관련된 기능을 제공합니다. 예를 들어, 회원 가입 폼에서 사용자가 입력한 이름, 이메일, 비밀번호 등의 데이터를 전송받기 위해 사용할 수 있는 Form 객체를 정의할 수 있습니다. 이 Form 객체는 컨트롤러로 전송되어 사용자 입력값을 처리하고, 필요한 경우 서비스나 데이터베이스와 상호작용하여 회원 가입 과정을 수행할 수 있습니다.

2. DTO (Data Transfer Object): DTO는 비즈니스 로직을 가지지 않고, 데이터 전송을 위해 사용되는 객체입니다. 주로 여러 계층 간의 데이터 교환을 위해 사용됩니다. DTO는 데이터를 캡슐화하고 전송할 목적으로 사용되며, 데이터의 속성을 가지는 단순한 객체입니다. DTO는 주로 데이터베이스에서 검색된 데이터나 서비스로부터 반환된 결과를 컨트롤러나 뷰로 전달하는 데 사용됩니다. 예를 들어, 사용자의 정보를 나타내는 User 객체가 있다고 가정해봅시다. 이 User 객체는 데이터베이스에 저장된 정보를 담고 있을 수 있습니다. 이때, 컨트롤러에서 User 객체를 반환하기보다, DTO 객체를 생성하여 User 객체의 필요한 데이터만을 선택적으로 담아서 전달할 수 있습니다. 이렇게 함으로써 필요한 데이터만을 전송함으로써 효율성을 높일 수 있습니다.

종합적으로, Form은 웹 애플리케이션의 사용자 입력을 받기 위한 객체로 사용되며, DTO는 계층간 데이터 전송을 위한 단순한 데이터 객체로 사용됩니다. Form은 주로 사용자 입력값을 바인딩하고 유효성 검사를 수행하는 데 사용되며, DTO는 데이터의 전송과 전달을 위한 목적으로 사용됩니다.

지피티씨 덕분에 궁금증이 해결되네요.

 

하지만 저는 작은 규모의 프로젝트이기 때문에 Form만을 사용하여 개발을 하고자 했습니다.

그러면서도, 이러한 개발 방식이 맞는 것인지 의문이 들기 시작했습니다.

DTO는 선택이 아니라 필수인가? 라는 생각도 드는 것이었죠.

그래서 또 질문을 했습니다. " 둘 다 만들어야하는 것이더냐 "

회원 가입과 같은 기능을 구현할 때 Form과 DTO를 모두 사용할 필요는 없습니다. 간단한 경우에는 Form 객체만 사용하여 사용자 입력을 처리할 수 있습니다. 하지만 복잡한 경우에는 Form 객체와 DTO 객체를 함께 사용하여 입력 데이터의 바인딩, 유효성 검사, 데이터 전달 등을 처리할 수 있습니다. 어떤 객체를 사용할지는 프로젝트의 구조와 요구사항에 따라 달라집니다.

라는 답변을 받았습니다.

거대한 규모의 프로젝트, 그리고 보안이 중요하다면 Form과 DTO를 모두 사용하는 것 같습니다.

하지만 저는 우선 Form만을 사용하여 데이터를 처리하도록 했습니다.


이어서, Repository는 위에서 구현을 했으니 Service를 구현하겠습니다.

MemberService.java는 다음과 같습니다.

package store.unibly.web.member;

import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import store.unibly.web.member.dto.SignUpForm;

@RequiredArgsConstructor
@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public Member signUp(SignUpForm signUpForm) {
        Member member = Member.builder()
                .loginId(signUpForm.getLoginId())
                .password(passwordEncoder.encode(signUpForm.getPassword1()))
                .name(signUpForm.getName())
                .phone(signUpForm.getPhone())
                .address(signUpForm.getAddress())
                .role(signUpForm.getRole())
                .build();
        return memberRepository.save(member);
    }
}

그리고 Controller를 구현하겠습니다.

package store.unibly.web.member;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import store.unibly.web.member.dto.SignUpForm;

import javax.validation.Valid;

@RequiredArgsConstructor
@Controller
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/signup")
    public String signUp(SignUpForm signUpForm) {
        return "member/signup";
    }

    @PostMapping("/signup")
    public String signUp(@Valid SignUpForm signUpForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            System.out.println(bindingResult.getAllErrors());
            return "member/signup";
        }

        if (!signUpForm.getPassword1().equals(signUpForm.getPassword2())) {
            bindingResult.rejectValue("password2", "passwordInCorrect",
                    "비밀번호가 일치하지 않습니다.");
            return "member/signup";
        }

        memberService.signUp(signUpForm);

        return "redirect:/";
    }
}

마지막으로, HTML을 사용해 웹 페이지 화면을 만들겠습니다.

HTML은 부트스트랩과 타임리프를 사용했는데, 부가적인 것을 제외하고 <form> </form> 으로 데이터를 받는 부분만 보여드리겠습니다!

                <form th:action="@{/signup}" th:object="${signUpForm}" method="post">
                    <div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
                        <div> 다시 시도해 주세요. </div>
                    </div>
                    <div class="mb-3">
                        <label for="loginId" class="form-label">아이디</label>
                        <input type="text" th:field="*{loginId}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label for="password1" class="form-label">비밀번호</label>
                        <input type="password" th:field="*{password1}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label for="password2" class="form-label">비밀번호 확인</label>
                        <input type="password" th:field="*{password2}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label for="name" class="form-label">이름</label>
                        <input type="text" th:field="*{name}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label for="phone" class="form-label">전화번호</label>
                        <input type="text" maxlength="11" placeholder="01012341234" th:field="*{phone}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label for="address" class="form-label">주소</label>
                        <input type="text" th:field="*{address}" class="form-control">
                    </div>
                    <div class="mb-3">
                        <label><input type="radio" th:field="*{role}" value="USER"> 사러왔어요 </label> &nbsp;&nbsp;&nbsp;&nbsp;
                        <label><input type="radio" th:field="*{role}" value="MANAGER"> 팔러왔어요 </label>
                    </div>
                    <button type="submit" class="btn btn-primary">회원가입</button>
                </form>

실행 화면은 아래와 같습니다.


그런데.. 저는 여기서 회원가입 버튼을 눌렀을 때 데이터 전달이 안 됐습니다.. ^^

( 위 코드는 고친 코드라, 그대로 해보신 분들은 되실 거에요...)

왜 안되나 에러를 출력하기 위해 아래와 같은 코드를 컨트롤러에 집어넣어 확인해보았습니다.

System.out.println(bindingResult.getAllErrors());

그 결과 Form에 어떠한 데이터도 담기고 있지 않다는 것을 알게되었습니다!

아무리봐도 어디가 잘못되었는지 모르겠어서 이런 저런 시도를 한 결과

Form에 @Setter를 추가하니 정상 작동을 했습니다. (허무)


돌아는 가는데 살짝 의문이 들었습니다. 나는 Set 메소드를 사용하지 않고 Service를 구현했기 때문에, Setter를 사용하지 않아도 돌아갈 것이라고 예상했는데 왜 Setter 애노테이션이 필요한걸까? (참고로 롬복 사용중입니다)

내부적으로 Set 메소드가 사용되는걸까? 의문을 가지고 또 지피티씨에게 질문을 했습니다. ㅋㅋㅋ

 

Q : 나는 Set 메소드를 쓴 적이 없는데, Setter의 Set 메소드가 내부적으로 사용되어서, @Setter가 필요한거야?

네, 맞습니다.
Spring MVC에서는 데이터 바인딩을 위해 Setter 메서드를 내부적으로 사용합니다.
데이터 바인딩은 클라이언트에서 전송된 HTTP 요청의 데이터를 컨트롤러의 매개변수나 객체에 자동으로 매핑하는 과정입니다.
컨트롤러의 매개변수로 객체를 사용하는 경우, Spring MVC는 Setter 메서드를 사용하여 객체의 필드 값을 설정합니다.
요청의 데이터를 바탕으로 객체의 필드 값을 적절히 설정하고, 컨트롤러에서 해당 객체를 사용할 수 있도록 해줍니다.
따라서, SignUpForm 객체를 컨트롤러에서 사용하기 위해서는 Setter 메서드가 필요합니다.
@Setter 어노테이션을 사용하면 Lombok이 자동으로 Setter 메서드를 생성하여 데이터 바인딩이 가능하게 해줍니다.
즉, SignUpForm 객체의 필드에 데이터를 저장하려면 Setter 메서드가 필요하며, @Setter 어노테이션을 사용하여 자동으로 생성할 수 있습니다.

데이터 바인딩과 관련된 기능은 Spring MVC 프레임워크에 내장되어 있습니다.
Spring MVC는 웹 애플리케이션 개발을 위한 프레임워크로, 요청과 응답 처리, 데이터 바인딩, 핸들러 메소드 호출 등 다양한 기능을 제공합니다.

맞다고 하는군요.

 

그래서 내부 로직을 보여달라고 여러 번의 질문을 던지니 다음과 같은 코드를 건네주었습니다.

public class WebDataBinder {
    private ConversionService conversionService;
    private PropertyEditorRegistry propertyEditorRegistry;

    public WebDataBinder(Object target) {
        // 대상 객체와 연결된 WebDataBinder 생성
        // target: 바인딩 대상 객체
    }

    public void setConversionService(ConversionService conversionService) {
        this.conversionService = conversionService;
    }

    public void setPropertyEditorRegistry(PropertyEditorRegistry propertyEditorRegistry) {
        this.propertyEditorRegistry = propertyEditorRegistry;
    }

    public void bind(ServletRequest request) {
        // 요청 데이터를 바인딩하여 대상 객체의 필드에 설정하는 메소드

        // 요청 데이터 분석
        // 요청 파라미터를 읽어서 필드 이름과 값으로 추출

        // 데이터 타입 변환
        // 필드의 데이터 타입에 따라 요청 데이터를 적절한 타입으로 변환
        // ConversionService를 사용하여 변환 수행

        // 데이터 설정
        // 변환된 값으로 대상 객체의 필드를 설정
        // PropertyEditorRegistry를 사용하여 설정

        // 데이터 바인딩 완료 후 처리
        // 바인딩 결과에 대한 후속 작업 수행
    }
}

프레임워크를 열어 해당 코드 부분을 직접 찾아보면 좋을 것 같아요


GPT가 100% 정확한 것은 아니며, 인터넷에 있는 어딘가의 데이터를 잘못 가져와서 저에게 잘못 설명하는 걸 수도 있습니다.

그치만 어느정도 궁금증을 해결해주고, 공부를 해나가는 과정에서 약간의 서포트 역할..을 해주어서 정말 많은 도움을 받고 있습니다.. (하지만 보안상의 문제가 있다고 하니, 중요한 프로젝트나 코드에 대해서는 질문하지 않는게 좋을 것 같습니다)


블로그 내용 중 잘못된 내용이 있다면 댓글로 알려주세요!

다음으로는 열심히 로그인과 로그아웃을 구현할 예정입니다.

그 과정을 포스팅 할지는 모르겠지만.. 다시 뵙는 그날까지 모두 행복하시길 ..~


참고 자료는 다음과 같습니다. (+구글링, Chat GPT)

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

3-06 회원가입

* `[완성 소스]` : [https://github.com/pahkey/sbb3/tree/3-06](https://github.com/pahkey/sbb3/tree/3-06) …

wikidocs.net