Spring Tools for Eclipse 기준 설명

1. https://spring.io/tools 접속하여 STS(for Eclipse) 설치 후 실행
2. 상단 Quick Access에 Git Repositories 검색 후 클릭
3. Git Repositories 탭으로 가서 Clone a Git repository
4. gitlab 사이트 Import 하려는 프로젝트로 들어가서 Clone -> Clone with HTTPS 부분 복사
5. STS로 돌아가서 URL 부분에 붙여넣기 하면 세부 항목까지 자동 입력(Port는 빈칸)
6. master 브랜치 체크 후 Next & Finish
7. Git Repositories 탭의 프로젝트 우클릭 후 Import Projects
8. Package Explorer에 받아진 프로젝트 확인
9. Window > Preferences > Server > Runtime Environments > 우측 Add > Tomcat 버전 선택 > Browse...
   클릭 후 다운로드한 Tomcat 폴더 선택 > Finish > Apply and Close

   (Tomcat 다운로드 https://tomcat.apache.org)
10. 상단 Quick Access에 Servers 검색 후 클릭
11. Servers 탭에서 우클릭 > New > Server > Tomcat 버전 선택 > Available 부분에 있는 항목 선택 후 Add > Finish
12. Project는 우클릭 후 Maven > Update Project...와 최상단 탭 Project > Clean...

13. x 표시가 사라지지 않는다면 설치되지 않은 플러그인이 있는지 확인

     - Lombok을 사용하는 경우 -

     Eclipse의 경우 Maven / Gradle에 Lombok Dependency가 추가되어 있어도 Lombok을 따로 설치해 줘야 한다.

     https://projectlombok.org/download 접속하여 다운로드한다.

     다운로드한 lombok.jar 파일을 STS 실행파일이 있는 위치로 옮긴다.

     명령 프롬프트(CMD)를 관리자 권한으로 실행시킨 후 lombok.jar 파일이 위치한 폴더로 이동한다.

      lombok.jar 파일이 C:\STS\contents\stsRELEASE  폴더 안에 있다면

      cd C:\STS\contents\stsRELEASE 명령어로 이동 후 java -jar lombok.jar

      Lombok을 설치할 IDE를 체크한 후 Install / Update 버튼을 클릭, 설치가 끝났다면 실행 중인 STS를 재실행

      STS 실행파일 위치에 실행파일명.ini 파일 열어보면 -javaagent: 부분에 lombok.jar 경로가 추가된 것 확인

14. 13번에서 추가로 설치한 플러그인이 있다면 12번으로 간다.
15. Servers 탭의 Tomcat 더블클릭 후 Overview 탭 Ports의 HTTP 부분 옆 Port Number 변경

     & Modules 탭의 Path를 / 로 변경
16. Servers 탭의 Tomcat 우클릭 > Clean...

17. Start the server 후 브라우저 실행하여 localhost:8080 입력 (Port Number가 8080일 경우)

의존성 주입 방식

 

1. @Autowired (필드 주입 : Field Injection)

참고 : 필드를 final로 선언 불가

@Service
public class ArticleService {

  @Autowired
  private ArticleRepository articleRepository;
}

 

2. private final (생성자 주입 : Constructor Injection)

@Service
public class ArticleService {

  private final ArticleRepository articleRepository;

  public ArticleService(ArticleRepository articleRepository) {
    this.articleRepository = articleRepository;
  }
}

 

private final 방식이 더 좋은 이유

1. 순환 참조를 방지할 수 있다. (순환 참조 발생 시, Application이 구동되지 않는다.)

2. 테스트에 용이하다.

3. final 선언이 가능해 불변성이 보장된다.

4. 코드의 품질을 높일 수 있다.

5. 오류를 방지할 수 있다.

Spring Boot에서 JWT를 활용한 인증 구현

세션 VS 토큰

 

[세션]

1. 클라이언트가 로그인 요청을 하고, 로그인 정보가 일치하면 서버는 세션을 생성/유지 (클라이언트마다 하나씩)

(세션에는 사용자 ID, 로그인 시간, IP 등을 저장)

2. 서버는 로그인 응답 (세션을 찾을 수 있는 세션 ID를 클라이언트에 전달 (보통 쿠키로 전달))

(세션은 서버에 저장되는 값, 쿠키는 클라이언트에 저장되는 값)

3. 클라이언트가 세션 ID와 함께 서비스 요청을 하면 서버는 세션 ID로 세션을 찾아 인증된 유저임을 확인

4. 서버는 서비스 응답

서버가 세션을 들고있다.

 

서버가 1대라면 세션을 사용해도 괜찮지만, 서버는 여러 대가 존재할 것이다. (모두 세션을 가져야 함 & 동기화)

세션 클러스터링 등 작업이 복잡하며 DB에도 부담을 줄 수 있다.

 

[토큰]

1. 클라이언트가 로그인 요청을 하면 서버는 토큰을 생성 (클라이언트마다 하나씩)

2. 서버는 토큰과 함께 로그인 응답

3. 클라이언트가 토큰과 함께 서비스 요청을 하면 서버는 토큰으로 인증된 유저임을 확인

4. 서버는 토큰과 함께 서비스 응답

클라이언트와 서버가 토큰을 주고 받는다.

 

build.gradle의 dependencies에 아래의 코드 추가

implementation 'javax.xml.bind:jaxb-api:'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

SecurityController

package com.example.firstproject.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;

@RestController
@RequestMapping("/security")
public class SecurityController {

    @Autowired
    private SecurityService securityService;

    @GetMapping("/create/token")
    public Map<String, Object> createToken(@RequestParam(value = "subject") String subject) { // 일반적으로 subject를 ID 값으로, PW를 key 값으로 같이 보내는 POST 방식이 맞지만 여기서는 확인을 위해 GET 방식으로 한다.
        String token = securityService.createToken(subject, (2 * 1000 * 60)); // 2분
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("result", token);
        return map;
    }

    @GetMapping("/get/subject")
    public Map<String, Object> getSubject(@RequestParam(value = "token") String token) {
        String subject = securityService.getSubject(token);
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("result", subject);
        return map;
    }
}

SecurityService

package com.example.firstproject.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Service;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;

@Service
public class SecurityService {
    private static final String SECRET_KEY = "qweqiwehqhruhqwiejqiwejqiwheuqfjnqweojqwiejqwuequwe";

    // 로그인 서비스 보낼 때 같이
    public String createToken(String subject, long expTime) {

        if (expTime <= 0) {
            throw new RuntimeException("만료 시간은 0보다 커야합니다.");
        }

        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);

        Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName());

        return Jwts.builder()
                   .setSubject(subject)
                   .signWith(signingKey, signatureAlgorithm)
                   .setExpiration(new Date(System.currentTimeMillis() + expTime))
                   .compact();
    }

    // 실제로 사용할 때는 안에 있는 로직을 이용해 boolean 타입을 리턴하는 토큰 검증 메소드를 만들고 토큰 검증 로직에서 이 메소드를 호출해서 사용
    public String getSubject(String token) {
        Claims claims = Jwts.parserBuilder()
                            .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
                            .build()
                            .parseClaimsJws(token)
                            .getBody();

        return claims.getSubject();
    }
}

