최고의 보안 전문가? 그거 나잖아

[web] 스프링부트로 게시판 만들기 - 점프 투 스프링부트 본문

프로그래밍

[web] 스프링부트로 게시판 만들기 - 점프 투 스프링부트

ARI_II 2024. 5. 9. 16:10

 

 
springboot는 예전에 클라우드 워게임 만들때 딱 한번 진짜 파일다운로드 기능 하나 구현해 본적이 있다.
자바는... 학교에서 한번 배우긴 했는데 c++ 랑 비슷한 거같아 그렇게 어려울거라고 생각은 안해서 springboot로 한번 도전,,! 오늘 안에 끝내는게 목표..!
(근데 앞으로의 고난과 역경에서 자바가 문제가 아니었다는게 진짜... 과거의 나 너무 무식했다..근데 또 하다보니 재밌어서 스프링으로 하길 잘했다는 생각이.. php로 경험할 수 없는 신기한 기능이 짱많..)

https://wikidocs.net/

 

2-03 JPA로 데이터베이스 사용하기

* `[완성 소스]` : [https://github.com/pahkey/sbb3/tree/v2.03](https://github.com/pahkey/sbb3/tree/v2.03…

wikidocs.net

 기능 구성은 스프링부트 바이블이라는 위키독스를 참고해서 진행 할 예정... 
* 책에서 직접 인용한 부분은 인용문구 표시를 해놓았다. 
 

 
 
 본격적으로 웹 개발을 하려고 했는데 모르는 말이 너어무 많았다... 아예 노베.....
 
MVC ->  model, view , controller 로 개발하는 형태 
 
일단 lombok 저 친구부터 문제였는데 게터(Getter)와 세터(Setter) 메서드, 생성자, toString 메서드 등을 자동으로 생성해서 귀찮음을 줄여주는 역할을 한다고 한다. 고작 게시판에게 기능이 과한듯 싶지만 이것저것 써보는 것도 나쁘지 않을 것같아서 사용해보기로 했다. 
 
게터 getter -> 인스턴스 변수의 값을 가져오는 메서드 , 다른 클래스나 외부에서 값 읽기 가능 
세터 setter -> 인스턴스 변수의 값을 설정하는 메서드 , 다른 클래스나 외부에서 값 변경 가능 
 
 

1. h2로 데이터 베이스 구축하기 

 

 
설정한 경로 jdbc:h2:~/local 로 JDBC URL 을 통해 접근하기 해당부분에서 데이터베이스 작업을 수행 할 수 있다.
 
아니 스프링을 처음 접하면서 제일 신기했던건 sql 을 사용하지 않고도 테이블을 만들고 값을 관리할 수 있다는 것이다... ORM...
 
 
 

 
 

엔티티는 데이터베이스 테이블과 매핑되는 자바 클래스 

 
Question.java

package com.webhacking.arisweb;
import java.time.LocalDateTime;
import java.util.List;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity  // 엔티티라고 명시해주는 것
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(length = 200)
    private String subject;

    @Column(columnDefinition = "TEXT")
    private String content;

    private LocalDateTime createDate;

    @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
    private List<Answer> answerList;
}

 
 
 

2.JPA 리포지토리

 

우리는 앞서 엔티티로 테이블을 구성하여 데이터를 관리할 준비를 마쳤다. 하지만 엔티티만으로는 테이블의 데이터를 저장, 조회, 수정, 삭제 등을 할 수 없다. 이와 같이 데이터를 관리하려면 데이터베이스와 연동하는 JPA 리포지터리가 반드시 필요하다.

엔티티가 데이터베이스 테이블을 생성했다면, 리포지터리는 이와 같이 생성된 데이터베이스 테이블의 데이터들을 저장, 조회, 수정, 삭제 등을 할 수 있도록 도와주는 인터페이스이다. 이때 리포지터리는 테이블에 접근하고, 데이터를 관리하는 메서드(예를 들어 findAll, save 등)를 제공한다.

 
 
그리고 스프링 신기하다고 느낀 또하나... Junit
 
 

리포지터리를 이용하여 데이터를 저장하려면 질문을 등록하는 화면과 사용자가 입력한 질문 관련 정보를 저장하는 컨트롤러, 서비스 파일 등이 필요하다. 하지만 JUnit을 사용하면 이러한 프로세스를 따르지 않아도 리포지터리만 개별적으로 실행해 테스트해볼 수 있다. 

테스트 코드 작성 시에만 
@Autowired
를 사용하고 실제 코드 작성 시에는 생성자를 통한 객체 주입 방식을 사용해 보자.

 
 

 
 

 
 
신기... 그리고 또 신기한거 
 

