이번 글에서는 Spring Security를 이용하여 회원가입 및 로그인을 구현해보도록 하겠습니다.

전체 코드는 깃헙을 참고하시길 바랍니다.
  • 개발환경
    • IntelliJ 2019.02
    • Java 11
    • SpringBoot 2.1.9
    • Gradle 5.6
  • 라이브러리 일부
    • org.springframework.boot:spring-boot-starter-web:2.1.9.RELEASE 
    • org.projectlombok:lombok:1.18.10
    • org.springframework.boot:spring-boot-starter-data-jpa:2.1.9.RELEASE 
    • org.springframework.boot:spring-boot-starter-thymeleaf:2.1.9.RELEASE
    • org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE
    • org.springframework.boot:spring-boot-starter-security:2.1.9.RELEASE 

[ 프로젝트 구조 ]




1. 의존성 추가
Spring Security를 사용하려면, 의존성을 추가해야합니다.
뿐만 아니라, Thymeleaf에서 Spring Security 통합 모듈을 사용하기 위한 의존성도 추가해줘야 합니다. ( 참고 )

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




2. Spring Security 설정

먼저 이 글의 핵심인 Spring Security를 설정해보도록 하겠습니다.


Spring Security는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있습니다. ( 참고 )


그래서 간단한 구현단계에서는 별도의 로직을 작성하지 않아도 설정만으로 로그인/로그아웃 등의 처리가 가능합니다.

설정은 WebSecurityConfigurerAdapter라는 클래스를 상속받은 클래스에서 메서드를 오버라이딩하여 조정할 수 있는데요, 그 클래스를 구현해보겠습니다.



src/main/java/com/victolee/signuplogin/config/SecurityConfig.java
package com.victolee.signuplogin.config;

import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MemberService memberService;

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

@Override
public void configure(WebSecurity web) throws Exception
{
// static 디렉터리의 하위 파일 목록은 인증 무시 ( = 항상통과 )
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 페이지 권한 설정
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/myinfo").hasRole("MEMBER")
.antMatchers("/**").permitAll()
.and() // 로그인 설정
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/user/login/result")
.permitAll()
.and() // 로그아웃 설정
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/user/logout/result")
.invalidateHttpSession(true)
.and()
// 403 예외처리 핸들링
.exceptionHandling().accessDeniedPage("/user/denied");
}

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
}
}
  • @EnableWebSecurity
    • @Configuration 클래스에 @EnableWebSecurity 어노테이션을 추가하여 Spring Security 설정할 클래스라고 정의합니다.
    • 설정은 WebSebSecurityConfigurerAdapter 클래스를 상속받아 메서드를 구현하는 것이 일반적인 방법입니다. 
  • WebSecurityConfigurerAdapter 클래스
  • passwordEncoder()
    • BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 객체입니다.
    • Service에서 비밀번호를 암호화할 수 있도록 Bean으로 등록합니다.

다음으로 configure() 메서드를 오버라이딩하여, Security 설정을 잡아줍니다.
  • configure(WebSecurity web)
    • WebSecurity는 FilterChainProxy를 생성하는 필터입니다.
    • web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
      • 해당 경로의 파일들은 Spring Security가 무시할 수 있도록 설정합니다.
      • 즉, 이 파일들은 무조건 통과하며, 파일 기준은 resources/static 디렉터리입니다. ( css, js 등의 디렉터리를 추가하진 않았습니다. )
  • configure(HttpSecurity http)
    • HttpSecurity를 통해 HTTP 요청에 대한 웹 기반 보안을 구성할 수 있습니다.
    • authorizeRequests()
      • HttpServletRequest에 따라 접근(access)을 제한합니다.
      • antMatchers() 메서드로 특정 경로를 지정하며, permitAll(), hasRole() 메서드로 역할(Role)에 따른 접근 설정을 잡아줍니다. 여기서 롤은 권한을 의미합니다. 즉 어떤 페이지는 관리지만 접근해야 하고, 어떤 페이지는 회원만 접근해야할 때 그 권한을 부여하기 위해 역할을 설정하는 것입니다. 예를 들어,
        • .antMatchers("/admin/**").hasRole("ADMIN")
          • /admin 으로 시작하는 경로는 ADMIN 롤을 가진 사용자만 접근 가능합니다.
        • .antMatchers("/user/myinfo").hasRole("MEMBER")
          • /user/myinfo 경로는 MEMBER 롤을 가진 사용자만 접근 가능합니다.
        • .antMatchers("/**").permitAll()
          • 모든 경로에 대해서는 권한없이 접근 가능합니다.
        • .anyRequest().authenticated()
          • 모든 요청에 대해, 인증된 사용자만 접근하도록 설정할 수도 있습니다. ( 예제에는 적용 안함 )
    • formlogin()
      • form 기반으로 인증을 하도록 합니다. 로그인 정보는 기본적으로 HttpSession을 이용합니다.
      • /login 경로로 접근하면, Spring Security에서 제공하는 로그인 form을 사용할 수 있습니다.
      • .loginPage("/user/login")
        • 기본 제공되는 form 말고, 커스텀 로그인 폼을 사용하고 싶으면 loginPage() 메서드를 사용합니다.
        • 이 때 커스텀 로그인 form의 action 경로와 loginPage()의 파라미터 경로가 일치해야 인증을 처리할 수 있습니다. ( login.html에서 확인 )
      • .defaultSuccessUrl("/user/login/result")
        • 로그인이 성공했을 때 이동되는 페이지이며, 마찬가지로 컨트롤러에서 URL 매핑이 되어 있어야 합니다.
      • .usernameParameter("파라미터명")
        • 로그인 form에서 아이디는 name=username인 input을 기본으로 인식하는데, usernameParameter() 메서드를 통해 파라미터명을 변경할 수 있습니다. ( 예제에는 적용 안함 )
    • logout()
      • 로그아웃을 지원하는 메서드이며, WebSecurityConfigurerAdapter를 사용할 때 자동으로 적용됩니다.
      • 기본적으로 "/logout"에 접근하면 HTTP 세션을 제거합니다.
      • .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
        • 로그아웃의 기본 URL(/logout) 이 아닌 다른 URL로 재정의합니다.
      • .invalidateHttpSession(true)
        • HTTP 세션을 초기화하는 작업입니다.
      • deleteCookies("KEY명")
        • 로그아웃 시, 특정 쿠기를 제거하고 싶을 때 사용하는 메서드입니다. ( 예제에는 적용안함 )
    • .exceptionHandling().accessDeniedPage("/user/denied");
      • 예외가 발생했을 때 exceptionHandling() 메서드로 핸들링할 수 있습니다.
      • 예제에서는 접근권한이 없을 때, 로그인 페이지로 이동하도록 명시해줬습니다
  • configure(AuthenticationManagerBuilder auth)
    • Spring Security에서 모든 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManager를 생성하기 위해서는 AuthenticationManagerBuilder를 사용합니다.
      • 로그인 처리 즉, 인증을 위해서는 UserDetailService를 통해서 필요한 정보들을 가져오는데, 예제에서는 서비스 클래스(memberService)에서 이를 처리합니다.
      • 서비스 클래스에서는 UserDetailsService 인터페이스를 implements하여, loadUserByUsername() 메서드를 구현하면 됩니다.
    • 비밀번호 암호화를 위해, passwordEncoder를 사용하고 있습니다.


코드는 간단한데, 풀어쓰니 조금 복잡해진 것 같습니다.

특히 HttpSecurity 부분이 조금 복잡한데, 위 링크에서 API들을 참고하시면 좋을 것 같습니다.





3. 컨트롤러 / 서비스 / 도메인 등 구현

다음으로 각 Layer들을 구현해보도록 하겠습니다.

Service를 유념해서 봐주시길 바라며, 로그인, 로그아웃에 대한 로직이 없는 것도 살펴보시길 바랍니다.


1) src/main/java/com/victolee/signuplogin/controller/MemberController.java

package com.victolee.signuplogin.controller;

import com.victolee.signuplogin.dto.MemberDto;
import com.victolee.signuplogin.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@AllArgsConstructor
public class MemberController {
private MemberService memberService;

// 메인 페이지
@GetMapping("/")
public String index() {
return "/index";
}

// 회원가입 페이지
@GetMapping("/user/signup")
public String dispSignup() {
return "/signup";
}

// 회원가입 처리
@PostMapping("/user/signup")
public String execSignup(MemberDto memberDto) {
memberService.joinUser(memberDto);

return "redirect:/user/login";
}

// 로그인 페이지
@GetMapping("/user/login")
public String dispLogin() {
return "/login";
}

// 로그인 결과 페이지
@GetMapping("/user/login/result")
public String dispLoginResult() {
return "/loginSuccess";
}

// 로그아웃 결과 페이지
@GetMapping("/user/logout/result")
public String dispLogout() {
return "/logout";
}

// 접근 거부 페이지
@GetMapping("/user/denied")
public String dispDenied() {
return "/denied";
}

// 내 정보 페이지
@GetMapping("/user/info")
public String dispMyInfo() {
return "/myinfo";
}

// 어드민 페이지
@GetMapping("/admin")
public String dispAdmin() {
return "/admin";
}
}

컨트롤러에서 특별한 것은 없습니다.



2) src/main/java/com/victolee/signuplogin/service/MemberService.java

package com.victolee.signuplogin.service;