POSTMAN 테스트

1. /create/token

 

2. /get/subject

 

3. /get/subject 토큰 유지 시간으로 설정한 2분이 지나면

TDD(Test Driven Development)란?

테스트 코드를 만들고, 이를 통과하는 최소한의 코드로 시작하여

점진적으로 개선, 확장해가는 개발 방식을 말한다.

 

Article 서비스를 검증하는 테스트 코드를 작성해보자

 

Article 서비스에서 테스트를 원하는 메소드명 우클릭 Generate > Test를 클릭하고

열린 창 하단에 테스트를 할 메소드명을 체크 후 OK를 클릭한다.

 

ArticleServiceTest라는 클래스 파일이 만들어지고

이 파일의 경로는 src > test > java > com.example.firstproject > service가 된다.

 

H2 DB를 사용하고 있었기 때문에 data.sql을 참고해 테스트 코드를 작성한다.

 

예상 시나리오 작성 -> 실제 결과와 비교하여 검증

 

<data.sql>

INSERT INTO ARTICLE(ID, TITLE, CONTENT) VALUES (2, 'AAAA', '1111');
INSERT INTO ARTICLE(ID, TITLE, CONTENT) VALUES (3, 'BBBB', '2222');
INSERT INTO ARTICLE(ID, TITLE, CONTENT) VALUES (4, 'CCCC', '3333');

<ArticleServiceTest>

package com.example.firstproject.service;

import com.example.firstproject.dto.ArticleDto;
import com.example.firstproject.entity.Article;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest // 해당 클래스는 스프링부트와 연동되어 테스팅된다.
class ArticleServiceTest {

    @Autowired ArticleService articleService;

    @Test
    void readAll() {
        // 예상
        Article a = new Article(2L, "AAAA", "1111");
        Article b = new Article(3L, "BBBB", "2222");
        Article c = new Article(4L, "CCCC", "3333");
        List<Article> expectedList = new ArrayList<Article>(Arrays.asList(a, b, c));

        // 실제
        List<Article> articleList = articleService.readAll();

        // 비교
        assertEquals(expectedList.toString(), articleList.toString());
    }

    @Test
    void read_success() { // read 메소드 성공 테스트
        // 예상
        Long id = 2L;
        Article expected = new Article(id, "AAAA", "1111");

        // 실제
        Article article = articleService.read(id);

        // 비교
        assertEquals(expected.toString(), article.toString());
    }