이러한 마법은 JPA에 리포지터리의 메서드명을 분석하여 쿼리를 만들고 실행하는 기능이 있기 때문에 가능하다. 
findBy + 엔티티의 속성명(예를 들어 findBySubject)과 같은 리포지터리의 메서드를 작성하면 입력한 속성의 값으로 데이터를 조회할 수 있다

 
 
예를들어 QuestionRepository 에 findBySubject 메소드를 입력하고 매개변수 값으로 String subject값을 설정하면 subject 칼럼의 값을 찾을 수 있게 실행이 가능하다... wow.. 매개변수 여러개도 가능... 
 
위키독스의 예시는 다음과 같다.... 

 
 

3. controller 와 타임리프

 
컨트롤러 후기
플라스크랑 비슷한 것같다. 예전에 플라스크로 api 스캐너 웹 인터페이스 개발해본적이 있는데,,, 그 때 플라스크랑 진자 사용했었는데 비슷한 것같다.. 엄.. 스프링이 플라스크라면 타임리프가 진자... 
 

@RequiredArgsConstructor 애너테이션의 생성자 방식으로 questionRepository 객체를 주입했다. @RequiredArgsConstructor는 롬복(Lombok)이 제공하는 애너테이션으로, final이 붙은 속성을 포함하는 생성자를 자동으로 만들어 주는 역할을 한다. 따라서 스프링 부트(Spring Boot)가 내부적으로 QuestionController를 생성할 때 롬복으로 만들어진 생성자에 의해 questionRepository 객체가 자동으로 주입된다.

그리고 QuestionRepository의 findAll 메서드를 사용하여 질문 목록 데이터인 questionList를 생성하고 Model 객체에 ‘questionList’라는 이름으로 저장했다. 여기서 Model 객체는 자바 클래스(Java class)와 템플릿(template) 간의 연결 고리 역할을 한다. Model 객체에 값을 담아 두면 템플릿에서 그 값을 사용할 수 있다. Model 객체는 따로 생성할 필요 없이 컨트롤러의 메서드에 매개변수로 지정하기만 하면 스프링 부트가 자동으로 Model 객체를 생성한다.

 
 
QuestionController.java

package com.webhacking.arisweb;

import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionRepository questionRepository;

    @GetMapping("/question/list")
    public String list(Model model) {
        List<Question> questionList = this.questionRepository.findAll();
        model.addAttribute("questionList", questionList);
        return "question_list";
    }
}

 
 
question_list.html

<table>
    <thead>
    <tr>
        <th>제목</th>
        <th>작성일시</th>
    </tr>
    </thead>
    <tbody>
    <tr th:each="question : ${questionList}">
        <td th:text="${question.subject}"></td>
        <td th:text="${question.createDate}"></td>
    </tr>
    </tbody>
</table>

 

 
 

4. 서비스

 

서비스(service)는 간단히 말해 스프링에서 데이터 처리를 위해 작성하는 클래스이다.

 
서비스 진짜 필수로 필요한건 아니지만 보안과 효율 측면에서 다들 많이 사용하는 것같다.
 

1. 복잡한 코드를 모듈화할 수 있다
예를 들어 A라는 컨트롤러가 어떤 기능을 수행하기 위해 C라는 리포지터리의 메서드 a, b, c를 순서대로 실행해야 한다고 가정해 보자. 그리고 B라는 컨트롤러도 A 컨트롤러와 동일한 기능을 수행해야 한다면 A, B 컨트롤러가 C 리포지터리의 메서드 a, b, c를 호출해 사용하는 중복된 코드를 가지게 된다. 이런 경우 C 리포지터리의 a, b, c 메서드를 호출하는 기능을 서비스로 만들고 컨트롤러에서 이 서비스를 호출하여 사용할 수 있다. 즉, 서비스를 사용하면 이와 같은 모듈화가 가능하다.

2.엔티티 객체를 DTO 객체로 변환할 수 있다.
우리가 앞에서 작성한 Question, Answer 클래스는 모두 엔티티 클래스이다. 엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이므로 컨트롤러 또는 타임리프와 같은 템플릿 엔진에 전달해 사용하는 것은 좋지 않다. 왜냐하면 엔티티 객체에는 민감한 데이터가 포함될 수 있는데, 타임리프에서 엔티티 객체를 직접 사용하면 민감한 데이터가 노출될 위험이 있기 때문이다.