import com.victolee.signuplogin.domain.Role;
import com.victolee.signuplogin.domain.entity.MemberEntity;
import com.victolee.signuplogin.domain.repository.MemberRepository;
import com.victolee.signuplogin.dto.MemberDto;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@Service
@AllArgsConstructor
public class MemberService implements UserDetailsService {
private MemberRepository memberRepository;

@Transactional
public Long joinUser(MemberDto memberDto) {
// 비밀번호 암호화
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
memberDto.setPassword(passwordEncoder.encode(memberDto.getPassword()));

return memberRepository.save(memberDto.toEntity()).getId();
}

@Override
public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException {
Optional<MemberEntity> userEntityWrapper = memberRepository.findByEmail(userEmail);
MemberEntity userEntity = userEntityWrapper.get();

List<GrantedAuthority> authorities = new ArrayList<>();

if (("admin@example.com").equals(userEmail)) {
authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(Role.MEMBER.getValue()));
}

return new User(userEntity.getEmail(), userEntity.getPassword(), authorities);
}
}
  • joinUser()
    • 회원가입을 처리하는 메서드이며, 비밀번호를 암호화하여 저장합니다.
  • loadUserByUsername()
    • 상세 정보를 조회하는 메서드이며, 사용자의 계정정보와 권한을 갖는 UserDetails 인터페이스를 반환해야 합니다.
    • 매개변수는 로그인 시 입력한 아이디인데, 엔티티의 PK를 뜻하는게 아니고 유저를 식별할 수 있는 어떤 값을 의미합니다. Spring Security에서는 username라는 이름으로 사용합니다.
      • 예제에서는 아이디가 이메일이며, 로그인을 하는 form에서 name="username"으로 요청해야 합니다.
    • authorities.add(new SimpleGrantedAuthority());
      • 롤을 부여하는 코드입니다. 롤 부여 방식에는 여러가지가 있겠지만, 회원가입할 때 Role을 정할 수 있도록 Role Entity를 만들어서 매핑해주는 것이 좋은 방법인것 같습니다. ( 참고 )
      • 예제에서는 복잡성을 줄이기 위해, 아이디가 "admin@example.com"일 경우에 ADMIN 롤을 부여했습니다.
    • new User()
      • return은 SpringSecurity에서 제공하는 UserDetails를 구현한 User를 반환합니다. ( org.springframework.security.core.userdetails.User )
      • 생성자의 각 매개변수는 순서대로 아이디, 비밀번호, 권한리스트입니다.



3) src/main/java/com/victolee/signuplogin/domain/Role.java

package com.victolee.signuplogin.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
ADMIN("ROLE_ADMIN"),
MEMBER("ROLE_MEMBER");

private String value;
}
  • Service에서 사용하는 Enum객체입니다.



4) src/main/java/com/victolee/signuplogin/domain/repository/MemberRepository.java

package com.victolee.signuplogin.domain.repository;

import com.victolee.signuplogin.domain.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
Optional<MemberEntity> findByEmail(String userEmail);
}
  • Email을 Where 조건절로 하여, 데이터를 가져올 수 있도록 findByEmail() 메서드를 정의했습니다.


5) src/main/java/com/victolee/signuplogin/domain/entity/MemberEntity.java

package com.victolee.signuplogin.domain.entity;

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

import javax.persistence.*;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "member")
public class MemberEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;

@Column(length = 20, nullable = false)
private String email;

@Column(length = 100, nullable = false)
private String password;

@Builder
public MemberEntity(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}

6) src/main/java/com/victolee/signuplogin/dto/MemberDto.java

package com.victolee.signuplogin.dto;

import com.victolee.signuplogin.domain.entity.MemberEntity;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class MemberDto {
private Long id;
private String email;
private String password;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;

public MemberEntity toEntity(){
return MemberEntity.builder()
.id(id)
.email(email)
.password(password)
.build();
}

@Builder
public MemberDto(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
}

Entity와 Dto에서도 특별한 것은 없습니다.





4. 퍼블리싱

마지막으로 HTML을 구현해보도록 하겠습니다.

테스트를 위한 페이지들이 많으며, 핵심 파일은 index.html, login.html 입니다.


1) src/main/resources/templates/admin.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>어드민</title>
</head>
<body>
<h1>어드민 페이지입니다.</h1>
<hr>
</body>
</html>



2) src/main/resources/templates/denied.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>접근 거부</title>
</head>
<body>
<h1>접근 불가 페이지입니다.</h1>
<hr>
</body>
</html>



3) src/main/resources/templates/index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>메인</title>
</head>
<body>
<h1>메인 페이지</h1>
<hr>
<a sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
<a sec:authorize="isAnonymous()" th:href="@{/user/signup}">회원가입</a>
<a sec:authorize="hasRole('ROLE_MEMBER')" th:href="@{/user/info}">내정보</a>
<a sec:authorize="hasRole('ROLE_ADMIN')" th:href="@{/admin}">어드민</a>
</body>
</html>
  • sec:authorize를 사용하여, 사용자의 Role에 따라 보이는 메뉴를 다르게 합니다.
    • isAnonymous()
      • 익명의 사용자일 경우, 로그인, 회원가입 버튼을 노출합니다.
    • isAuthenticated()
      • 인증된 사용자일 경우, 로그아웃 버튼을 노출줍니다.
    • hasRole()
      • 특정 롤을 가진 사용자에 대해, 메뉴를 노출합니다.



4) src/main/resources/templates/login.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인</h1>
<hr>

<form action="/user/login" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />

<input type="text" name="username" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">로그인</button>
</form>
</body>
</html>
  • <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
    • form에 히든 타입으로 csrf 토큰 값을 넘겨줍니다.
    • Spring Security가 적용되면 POST 방식으로 보내는 모든 데이터는 csrf 토큰 값이 필요합니다. ( 뒤에서 살펴 볼 join.html에서는 편리한 방법을 사용하고 있습니다. )
  • <input type="text" name="username" placeholder="이메일 입력해주세요">
    • 로그인 시 아이디의 name 애트리뷰트 값은 username이어야 합니다.



5) src/main/resources/templates/loginSuccess.html

<!DOCTYPE html>
<html lang="en" xmlns:sec="" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그인 성공</title>
</head>
<body>
<h1>로그인 성공!!</h1>
<hr>
<p>
<span sec:authentication="name"></span>님 환영합니다~
</p>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>
  • sec:authentication="name"
    • useranme 값을 가져옵니다. ( 참고 )



6) src/main/resources/templates/logout.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>로그아웃</title>
</head>
<body>
<h1>로그아웃 처리되었습니다.</h1>
<hr>
<a th:href="@{'/'}">메인으로 이동</a>
</body>
</html>



7) src/main/resources/templates/myinfo.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>내정보</title>
</head>
<body>
<h1>내정보 확인 페이지입니다.</h1>
<hr>
</body>
</html>



8) src/main/resources/templates/signup.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원 가입</h1>
<hr>

<form th:action="@{/user/signup}" method="post">
<input type="text" name="email" placeholder="이메일 입력해주세요">
<input type="password" name="password" placeholder="비밀번호">
<button type="submit">가입하기</button>
</form>
</body>
</html>

  • 로그인 페이지와 달리 input hidden 타입으로 csrf 토큰 값을 넘겨주지 않고 있는데요, th:action을 사용하면 Thymeleaf가 csrf 토큰 값을 자동으로 추가해주므로 편리합니다.





5. 테스트

이제 애플리케이션을 실행하고, 회원가입/로그인/로그아웃 및 권한에 따른 메뉴가 잘 보이는지 확인하시길 바랍니다.



  • 1~4
    • 어드민 계정( admin@example.com )으로 회원가입 및 로그인을 진행합니다.
  • 5~6
    • 메인 페이지에서 "어드민" 버튼이 노출되는 것을 확인하고, 접근을 해봅니다.
  • 7
    • 브라우저 URL에 "/user/myinfo"를 입력하여 멤버만 접근할 수 있는 페이지에 접근 불가능한지 확인합니다.
  • 8
    • 로그아웃을 합니다.


다음으로 어드민 이메일이 아닌 아이디로 가입하여 같은 테스트를 해봅니다. ( MEMBER 롤을 가지므로, admin 페이지에는 접근 불가 )





이상으로 Spring Security를 이용하여 회원가입/로그인/로그아웃을 진행해보았습니다.

Security는 설정만 해주면 Filter, Interceptor에서 인증을 해주기 때문에 한 번 작성하고 나면, 딱히 건드리지 않는 부분이긴합니다.


본문에서는 다루지 않았지만, 커스텀 로그인 성공, 로그인 실패, 로그아웃 처리 등을 할 수 있는 handler들이 있습니다.

이 내용도 다루고 싶었지만, 글이 길어지는 관계로 링크로 대체하겠습니다.

AuthenticationSuccessHandler / AuthenticationFailureHandler ( 참고 )

SimpleUrlLogoutSuccessHandler ( 참고 )



[ 참고자료 ]

https://spring.io/guides/topicals/spring-security-architecture#_web_security

https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html#headers--

https://gs.saro.me/dev?tn=481

https://galid1.tistory.com/576

https://velog.io/@hellozin/Spring-Security-Form-Login-%EA%B0%84%EB%8B%A8-%EC%82%AC%EC%9A%A9-%EC%84%A4%EB%AA%85%EC%84%9C-f2jzojj8bj

https://xmfpes.github.io/spring/spring-security/

http://progtrend.blogspot.com/2018/07/spring-boot-security.html

  1. 2019.10.20 21:12

    비밀댓글입니다


Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.

최종 소스는 깃헙에서 확인하실 수 있습니다.


이 글에서는 게시글 목록/상세화면 조회, 수정, 삭제하는 방법에 대해 알아보도록 하겠습니다.




지난 글에서 CRUD 공통으로 사용하는 domain, dto, (Controller, Service 일부) 패키지를 구현했었습니다.

그래서 게시글 목록, 수정, 삭제 처리를 구현하기 위해서는 퍼블리싱, 컨트롤러, 서비스 부분만 구현해주면 됩니다.



