관리 메뉴

나구리의 개발공부기록

스프링 MVC - 웹페이지 만들기, 프로젝트 생성 및 요구사항 분석, 요구사항 분석, 상품 도메인 개발, 상품 서비스 HTML, 상품 목록 - 타임리프 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술

스프링 MVC - 웹페이지 만들기, 프로젝트 생성 및 요구사항 분석, 요구사항 분석, 상품 도메인 개발, 상품 서비스 HTML, 상품 목록 - 타임리프

소소한나구리 2024. 3. 4. 23:35

  출처 : 인프런 - 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/Gmptq


1. 프로젝트 생성 및 요구사항 분석

1) 프로젝트 생성

(1) Project

  • Gradle, Java, 최신 스프링 부트

(2) Project Metadata

  • Group : hello
  • Artifact, Name : item-service
  • Packaging Name : hello.itemservice(패키지 네임에는 특수기호 없이 설정)
  • Packaging: Jar
  • Java : 17

(3) Dependencies

  • Spring Web
  • Thymeleaf
  • Lombok

(4) Welcome 페이지 추가

2) 요구사항 분석

(1) 상품 도메인 모델

  • 상품 ID, 상품명, 가격, 수량

(2) 상품 관리기능

  • 상품 목록, 상품 상세, 상품 등록, 상품 수정, 상품 삭제는 직접 구현 해볼 수 있으면 구현

(3) 서비스 화면

 

(4) 서비스 제공 흐름

  • 검은색 네모 -> 컨트롤러
  • 하얀색 네모 -> 뷰

 

(5) 개발 환경 가상 시나리오

  • 백엔드 개발자는 기본적으로 이런 시나리오에서 개발할 줄 알아야 상황에 따라 개발을 할 수 있음
  • 디자이너 : 요구사항에 맞춰 디자인, 디자인 결과물을 웹 퍼블리셔에게 전달
  • 웹 퍼블리셔 : 디자이너에게 받은 디자인을 기반으로 HTML, CSS를 만들어서 개발자에게 제공
  • 백엔드 개발자 : 디자이너, 웹 퍼블리셔를 통해 HTML 화면이 나오기 전까지 시스템 설계, 핵심 비즈니스 모델 개발, HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면을 그리고 웹 화면을 제어

(6) 웹 프론트엔드 개발자가 있으면? (대부분의 기업 환경)

  • React, Vue.js 같은 웹 클라이언트 기술을 사용하고 웹 프론트엔드 개발자가 있으면 웹 퍼블리셔의 역할을 프론트엔드 개발자가 대신 하기도 함
  • 웹 프론트엔드 개발자가 웹 클라이언트 기술들을 사용하여 HTML을 동적으로 만드는 역할과 웹 화면의 흐름을 담당하므로 백엔드 개발자는 HTTP API를 통해 웹 클라이언트가 필요로하는 기능과 데이터만 제공하면 됨
  • 백엔드 개발자가 내부에서 사용하는 간단한 어드민의 경우 React, Vue.js 을 조금 배워서 만드는 경우도 있음(대부분은 개발 일정을 맞추기 위해 따로 따로 개발을 함)

2. 상품 도메인 개발

1) 개발

(1) Item - 상품객체

  • domain.item 패키지 구조를 만들어서 작성
  • 실무에서는 @Data를 domain 객체에 사용하진않으며 필요한 것만 분리해서 사용하는 것을 권장하며 DTO에서는 사용해도 괜찮음
package hello.itemservice.domain.item;

// 실무 에서는 핵심 도메인에 사용 하게 되면 많이 위험함 -> @Getter, @Setter 정도나 필요한 것만 분리 해서 사용 해야함
@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;
    }
}

 

(2) ItemRepository - 상품저장소

  • Map<Long, Item> store = new HashMap<>(): 실무에서는 멀티쓰레드에서 동시성 문제가 있으므로 ConcurrentHashMap을 사용해야함
  • long sequence: 마찬가지로 동시성 문제로 AtomicLong이나 다른 것을 사용해야함
  • 위 두 변수를 static으로 생성하여 계속 객체가 생성되지 않도록 방지
  • return new ArrayList<>(store.values()): store.values()를 직접 반환해도 되지만 안전하게 한번더 감싸서 반환
  • update(): 원래는 id가 없는 별도의 DTO객체를 생성한 후 그 DTO객체를 파라미터로 받아서 명확하게 도메인과 구분해서 사용해야하지만 예제이므로 간단하게 작성