이러한 이유로 Question, Answer 같은 엔티티 클래스는 컨트롤러에서 사용하지 않도록 설계하는 것이 좋다. 그래서 Question, Answer를 대신해 사용할 DTO (Data Transfer Object) 클래스가 필요하다. 그리고 Question, Answer 등의 엔티티 객체를 DTO 객체로 변환하는 작업도 필요하다. 그러면 엔티티 객체를 DTO 객체로 변환하는 일은 어디서 처리해야 할까? 이때도 서비스가 필요하다. 서비스는 컨트롤러와 리포지터리의 중간에서 엔티티 객체와 DTO 객체를 서로 변환하여 양방향에 전달하는 역할을 한다.

 
게시판 사람들이 만든거 염탐해본 결과 DTO 도 많이 사용하던데.. 이 책에서는 사용하지 않는다고 한다. 

public class QuestionService {

    private final QuestionRepository questionRepository;

    public List<Question> getList() {
        return this.questionRepository.findAll();
    }
}

 
 
서비스를 이해가기 가장 좋은 방법은 컨트롤러의 변경 전 후를 비교하는 것같다..
 
 
## 컨트롤러 변경전 -> 기존에는 리포지토리에서 findAll을 통해 값을 가지고 왔다. 

public class QuestionController {

    private final QuestionRepository questionRepository;

    @GetMapping("/question/list")
    public String list(Model model) {
        List<Question> questionList = this.questionRepository.findAll();
        model.addAttribute("questionList", questionList);
        return "question_list";
    }
}

 
## 컨트롤러 변경후 -> 변경 후는 서비스를 통해 findAll이 구현되어있는 getList 메소드를 통해 값을 가지고 왔다. 

public class QuestionController {

    private final QuestionService questionService; //이부분 리포에서 서비스로

    @GetMapping("/question/list")
    public String list(Model model) {
        List<Question> questionList = this.questionService.getList();//이부분 getlist로
        model.addAttribute("questionList", questionList);
        return "question_list";
    }
}

 
 
음.. 이렇게만보면 굳이 싶긴한대... 기능이 적어서 그런거같다... 
 
 
그리고 그 뒤 답변기능 추가 등 다양한 기능 구현이 있으나 다 앞에서 개념 응용이라 신기한 건 없어서 블로그에 따로 적어놓진 않았다
 

 
답변 저장까지 그대로 구현
그 뒤는 부트스트랩을 활용한 프론트앤드 구현인데 보안에 있어 프론트앤드가 중요한 부분은아니라 마찬가지로 스킵 -- 
슬픈소식은 위키독스에 나와있는것처럼 index.html 을 만들고 상속해서사용하려고 했는데 안되서 그냥 일일이 코딩해준 이슈가....ㅠ
 

6. 폼

 

폼(form) 클래스 또한 컨트롤러, 서비스와 같이 웹 프로그램을 개발하는 주요 구성 요소 중 하나로, 웹 프로그램에서 사용자가 입력한 데이터를 검증하는 데 사용한다.

 
Spring Boot Validation 라이브러리 설치해서 폼을 사용할 수 있는데 아래와 같은 기능이 있다. 

 
폼은 아래와 같이 구성된다
 

package com.webhacking.arisweb.form;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class QuestionForm {
    @NotEmpty(message="제목은 필수항목입니다.")
    @Size(max=200)
    private String subject;

    @NotEmpty(message="내용은 필수항목입니다.")
    private String content;
}

 
 
questioncontroller 부분 form을 활용한 vaild 검사하도록 수정하기 

@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "question_form";
    }
    this.questionService.create(questionForm.getSubject(), questionForm.getContent());
    return "redirect:/question/list";
}

 
 
 

7.페이징 기능

 

org.springframework.data.domain.Page: 페이징을 위한 클래스이다.
org.springframework.data.domain.PageRequest: 현재 페이지와 한 페이지에 보여 줄 게시물 개수 등을 설정하여 페이징 요청을 하는 클래스이다.
org.springframework.data.domain.Pageable: 페이징을 처리하는 인터페이스이다.

 
해당 패키지들만 있으면 구현 진짜 이지 피지 아파치 
 
 
+ 그리고 부차적인 여러 기능들이 설명되어있지만... 지금 개발이 중요한게 아니기 때문에 빠른 스킵... 
 
 

8. 스프링 시큐리티 ⭐️

 
역시 어딜가든 보안이 제일 어려워...
 

 
진짜 gradle 에 springsecurity 추가만 했는데 접속하자마자 로그인 화면이 뜬다.. 짱신기.. (물론 타임리프 확장팩도 추가함)
 

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

 
 

@EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션이다. 이 애너테이션을 사용하면 스프링 시큐리티를 활성화하는 역할을 한다. 내부적으로 SecurityFilterChain 클래스가 동작하여 모든 요청 URL에 이 클래스가 필터로 적용되어 URL별로 특별한 설정을 할 수 있게 된다. 스프링 시큐리티의 세부 설정은 @Bean 애너테이션을 통해 SecurityFilterChain 빈을 생성하여 설정할 수 있다.

 
 