1. 게시글 목록

1) 퍼블리싱

src/main/resources/static/css/board.css

table {
border-collapse: collapse;
}

table, th, td {
border: 1px solid black;
}
  • 게시글 목록의 테이블을 꾸며주는 css입니다.

다음으로 게시글 리스트 페이지입니다.
이전 글에서 일부 구현했었는데요, <table>이 수정된 내용입니다.
src/main/resources/templates/board/list.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>

<link rel="stylesheet" th:href="@{/css/board.css}">
</head>
<body>
<!-- HEADER -->
<div th:insert="common/header.html" id="header"></div>

<a th:href="@{/post}">글쓰기</a>

<table>
<thead>
<tr>
<th class="one wide">번호</th>
<th class="ten wide">글제목</th>
<th class="two wide">작성자</th>
<th class="three wide">작성일</th>
</tr>
</thead>

<tbody>
<!-- CONTENTS !-->
<tr th:each="board : ${boardList}">
<td>
<span th:text="${board.id}"></span>
</td>
<td>
<a th:href="@{'/post/' + ${board.id}}">
<span th:text="${board.title}"></span>
</a>
</td>
<td>
<span th:text="${board.writer}"></span>
</td>
<td>
<span th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd HH:mm')}"></span>
</td>
</tr>
</tbody>
</table>

<!-- FOOTER -->
<div th:insert="common/footer.html" id="footer"></div>
</body>
</html>

  • <link rel="stylesheet" th:href="@{/css/board.css}">
    • 앞에서 작성한 board.css를 불러오는 코드입니다.
    • css, js, img 같은 정적자원들을 src/main/resources/static 경로에 저장하면 스프링부트가 인식을 하게 됩니다.
  • <tr th:each="board : ${boardList}">
    • thymeleaf에서 반복문을 사용하는 부분입니다.
    • 컨트롤러가 넘겨주는 변수는 ${ } 으로 받을 수 있습니다.
      • 즉, boardList는 컨트롤러가 넘겨주는 변수이며, 원소는 board 변수로 사용하여 각 속성을 사용할 수 있습니다.
  • <span th:text="${board.id}"></span>
    • 변수 값을 escape 처리하여, 태그의 텍스트로 사용합니다.
  • <a th:href="@{'/post/' + ${board.id}}">
    • 글 제목을 누르면 상세페이지로 이동하기 위해 path variable을 사용했습니다.
  • <span th:text="${#temporals.format(board.createdDate, 'yyyy-MM-dd HH:mm')}"></span>
    • #temporals.format() 메서드를 사용하여 날짜를 포맷팅하는 방법입니다.
    • createdDate는 LocalDateTime 타입이기 때문에 java.util.Date를 사용하는 #dates.format()를 사용하지 않습니다.



2) Controller

src/main/java/com/victolee/board/controller/BoardController.java

package com.victolee.board.controller;

import com.victolee.board.dto.BoardDto;
import com.victolee.board.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;

@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;

/* 게시글 목록 */
@GetMapping("/")
public String list(Model model) {
List<BoardDto> boardList = boardService.getBoardlist();

model.addAttribute("boardList", boardList);
return "/board/list.html";
}


...

}

  • public String list(Model model) { }
    • Model 객체를 통해 View에 데이터를 전달합니다.



3) Service

src/main/java/com/victolee/board/service/BoardService.java

package com.victolee.board.service;

import com.victolee.board.domain.entity.BoardEntity;
import com.victolee.board.domain.repository.BoardRepository;
import com.victolee.board.dto.BoardDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;

@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;

@Transactional
public List<BoardDto> getBoardlist() {
List<BoardEntity> boardEntities = boardRepository.findAll();
List<BoardDto> boardDtoList = new ArrayList<>();

for ( BoardEntity boardEntity : boardEntities) {
BoardDto boardDTO = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();

boardDtoList.add(boardDTO);
}

return boardDtoList;
}


...

}

  • Controller와 Service간에 데이터 전달은 dto 객체로 하기 위해, Repository에서 가져온 Entity를 반복문을 통해 dto로 변환하는 작업이 있습니다.


4) 테스트
이전 글에서 대부분 언급했던 내용들이라, 딱히 추가설명할 부분이 없는 것 같네요.
프로젝트를 실행해보시고, 목록이 잘 출력되는지 확인해보세요.





2. 게시글 상세조회 / 수정 / 삭제

다음으로 게시글 제목을 클릭하면 이동되는 상세페이지와 수정, 삭제 한꺼번에 구현 해보도록 하겠습니다.



1) 게시글 상세조회 페이지 - js

src/main/resources/static/js/board.js

console.log(boardDto);
  • boardDto를 콘솔로 찍어보는 스크립트입니다.
  • 컨트롤러에서 넘겨준 Java 변수를 JS에서 어떻게 사용할 수 있는지 알아보기 위한 예제입니다.



2) 게시글 상세조회 페이지 + 수정/삭제 버튼

src/main/resources/templates/board/detail.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>

<h2 th:text="${boardDto.title}"></h2>
<p th:inline="text">작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]</p>

<p th:text="${boardDto.content}"></p>

<!-- 수정/삭제 -->
<div>
<a th:href="@{'/post/edit/' + ${boardDto.id}}">
<button>수정</button>
</a>

<form id="delete-form" th:action="@{'/post/' + ${boardDto.id}}" method="post">
<input type="hidden" name="_method" value="delete"/>
<button id="delete-btn">삭제</button>
</form>
</div>

<!-- 변수 셋팅 -->
<script th:inline="javascript">
/*<![CDATA[*/
var boardDto = /*[[${boardDto}]]*/ "";
/*]]>*/
</script>

<!-- script -->
<script th:inline="javascript" th:src="@{/js/board.js}"></script>
</body>
</html>
  • <p th:inline="text">작성일 : [[${#temporals.format(boardDto.createdDate, 'yyyy-MM-dd HH:mm')}]]</p>
    • th:text를 사용하면, 태그 사이에 작성한 내용은 사라지고 th:text 값으로 덮어씌어집니다.
    • 이를 해결하기 위해 th:inline 를 사용하며, 변수는 [[ ${ } ]] 으로 표기합니다.
  • <input type="hidden" name="_method" value="delete"/>
    • RESTful API 작성을 위해 hiddenHttpMethodFilter를 이용한 것입니다. ( 참고 )
    • 그러면 form 태그의 method는 post이지만, 실제로는 컨트롤러에서 delete로 매핑이됩니다.
  • /*<![CDATA[*/ ~~~ /*]]>*/
    • JS에서 Java 변수를 사용하기 위한 방식입니다.
    • 위에서 boardDto를 콘솔로 출력하는 스크립트를 작성하였으므로 게시글 상세 페이지에 접근 시, 개발자도구 콘솔창에서 확인할 수 있습니다.



3) 게시글 수정 페이지

src/main/resources/templates/board/update.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:action="@{'/post/edit/' + ${boardDto.id}}" method="post">
<input type="hidden" name="_method" value="put"/>
<input type="hidden" name="id" th:value="${boardDto.id}"/>

제목 : <input type="text" name="title" th:value="${boardDto.title}"> <br>
작성자 : <input type="text" name="writer" th:value="${boardDto.writer}"> <br>
<textarea name="content" th:text="${boardDto.content}"></textarea><br>

<input type="submit" value="수정">
</form>
</body>
</html>
  • <input type="hidden" name="_method" value="put"/>
    • 마찬가지로 Restful API 작성을 위한 것으로, 컨트롤러에서 put 메서드로 매핑이됩니다.
  • <input type="hidden" name="id" th:value="${boardDto.id}"/>
    • hidden 타입을 게시글 id 값을 넘겨준 이유는 JPA(BoardRepository.save())에서 insert와 update를 같은 메서드로 사용하기 때문입니다.
    • 즉, 같은 메서드를 호출하는데 id 값이 없다면 insert가 되는 것이고, id 값이 이미 존재한다면 update가 되도록 동작됩니다. 따라서 Service에서 update를 위한 메서드는 없고, insert와 같은 메서드를 사용합니다.


4) Controller

src/main/java/com/victolee/board/controller/BoardController.java

package com.victolee.board.controller;

import com.victolee.board.dto.BoardDto;
import com.victolee.board.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;

...


@GetMapping("/post/{no}")
public String detail(@PathVariable("no") Long no, Model model) {
BoardDto boardDTO = boardService.getPost(no);

model.addAttribute("boardDto", boardDTO);
return "/board/detail.html";
}

@GetMapping("/post/edit/{no}")
public String edit(@PathVariable("no") Long no, Model model) {
BoardDto boardDTO = boardService.getPost(no);

model.addAttribute("boardDto", boardDTO);
return "/board/update.html";
}

@PutMapping("/post/edit/{no}")
public String update(BoardDto boardDTO) {
boardService.savePost(boardDTO);

return "redirect:/";
}

@DeleteMapping("/post/{no}")
public String delete(@PathVariable("no") Long no) {
boardService.deletePost(no);

return "redirect:/";
}


...
}

  • 위에서부터 차례대로 "게시글 상세조회 페이지", "게시글 수정 페이지", "게시글 수정", "게시글 삭제" 입니다.
  • @GetMapping("/post/{no}")
  • @PathVariable("no") Long no
    • 유동적으로 변하는 Path Variable을 처리하는 방법입니다.
    • URL 매핑하는 부분에서 {변수} 처리를 해주면, 메서드 파라미터로 @PathVariable("변수") 이렇게 받을 수 있습니다.
  • update() 
    • 게시글 추가에서 사용하는 boardService.savePost() 메서드를 같이 사용하고 있습니다.



5) Service