@Repository // ComponentScan 대상이 됨
public class ItemRepository {

    // 실무에서는 HashMap을 사용하면 안됨 -> 멀티쓰레드에서 동시성 문제가 있어서 ConcurrentHashMap<>() 을 사용해야함
    private static final Map<Long, Item> store = new HashMap<>();
    // 마찬가지로 동시성 문제로 실무에서는 AtomicLong 등등 다른것을 사용해야함
    private static long sequence = 0L;

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

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

    // store.values()를 직접 반환해도 되지만 안전하게 ArrayList로 감싸서 반환(Map의 values에 변경을 가할 수 없게 됨)
    public List<Item> findAll() {
        return new ArrayList<>(store.values());
    }

    // 지금은 프로젝트가 작아서 직접 코딩하지만 정석대로 개발하려면 id가없는 별도의 객체를 만드는 것이 좋음(ex: ItemParamDto)
    // 언뜻 보기에는 코드중복처럼 보이지만 코드의 명확성이 더 중요함
    public void update(Long itemId, Item updateParam) {
        Item findItem = findById(itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

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

 

(3) ItemRepositoryTest - 상품 저장소 테스트

  • ItemRepository의 메서드들을 검증
package hello.itemservice.domain.item;

class ItemRepositoryTest {

    ItemRepository itemRepository = new ItemRepository();

    // 테스트 후 데이터 초기화
    @AfterEach
    void afterEach() {
        itemRepository.clearStore();
    }

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

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

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

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

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

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

        // then
        assertThat(result).contains(item1, item2, item3);
        assertThat(result).hasSize(3);
        assertThat(result.size()).isEqualTo(3); // 위와 같은 테스트
    }

    @Test
    void update() {
        // given
        Item item1 = new Item("itemA", 10000, 10);
        Item saveItem = itemRepository.save(item1);
        Long itemId = saveItem.getId();

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

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

    @Test
    void delete() {
        // given
        Item item1 = new Item("itemA", 10000, 10);
        Item saveItem = itemRepository.save(item1);
        Long itemId = saveItem.getId();
        
        // when
        itemRepository.delete(itemId);
        
        // then
        Item findItem = itemRepository.findById(itemId);
        assertThat(findItem).isNull();
    }
}

3. 상품서비스 - HTML

1) 부트스트랩

(1) HTML을 편리하게 개발하기 위해 부트스트랩을 사용

  • 웹사이트를 쉽게 만들수 있게 도와주는 HTML, CSS, JS 프레임워크
  • 하나의 CSS로 휴대폰, 태블릿, 데스크탑까지 다양한기기에서 동작하고 다양한 기능을 제공하여 사용자가 쉽게 웹사이트를 제작, 유지, 보수할 수 있도록 도와줌
  • 백엔드 개발자가 어드민 페이지등을 만들때 자주 사용함

(2) HTML, CSS 파일 적용

  • 해당 프로젝트에서는 부트스트랩 V5.0 사용
  • 다운받은 css 파일은 /resources/static 경로에 css 디렉토리를 만들어서 복사 붙혀넣기(bootstrap.min.css파일 사용)
  • HTML도 /resources/static 경로에 /html 디렉토리를 만들어서 만들고자 하는 html파일을 생성

(3) items.html - 상품목록

  • resources/static/html/items.html 경로에 작성
<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>상품 목록</h2>
    </div>

    <div class="row">
        <div class="col">
            <button class="btn btn-primary float-end"
                    onclick="location.href='addForm.html'" type="button">상품 등록
            </button>
        </div>
    </div>

    <hr class="my-4">
    <div>
        <table class="table">
            <thead>
            <tr>
                <th>ID</th>
                <th>상품명</th>
                <th>가격</th>
                <th>수량</th>
            </tr>
            </thead>
            <tbody>
            <tr>
                <td><a href="item.html">1</a></td>
                <td><a href="item.html">테스트 상품1</a></td>
                <td>10000</td>
                <td>10</td>
            </tr>
            <tr>
                <td><a href="item.html">2</a></td>
                <td><a href="item.html">테스트 상품2</a></td>
                <td>20000</td>
                <td>20</td>
            </tr>
            </tbody>
        </table>
    </div>

</div> <!-- /container -->

</body>
</html>

 

(4) item.html - 상품상세

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 상세</h2>
    </div>

    <div>
        <label for="itemId">상품 ID</label>
        <input type="text" id="itemId" name="itemId" class="form-control" value="1" readonly>
    </div>
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" readonly>
    </div>
    <div>
        <label for="price">가격</label>
        <input type="text" id="price" name="price" class="form-control" value="10000" readonly>
    </div>
    <div>
        <label for="quantity">수량</label>
        <input type="text" id="quantity" name="quantity" class="form-control" value="10" readonly>
    </div>

    <hr class="my-4">

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-primary btn-lg" onclick="location.href='editForm.html'" type="button">상품 수정
            </button>
        </div>
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" type="button">목록으로
            </button>
        </div>
    </div>

</div> <!-- /container -->
</body>
</html>

 

(5) addForm.html - 상품 등록 폼

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>

    <h4 class="mb-3">상품 입력</h4>

    <form action="item.html" method="post">
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" type="button">취소
                </button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

(6) editForm.html - 상품 수정 폼

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 수정 폼</h2>
    </div>

    <form action="item.html" method="post">
        <div>
            <label for="id">상품 ID</label>
            <input type="text" id="id" name="id" class="form-control" value="1" readonly>
        </div>
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="form-control" value="상품A">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control" value="10000">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="form-control" value="10">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">저장</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='item.html'" type="button">취소
                </button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

** 참고

  • /resources/static 경로에 넣어두었기 때문에 스프링부트가 정적 리소스를 제공하여 웹 브라우저에서 URL을 입력하면 정상적으로 뜨는 것을 확인할 수 있음
  • http://localhost:8080/html/items.html
  • 정적 리소스여서 해당 파일을 탐색기를 통해 직접 열어도 동작함
  • 이렇게 정적 리소스가 공개되는 /resources/static폴더에 HTML을 넣어두면 실제 서비스에서도 공개가 되므로 주의가 필요함
  • 서비스를 운영한다면 공개할 필요가 없는 HTML은 해당 경로에 두면 안됨

4. 상품목록 - 타임리프

1) 본격적인 컨트롤러와 뷰 템플릿 개발

(1-1) BasicItemController