스프링 시큐리티는 이러한 공격을 방지하기 위해 CSRF 토큰을 세션을 통해 발행하고, 웹 페이지에서는 폼 전송 시에 해당 토큰을 함께 전송하여 실제 웹 페이지에서 작성한 데이터가 전달되는지를 검증한다.

 
.csrf((csrf) -> csrf .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**"))) 

기본적으로 h2-console 은 스프링에 포함되지않기 때문에 csrf 토큰이 자동으로 발급되지 않아 h2-console 접근시 오류가 발생한다. 따라서 위와 같은 코드를 넣어줘 csrf 방지 기능을 무시하도록 한다.
 

이와 같은 오류가 발생하는 원인은 H2 콘솔의 화면이 프레임(frame) 구조로 작성되었기 때문이다. 즉, H2 콘솔 UI(user interface) 레이아웃이 이 화면처럼 작업 영역이 나눠져 있음을 의미한다. 스프링 시큐리티는 웹 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더의 기본값을 DENY로 사용하는데, 프레임 구조의 웹 사이트는 이 헤더의 값이 DENY인 경우 이와 같이 오류가 발생한다.

 
 
스프링에서 클릭재킹 공격 방지를 위해 X-Frame-Options 를 deny 하는 것이 기본값이다. 따라서 h2-console 접속시 화면이 깨지는 현상이 발생한다. 
 
.headers((headers) -> headers .addHeaderWriter(new XFrameOptionsHeaderWriter( XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
 
해당 코드로 deny 기능을 sameorigin으로 대체 -> sameorigin 은 동일 출처를 검증하는 것으로 프레임에 포함된 웹 페이지가 같은 사이트에서 제공하는 경우에는 허락한다.
 
 

9. 회원가입, 로그인 

 
 
회원가입도 비슷하다, user 엔티티와 레포지토리, 서비스를 만든 후 같은 원리로 작성... 앞서 작성한 기능들과 같은 건 제외하고
새로운부분은
 

User 서비스에는 User 리포지터리(UserRepository)를 사용하여 회원(User) 데이터를 생성하는 create 메서드를 추가했다. 이때 User의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다. 그러므로 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.

BCryptPasswordEncoder 클래스는 비크립트(BCrypt) 해시 함수를 사용하는데, 비크립트는 해시 함수의 하나로 주로 비밀번호와 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술이다.

 
역시 보안이! 스프링시큐리티의 BCryptPasswordEncoder 클래스
 
객체를 직접 생성하는 것 보다 bean 을 사용해서 관리하는게 유지보수에 편하다고 한다.

그리고 로그인 부분에 error 처리
 

스프링 시큐리티의 로그인이 실패할 경우에는 시큐리티의 기능으로 인해 로그인 페이지로 리다이렉트된다. 이때 페이지 매개변수로 error가 함께 전달된다. 따라서 로그인 페이지의 매개변수로 error가 전달될 경우 ‘사용자 ID 또는 비밀번호를 확인해 주세요.’라는 오류 메시지를 출력하도록 했다.

 
로그인 기능 구현

스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러 가지가 있는데, 그중에서 가장 간단한 방법으로 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자 ID와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식이 있다. 하지만 우리는 이미 3-06절에서 회원 가입을 통해 회원 정보를 DB에 저장했으므로 DB에서 회원 정보를 조회하여 로그인하는 방법을 사용할 것이다.

 

3-05절에서 언급했지만 스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다. 스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 내용이 필요하다. 그러므로 우리는 사용자가 로그인한 후, ADMIN 또는 USER와 같은 권한을 부여해야 한다.

 
Role enum은 다음과 같이 구성된다. 권한에 따라 기능을 부여할 수 있다. (클라우드가 생각나는 부분...)

package com.webhacking.arisweb;

import lombok.Getter;


@Getter
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    UserRole(String value) {
        this.value = value;
    }

    private String value;

 
 

AuthenticationManager는 스프링 시큐리티의 인증을 처리한다. 
AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리한다.

 

@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

 
 
 
그렇게 여차저차 수정,삭제 기능까지 완성 (물론 복붙... 근데 바이블인 이유가 있다.. 글도 진짜 친절하고 자세하다 점프투 쵝오 b )
 
이상...
스프링 하루동안 즐거웠고 앞으로 크게 볼일 없을듯 싶다..
 
 
-----------
 
 
전에 있던 블로그에 비공개로 쓴 글인데 가끔 상기하려고 가져와봤다 히히