src/main/java/com/victolee/board/service/BoardService.java

package com.victolee.board.service;

import com.victolee.board.domain.entity.BoardEntity;
import com.victolee.board.domain.repository.BoardRepository;
import com.victolee.board.dto.BoardDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;

...

@Transactional
public BoardDto getPost(Long id) {
Optional<BoardEntity> boardEntityWrapper = boardRepository.findById(id);
BoardEntity boardEntity = boardEntityWrapper.get();

BoardDto boardDTO = BoardDto.builder()
.id(boardEntity.getId())
.title(boardEntity.getTitle())
.content(boardEntity.getContent())
.writer(boardEntity.getWriter())
.createdDate(boardEntity.getCreatedDate())
.build();

return boardDTO;
}

@Transactional
public Long savePost(BoardDto boardDto) {
return boardRepository.save(boardDto.toEntity()).getId();
}

@Transactional
public void deletePost(Long id) {
boardRepository.deleteById(id);
}
}

  • findById()
    • PK 값을 where 조건으로 하여, 데이터를 가져오기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.
    • 반환 값은 Optional 타입인데, 엔티티를 쏙 빼오려면 boardEntityWrapper.get(); 이렇게 get() 메서드를 사용해서 가져옵니다.
  • deleteById()
    • PK 값을 where 조건으로 하여, 데이터를 삭제하기 위한 메서드이며, JpaRepository 인터페이스에서 정의되어 있습니다.



6) 테스트

프로젝트를 실행해보시고, 상세조회, 수정, 삭제가 잘 되는지 확인해봅니다.





이상으로 게시판 CRUD를 구현해보았습니다.

다루고 싶은 내용들은 많이 있는데, 내용이 길어지면 복잡성만 늘어나서 최대한 간단하게 해보려 했습니다.


시스템 또는 UI가 복잡해지면, 더 많은 문법과 레퍼런스들을 찾아봐야 할 것입니다.

그래도 스프링부트에서 기본적인 CRUD를 구현해봤다는 것에 의의를 두시길 바라며, 진입장벽을 낮추는데 도움이 되셨기를 바랍니다!

잘못된 점 또는 더 나은 방법에 대해서는 댓글로 공유해주시면 감사하겠습니다.



Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.

최종 소스는 깃헙에서 확인하실 수 있습니다.


이 글에서는 게시글을 추가하는 작업을 해보도록 하겠습니다.






1. 퍼블리싱

게시글을 추가하려면 입력할 수 있는 폼이 있어야 하기 때문에, 퍼블리싱을 먼저 해보려 합니다.


1) 헤더와 푸터

templates/common/header.html

<h1>헤더 입니다.</h1>
<hr>

templates/common/footer.html

<hr>
<h1>푸터입니다.</h1>

모든 페이지에 존재하는 헤더와 푸터입니다.




2) 게시글 리스트 페이지

templates/board/list.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<!-- HEADER -->
<div th:insert="common/header.html" id="header"></div>

<a th:href="@{/post}">글쓰기</a>

<!-- FOOTER -->
<div th:insert="common/footer.html" id="footer"></div>
</body>
</html>

thymeleaf 문법을 볼 수 있습니다.

  • xmlns:th="http://www.w3.org/1999/xhtml"
    • IntelliJ에서 thymeleaf 문법 사용 시, 문법 에러가 발생하여 추가하였습니다.
    • 꼭 추가를 안해도 정상동작 하지만, 빨간줄이 싫어서 추가했습니다.
  • th:insert
    • 헤더와 푸터처럼 다른 페이지를 현재 페이지에 삽입하는 역할을 합니다. ( 참고 )
    • JSP의 include와 같습니다.
  • th:href
    • thymeleaf에서 html 속성은 대부분 이처럼 th: 으로 바꿔서 사용할 수 있습니다.
    • @{ } 의 의미는 애플리케이션이 설치된 경로를 기준으로 하는 상대경로 입니다. ( 참고 )
      • 예제에서 @{/post}는 URL이 http://localhost:8080/post 가 되겠네요.

다음 글에서도 thymeleaf에 대한 내용을 몇 가지 더 소개할 예정이지만 모두 다룰 수 없어서, 그 밖에 thymeleaf 레퍼런스는 위 링크의 공식문서 또는 여기를 참고하면 좋을 것 같습니다.




3) 글쓰기 입력 폼 페이지

templates/board/write.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/post" method="post">
제목 : <input type="text" name="title"> <br>
작성자 : <input type="text" name="writer"> <br>
<textarea name="content"></textarea><br>

<input type="submit" value="등록">
</form>
</body>
</html>

입력 폼 페이지에서 특별한 것은 없습니다.





2. Controller

다음으로 URL을 매핑하고, 비즈니스 로직 함수를 호출하여 view에 뿌려주는 역할을 하는 컨트롤러를 구현해보겠습니다.

이 글에서는 게시글을 DB에 INSERT하는 write() 메서드만 구현하고, 나머지 메서드는 다음 글에서 작성하도록 하겠습니다.

src/main/java/com/victolee/board/controller/BoardController.java

package com.victolee.board.controller;

import com.victolee.board.dto.BoardDto;
import com.victolee.board.service.BoardService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@AllArgsConstructor
public class BoardController {
private BoardService boardService;

@GetMapping("/")
public String list() {
return "/board/list.html";
}

@GetMapping("/post")
public String write() {
return "/board/write.html";
}

@PostMapping("/post")
public String write(BoardDto boardDto) {
boardService.savePost(boardDto);

return "redirect:/";
}
}

  • @Controller
    • 컨트롤러임을 명시하는 어노테이션입니다.
    • MVC에서 컨트롤러로 명시된 클래스의 메서드들은 반환 값으로 템플릿 경로를 작성하거나, redirect를 해줘야 합니다.
      • 템플릿 경로는 templates 패키지를 기준으로한 상대경로입니다.
    • @RestController도 존재하는데, 이는 @Controller, @ResponseBody가 합쳐진 어노테이션입니다.
      • view 페이지가 필요없는 API 응답에 어울리는 어노테이션입니다.
  • @AllArgsConstructor
    • Bean 주입 방식과 관련이 있으며, 생성자로 Bean 객체를 받는 방식을 해결해주는 어노테이션입니다. 그래서 BoardService 객체를 주입 받을 때 @Autowired 같은 특별한 어노테이션을 부여하지 않았습니다. ( 참고 )
    • 그 밖에, @NoArgsConstructor @RequiredArgsConstructor 어노테이션이 있습니다. ( 참고 )
  • @GetMapping / @PostMapping
    • URL을 매핑해주는 어노테이션이며, HTTP Method에 맞는 어노테이션을 작성하면 됩니다. ( 참고 )
  • list() 메서드는 지금 구현하지 않고, 다음 글에서 구현할 것입니다.
  • dto는 Controller와 Service 사이에서 데이터를 주고 받는 객체를 의미합니다.




3. Service

다음으로 비즈니스 로직을 수행하는 Service를 구현해보도록 하겠습니다.

src/main/java/com/victolee/board/service/BoardService.java

package com.victolee.board.service;

import com.victolee.board.domain.entity.BoardEntity;
import com.victolee.board.domain.repository.BoardRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@AllArgsConstructor
@Service
public class BoardService {
private BoardRepository boardRepository;

@Transactional
public Long savePost(BoardDto boardDto) {
return boardRepository.save(boardDto.toEntity()).getId();
}
}

  • @AllArgsConstructor
    • Controller에서 봤던 어노테이션 입니다.
    • 마찬가지로 Repository를 주입하기 위해 사용합니다.
  • @Service
    • 서비스 계층임을 명시해주는 어노테이션입니다.
  • @Transactional
    • 선언적 트랜잭션이라 부르며, 트랜잭션을 적용하는 어노테이션입니다.
  • save()
    • JpaRepository에 정의된 메서드로, DB에 INSERT, UPDATE를 담당합니다.
    • 매개변수로는 Entity를 전달합니다.





4. Repository

다음으로 데이터 조작을 담당하는 Repository를 구현해보겠습니다.

src/main/java/com/victolee/board/domain/repository/BoardRepository.java

package com.victolee.board.domain.repository;

import com.victolee.board.domain.entity.BoardEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<BoardEntity, Long> {
}
  • Repository는 인터페이스로 정의하고, JpaRepository 인터페이스를 상속받으면 됩니다.
    • JpaRepository의 제네릭 타입에는 Entity 클래스와 PK의 타입을 명시해주면 됩니다.
    • JpaRepository에는 일반적으로 많이 사용하는 데이터 조작을 다루는 함수가 정의되어 있기 때문에, CRUD 작업이 편해집니다.





5. Entity

다음으로 DB 테이블과 매핑되는 객체를 정의하는 Entity를 구현해보겠습니다.

Entity는 JPA와 관련이 깊습니다.


1) BoardEntity 구현

src/main/java/com/victolee/board/domain/entity/BoardEntity.java

package com.victolee.board.domain.entity;

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