  • itemservice하위에 web.item.basic 패키지 경로를 만들어서 작성
  • itemRepository에서 모든 상품을 조회 후 모델에 담고 뷰 템플릿을 호출
  • @RequiredArgsConstructor: final이 붙은 멤버변수만 사용해서 생성자를 자동 생성, 생성자 주입코드를 생략 할 수 있음
  • 생성자가 1개만 있으면 @Autowired를 생략해도 의존관계가 자동으로 주입이 되는데 final을 키워드를 빼면 ItemRepository 의존관계 주입이 안되므로 주의해야함

(1-2) 테스트용 데이터 추가

  • 테스트용 데이터가 없으면 회원 목록 기능이 정상 동작하는지 확인하기 어려워서 간단히 테스트용 데이터를 추가
  • @PostConstruct: 해당 빈의 의존관계가 모두 주입이되고 나면 초기화 용도로 호출됨
package hello.itemservice.web.item.basic;

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor // final이 붙은 멤버변수만 사용해서 생성자를 자동으로 생성(롬복라이브러리)
public class BasicItemController {

    private final ItemRepository itemRepository;

//    @Autowired //생성자 주입 -> 객체를 스프링 빈으로 등록, 생성자가 1개만 있어서 생략이 가능
//    public BasicItemController(ItemRepository itemRepository) {
//        this.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));
    }    
}

 

(2) items.html 정적 HTML을 뷰 템플릿 영역으로 복사후 수정

  • 타임리프를 활용하여 동적으로 만들기위해 static 디렉토리에 위치한 items.html 파일을 templates 디렉토리에 basic디렉토리를 만들어서 복사 후 수정
<!DOCTYPE HTML>
<!--타임리프 적용-->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <!--타임리프를 이용하여 css를 절대경로로 수정-->
    <link href="../css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>

... 동일 코드 생략

        <div class="col">
            <!-- th:onclick="|...'@{...}'|" 문법 -> 상품등록 버튼을 클릭했을때 접속되는 경로를 수정-->
            <button class="btn btn-primary float-end"
                    onclick="location.href='addForm.html'"
                    th:onclick="|location.href='@{/basic/items/add}'|"
                    type="button">상품 등록</button>
        </div>
    </div>
    