    @Test
    void read_fail() { // read 메소드 실패 테스트 // 존재하지 않는 id를 입력한 경우
        // 예상
        Long id = -1L;
        Article expected = null;

        // 실제
        Article article = articleService.read(id); // return articleRepository.findById(id).orElse(null); 이렇게 작성되어 있으므로 null 값을 갖는 expected로 비교

        // 비교
        assertEquals(expected, article); // null은 toString 메소드를 호출할 수 없음
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void create_success() { // create 메소드 성공 테스트 // title과 content만 있는 dto 입력
        // 예상
        String title = "DDDD";
        String content = "4444";
        ArticleDto dto = new ArticleDto(null, title, content);
        Article expected = new Article(1L, title, content);

        // 실제
        Article article = articleService.create(dto);

        // 비교
        assertEquals(expected.toString(), article.toString());
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void create_fail() { // create 메소드 실패 테스트 // id가 포함된 dto 입력
        // 예상
        String title = "DDDD";
        String content = "4444";
        ArticleDto dto = new ArticleDto(2L, title, content);
        Article expected = null;

        // 실제
        Article article = articleService.create(dto); // id가 존재하는 경우 null return하도록 작성되어 있음

        // 비교
        assertEquals(expected, article);
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void update_success_1() { // update 메소드 성공 테스트 케이스 1 // 존재하는 id와 변경할 title, content만 있는 dto 입력
        // 예상
        Long id = 2L; // 대상 id
        String title = "AAAAAAAA"; // 변경할 title
        String content = "11111111"; // 변경할 content
        ArticleDto dto = new ArticleDto(null, title, content);
        Article expected = new Article(id, "AAAAAAAA", "11111111");

        // 실제
        Article article = articleService.update(id, dto);

        // 비교
        assertEquals(expected.toString(), article.toString());
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void update_success_2() { // update 메소드 성공 테스트 케이스 2 작성해보기
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void update_fail_1() { // update 메소드 실패 테스트 케이스 1 // 존재하지 않는 id와 변경할 title, content만 있는 dto 입력
        // 예상
        Long id = -1L; // 대상 id
        String title = "AAAAAAAA"; // 변경할 title
        String content = "11111111"; // 변경할 content
        ArticleDto dto = new ArticleDto(null, title, content);
        Article expected = null;

        // 실제
        Article article = articleService.update(id, dto); // id에 해당하는 엔티티가 없는 경우 null return하도록 작성되어 있음

        // 비교
        assertEquals(expected, article);
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void update_fail_2() { // update 메소드 실패 테스트 케이스 2 작성해보기
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void delete_success_1() { // delete 메소드 성공 테스트 케이스 1 // 존재하는 id 입력
        // 예상
        Long id = 2L;
        Article expected = new Article(id, "AAAA", "1111");

        // 실제
        Article article = articleService.delete(id);

        // 비교
        assertEquals(expected.toString(), article.toString());
    }

    @Test
    @Transactional // 조회가 아닌 생성, 변경, 삭제의 경우 Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다.
    void delete_fail_1() { // delete 메소드 실패 테스트 케이스 1 // 존재하지 않는 id 입력
        // 예상
        Long id = -1L;
        Article expected = null;

        // 실제
        Article article = articleService.delete(id);

        // 비교
        assertEquals(expected, article);
    }
}

테스트 코드 작성 후 메소드 옆 재생 버튼 클릭 > Run ArticleServiceTest 클릭하면 해당 메소드의 테스트가 시작된다.

화면 좌측 하단 Show Passed, Show Ignored를 선택하고

메소드명 옆에 녹색 체크(테스트 정상) 또는 X 표시(테스트 실패)를 확인한다.

모든 메소드에 대해 테스트를 진행하고자 한다면 클래스명 옆에 있는 재생 버튼을 클릭하면 된다.

조회가 아닌 생성, 변경, 삭제 테스트의 경우

Transaction으로 묶어서 Rollback할 수 있게 처리해줘야 한다. (어노테이션 추가 @Transactional)

1. DTO + ApiController + Entity + Repository 구조 (Service 계층 없을 경우)

 

Client : 손님

RestController(Server) : 웨이터 & 셰프

Repository(Server) : 주방 보조

DataBase : 창고

 

src > main > java > com.example.firstproject > api의 ArticleApiController

package com.example.firstproject.api;

import com.example.firstproject.dto.ArticleDto;
import com.example.firstproject.entity.Article;
import com.example.firstproject.repository.ArticleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
public class ArticleApiController {

    // Service 계층 없이 구현했을 경우

    @Autowired
    private ArticleRepository articleRepository;

    // GET
    @GetMapping("/api/article")
    public List<Article> readAll() {
        return articleRepository.findAll(); // 전체 조회 후 Entity 반환
    }

    // GET
    @GetMapping("/api/article/{id}")
    public Article read(@PathVariable Long id) {
        return articleRepository.findById(id).orElse(null); // 조건부 조회 후 Entity 반환
    }

    // POST
    @PostMapping("/api/article")
    public Article create(@RequestBody ArticleDto dto) { // REST API의 경우 JSON으로 데이터 넘길 때 RequestBody를 써야 한다.
        Article article = dto.toEntity(); // 입력받은 DTO를 Entity로
        return articleRepository.save(article); // Entity 저장
    }

    // PATCH 또는 PUT
    @PatchMapping("/api/article/{id}")
    public ResponseEntity<Article> update(@PathVariable Long id, @RequestBody ArticleDto dto) {
        // 1: 수정용 엔티티 생성
        Article article = dto.toEntity(); // 입력받은 DTO를 Entity로
        // 2: 대상 엔티티를 조회
        Article target = articleRepository.findById(id).orElse(null); // 조건부 조회 후 Entity 반환
        // 3: 잘못된 요청 처리(대상이 없거나, id가 다른 경우)
        if (target == null || id != target.getId()) {
            // 400, 잘못된 요청 응답
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
        }
        // 4: 업데이트 및 정상 응답(200)
        target.patch(article); // 조건부 조회 Entity에 입력받은 값 적용
        Article updated = articleRepository.save(target); // Entity 저장
        return ResponseEntity.status(HttpStatus.OK).body(updated);
    }

    // DELETE
    @DeleteMapping("/api/article/{id}")
    public ResponseEntity<Article> delete(@PathVariable Long id) {
        // 1: 대상 찾기
        Article target = articleRepository.findById(id).orElse(null); // 조건부 조회 후 Entity 반환
        // 2: 잘못된 요청 처리
        if (target == null) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
        }
        // 3: 대상 삭제
        articleRepository.delete(target); // Entity 삭제
        return ResponseEntity.status(HttpStatus.OK).build();
    }
}

2. DTO + ApiController + Service + Entity + Repository 구조

일반적으로 Service의 업무처리가 Transaction 단위로 진행되기 때문에 Service 계층을 두고 역할을 나누도록 한다.

 

Client : 손님

RestController(Server) : 웨이터

Service(Server) : 셰프

Repository(Server) : 주방 보조

DataBase : 창고

 

src > main > java > com.example.firstproject > api의 ArticleApiController

package com.example.firstproject.api;

import com.example.firstproject.dto.ArticleDto;
import com.example.firstproject.entity.Article;
import com.example.firstproject.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
public class ArticleApiController {

    @Autowired // DI, 생성 객체를 가져와 연결
    private ArticleService articleService;

    @GetMapping("/api/article")
    public List<Article> readAll() { // 전체 조회
        return articleService.readAll();
    }

    @GetMapping("/api/article/{id}")
    public Article read(@PathVariable Long id) { // 조건부 조회
        return articleService.read(id);
    }

    @PostMapping("/api/article")
    public ResponseEntity<Article> create(@RequestBody ArticleDto dto) { // 추가 // REST API의 경우 JSON으로 데이터 넘길 때 RequestBody를 써야 한다.
        Article created = articleService.create(dto);
        return (created != null) ? ResponseEntity.status(HttpStatus.OK).body(created) : ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    @PatchMapping("/api/article/{id}")
    public ResponseEntity<Article> update(@PathVariable Long id, @RequestBody ArticleDto dto) { // 수정
        Article updated = articleService.update(id, dto);
        return (updated != null) ? ResponseEntity.status(HttpStatus.OK).body(updated) : ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    @DeleteMapping("/api/article/{id}")
    public ResponseEntity<Article> delete(@PathVariable Long id) { // 삭제
        Article deleted = articleService.delete(id);
        return (deleted != null) ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() : ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    @PostMapping("/api/transaction-test")
    public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleDto> dtos) { // Transaction 실패 -> Rollback
        List<Article> createdList = articleService.createArticles(dtos);
        return (createdList != null) ? ResponseEntity.status(HttpStatus.OK).body(createdList) : ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
}

src > main > java > com.example.firstproject > service의 ArticleService

package com.example.firstproject.service;

import com.example.firstproject.dto.ArticleDto;
import com.example.firstproject.entity.Article;
import com.example.firstproject.repository.ArticleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;

@Service // 서비스 선언! (서비스 객체를 Spring Boot에 생성)
public class ArticleService {

    @Autowired // DI(Dependency Injection)
    private ArticleRepository articleRepository;

    public List<Article> readAll() { // 전체 조회
        return articleRepository.findAll();
    }

    public Article read(Long id) { // 조건부 조회
        return articleRepository.findById(id).orElse(null);
    }

    public Article create(ArticleDto dto) { // 추가
        Article article = dto.toEntity();
        if (article.getId() != null) {
            return null;
        }
        return articleRepository.save(article);
    }

    public Article update(Long id, ArticleDto dto) { // 수정
        // 1: 수정용 엔티티 생성
        Article article = dto.toEntity(); // 입력받은 DTO를 Entity로
        // 2: 대상 엔티티를 조회
        Article target = articleRepository.findById(id).orElse(null); // 조건부 조회 후 Entity 반환
        // 3: 잘못된 요청 처리(대상이 없거나, id가 다른 경우)
        if (target == null || id != target.getId()) {
            // 400, 잘못된 요청 응답
            return null;
        }
        // 4: 업데이트 및 정상 응답(200)
        target.patch(article); // 조건부 조회 Entity에 입력받은 값 적용
        Article updated = articleRepository.save(target); // Entity 저장
        return updated;
    }

    public Article delete(Long id) { // 삭제
        // 1: 대상 찾기
        Article target = articleRepository.findById(id).orElse(null);
        // 2: 잘못된 요청 처리
        if (target == null) {
            return null;
        }
        // 3: 대상 삭제
        articleRepository.delete(target); // Entity 삭제
        return target;
    }

    @Transactional // 해당 메소드를 Transaction으로 묶는다. (해당 메소드 실행 중 실패 -> 이 메소드가 실행되기 전 상태로 Rollback)
    public List<Article> createArticles(List<ArticleDto> dtos) { // Transaction 테스트
        // 1: DTO 묶음을 Entity 묶음으로 변환
        List<Article> articleList = dtos.stream().map(dto -> dto.toEntity()).collect(Collectors.toList());
        // 2: Entity 묶음을 DB에 저장
        articleList.stream().forEach(article -> articleRepository.save(article));
        // 3: 강제 예외 발생
        articleRepository.findById(-1L).orElseThrow(
                () -> new IllegalArgumentException("결제 실패!")
        );
        // 4: 결과값 반환
        return articleList;
    }
}

src > main > java > com.example.firstproject > dto의 ArticleDto

package com.example.firstproject.dto;

import com.example.firstproject.entity.Article;
import lombok.AllArgsConstructor;
import lombok.ToString;

@AllArgsConstructor // Lombok 사용
@ToString // Lombok 사용
public class ArticleDto { // DTO 클래스

    private Long id;
    private String title;
    private String content;

//    public ArticleDto(Long id, String title, String content) { // @AllArgsConstructor와 같음
//        this.id = id;
//        this.title = title;
//        this.content = content;
//    }

//    @Override
//    public String toString() { // @ToString과 같음
//        return "ArticleDto{" +
//                "id=" + id +
//                ", title='" + title + '\'' +
//                ", content='" + content + '\'' +
//                '}';
//    }

    public Article toEntity() {
        return new Article(id, title, content);
    }
}

src > main > java > com.example.firstproject > entity의 Article

package com.example.firstproject.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import javax.persistence.*;

@Entity // DB가 해당 객체를 인식 가능! (해당 클래스로 테이블 생성)
@NoArgsConstructor // Lombok 사용
@AllArgsConstructor // Lombok 사용
@ToString // Lombok 사용
@Getter // Lombok 사용
public class Article {

    @Id // 대표값을 지정! PK (EX : 주민등록번호)
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 1, 2, 3,  ... DB가 id를 자동 채번하도록 하는 어노테이션 // JPA 버전 문제로 자동 채번이 안될 수 있다.
    private Long id;

    @Column
    private String title;

    @Column
    private String content;

//    public Article() { // Entity 기본 생성자 필요 // @NoArgsConstructor와 같음
//
//    }

//    public Article(Long id, String title, String content) { // @AllArgsConstructor와 같음
//        this.id = id;
//        this.title = title;
//        this.content = content;
//    }

//    @Override
//    public String toString() { // @ToString과 같음
//        return "Article{" +
//                "id=" + id +
//                ", title='" + title + '\'' +
//                ", content='" + content + '\'' +
//                '}';
//    }

//    // @Getter와 같음
//    public Long getId() {
//        return id;
//    }
//
//    public String getTitle() {
//        return title;
//    }
//
//    public String getContent() {
//        return content;
//    }

    public void patch(Article article) {
        if (article.title != null) {
            this.title = article.title;
        }

        if (article.content != null) {
            this.content = article.content;
        }
    }
}

src > main > java > com.example.firstproject > repository의 ArticleRepository

package com.example.firstproject.repository;

import com.example.firstproject.entity.Article;
import org.springframework.data.repository.CrudRepository;
import java.util.ArrayList;

public interface ArticleRepository extends CrudRepository<Article, Long> { // JPA가 제공 <관리대상 Entity, PK 타입>

    @Override
    ArrayList<Article> findAll(); // List 형태로 받기 위해 Override 했음
}

 

Postman을 사용한 API 테스트 화면

 

<application.properties에 추가=""></application.properties에>

# H2 DB, 웹 콘솔 접근 허용
spring.h2.console.enabled=true
# 실행과 동시에 데이터 생성 가능하도록 변경
spring.jpa.defer-datasource-initialization=true
# JPA 로깅 설정
# 디버그 레벨로 쿼리 출력
logging.level.org.hibernate.SQL=DEBUG
# 정리해서 보여주기
spring.jpa.properties.hibernate.format_sql=true
# 파라미터 보여주기
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

# DB URL 고정 설정
# 유니크 URL 생성 X
spring.datasource.generate-unique-name=false
# 고정 URL 설정
spring.datasource.url=jdbc:h2:mem:testdb

<build.gradle에 추가=""> (Lombok 추가 : 코드 간소화, 로깅 가능하게 해주는 라이브러리)</build.gradle에>

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

화면 우측 상단 코끼리와 새로고침이 있는
Load Gradle Changes를 클릭해 다운로드 받아올 수 있도록 한다.

Help -> Find Action -> plugins 입력 -> Marketplace -> Lombok 검색 후 Install

Preferences > Build, Execution, Deployment > Compiler > Annotation Processors
Enable annotation processing체크 해제되어 있다면 체크

 

<실행과 동시에 데이터 생성>

resources > data.sql 파일 생성 -> INSERT 쿼리 추가

EX : INSERT INTO ARTICLE(ID, TITLE, CONTENT) VALUES (2, 'AAAA', '1111');

 

<H2 DB 확인>

프로젝트 실행 후 localhost:8080/h2-console

JDBC URL에 jdbc:h2:mem:testdb입력(application.properties에 설정한 고정 URL) 후 Connect

<MVC 기본 구조>

src > main > java > com.example.firstproject > controller의 ArticleController

package com.example.firstproject.controller;

import com.example.firstproject.dto.ArticleDto;
import com.example.firstproject.entity.Article;
import com.example.firstproject.repository.ArticleRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;

@Controller
@Slf4j // 로깅을 위한 골뱅이(어노테이션)
public class ArticleController {

    @Autowired // new 안써도 된다! 스프링 부트가 미리 생성해놓은 객체를 가져다가 자동 연결
    private ArticleRepository articleRepository;

    @GetMapping("/article/new")
    public String newArticleDto() {
        return "article/new";
    }

    @PostMapping("/article/create")
    public String createArticle(ArticleDto dto) {
        log.info(dto.toString());
//        System.out.println(dto.toString()); // 실제 서버에서 기록이 남지도 않고 성능에도 문제를 줌 -> 로깅 기능으로 대체!

        // 1. DTO 변환! Entity로
        Article article = dto.toEntity();
        log.info(article.toString());
//        System.out.println(article.toString());

        // 2. Repository에게 Entity를 DB에 저장하게 함
        Article saved = articleRepository.save(article);
        log.info(saved.toString());
//        System.out.println(saved.toString());

        return "redirect:/article/" + saved.getId();
    }

    @GetMapping("/article/{id}")
    public String show(@PathVariable Long id, Model model) {
        log.info("id = " + id);

        // 1: id로 데이터를 가져옴
        Article articleEntity = articleRepository.findById(id).orElse(null);

        // 2. 가져온 데이터를 모델에 등록
        model.addAttribute("article", articleEntity);

        // 3. 보여줄 페이지를 설정
        return "article/show";
    }

    @GetMapping("/article")
    public String index(Model model) {
        // 1: 모든 Article을 가져온다.
        List<Article> articleEntityList = articleRepository.findAll();

        // 2: 가져온 Article 묶음을 뷰로 전달
        model.addAttribute("articleList", articleEntityList);

        // 3: 뷰 페이지를 설정
        return "article/index";
    }

    @GetMapping("/article/{id}/edit")
    public String edit(@PathVariable Long id, Model model) {
        // 수정할 데이터를 가져오기
        Article articleEntity = articleRepository.findById(id).orElse(null);

        // 모델에 데이터를 등록 !
        model.addAttribute("article", articleEntity);

        return "article/edit";
    }

    @PostMapping("/article/update")
    public String update(ArticleDto dto) {

        // 1: DTO를 Entity로 변환
        Article articleEntity = dto.toEntity();

        // 2: Entity를 DB에 저장
        // 2-1: DB에서 기존 데이터를 가져온다.
        Article target = articleRepository.findById(articleEntity.getId()).orElse(null);

        // 2-2 기존 데이터가 있다면 값을 갱신
        if (target != null) {
            articleRepository.save(articleEntity); // 엔티티가 db를 갱신
        }

        // 3: 수정 결과 페이지로 리다이렉트 한다.
        return "redirect:/article/" + articleEntity.getId();
    }

    @GetMapping("/article/{id}/delete")
    public String delete(@PathVariable Long id, RedirectAttributes rttr) {

        // 1: 삭제 대상을 가져온다
        Article target = articleRepository.findById(id).orElse(null);

        // 2: 대상을 삭제한다.
        if (target != null) {
            articleRepository.delete(target);
            rttr.addFlashAttribute("msg", "delete complete!");
        }

        // 3: 결과 페이지로 리다이렉트 한다.
        return "redirect:/article";
    }
}

src > main > java > com.example.firstproject > dto의 ArticleDto

package com.example.firstproject.dto;

import com.example.firstproject.entity.Article;
import lombok.AllArgsConstructor;
import lombok.ToString;

@AllArgsConstructor // Lombok 사용
@ToString // Lombok 사용
public class ArticleDto { // DTO 클래스

    private Long id;
    private String title;
    private String content;

//    public ArticleDto(Long id, String title, String content) { // @AllArgsConstructor와 같음
//        this.id = id;
//        this.title = title;
//        this.content = content;
//    }

//    @Override
//    public String toString() { // @ToString과 같음
//        return "ArticleDto{" +
//                "id=" + id +
//                ", title='" + title + '\'' +
//                ", content='" + content + '\'' +
//                '}';
//    }

    public Article toEntity() {
        return new Article(id, title, content);
    }
}

src > main > java > com.example.firstproject > entity의 Article

package com.example.firstproject.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import javax.persistence.*;

@Entity // DB가 해당 객체를 인식 가능! (해당 클래스로 테이블 생성)
@NoArgsConstructor // Lombok 사용
@AllArgsConstructor // Lombok 사용
@ToString // Lombok 사용
@Getter // Lombok 사용
public class Article {

    @Id // 대표값을 지정! PK (EX : 주민등록번호)
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 1, 2, 3,  ... DB가 id를 자동 채번하도록 하는 어노테이션 // JPA 버전 문제로 자동 채번이 안될 수 있다.
    private Long id;

    @Column
    private String title;

    @Column
    private String content;

//    public Article() { // Entity 기본 생성자 필요 // @NoArgsConstructor와 같음
//
//    }

//    public Article(Long id, String title, String content) { // @AllArgsConstructor와 같음
//        this.id = id;
//        this.title = title;
//        this.content = content;
//    }

//    @Override
//    public String toString() { // @ToString과 같음
//        return "Article{" +
//                "id=" + id +
//                ", title='" + title + '\'' +
//                ", content='" + content + '\'' +
//                '}';
//    }

//    // @Getter와 같음
//    public Long getId() {
//        return id;
//    }
//
//    public String getTitle() {
//        return title;
//    }
//
//    public String getContent() {
//        return content;
//    }

    public void patch(Article article) {
        if (article.title != null) {
            this.title = article.title;
        }

        if (article.content != null) {
            this.content = article.content;
        }
    }
}

src > main > java > com.example.firstproject > repository의 ArticleRepository

package com.example.firstproject.repository;

import com.example.firstproject.entity.Article;
import org.springframework.data.repository.CrudRepository;
import java.util.ArrayList;

public interface ArticleRepository extends CrudRepository<Article, Long> { // JPA가 제공 <관리대상 Entity, PK 타입>

    @Override
    ArrayList<Article> findAll(); // List 형태로 받기 위해 Override 했음
}

 

src > main > resources > templates > layouts > header.mustache in [Framework] Spring Boot [JPA + H2] 시작

src > main > resources > templates > layouts > footer.mustache in [Framework] Spring Boot [JPA + H2] 시작

 

src > main > resources > templates > article > index.mustache

{{>layouts/header}}
<table class="table">
    <thead>
    <tr>
        <th scope="col">ID</th>
        <th scope="col">Title</th>
        <th scope="col">Content</th>
    </tr>
    </thead>
    <tbody>
    {{#articleList}}
        <tr>
            <th>{{id}}</th>
            <td><a href="/article/{{id}}">{{title}}</td>
            <td>{{content}}</td>
        </tr>
    {{/articleList}}
    </tbody>
</table>

<a href="/article/new">new Article</a>

{{>layouts/footer}}

src > main > resources > templates > article > new.mustache

{{>layouts/header}}

<form action="/article/create" method="post">
    <input type="text" name="title">
    <textarea name="content"></textarea>
    <button type="submit">submit</button>
    <a href="/article">back</a>
</form>

{{>layouts/footer}}

src > main > resources > templates > article > show.mustache

{{>layouts/header}}
<table class="table">
    <thead>
    <tr>
        <th scope="col">ID</th>
        <th scope="col">Title</th>
        <th scope="col">Content</th>
    </tr>
    </thead>
    <tbody>
    {{#article}}
    <tr>
        <th>{{id}}</th>
        <td>{{title}}</td>
        <td>{{content}}</td>
    </tr>
    {{/article}}
    </tbody>
</table>
<a href="/article/{{article.id}}/edit" class="btn btn-primary">edit</a>
<a href="/article/{{article.id}}/delete" class="btn btn-danger">delete</a>
<a href="/article">go to article</a>
{{>layouts/footer}}

src > main > resources > templates > article > edit.mustache

{{>layouts/header}}

{{#article}}
    <form action="/article/update" method="post">
        <input type="hidden" name="id" value="{{id}}">
        <input type="text" name="title" value="{{title}}">
        <textarea name="content">{{content}}</textarea>
        <button type="submit">submit</button>
        <a href="/article/{{id}}">back</a>
    </form>
{{/article}}

{{>layouts/footer}}

필드 영역 드래그 -> Generate -> Getter, Setter 등 쉽게 만들 수 있다.

 

Entity의 @GeneratedValue(strategy = GenerationType.IDENTITY)로 인해

PK인 ID가 DB에 의해 자동 채번되어야 하지만, PK가 정상적으로 채번되지 않는다면

build.gradle의 plugins의 'org.springframework.boot' version을 2.4.1 등으로 변경해주는 방법이 있다.

Spring Boot 버전에 따라 JPA 버전도 결정되는데, JPA 버전이 상향됨에 따라 발생하는 문제로 확인된다.

단, 이 경우 다른 도구들과의 버전 호환 문제가 발생할 수 있다.

IntelliJ(IDE) Spring Boot 개발 환경 세팅

 

필요 : JDK, IDE, Spring Boot 프로젝트

 

JDK : https://adoptopenjdk.net/ 접속 -> JDK 다운로드

 

IDE : https://www.jetbrains.com/idea/download/#section=windows 접속 -> Community버전 다운로드

 

Spring Boot : https://start.spring.io/ 접속 -> 다운로드

 

IntelliJ에서 다운로드 받은 Spring Boot 프로젝트 오픈

src > main > java > ~~Application (이곳에 메인 메소드가 있으며, 프로젝트가 실행되는 부분)

 

실행 후 localhost:8080 접속

 

Model : 데이터 담당
View Templates : 화면 담당
Controller : 처리 과정 담당

 

View Templates은 Mustache를 이용해보자

src > main > java > com.example.firstproject > controller의 FirstController

package com.example.firstproject.controller;

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

@Controller
public class FirstController {

    @GetMapping("/hi") // localhost:8080/hi 보내면 greetings.mustache 리턴
    public String niceToMeetYou(Model model) {
        model.addAttribute("userName", "Seung Yub");
        return "greetings"; // templates/greetings.mustache -> 브라우저로 전송!
    }

    @GetMapping("/bye")
    public String seeYou(Model model) {
        model.addAttribute("nickName", "Hong Gil Dong");
        return "goodbye";
    }
}

src > main > resources > templates > greetings.mustache

{{>layouts/header}}

    <!-- content -->
    <div class="bg-dark text-white p-5">
        <h1>Hi, {{userName}}!</h1>
    </div>

{{>layouts/footer}}

src > main > resources > templates > goodbye.mustache

{{>layouts/header}}

    <!-- content -->
    <div class="bg-dark text-white p-5">
        <h1>Bye, {{nickName}}!</h1>
    </div>

{{>layouts/footer}}

src > main > resources > templates > layouts > header.mustache

<!doctype html>
<html lang="ar" dir="rtl">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.rtl.min.css" integrity="sha384-OXTEbYDqaX2ZY/BOaZV/yFGChYHtrXH2nyXJ372n2Y8abBhrqacCEe+3qhSHtLjy" crossorigin="anonymous">
    <title>Document</title>
</head>
<body>
<!-- navigation -->
<nav class="navbar navbar-expand-lg bg-light">
    <div class="container-fluid">
        <a class="navbar-brand" href="#">Navbar</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link active" aria-current="page" href="#">Home</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Link</a>
                </li>
                <li class="nav-item dropdown">
                    <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                        Dropdown
                    </a>
                    <ul class="dropdown-menu">
                        <li><a class="dropdown-item" href="#">Action</a></li>
                        <li><a class="dropdown-item" href="#">Another action</a></li>
                        <li><hr class="dropdown-divider"></li>
                        <li><a class="dropdown-item" href="#">Something else here</a></li>
                    </ul>
                </li>
                <li class="nav-item">
                    <a class="nav-link disabled">Disabled</a>
                </li>
            </ul>
            <form class="d-flex" role="search">
                <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
                <button class="btn btn-outline-success" type="submit">Search</button>
            </form>
        </div>
    </div>
</nav>
<!-- alert msg -->
{{#msg}}
<div class="alert alert-primary" role="alert">
    {{msg}}
</div>
{{/msg}}

src > main > resources > templates > layouts > footer.mustache

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-u1OknCvxWvY5kfmNBILK2hRnQC3Pr17a+RTT6rIHI7NnikvbZlHgTPOOmMi466C8" crossorigin="anonymous"></script>
</body>
</html>

+ Recent posts