import javax.persistence.*;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "board")
public class BoardEntity extends TimeEntity {

@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;

@Column(length = 10, nullable = false)
private String writer;

@Column(length = 100, nullable = false)
private String title;

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

@Builder
public BoardEntity(Long id, String title, String content, String writer) {
this.id = id;
this.writer = writer;
this.title = title;
this.content = content;
}
}
  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 파라미터가 없는 기본 생성자를 추가하는 어노테이션입니다. ( JPA 사용을위해 기본 생성자 생성은 필수 )
      • access는 생성자의 접근 권한을 설정하는 속성이며, 최종적으로 protected BoardEntity() { }와 동일합니다.
      • protected인 이유는 Entity 생성을 외부에서 할 필요가 없기 때문입니다.
  • @Getter
    • 모든 필드에 getter를 자동생성 해주는 어노테이션입니다.
    • @Setter 어노테이션은 setter를 자동생성 해주지만, 무분별한 setter 사용은 안정성을 보장받기 어려우므로 Builder 패턴을 사용합니다. ( 참고 - 무분별한 Setter 남용)
      • 참고로 @Getter와 @Setter를 모두 해결해주는 @Data 어노테이션도 있습니다.
  • @Entity
    • 객체를 테이블과 매핑 할 엔티티라고 JPA에게 알려주는 역할을 하는 어노테이션 입니다. ( 엔티티 매핑 )
    • @Entity가 붙은 클래스는 JPA가 관리하며, 이를 엔티티 클래스라 합니다.
  • @Table(name = "board")
    • 엔티티 클래스와 매핑되는 테이블 정보를 명시하는 어노테이션입니다.
    • name 속성으로 테이블명을 작성할 수 있으며, 생략 시 엔티티 이름이 테이블명으로 자동 매핑됩니다.
  • @Id
    • 테이블의 기본 키임을 명시하는 어노테이션 입니다.
    • 저는 일반적으로 Id를 대체키로 사용하는 것이 좋다는 관점이며, Long 타입을 사용합니다.
  • @GeneratedValue(strategy= GenerationType.IDENTITY)
    • 기본키로 대체키를 사용할 때, 기본키 값 생성 전략을 명시합니다. ( 참고 )
  • @Column
    • 컬럼을 매핑하는 어노테이션입니다.
  • @Builder
    • 빌더패턴 클래스를 생성해주는 어노테이션입니다.
    • @Setter 사용 대신 빌더패턴을 사용해야 안정성을 보장할 수 있습니다.


Entity 클래스는 테이블과 관련이 있는 것을 알 수 있습니다.

비즈니스 로직은 Entity를 기준으로 돌아가기 때문에 Entity를 Request, Response 용도로 사용하는 것은 적절하지 못합니다.

그래서 데이터 전달목적을 갖는 dto 클래스를 정의하여 사용합니다.

이와 관련하여 이 글이 글을 참고해보시면 좋을 것 같네요.




2) TimeEntity 구현

다음으로 BoardEntity는 TimeEntity를 상속하고 있는데요.

TimeEntity는 데이터 조작 시 자동으로 날짜를 수정해주는 JPA의 Auditing 기능을 사용합니다.

src/main/java/com/victolee/board/domain/entity/TimeEntity.java

package com.victolee.board.domain.entity;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;

@LastModifiedDate
private LocalDateTime modifiedDate;
}
  • @MappedSuperclass
    • 테이블로 매핑하지 않고, 자식 클래스(엔티티)에게 매핑정보를 상속하기 위한 어노테이션입니다.
  • @EntityListeners(AuditingEntityListener.class)
    • JPA에게 해당 Entity는 Auditing기능을 사용한다는 것을 알리는 어노테이션입니다.
  • @CreatedDate
    • Entity가 처음 저장될때 생성일을 주입하는 어노테이션입니다.
    • 이때 생성일은 update할 필요가 없으므로, updatable = false 속성을 추가합니다. ( 참고 )
      • 속성을 추가하지 않으면 수정 시, 해당 값은 null이 되어버립니다.
  • @LastModifiedDate
    • Entity가 수정될때 수정일자를 주입하는 어노테이션입니다.



3) @EnableJpaAuditing
마지막으로 JPA Auditing 활성화를 위해 Application에서 @EnableJpaAuditing 어노테이션을 추가해줍니다.
src/main/java/com/victolee/board/BoardApplication.java
package com.victolee.board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}




4) Plugin

또한 IntelliJ에서 Lombok을 사용하려면 플러그인을 설치해야 합니다.





6. DTO

마지막으로 데이터 전달 객체인 dto를 구현해보겠습니다.

src/main/java/com/victolee/board/dto/BoardDto.java

package com.victolee.board.dto;

import com.victolee.board.domain.entity.BoardEntity;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class BoardDto {
private Long id;
private String writer;
private String title;
private String content;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;

public BoardEntity toEntity(){
BoardEntity build = BoardEntity.builder()
.id(id)
.writer(writer)
.title(title)
.content(content)
.build();
return build;
}

@Builder
public BoardDto(Long id, String title, String content, String writer, LocalDateTime createdDate, LocalDateTime modifiedDate) {
this.id = id;
this.writer = writer;
this.title = title;
this.content = content;
this.createdDate = createdDate;
this.modifiedDate = modifiedDate;
}
}
  • toEntity()
    • dto에서 필요한 부분을 빌더패턴을 통해 entity로 만듭니다.
      • 필요한 Entity는 이런식으로 추가하면 됩니다.
  • dto는 Controller <-> Service <-> Repository 간에 필요한 데이터를 캡슐화한 데이터 전달 객체입니다.
    • 그런데 예제에서 Service에서 Repository 메서드를 호출할 때, Entity를 전달한 이유는 JpaRepository에 정의된 함수들은 미리 정의되어 있기 때문입니다. 그래서 Entity를 전달할 수 밖에 없었는데, 요점은 각 계층에서 필요한 객체전달은 Entity 객체가 아닌 dto 객체를 통해 주고받는 것이 좋다는 것입니다.





7. 테스트

스프링부트를 시작하고, http://localhost:8080/에 접속합니다.



게시글을 등록하고 MySQL에서 확인을 해보면 정상적으로 저장이되고, 등록일과 수정일 또한 자동으로 추가가 된 것을 확인할 수 있습니다.

테이블이 자동 생성된 이유는 application.yml 파일에 spring.jpa.hibernate.ddl_auto: create와 관련이 있습니다.





***error


IntelliJ에서 실행시, 위와 같이 cannot find symbol 에러가 발생한다면, 설정에서 Enable annotation processing을 체크해주도록 합니다.







이상으로 게시글 등록을 구현해보았습니다.

전반적인 내용들을 다루기 때문에 글이 길어졌네요.

다음 글에서는 게시글 조회, 수정, 삭제 방법에 대해 알아보도록 하겠습니다.


[ 참고자료 ]

https://www.thymeleaf.org/doc/articles/layouts.html

https://www.thymeleaf.org/doc/articles/standardurlsyntax.html#context-relative-urls

https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-ann-requestmapping

https://jojoldu.tistory.com/251

https://www.popit.kr/실무에서-lombok-사용법

https://blusky10.tistory.com/316

https://gmlwjd9405.github.io/2018/12/25/difference-dao-dto-entity.html

http://blog.devenjoy.com/?p=383


Springboot로 디자인이 하나도 없고 매우 간단한 게시판을 구현하는 시리즈입니다.

최종 소스는 깃헙에서 확인하실 수 있습니다.


시리즈를 시작하기에 앞서...

프로젝트 구조나 개발 방법은 개인마다 조직마다 차이가 있습니다.

이 시리즈에서 제시 및 구현하는 방법 역시 정답은 아니며, 단지 입문자에게 참고가 되기를 바라며 작성하였습니다.

혹시라도 글을 읽으시다가 더 좋은 방법이나 팁을 공유해주시면 많은 분들에게 도움이 될 것 같습니다!


  • 개발환경
    • IntelliJ 2019.02
    • Java 11
    • SpringBoot 2.1.8
    • Gradle 5.6
    • MySQL 8.0.16
  • 라이브러리
    • JPA
    • Thymeleaf
    • Lombok
    • LiveReload





1. 프로젝트 생성 및 환경 설정

먼저 프로젝트를 생성하여 환경 설정을 해보도록 하겠습니다.


  • 프로젝트 생성
    • 이글을 참고하여, Hello world를 출력해봅니다. 
    • 선택할 Dependency는 다음과 같습니다.
  • Database 환경 설정
      • 위의 두 글을 참고하여, MySQL 연동 및 JPA 설정을 셋팅합니다.


설정과 관련된 주요 파일은 다음과 같습니다.

bulid.gradle

plugins {
id 'org.springframework.boot' version '2.1.8.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}

group = 'com.vitolee.board'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
developmentOnly
runtimeClasspath {
extendsFrom developmentOnly
}
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}


apllication.yml

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/board?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 1234

jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
show-sql: true
hibernate:
format_sql: true
ddl-auto: create

logging:
level:
org:
hibernate:
SQL: DEBUG
type:
descriptor:
sql:
BasicBinder: TRACE





2. 프로젝트 구조

프로젝트를 생성하였으면, 디렉토리와 파일을 생성하여 구조만 잡아보도록 하겠습니다.



파일은 이후의 글에서 관련 내용이 나올때, 그 때 추가하셔도 되고, 지금 미리 추가하셔도 됩니다.


자바 파일의 디렉토리를 위와 같이 구성한 이유는 3 Layer Architecture와 관련이 있습니다.

각 디렉터리의 역할은 다음과 같습니다.

  • controller
    • URL과 실행 함수를 매핑
    • 비즈니스 로직이 있는 service를 호출하여 비즈니스 로직 처리
    • 반환할 템플릿을 정의 및 JSON 등으로 응답
  • service
    • 비즈니스 로직을 구현
    • 데이터 처리(모델)를 담당하는 repository에서 데이터를 가져와서 controller에 넘겨주거나, 비즈니스 로직을 처리
  • domain > entity
    • DB 테이블과 매핑되는 객체(Entity)를 정의
    • JPA에서는 Entity를 통해 데이터를 조작함
  • domain > repository
    • 데이터를 가져오거나 조작하는 함수를 정의
    • Interface를 implements하여 미리 만들어진 함수를 사용할 수 있으며, 또한 직접 구현이 가능
  • dto
    • controller와 service 간에 주고 받을 객체를 정의하며, 최종적으로는 view에 뿌려줄 객체
    • Entity와 속성이 같을 수 있으나, 여러 service를 거쳐야 하는 경우 dto의 몸집은 더 커짐
      • ex) AEntity에 a 속성, BEntity에 b속성이 있을 때, ZDto에 a,b 속성으로 정의될 수 있음
      • entity와 dto를 분리한 이유는 Entity는 DB 테이블이 정의되어 있으므로, 데이터 전달 목적을 갖는 객체인 dto를 정의하는 것이 좋다고 합니다. ( 참고 )