... 동일 코드 생략

            <tbody>
<!--        타임리프 문법으로 루프 돌리기-->
            <tr th:each="item : ${items}"> <!-- 모델에있는 items를 꺼내서 item에 저장 -->
                <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>
            <!-- 루프 대상 html 코드
            <tr>
                <td><a href="item.html">1</a></td>
                <td><a href="item.html">테스트 상품1</a></td>
                <td>10000</td>
                <td>10</td>
            </tr>
            <tr>
                <td><a href="item.html">2</a></td>
                <td><a href="item.html">테스트 상품2</a></td>
                <td>20000</td>
                <td>20</td>
            </tr> 루프 대상 -->
            
... 동일 코드 생략

</html>

2) 타임리프 간단히 알아보기

(1) 타임리프 사용 선언

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

 

(2-1) 속성 변경 - th:href

  • href="value1"을 th:href="value2"의 값으로 변경
  • 타임리프 뷰 템플릿을 거치면 원래 값을 th:xxx의 값으로 변경하고 만약 값이 없으면 새로 생성함
  • HTML을 그대로 볼 때는 href속성이 사용되고 뷰 템플릿을 거치면 th:href의 값이 href로 대체되면서 동적으로 값을 변경할 수 있음
  • 대부분의 HTML 속성을 th:xxx로 변경할 수 있음
<link th:href="@{/css/bootstrap.min.css}"
        href="../css/bootstrap.min.css" rel="stylesheet">

 

(2-2) 타임리프 핵심

  • th:xxx가 붙은 부분은 서버사이드에서 렌더링되고 기존것을 대체함
  • th:xxx가 없으면 기존 html의 xxx속성이 그대로 사용됨
  • HTML을 파일로 직접 열었을 때 th:xxx가 있어도 웹브라우저는 th:xxx 속성을 알지 못하므로 무시 되므로 HTML 파일보기를 유지하면서 템플릿 기능도 할 수 있음

(2-3) URL 링크 표현식 - @{...}

  • 타임리프는 URL 링크를 사용하는 경우 @{...}를 사용하여 URL 링크 표현식이라고 함
  • URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함함
  • 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있고 경로 변수{itemId}뿐 아니라 쿼리 파라미터도 생성함
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">
예시
<th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}">

url 결과
http://localhost:8080/basic/items/1?query=test

 

(3) 속성 변경 - th:onclick(상품 등록 폼으로 이동)

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

(4) 리터럴 대체 문법 - |...|

  • 타임리프에서 문자와 표현식 등은 분리 되어있어서 더해서 사용 해야함
  • 리터럴 대체 문법을 사용하면 더하기 기호 없이 편리하게 사용할 수 있음
<!-- 리터럴 대체 사용 전 -->
<span th:text="'Welcome to our application, ' + ${user.name} + '!'">
<button class="btn btn-primary float-end" th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''">

<!-- 리터럴 대체 사용 후 -->
<span th:text="|Welcome to our application, ${user.name}!|">
<button class="btn btn-primary float-end" th:onclick="|location.href='@{/basic/items/add}'|">

 

(5) 반복 출력 - th:each

  • 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고 반복문 안에서 item 변수를 사용할 수 있음
  • 컬렉션의 수만큼 <tr>...</tr>이 하위 태그를 포함해서 생성됨
<tr th:each="item : ${items}">

 

(6) 변수 표현식 -${...}

  • 모델에 포함된 값이나 타임리프 변수로 선언한 값을 조회할 수 있음
  • 프로퍼티 접근법을 사용함,(item.getPrice())
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>

 

(7) 내용 변경 - th:text

  • 내용의 값을 th:text의 값으로 변경
  • 위의 코드에서는 10000의 값을 ${item.price}의 값으로, 10의 값을${item.quantity}의 값으로 변경함

** 참고

  • 타임리프는 순수한 HTML파일을 웹 브라우저에서 열어도 내용을 확인해도 되고 서버를 통해 뷰 템플릿을 거쳐 동적으로 변경된 결과를 확인할 수 있음
  • JSP는 웹 브라우저에서 그냥 열면 JSP 소스코드와 HTML이 뒤죽박죽 되어서 정상적인 확인이 불가능해 오직 서버를 통해서 열어야 함 
  • 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 이라함