RE-Heat 개발자 일지

스프링 MVC 1편 - [7] 웹 페이지 만들기 [상편] 본문

백엔드/스프링

스프링 MVC 1편 - [7] 웹 페이지 만들기 [상편]

RE-Heat 2023. 7. 5. 20:50

출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., -

www.inflearn.com

인프런 김영한님의 스프링 MVC 1편 강의를 듣고 정리한 내용입니다.

 

[1] 프로젝트 생성

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

https://start.spring.io/

2. 실행 확인

기존 8080포트를 쓰고 있는 관계로 application.properties에 8081 포트로 변경

 

3. Welcom페이지 추가

정적페이지이므로 resources/static에 index.html 추가. 내용은 영한님이 제공해 주신 자료 복사·붙여넣기

 

4. UTF-8 세팅

미리 UTF-8로 세팅

 

5. gradle 실행 세팅(속도 향상 위해)

참고 : Jar가 아닌 War파일이며 IntelliJ가 무료버전이면 이 설정을 IntelliJ IDEA가 아닌 Gradle로 설정해야 톰캣이 정상적으로 동작한다.

 

[2] 요구사항 분석

■ 상품 도메인 모델

상품 ID

상품명

가격

수량

 

■ 상품 관리 기능

상품 목록

상품 상세

상품 등록

상품 수정

 

웹 개발 분업 (프론트엔드 개발자 없을 때)

디자이너 : 요구사항에 맞춰 디자인하고 웹 퍼블리셔에 넘김

웹 퍼블리셔 : 디자인 기반으로 HTML CSS 만들어 개발자에게 제공

백엔드 개발자 : 

    1] HTML 화면이 나오기 전 시스템을 설계하고 핵심 비즈니스 모델을 개발한다.

    2] 이후 HTML을 받으면 HTML을 뷰 템플릿으로 변환해 동적으로 화면을 그리고 웹 화면 흐름을 제어한다.

 

단, 프론트엔드 개발자가 있으면 프론트엔드 개발자가 HTML을 동적으로 만드는 역할 + 웹 화면 흐름을 담당한다. 이럴 땐 백엔드 개발자는 API를 통해 클라이언트가 필요로 하는 데이터 및 기능을 제공하면 된다.

 

참고 : 회사에 따라 웹 퍼블리셔가 따로 있거나 프론트엔드 개발자가 퍼블리셔 역할까지 하는 케이스가 있다.

 

[3] 상품 도메인 개발

 

Item

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

① 롬복 애노테이션 @Data

@Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredConstructor 기능 모두 씀

단, 예측하지 못하게 동작할 수 있기 때문에 핵심 도메인 모델에선 사용을 자제해야 한다.

② 프로퍼티 : id, itemName(item명), Price(가격), Quantity(수량)

 

참고]

https://joyful-class-maker.tistory.com/119

1) Id 타입 int 아닌 Long 쓰는 이유 : Long이 더 많은 값을 저장할 수 있기 때문.

2) long 대신 Long을 쓰는 이유 : Long은 값이 없으면 null로 초기화. 반면 long은 값이 없으면 0으로 초기화되는데, 실제 입력한 값이 0인지 입력되지 않아서 0인지 구분이 어렵다.

 

ItemRepository

@Repository
public class ItemRepository { //test 쉽게 만드는 건 ctrl+shift+T
    private static final Map<Long, Item> store = new HashMap<>();
    //static 실제론 HashMap쓰면 안 됨. 동시에 여러 쓰레드가 접근하므로. ConcurrentHashMap()을 사용해야 함.
    private static Long sequence = 0L; //static

    public Item save(Item item){
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    public Item findById(Long id){
        return store.get(id);
    }

    public List<Item> findAll(){
        return new ArrayList<>(store.values()); //감싸서 반환하면 실제 store에 영향을 끼치지 않아서
    }

    public void update(Long itemId, Item updateParam){
        //원래는 updateParam 객체(DTO를 만드는 게 낫다) id쓰는 거 헷갈릴 수 있음.
        //설계상 명확한 게 낫다. 중복보단 명확성이 더 중요.
        Item findItem = findById(itemId);

        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    public void clearStore(){
        store.clear();
    }
}

① @Repository로 스프링빈 등록

② private static final Map<Long, Item> store = new HashMap<>();가 DB 역할을 함.

③ 메소드

    1] save() : 상품 등록

    2] findById() : id로 item 객체 찾아서 반환.

    3] findAll() : map respository에서 모든 item 객체 반환

        ArrayList<>() 활용 이유 : ArrayList로 감싸면 실제 store에 영향을 끼치지 않음.

    4] update() : 상품 수정

    5] clearStore() : map respository 초기화. 테스트에서 사용

 

ItemRepositoryTest

class ItemRepositoryTest {

    ItemRepository itemRepository = new ItemRepository();

    @AfterEach
    void afterEach(){
        itemRepository.clearStore();
    }

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId());
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void findAll() {
        //given
        Item item1 = new Item("item1", 10000, 10);
        Item item2 = new Item("item2", 10000, 10);

        itemRepository.save(item1);
        itemRepository.save(item2);

        //when
        List<Item> result = itemRepository.findAll();

        //then
        assertThat(result.size()).isEqualTo(2);
        assertThat(result).contains(item1, item2); // item1, item2가 포함돼 있느냐?
    }

    @Test
    void update() {
        //given
        Item item1 = new Item("item1", 10000, 10);

        Item savedItem = itemRepository.save(item1);
        Long itemId = savedItem.getId();

        //when
        Item updateParam = new Item("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId);
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }
}

 