다음으로 resources 디렉터리의 역할은 다음과 같습니다.

  • static
    • css, js, img 등의 정적 자원들을 모아놓은 디렉터리입니다.
  • templates
    • 템플릿을 모아놓은 디렉터리입니다.
    • Thymeleaf는 HTML을 사용합니다.




3. devtools

다음은 개발 편의를 제공하는 LiveReload를 적용해주시면 좋습니다.

매번 서버를 껏다 켤 필요가 없기 때문이죠.


설정 방법은 여기를 참고해주세요!





이상으로 준비작업을 모두 마쳤습니다.

다음 글에서는 게시글을 추가하는 작업을 통해, 내부적으로 좀 더 알아보도록 하겠습니다.



[ 참고자료 ]

https://jojoldu.tistory.com/251#2-3-controller--dto-구현

https://haviyj.tistory.com/11


이 글에서는 스프링부트에서 JPA를 사용할 때 apllication.yml을 어떻게 설정해야 하는지에 대해 알아보도록 하겠습니다.

DB는 h2가 아닌 MySQL을 기준으로 다룬다는 점을 참고해주세요.

( h2 DB를 사용하신다면, 아래의 datasource를 생략하셔도 됩니다. )  



apllication.yml

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/example?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 1234

jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
show-sql: true
hibernate:
format_sql: true
ddl-auto: create

logging:
level:
org:
hibernate:
SQL: DEBUG
type:
descriptor:
sql:
BasicBinder: TRACE
  • spring.datasource
    • datasouce는 MySQL 설정과 관련된 것이므로 여기를 참고해주세요.
  • spring.jpa.database-platform
    • JPA 데이터베이스 플랫폼을 지정합니다.
    • 예제에서는 MySQL InnoDB를 사용하도록 설정했습니다.
  • spring.jpa.open-in-view
  • spring.jpa.show-sql
    • 콘솔에 JPA 실행 쿼리를 출력합니다.
  • spring.jpa.hibernate.format_sql
    • 콘솔에 출력되는 JPA 실행 쿼리를 가독성있게 표현합니다.
  • spring.jpa.hibernate.ddl_auto
    • 데이터베이스 초기화 전략을 설정합니다.
      • none
        • 아무것도 실행하지 않습니다.
      • create
        • SessionFactory가 시작될 때 기존테이블을 삭제 후 다시 생성합니다.
      • create-drop
        • create와 같으나 SessionFactory가 종료될 때 drop을 실행합니다.
      • update
        • 변경된 스키마만 반영합니다.
      • validate
        • 엔티티와 테이블이 정상적으로 매핑되었는지만 확인합니다.
  • logging.level.org.hibernate.type.descriptor.sql
    • SQL에서 물음표로 표기된 부분( bind parameter )을 로그로 출력해서 확인할 수 있습니다.

제가 주로 사용하는 설정은 위와 같습니다.
이 외에도 다양한 설정등이 있는데요, 개인적으로는 어노테이션으로 해결 가능한 것들은 어노테이션으로 처리하는 것을 좋아하기 때문에 설정을 잡지 않았습니다.
  • spring.jpa.hibernate.naming
    • 엔티티와 테이블에 대한 네이밍 전략
  • spring.jpa.hibernate.use-new-id-generator-mappings
    • auto increment에 대한 설정



이상으로 SpringBoot application.yml 파일에서 JPA를 설정하는 방법에 대해 알아보았습니다.

Hibernate, SpringBoot 버전마다 설정명, 기본값 등이 약간씩 차이가 있는 것 같네요.


[참고 자료]

https://jistol.github.io/java/2017/07/12/springboot-hibernate-sql-options/

https://www.popit.kr/스프링-부트-auto-configuration과-jpa하이버네이트-sql문-로깅



  • 개발환경
    • IntelliJ 2019.02
    • Java 11
    • SpringBoot 2.1.7
    • mysql-connector-java:8.0.17
    • Gradle 5.6


스프링부트 프로젝트를 생성할 때, 내장된 데이터베이스 h2가 아닌 MySQL을 사용하려고 했습니다.

application.properties 또는 application.yml 파일에 커넥션 정보를 작성해주면, 스프링부트가 이 정보를 오버라이드하여 MySQL을 사용할 수 있도록 한다고 하는데요.


아무리 설정을 잡아줘도 아래의 에러 때문에 Run(실행)이 안되서 고생을 했었습니다.

Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class


이 글에서는 위의 에러를 해결하여 MySQL을 연동하는 방법에 대해 알아보도록 하겠습니다.





1. 스프링부트 프로젝트 생성

Spring Initializr에서 Gradle로 빌드하여 스프링부트 프로젝트를 생성하도록 합니다.

의존성은 어떤걸 추가해도 상관없으며, SQL > MySQL Driver만 선택했을 경우에 build.gradle 파일은 다음과 같습니다.


build.gradle

plugins {
id 'org.springframework.boot' version '2.1.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
runtimeOnly 'mysql:mysql-connector-java'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}





2. 빌드 및 실행

프로젝트를 생성하고 빌드를 할 경우, 아래와 같은 에러가 발생할 것입니다.



스프링부트에서 밀고있는 h2 데이터베이스를 사용할 경우에는 AutoConfigure에서 설정을 잡아주기 때문에 별도의 설정이 필요없습니다.

하지만 MySQL을 사용한다면 src/main/resources/application.properties 파일에서 커넥션 정보를 작성하여 설정을 잡아줘야 합니다.


src/main/resources/application.properties

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/example?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234

  • driver-class-name
    • com.mysql.jdbc.Driver와 com.mysql.cj.jdbc.Driver 두가지가 있습니다.
    • 전자는 Deprecated이므로 com.mysql.cj.jdbc.Driver를 사용하도록 합니다.
  • url
    • localhost:3306/example
      • example은 database를 의미합니다.
    • serverTimezone=UTC
      • URL 쿼리 스트링에 serverTimezone을 작성해줘야 에러가 발생하지 않으므로, 꼭 작성하도록 합니다.
  • username / password
    • 계정명과 비밀번호를 작성하면 됩니다.


properties 파일이 아닌, yaml로 설정할 경우 아래와 같이 작성하시면 됩니다. ( yaml이 대세라고해서 써봤어요 ㅎㅎ )

src/main/resources/application.yml

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/example?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 1234


이렇게 설정을 잡고, Run(실행)을 했을 때 잘 돌아가면 연동은 이것으로 끝입니다.





3. DataSource 오류 발생 및 해결

설정을 잡아주고 실행을 했는데도 똑같은 에러가 발생한다면, 다음과 같이 처리해주시면 됩니다.


1) 라이브러리 체크

mysql-connector-java 라이브러리가 설치되어 있는지 확인합니다.



설치되어 있지 않다면, 의존성 추가가 안된것이므로 빌드를 하여 의존성을 추가해줍니다.



2) MySQL 접속 체크

설정 정보에 이상이 있는지, 즉 시스템의 MySQL 접속이 가능한지 IntelliJ에서 확인해볼 수 있습니다.

우측의 Database > + 버튼 > Data Source > MySQL 클릭




그러면 Data Sources and Drivers 팝업창이 뜨는데, 커넥션 정보를 작성하고 Test Connection을 해줍니다.



커넥션이 안되면, 시스템 MySQL에 접근을 못한다는 것이므로 이를 먼저 해결해줘야 합니다.



3) gradle project re-import

위의 체크사황에 이상이 없으면, Message 탭에서 메시지를 확인합니다.

Warning: Unable to make the module: demo.main, related gradle configuration was not found. Please, re-import the Gradle and try again




re-import 메시지를 확인했으면, 우측의 Gradle 탭 > 우클릭 > Reimport Gradle Project 클릭 합니다.




새로고침 > 빌드 > Run을 클릭합니다.




그러면 아래와 같이 Edit Configuration 팝업이 뜨는데요.

Use classpath of module을 선택해줍니다.



그리고 Run을 클릭하면, 초기 메시지는 사라지고 실행이 정상적으로 되는 것을 확인할 수 있습니다.



4) 성공



5) 그래도 안된다면..

File > Invalidate Caches / Restart ... 를 클릭해서 재시도 합니다.





이상으로 스프링부트에서 MySQL을 연동해보았습니다.

application.properties 또는 application.yml 파일에서 설정만 해주면 쉽게 연동이 가능합니다.


그런데.. 에러때문에 기나긴 삽질을 했네요... IntelliJ 문제 같긴했는데 역시나였습니다 ㅎㅎ

다른 분들도 잘 해결 되시길 바랍니다.


  1. sorry 2019.10.07 20:44

    3번 방법으로 해결했습니다 감사합니다~


IntelliJ IDE에서 Spring Initializr로 스프링부트 프로젝트를 생성할 수 있습니다.

https://start.spring.io/에서 스프링부트 프로젝트를 생성할 수도 있습니다. )




1. 프로젝트 생성

1)


New Project > Spring Initializr에서 SDK를 설정합니다.



2)

  • Group
    • 기본 패키지명
  • Artifact
    • 프로젝트 명
  • Type
    • 빌드 타입
  • Language
    • 사용 언어
  • Packaging
    • 패키징 방법
  • Java Version
    • 자바 버전

Gradle로 빌드할 것이므로 Type을 Gradle Project로 설정하면 되고, 나머지 정보는 각자 작성하시면 됩니다.




3)


의존성을 추가합니다.

예제에서는 일반적으로 많이 사용하는 것들을 추가했으며며, 본인이 필요하신 것들을 추가하시면 됩니다.


주로 다루는 언어, 프레임워크가 아니다보니, 관련 라이브러리에 대한 설명은 링크로 대체합니다!

주변에서 사용하시는 분들의 의견을 들어보면, Lombok은 JPA와 같이 사용시에 자잘한 오류가 발생했다고 하는데, 큰 이슈는 아닌 것 같습니다.

또 Web-flux는 WebTestClient가 있어서 테스트시에 주로 사용한다고 하셨습니다.



4)


프로젝트 경로를 적절히 선택하고 "Finish"를 클릭하여 프로젝트 생성을 마칩니다.





2. 빌드 오류

프로젝트를 생성하면 IDE에서 자동으로 빌드를 해주는데, 계속 빌드에 실패했었습니다.

( 저만의 이슈일 수 있으니, 빌드가 잘 되면 해당 부분은 넘어가시면 됩니다. )


[error 메시지]

Plugin [id: 'org.springframework.boot', version: '2.1.7.RELEASE'] was not found in any of the following sources:


- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)

- Plugin Repositories (could not resolve plugin artifact 'org.springframework.boot:org.springframework.boot.gradle.plugin:2.1.7.RELEASE')

  Searched in the following repositories:

    Gradle Central Plugin Repository



위의 에러는 settings.gradle 파일을 수정해서 빌드시킬수 있는데요, ( 참고 )

빌드를 성공해도 gradle에 추가한 dependencies가 External Libaries에 추가되지 않아, 스프링부트를 실행하지 못했었습니다.


제가 해결했던 방법은 IntelliJ를 2019 버전으로 사용하는 것입니다.

IntelliJ 2018 버전에서 도저히 실행이 안되서 혹시하고 2019 버전으로 재설치하니 잘 되더라구요.

( IntelliJ 2018 버전으로 잘 사용하고 계신다면, 어떻게 해결할 수 있는지 알고싶네요... )



위의 사진과 같이 External Libraries에 라이브러리들이 추가되면 성공입니다!





3. Hello World!

External Libraries에 의존성 라이브러리들이 추가되고, 빌드가 성공되셨다면 Hello World를 출력하는 것은 매우 쉽습니다.



위와 같이 Controller 패키지와 하위에 BoardController.java 파일을 생성하여 아래와 같이 작성해주시면 됩니다.

BoardController.java

package com.victolee.board.Controller;

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

@RestController
public class BoardController {
@GetMapping("/test")
public String test() {
return "Hello World!";
}
}


그 다음 BoardApplication에서 Run을 해주면 아래 사진처럼 로그가 출력됩니다.




마지막으로 컨트롤러에서 매핑한 URL( http://localhost:8080/test )을 브라우저에서 접근해보시면, Hello World가 출력되는 것을 확인하실 수 있습니다.





4. 어떻게 실행되는걸까?

특별히 한것도 없는데 어떻게 실행되는 걸까요?


모든 것은 Application 파일에 있는 @SpringBootApplication 어노테이션에 있습니다.

package com.vitolee.board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardApplication {

public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}

}

이에 관련된 내용은 링크를 참고하시길 바랍니다.



이상으로 IntelliJ + Gradle + SpringBoot로 프로젝트를 생성하여 웹에서 Hello World를 출력해보았습니다.

빌드 오류를 해결하려고 몇 일을 날렸는지 모르겠네요 ㅜㅜ



1. 인덱스

인덱스는 키 값으로 행 데이터의 위치를 식별하는데 사용하는 기능입니다.

그러기 위해서는 원본 테이블을 기준으로 잘 정렬된 별도의 테이블, 즉 인덱스 테이블을 생성해야 하고, 이로 인해 데이터 엑세스 성능을 높일 수 있습니다.

인덱스의 존재 유무에 따라 쿼리의 결과는 달라지지 않습니다.


인덱스를 효과적으로 사용하려면 정규화가 되어 있어야 합니다.

정규화가 되어 있지 않은 테이블은 컬럼이 많으며, 이에 따라 조합할 수 있는 인덱스가 많아지게 됩니다.

인덱스가 많으면, 갱신 성능이 나빠지고 디스크 공간도 많아지므로 인덱스를 효과적으로 사용하려면 정규화가 잘 되어 있어야 합니다.



인덱스 특징

인덱스는 다음과 같은 특징이 있습니다.

  • 인덱스 테이블
    • 이진트리 검색( 뒤에서 살펴볼 인덱스 종류 )을 위해서는 미리 데이터가 정렬되어 있어야 합니다.
    • 하지만 데이터가 항상 정렬되어 있기란 어렵기 때문에, 특정 컬럼을 기준으로 이진트리를 생성하는 새로운 테이블을 생성하여 미리 정렬된 상태를 만들어야 합니다.
    • MySQL에서는 테이블을 생성할 때 특정 컬럼을 PK로 설정하면, 그 컬럼에 대한 인덱스 테이블을 자동으로 만듭니다. ( 링크 )
  • 인덱스 테이블은 일반 테이블과 같이 데이터베이스 객체입니다.
  • 인덱스 테이블만으로는 아무런 기능을 할 수 없기 때문에 다른 테이블에 의존적입니다.


검색 성능이 좋다고 항상 좋은 것은 아닙니다. 언제나 trade off가 존재하죠.

인덱스를 사용하면 다음과 같은 단점이 있습니다.

  • 자원
    • 인덱스 테이블이라는 테이블이 생성되므로 메모리를 많이 소비하게 됩니다. 
    • 따라서 PK 같은 컬럼들을 인덱싱 하도록 하고, 많은 컬럼을 인덱스로 않도록 남용하지 않는 것이 좋습니다.
  • SELECT를 제외한 INSERT, UPDATE, DELETE에 대한 성능 저하
    • 어떤 테이블에 데이터를 추가할 경우, 이에 관계된 인덱스 테이블에서는 데이터를 정렬해야 하므로 전체적인 성능이 저하됩니다.





2. 인덱스 종류, B+ 트리를 중심으로...

인덱스에는 여러 종류가 있습니다.

일반적으로 B+트리 인덱스를 사용하는데, 어떤 인덱스를 사용하는지는 벤더 제품에따라 다릅니다.


다음은 인덱스의 종류에 대한 간단한 소개입니다.

  • 해시 인덱스 ( Hash Index ) - 참고
    • 해시 테이블을 이용한 인덱스로, 해시값을 사용합니다.
    • 해시 값을 사용하기 때문에 매우 빠르지만, 등가비교검색( 값이 같은지 다른지 )만 가능합니다.
    • 기본키는 등가비교만 해도 충분한 경우가 많으므로, 해시 인덱스를 고려해볼 수 있습니다.
  • 풀 텍스트 인덱스 ( Full Text Index ) - 참고
    • "ㅇㅇ"단어를 포함한 행을 검색하고 싶을 때 사용합니다.
    • B+트리는 LIKE 검색으로 중간일치, 후방일치 검색을 할 수 없지만, 전문검색 인덱스 방법으로는 가능합니다.
    • 형태 분석법, N 그램을 통해 단어를 구분합니다.
  • R 트리 인덱스 ( 공간 인덱스 ) - 참고
    • 최소 경계 사각형( MBR, Minimum Bounding Rectangle )이라는 개념을 사용해 인덱스를 구성합니다.
      • MBR이란 어떤 도형을 둘러싼 최소의 직사각형을 의미합니다.
    • 2차원의 데이터를 인덱싱하고 검색하려는 목적으로 사용되며, GPS( 위도, 경도 ) 같은 공간 검색에 활용됩니다.
  • 그 밖에...
    • 함수 인덱스
    • 비트맵 인덱스



B+ 트리
많은 인덱스 알고리즘 중에 자세히 알아볼 인덱스는 B+트리 입니다.

RDB에서 인덱스는 일반적으로 B+트리 자료구조를 이용하여 검색을 수행하며,
B+ 트리는 다음과 같이 구성됩니다.
  • 실제 데이터가 저장된 리프노드( Leaf nodes )
  • 리프노드까지의 경로 역할을 하는 논리프노드( Non-leaf nodes )
  • 경로의 출발점이 되는 루트 노드( Root node )
B+트리는 리프노드에 이르기까지에 대한 자식 노드에 포인터가 저장되어 있습니다.
즉, B+트리의 검색은 루트노드에서 어떤 리프 노드에 이르는 한 개의 경로만 검색하면 되므로, 매우 효율적이다.

위와 같은 구조로 인해, B+ 트리 인덱스는 등가 비교는 물론 범위 검색에도 사용될 수 있습니다.
  • 등가 비교
    • WHERE key = 123
  • 범위 검색
    • WHERE key BETWEEN 100 AND 200
    • LIKE 검색의 와일드카드는 전방 일치여야 인덱스 효과를 얻을 수 있습니다.
      • WHERE key LIKE 'foo%'
      • Leaf node에서 문자열이 사전 순서대로 정렬돼 있기 때문에 전방 일치가 아닌 LIKE 검색은 물리적으로 불가능합니다.

B+ 트리에 대해 더 자세히 알고 싶다면 아래의 링크를 추천합니다.
  • https://www.javatpoint.com/b-plus-tree
  • https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html





3. 인덱스를 설계 한다는 것

인덱스가 늘어날수록 테이블을 갱신할 때 오버헤드가 커지고, 디스크 공간이 늘어나므로 최적의 인덱스를 찾는 것이 중요합니다.