① @AfterEach로 그때그때 itemRepository를 클리어해 줌. 

   => @AfterEach : 각각의 테스트 메서드 실행 후 무조건 실행

   => @BeforeEach : 각각의 테스트 메서드 실행 전 무조건 실행

assertThat을 자주 사용돼 static화 해주면 편하다.

import static org.assertj.core.api.Assertions.assertThat;

참고]

테스트 코드는 given[준비]/when[실행]/then[검증] 순으로 짜는 게 바람직하다.

 

[4] 상품 서비스 HTML

웹 퍼블리셔가 HTML 마크업을 완료하면 잘 동작하는지 확인하는 과정

 

부트스트랩 설치방법

  • 부트스트랩을 다운로드하고 압축을 풀자.

HTML·CSS 파일

  • /resources/static/css/bootstrap.min.css → 부트스트랩 다운로드
  • /resources/static/html/items.html 
  • /resources/static/html/item.html
  • /resources/static/html/addForm.html
  • /resources/static/html/editForm.html

/resources/static에 넣어둬 스프링부트가 정적리소스로 제공한다.

 

접근 방법

① 절대경로로 직접 접근 가능

    파일 우클릭 => Copy Path/Reference => Absolute Path를 누르면 절대경로 카피됨

② 정적리소스이므로 http://localhost:8081/html/items.html 로도 동작한다.

 

[5] 상품 목록 - 타임리프

본격적으로 뷰 템플릿 구현!

 

BasicItemController

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "basic/items";
    }
    
    /*
        테스트용 데이터 추가
    * */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }

①메소드

    items() :

        1] findAll()로 아이템 전체목록을 받은 후 List에 담는다.

        2] 모델 객체에 items에 담음

        3] resources/templates/basic/itmes.html로 render 해줌

② 애노테이션

    1] @RequiredArgsConstructor :

        final이 붙은 멤버 변수만 이용해 생성자를 자동으로 만들어준다.

        이렇게 생성자 한 개만 있으면 스프링이 해당 생성자에 의존관계 주입(@AutoWired)

        따라서 final 키워드를 빼면 안 된다. 그러면 ItemRepository 의존관계 주입 X

        아래 코드는 이 애노테이션이 대체한 생성자   

public BasicItemController(ItemRepository itemRepository) {
    this.itemRepository = itemRepository;
}

    2] @PostConstructor

        의존성 주입이 이루어진 후 초기화를 수행하는 메서드. 즉, WAS가 뜨고 bean이 생성된 직후 딱 한 번만 실행됨

        의존성 주입 후 실행이 보장돼 빈의 초기화를 걱정할 필요 없음.

    3] @PreDestroy [참고용] 

        Spring이 애플리케이션 컨텍스트에서 bean을 제거하기 직전에 단 한 번 실행.

        자원 반환 등 종료 처리에 사용됨.

 

■ 타임리프 문법

① 타임리프 사용선언

<html xmlns:th="http://www.thymeleaf.org">

② 속성 변경 th:xxxx 

타임리프 뷰 템플릿을 거치면 기존 값을 th:xxx 값으로 변경한다. 값이 없으면 새로 생성함.

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
            href="../css/bootstrap.min.css" rel="stylesheet">
</head>

템플릿을 거치면 href="../css/bootstrap.min.css"대신 th:href="" <이 코드로 대체됨.

 

onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"

onclick도 뷰 템플릿을 거치면 th:onclick의 값으로 변경됨. 

 

③ URL 링크 표현식

1] 표현식 1

■ 단순 URL

th:href="@{/css/bootstrap.min.css}"

타임리프에선 URL을 나타내기 위해 @{...}으로 감싼다.

과거엔 서블릿 컨텍스트란 개념이 있어서 applcationA/... 이런 식으로 썼으나 지금은 쓰지 않는다.

 

2] 표현식 2

■ 경로 변수가 포함된 URL

th:href="@{/basic/items/{itemId}(itemId=${item.id})}"

()안에 경로변수 itemId를 초기화 할당.

경로변수뿐만 아니라 쿼리 파라미터도 생성가능

=> 경로변수를 지정하지 않고 바로 값을 꺼내는 것도 가능하다. 단, 리터럴 대체(|...|) 문법을 써 줘야 한다.

<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>

 

■ 경로 변수 + 쿼리 파라미터가 포함된 URL

th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"

이렇게 하면 생성되는 링크는 http://localhost:8080/basic/items/1?query=test

 

④ 리터럴 대체 |...|

타임리프는 문자와 표현식 등이 분리돼 있어 원래는 이런 식으로 코드를 작성해야 한다.

기존 ①  <span th:text="'Welcome to our application, ' + ${user.name} + '!'">

기존 ②  th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"

 

그러나 리터럴 대체 문법을 사용하면 위 코드를 아래 코드처럼 편리하게 사용할 수 있다.

리터럴 적용 ① <span th:text="|Welcome to our application, ${user.name}!|">

리터럴 적용 ② th:onclick="|location.href='@{/basic/items/add}'|"

 

⑤ 반복 출력 - th:each

<tbody>
    <tr th:each="item : ${items}">
        <td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
        <td><a href="item.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
        <td th:text="${item.price}">10000</td>
        <td th:text="${item.quantity}">10</td>
    </tr>
</tbody>

1] <tr th=each="item : ${items}"> 이렇게 하면 items 컬렉션 데이터[리스트]를 item 변수에 하나씩 꺼내 쓰고 반복문 안에서 item 변수를 사용할 수 있다.

2] 당연히 이 변수는 반복문 안에서만 사용 가능하다.

 

⑥ 변수 표현식 ${....}

모델에 포함돼 넘어온 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.

프로퍼티 접근법을 사용함. 그래서 item.id는 data.getId()와 같다.