인덱스를 설계 한다는 것은 어떤 컬럼을 조합해서 인덱스를 작성할 것인지,

즉 어떻게 컬럼을 조합해야 조회, 갱신의 모든 성능을 높일 수 있는지에 대한 논의입니다.


인덱스 설계는 응용프로그램에서 실행하는 쿼리에 대해 최적의 인덱스를 찾아내야 하는 것이므로, 최적의 인덱스 조합을 찾는 것은 오래 걸리는 어려운 작업이라고 할 수 있습니다.


단지, 다음과 같은 경우의 인덱스 설계는 피하는 것이 좋습니다.

  • 모든 컬럼이 인덱스
  • 인덱스에 포함된 컬럼이 한 개 밖에 없는 경우
  • 0또는 1같은 값 밖에 없는 플래그 컬럼이 인덱스인 경우




이상으로 인덱스에 대해 알아보았습니다.

B+ 트리를 이해하면, 인덱스가 왜 빠른지에 대해 알 수 있습니다.

이번 기회를 통해 말로만 듣던 B+트리에 대해 알아보는 계기가 되었네요.



책을 읽어서 개발 공부를 하는 것도 좋지만, 토이 프로젝트를 통해 공부하는 것이 나에겐 많은 도움이 되었다.

학생 때 친구들하고 프로젝트를 하면서 어떤 기능을 추가할까, 이건 왜 안될까 등등 하루종일 그 프로젝트에 대한 고민들로 가득찼었다.

그 때가 재밌어서 그런지, 좋은 아이디어가 있으면 또 뭔가를 만들고 싶었다.


간간히 아이디어 대한 고민을 했었는데, 갑자기 떠오른 아이디어가 있었다.

평소에 조금 불편했었던 것에서 생각이 시작이 됐는데, 이걸 토이 프로젝트로 하면 재밌을 것 같다는 생각이 들었다.

이것저것 기능들을 생각해보고, 사용하고 싶은 기술 스택을 나열하고 있는데... 벌써 재밌다.


그런데 아쉽게도 당장 해야할 공부들이 많다.

대략 1년 정도는 책보면서 공부해야 할 것들이 있어서, 토이 프로젝트는 그 뒤에 하려고 한다.

완성도 있는 프로젝트를 하기 위한 밑거름이라 생각하고 열심히 공부해야지...

2020년 안에는 결과물을 볼 수 있기를 기대해본다!


'끄적끄적 > 일기' 카테고리의 다른 글

토이 프로젝트  (1) 2019.05.12
2018년 회고  (10) 2019.03.01
  1. Favicon of https://n-log.tistory.com 꿈꾸는 엔 2019.06.06 21:42 신고

    프로젝트 하자아아


이번 글에서는 한 번의 API 요청으로 대량으로 document를 추가하고 조회하는 방법에 대해 알아보겠습니다.



1. MultiSearch API ( 링크 )

예를 들어 ES 서버에서 이메일 주소를 검색하는 상황을 가정해보겠습니다.

이메일 주소를 검색하기 위한 query DSL을 ES 서버에서 작성하고, Client에서는 해당 서버의 API를 호출하기만 하면 데이터를 받을 수 있습니다.

그런데 검색해야 할 이메일 주소가 100개라면 총 100번의 API를 호출해야 합니다.

이는 네트워크 비용이 발생하기 때문에 오류가 발생할 수 있으며, 성능 상의 이슈가 있을 수 있습니다.

따라서 한 번의 호출로 100개의 데이터를 조회하는 방법이 필요한데, ES에서는 이를 MultiSearch API로 제공합니다.


1) json 파일 작성

MultiSearch API를 사용하는 방법은 간단합니다.

json 파일을 생성해서 다음과 같은 포맷으로 쿼리를 작성하면 됩니다.

# vi query.json

{"index" : "test"} {"query" : {"match_all" : {}}, "from" : 0, "size" : 10} {} {"query" : { "term" : { "address" : "street" }}}

연속된 2개의 json이 header와 body로 나뉘어 하나의 쌍을 이루며, 결론적으로 위의 예제는 총 2개의 검색을 수행됩니다.

header에는 index와 search_type, preference, routing 같은 정보들을 작성하며,

body에는 query, aggregations, from, size 같은 요청 쿼리를 작성합니다.


json을 개행으로 구분하기 때문에, body의 모양을 예쁘게 하겠다고 개행을 해버리면 MultiSearch API를 호출할 수 없습니다.

즉, 다음과 같이 작성하면 안됩니다.

# vi query.json

{"index" : "test"}

{ "query" : { "match_all" : {} }, "from" : 0, "size" : 10 }

만약 쿼리가 길어서 별도의 파일로 보관하여 유지보수를 용이하게 하고 싶다면 template을 사용할 수 있습니다.

자세한 사용법은 여기를 참고해주세요!




2) MutlSearch API 호출

여러 키워드를 검색하기 위한 json 파일을 준비했다면, 다음과 같이 curl 요청으로 API를 호출할 수 있습니다.

# curl -X GET 'localhost:9200/인덱스/_msearch?pretty' -H 'Content-Type: application/x-ndjson' --data-binary @query.json


인덱스 명시

특정 index에 대해서 MultiSearch API를 호출하려면 URI에 인덱스를 명시해주고, 모든 index에 대해 검색하려면 'localhost:9200/_msearch'만 작성하면 됩니다.

그런데 모든 index에 대해 검색하면 성능이 좋지 않으므로 json 파일 각 header에서 인덱스를 명시하는 것이 좋습니다.


_msearch

MultiSerach API를 호출하기 위해서는 _search가 아닌 _msearch입니다.


Content-Type

공식 문서에서는 application/x-ndjson으로 명시할 것을 권장합니다.


--data-binary

MultiSearch에서 json 파일로 검색하기 위해서는 -d가 옵션이 아닌 --data-binary 옵션으로 full name을 작성해줘야 에러가 발생안합니다.




3) response

MultiSearch API 호출 결과 응답은 다음과 같은 양식을 따릅니다.

{ "responses" : [ { 단일 검색 결과 }, {

단일 검색 결과

},

... ] }

  • responses
    • MutlSearch로 총 100개의 검색을 수행했다면, responses 하위에는 100개의 json이 있습니다.
    • 각 json은 _search API로 단일 호출했을 때의 결과가 그대로 있다고 보시면 됩니다.

그런데 검색하는 키워드가 많을 수록 데이터의 양은 엄청 커질 것이라는 것을 예측할 수 있습니다.

따라서 query를 작성할 때 특정 필드만 보여주는 방법 등으로 결과 document를 가공할 필요가 있습니다.



대량으로 document를 조회하는 방법은 이와 같이 MultiSearch API를 사용하면 됩니다.

다음은 대량으로 document를 추가하는 방법인 Bulk API에 대해 알아보겠습니다.





2. Bulk API ( 링크 )

Bulk API로 document를 대량으로 추가하는 방법도 MultiSearch API와 유사합니다.

json 파일을 만들어서 header와 body를 작성한 후, API를 호출하면 됩니다.


1) json 파일 작성

지금까지 Bulk API로 document를 추가하는 방법에 대해서만 언급했지만, 사실은 Bulk API를 통해 document를 update, delete도 할 수 있습니다.

# vi query.json

{ "index" : {} } { "name" : "test_auto_generate_id" } { "index" : { "_id" : "1" } } { "name" : "name_create1" } { "create" : {"_id" : "2" } } { "name" : "name_create2" } { "update" : {"_id" : "2" } } { "doc" : { "name" : "name_update" } } { "delete" : { "_id" : "2" } }

  • { "index" : {} } / { "name" : "test_auto_generate_id" }
    • URL에 index와 type을 명시한 경우 header에 "index" : {} 만 명시해도 해당 index, type에 document가 추가됩니다.
    • header에 _id를 명시하지 않아도 자동으로 _id 값이 생성됩니다.
  • { "index" : { "_id" : "1" } } / { "name" : "name_create1" }
    • _id 값을 부여해서 document를 추가하는 방법입니다.
  • { "create" : {"_id" : "2" } } / { "name" : "name_create2" }
    • header에 "index"가 아닌 "create"를 명시하여 document를 추가하는 방법입니다.
  • { "update" : {"_id" : "2" } } / { "doc" : { "name" : "name_update" } }
    • Bulk API로 document를 update 할 수도 있습니다.
    • update를 하려면 body에 "doc" 필드를 명시해줘야 합니다.
  • { "delete" : { "_id" : "2" } }
    • Bulk API로 document를 delete 할 수도 있습니다.
    • delete를 할 때 body는 작성할 필요가 없습니다.



2) Bulk API 호출

다음은 Bulk API를 호출하는 curl 요청입니다.

# curl -X POST 'localhost:9200/victolee/test/_bulk?pretty' -H 'Content-Type: application/x-ndjson' --data-binary @query.json

위 예제에서는 URI에 특정 index와 type을 명시해줬으며,

Content-Type도 MultiSearch API와 같이 application/x-ndjson으로 작성했고, --data-binary 옵션으로 json 파일을 넘겨줍니다.


Bulk API를 호출할 때 여러 작업 중 하나가 실패하더라도 Bulk API는 실패하지 않습니다.

즉, 어떤 이유로 어느 한 작업이 실패하더라도 나머지 작업은 계속 처리됩니다.

response에서 각 작업의 상태를 전송 순서와 동일하게 표시하므로 어떤 작업이 실패했는지 여부를 알 수 있습니다.





이상으로 대량으로 document를 추가/수정/삭제하는 Bulk API 그리고 대량으로 document를 조회하는 MultiSearch API에 대해 알아보았습니다.


+ Recent posts