JVM은 Java Virtual Machine의 약자이다. 자바의 대부분 강의에서 초반 부분에서는 System.out.println("Hello world")라는 java 코드를 막 작성하고 이것을 자바 컴파일러로 컴파일하고 콘솔로 실행시키는 실습을 많이 하곤 한다. 이 때 JVM이라는 용어가 등장한다. JVM 자체도 깊게 다루면 끝도 없고 컴퓨터 구조 관련 얘기가 무수히 나오기 때문에 그 땐 이야기를 자세히 다뤄주진 않는다. 이전에 시간을 따로 내서 정리해야 겠다는 생각을 했었고 그게 바로 지금인거 같아서 글을 작성하려 한다.
JVM
자바가 다른 언어와 구별되는 큰 장점으로 가장 많이 거론되는 것이 OS 플랫폼의 독립성이다. WORN(write once, run, anywhere)라는 말이 있는데 이말은 플랫폼 상관없이 한번 쓰기만 하면 어디에서나 동작가능하게 해준다는 말로 이를 가능하게 해주는 것이 바로 JVM이다. (반면 C언어는 OS에 종속적인 컴파일러가 해당 소스코드를 컴파일한다.그래서 OS 종속적이다. )
JAVA의 실행
우리가 작성한 자바코드(.java확장자)는 자바 컴파일러가 컴파일하여 자바 바이트코드라는 것이 된다. 이것은 JVM이 실행할 수 있는 형태로 .class 확장자가 붙은 것이 바이트코드이다. JVM이라는 프로그램 자체는 물론 리눅스 전용 JVM, 윈도우 전용 JVM 따로 있지만 .class 파일을 받아서 해석하는 방식은 모두 통일된 방식으로 해석하기 때문에 OS 독립적이라 말한다. JVM이 동작하는 위치를 아래의 그림처럼 도식화 할 수 있다.
앞에서 언급한 .class는 아직 기계어가 아니다. 아직 중간언어인 셈이다. 이것이 다른 OS에 가더라도 동일하게 동작되는 형태인 것이며 아직 한번의 변환이 더 남았다. 그 과정은 중간언어와 주변 OS 정보들을 정리하여 해당 OS에 적합한 형태로 변환하는 과정이다. 이러한 작업을 하는 것이 Execution Engine이고 이 때 사용되는 방식은 인터프리터 방식으로 이 때 최종 기계어로 바꾸며 한줄 한줄 읽어가면서 실행이 된다. 하지만 같은 메소드라도 반복적으로 호출이 되면 매번 해석하고 수행하는데 있어서 속도가 느리기 때문에 이때 JIT 컴파일러가 사용된다. JIT 컴파일러는 자주 사용될 수 있는 bytecode를 캐싱하고 최적화함으로써 코드가 interpreter에 의해 여러번 변환되는 작업의 수를 줄일 수 있다. 이와 같이 자바는 컴파일러 방식과 인터프리터 방식을 모두 사용한다.
JVM 구성요소
이제 위 그림에서 구성 요소들을 하나씩 알아보려 한다.
Garbage Collector
메모리 관리 기능을 자동으로 수행한다. 애플리케이션이 생성한 객체의 생존 여부를 판단하여 더이상 사용하지 않는 객체를 메모리 해제한다. ( Garbage Collector에 대한 동작원리는 나중에 정리하자 )
Class Loader
JVM내로 클래스를 로드(올림)하고 링크(배치)하는 작업을 수행하는 모듈로 런타임시 동적으로 클래스를 로드한다. Runtime Data Area에 올라간다.
Execution Engine
Class Loader를 통해 JVM 내 런타임 데이터 영역에 배치된 바이트 코드를 실행한다. 자바 바이트 코드를 명령어 단위로 읽어서 실행한다.
Runtime Data Areas
JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.
실서버에서 호스팅을 하던 중에 java.lang.OutOfMemoryError: Java heap space라는 에러가 나면 이쪽에서 나는 문제라 한다. ( 해결방법은 구글링~ )
해당 영역은 5가지로 구분된다. Method, Heap, Stack, PC Register, Native Method
Method Area, Heap Area는 모든 스레드가 공유하는 영역,
Stack Area, PC Register, Native Method Stack Area는 각 스레드마다 생성되는 개별영역이다.
공유의 형태를 다음과 같이 도식화할 수 있다.
(Heap은 모든 스레드가 공유하기 때문에 동기화 문제가 생길 수 있다는 것 주의!)
힙 영역은 가비지 컬렉션에 대상이 되는 공간인데 효율적인 가비지 컬렉션을 수행하기 위해
다음과 같이 여러 영역으로 나뉜다.
힙힙!
Young Generation : 생명주기가 짧은 객체를 GC 대상으로 한다.
Eden : new를 통해 새로 생성된 객체가 위치되고 정기적인 쓰레기를 수집하고나서 살아남으면 Survivor로 이동된다.
Survivor1/2 : 살아남은 객체가 순차적으로 이동된다.
Old Generation : 생명 주기가 긴 객체를 GC 대상으로 하는 영역, Young Generation에서 마지막까지 살아남은 객체가 이동
Permanent : 클래스와 메소드 메타 정보가 저장된 공간이다. static 변수와 static 메소드가 저장되는 공간
최근에 스프링부트 강의를 보는데 이전 버전 Spring Boot에서는 application.yml이 자동 생성되고 특정 버전이후부터는 application.properties 파일이 자동 생성되는 것을 확인할 수 있었다. 이 두가지 파일은 작성하는 설정의 의미는 동일하나 작성 표기법이 살짝다르다. 그리고 서로 변환이 가능해서 properties로 작성하고 싶으면 그 방식대로 작성하고 yml파일 형식으로 작성하고 싶으면 그 방식대로 작성하면 된다. 뭐가 더 편하고 좋다라는 것은 사용하는 사람들 마다 다르다. 개인적으로는 가독성은 properties 파일, 작성의 편의성은 yml파일이 좋다고 생각된다. .properties 파일은 작성법이 직관적이나 yml 파일은 초심자가 이해하는데 약간의 사전지식이 필요하다. 그리고 현재 인프런이나 패스트캠퍼스에 올라온 강의들은 yml형식으로 강의한 것들이 많으므로 이것에 대한 지식은 나름 필요하다고 생각된다. 그래서 이번 포스트에서는 application.properties파일과 application.yml파일의 차이를 알아보고 yml파일 작성법에 대해서 소개하려고 한다.
필자는 다음과 같은 프로젝트를 생성하였고 스프링부트 버전은 2.7.13이다.
프로젝트 생성
해당 버전의 스프링부트 프로젝트에서는 application.properties가 자동생성된다. 이것을 yml 파일로 바꾸고 싶다면 파일의 확장자명만 yml으로 바꾸면 된다. 이 application.properties 파일은 별다른 설정이 없다라도 기본으로 인식이 되지만 새로 생성하는 또다른 설정 파일을 인식하기 위해선 추가로 지정해주는 과정이 필요하다.
properties 파일 vs yml 파일
위의 파일은 properties 형식으로 작성한 파일이고 아래 그림은 yml 형식으로 작성한 파일이다. properties 형식은 key, value 형식으로 작성되어 사이사이에 dot(.)을 넣어서 key이름을 작성한다. 사실 이것이 계층구조인데 이러한 계층구조를 properties파일 형식에선 각 계층을 모두 적어준다.
properties
위에서 dot(.)으로 구분한 key이름은 yml 파일에선 좀 더 심플해진 계층구조로 표현이 되어 상위계층명을 생략하여 다음 라인에 입력한다. 그리고 라인마다 중간 중간에 공백이 있다. 이건 계층의 depth를 포현하며 이때 공백 한칸은 tab을 입력하기 보다는 space 두번을 쳐서 입력하는 것이 좋다. 그리고 key옆의 클론(:)과 value 사이에는 한 칸의 space가 있어야 한다.
yml
그 다음으로는 리스트를 작성할 때의 차이이다. yml 형식의 특징은 반복의 최소화이다. yml 파일의 리스트 표기는 - 로 표기를 한다. 같은 index 표기시엔 다음 라인엔 -를 쓰지 않는다. 반면에 properties 형식에서는 기존 리스트 표기형식과 동일하다.
리스트 표현형식 : properties > yml
주석
주석은 yml 파일, properties 파일 모두 #으로 동일하다.
직접 작성시
properties
최대한 인텔리센스에서 제공하는 자동완성으로 작성하는 것이 좋다. 오타를 내는 것을 조심하자.
자동완성
yml
yml 파일 자동완성은 매우 편하다. 한방에 계층 구조를 만들어준다. 그리고 한라인 한라인 엔터치고 작성하면 작성 속도도 yml 파일이 월등히 빠르다고 생각한다.
JPA에서는 이전에 서블릿이나 스프링에서 사용하던 DAO 클래스와 비슷한 역할을 하는 클래스를 우리가 정의해야 한다. Jpa의 표준 명세라는 것이 있고 우리가 그것을 가져다 쓰는 방식으로 구현을 하게 되는 것이다. 그래서 간단한 쿼리 같은 것은 메서드 하나로 뚝딱이지만 복잡한 쿼리는 이러한 메서드들로 해결이 되지 않는다는 문제점이 있다. 하지만 JPA 입문 초반에는 복잡한 쿼리에 대한 고민보다는 이러한 표준 명세에 대하여 익히는 것을 우선으로 하되 JpaRepository API 인터페이스 공부를 제대로 잡고 하는 것이 필요하다. 여기서는 '진짜 심플한 예제'부터 시작한다. 해당 엔터티를 하나 정의하고 그에 대한 엔터티 Repository를 만든다.
명명 방식은 다음과 같다.
엔터티명 + Repository (클래스명은 자유이긴한데 일반적으로 이렇게 정의)
이렇게 우리가 정의한 클래스는 JpaRepository를 상속 받아서 써야 한다.
이 때 상속을 받게 되면 기본적으로 부모 클래스에서 이미 정의된 메서드를 사용할 수 있다.
JpaRepository의 클래스 계층구조는 다음과 같다.
요런 계층 구조
CrudRepository에서는 existsById, findById, save, saveAll, findAll, count, deleteById, delete, deleteAll 등의 메서드를 정의하고 있다. 말 그대로 Crud에 관련한 기본 메서드 정의이다.
PagingAndSortingRepository
페이징 처리를 위한 메서드를 제공
QueryByExampleExecutor
어떤 명세를 인자로 받으면 그에 맞는 동적으로 쿼리를 제공해준다.
사용자 정의 메소드
위에서 상속하는 클래스가 제공하는 메서드를 쓰지 않고 JPA가 약속한 규칙에 따라 사용자가 메서드를 정의해서 써야 한다. 주로 목적은 테이블 칼럼에 맞추고 Where절 부분에 해당하는 조건을 파라미터로 받기 위한 것이다.
메서드 이름은 CamelCase로 입력해야 한다.
예시)
findBy○○○(String ○○○)
findBy○○○Null
findAllByOrderBy○○○
findAllByOrderBy○○○Desc
findBy○○○In(String[] strings)
ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, String>{
// 여기다가 이제 메서드 정의만 추가 ↓↓↓↓↓↓↓
}
m9
where 조건이 붙은 쿼리를 정의하는 느낌으로 구성할 수 있다.
findByName("마우스") ----> ItemRepository에 Item findByName(String name); 추가
아래의 표현은 결과가 동일하다.
Item findByName(String name);
Item findByNameIs(String name);
Item findByNameEquals(String name);
itemRepo.findByPrice(100000) ----> ItemRepository에 Item findByPrice(int price); 추가
@GetMapping("/item/m9")
public String m9(Model model) {
//findByName으로 자동으로 구현
// Item item = itemRepo.findByName("마우스");
// Item item = itemRepo.findByPrice(100000);
// 메서드 이름 패턴이 중요하다.
// 가장 중요하게 생각하는 패턴이 > findBy이다.
// findBy컬럼명
// - By : Po
//Item item = itemRepo.findByNameIs("키보드");
Item item = itemRepo.findByNameEquals("키보드");
model.addAttribute("result", item);
return "item/result";
}
@GetMapping("/item/m13")
public String m13(Model model, @RequestParam("name") Item result) {
// PK를 리퀘스트 했을 뿐인데 그걸 베이스로 쿼리를 만들어서 아이템에 넣어주는 작업도 해주는 것이다.
// 도메인 클래스 컨버터(Domain Class Converter)
// - PK를 넘겨서, 바로 Entity를 조회할 수 있다.
System.out.println(result);
model.addAttribute("result", result);
// item/m13?name=키보드
//"마우스" > PK
// Optional<Item> result = itemRepo.findById(name);
// model.addAttribute("result", result.get()); // Optional은 꺼내야 한다.
return "item/result";
}
// 이 떄는 @PathVariable 이나 @RequestParam를 생략하면 안된다.
@GetMapping("/item/m14/{name}")
public String m14(Model model, @PathVariable("name") Item result) {
//item/m13?name=마우스
//item/m13/마우스
model.addAttribute("result", result);
return "item/result";
}
m15
First, Top 등의 최상위 한개,상위 몇개, 범위 지정등의 쿼리를 정의할 수 있다.
findFirstBy, findTopBy는 레코드 하나를 반환한다.
findFirstBy, findTopBy는 결과는 동일
findTop숫자By 등의 범위 지정 쿼리도 가능하다.
@GetMapping("/item/m15")
public String m15(Model model) {
// First
// Top
// 둘다 첫번쨰거 가져오는데 First는 무조건하나를 가져오는데 ★ Top은 원하는 범위를 선택한다.
// Item result = itemRepo.findFirstByOrderByPriceAsc();
// Item result = itemRepo.findTopByOrderByPriceAsc();
List<Item> list = itemRepo.findTop3ByOrderByPriceDesc();
model.addAttribute("list", list);
return "item/result";
}
m16
PageRequest : 페이징 기능을 담당한다.
레코드가 여러개 있을 때 페이징을 하는데 PageRequest.of 메서드로 기능을 처리한다. page로 몇 페이지, size로 몇개씩 가져올 것인가를 지정하고 Sort.by를 넣어서 정렬기준을 정할 수 있다.
반환값은 PageRequest라는 객체를 반환하며 findPageListBy라는 메서드로 엔터티 형식 객체로 바꿔준다.
List<Item> findPageListBy(PageRequest pageRequest); 이건 ItemRepository에 이렇게 정의가 필요하다.
밖에서 사용법of 정의
요청URL : http://localhost:8092/item/m16?page=1
@GetMapping("/item/m16")
public String m16(Model model, int page) {
// PageRequest 이게 페이징을 담당한다.
PageRequest pageRequest = PageRequest.of(page, 5, Sort.by("name"));
// Page는 0부터다 !!!
List<Item> list = itemRepo.findPageListBy(pageRequest);
model.addAttribute("list", list);
return "item/result";
}
m17, m18
@Query라는 애너테이션을 사용한다. @Query의 value에 들어가는 부분이 JPQL을 작성할 수 있는데 nativeQuery=true로 지정하면 SQL을 직접 정의할 수 있다. 이렇게 셋팅하면 JPQL과는 성격이 다름
@Query(value="select * from Item where color = :color", nativeQuery=true)
: 을 써서 파라미터 바인딩이 가능한데 :#을 쓰는 경우도 있다.
@GetMapping("/item/m17")
public String m17(Model model) {
//@Query
// - 사용자 쿼리 작성
// - 쿼리 메소드 키워드로 작성 불가능 쿼리 > 직접 SQL 작성
// select * from Item
List<Item> list = itemRepo.findAllItem(); // 메서드 이름이 중요하지 않다. 쿼리를 내맘대로 짠다. 쿼리를 내가 만든다.
model.addAttribute("list", list);
return "item/result";
}
@GetMapping("/item/m18")
public String m18(Model model, String color) {
List<Item> list = itemRepo.findAllItemByColor(color);
model.addAttribute("list",list);
return "item/result";
}
ItemRepository.java 부분
// 이렇게 붙여주면 된다. 쿼리가 길고 복잡하면 이방법으로 쓴다.
@Query(value="select * from Item", nativeQuery=true)
List<Item> findAllItem();
// 이 쿼리를 JPQL 이라고 한다 > Java Persistence Language
@Query(value="select * from Item where color = :color", nativeQuery=true)
List<Item> findAllItemByColor(String color);
이전 글을 이어서 진행한다. 인스턴스 생성까지 진행하였다. 생성된 인스턴스는 인스턴스 페이지에서 연결 버튼을 누르면 AWS 사이트 상에서 원격 연결을 할 수 있다.
인스턴스 접속
연결연결연결된 모습
이렇게 aws 사이트 상에서 원격 접속이 가능하지만 putty나 MobaXterm이라는 원격접속 프로그램을 이용하는 것이 더 일반적이다. putty나 MobaXterm툴 둘다 유명한 툴인데 개인적으로는 파일 업로드의 간편함으로 인해 MobaXterm이라는 툴을 선호한다. 이 부분에 대해서는 일단은 지금은 넘어가고 나중에 다루려고 한다.
탄력적 IP
인스턴스 페이지 우측 상단쪽에 보면 인스턴스 중지, 시작, 재부팅, 종료 등의 탭이 있다. 가끔 서버가 먹통이 될 경우에 인스턴스 중지 혹은 재부팅을 하는 경우가 있을 수 있다. 근데 처음 인스턴스를 생성하고 해당 인스턴스에 대하여 탄력적 IP를 부여하지 않으면 인스턴스 중지할 때마다 IP가 바뀌니까 조심하도록 해야한다. 만약에 EC2에서 특정 서비스를 할 것이라면 반드시 할당해야 하는 것이 탄력적 IP라고 봐도 무방하다. 그리고 저기 탭에서 인스턴스 종료는 인스턴스 삭제이니 절대로 누르지 말 것...
인스턴스
탄력적 IP 할당은 네트워크 및 보안 탭에서 탄력적 IP를 선택하고 탄력적 IP 페이지에서 탄력적 IP 주소를 할당하고 생성된 탄력적 IP를 가지고 탄력적 IP주소 연결을 하여 기존의 인스턴스에 할당하면 된다. (주소 할당 및 연결과정은 생략)
탄력적 IP
탄력적 IP가 인스턴스에 연결이 되면 이제 인스턴스를 중지 시키더라도 IP가 바뀌지 않는다. 이렇게 할당된 탄력적 IP는 1개까지는 무료로 이용할 수 있으나 2개를 사용하는 것부터 과금이 발생된다. 그리고 반드시 탄력적 IP가 할당된 상태에서는 인스턴스와 연결시켜야 하며 사용상태 이어야 한다. 만약에 탄력적 IP를 할당하였는데 인스턴스를 중지 시킨다면 과금이 발생할 것이다. 즉 사용하지 않는 탄력적 IP는 과금의 대상이라는 것이니 주의하자.
JPA는 Java Persistent API의 약자이고 자바 진영에서 ORM(object-relational mapping) 기술 표준으로 사용되는 인터페이스이다. JPA를 구현하는 기술로 대표적인 오픈소스는 Hibernate가 있다. 레거시 프로젝트에서는 많이 사용하지 않으나 해외에서나 아니면 서비스업 회사나 스타트업 회사에서는 많이 사용한다고 한다. JPA, Mybatis의 통계적인 사용 비율이 3:7이라고는 말이 전해져 내려오나 실제로 그런지는 잘 모르겠다.
ORM(Object Relational Mapping)
Entity 객체외 Database 테이블을 서로 매핑해서, SQL 쿼리가 아닌 Java 메소드를 사용해서 데이터를 조작한다.
DB의 데이터 구조를 자바의 객체 구조로 취급한다.
프로젝트 셋팅
STS4)
New > New Spring Starter Project
- Name : boot-jpa
- Type : Maven
- Java Version : 11
- Packaging : Jar
- Group : com.test
- Aritifact : boot-jpa
- Package : com.test.jpa
Dependency 추가
- Spring Web
- Lombok
- Mabatis Framework
- Oracle Driver
- Spring boot Devtools
- Thymeleaf
- Spring JPA
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Item {
// 엔티티가 참조하는 테이블의 컬럼 > 멤버 정의
// PK는 꼭 붙여야 한다.
@Id
private String name;
private int price;
private String color;
private String owner;
@Column(insertable=false, updatable=false) // 얘를 업데이트하는 대상에서 뺴버린다.
private String orderdate; // 이 컬럼은 insert를 안해도 된다. default가 있다.
}
ItemController.java 클래스 내부
@EnableJpaRepositories를 BootJpaApplication에 정의됨에 따라 스캔이 가능해짐
이걸 사용하는 쪽(Controller나 Service로직)에서 @Autowired 하여 쓴다.
JPA는 쿼리들을 메서드화 시켰다.
insert(DB) > (JPA) > save(Method)
<S extends T> S save(S entity)
m1 > 객체 setter로 정의 > 레코드 Insert : [C]RUD
@Autowired
private ItemRepository itemRepo;
@GetMapping("/item/m1")
public String m1(Model model) {
System.out.println("m1");
//[C]RUD
// - 레코드 추가하기
Item item = new Item();
item.setName("잉크젯 프린터");
item.setPrice(250000);
item.setColor("yellow");
item.setOwner("홍길동");
// save > (JPA) > insert
Item result = itemRepo.save(item);
// Hibernate: insert into item (color, orderdate, owner, price, name) values (?, ?, ?, ?, ?)
// orderdate date default sysdate not null > db엔 이렇게 되어 있다.
model.addAttribute("result", result);
return "item/result";
}
결과
m2 > 객체 Builder 패턴으로 정의 > 레코드 Insert : [C]RUD
Builder 패턴 사용으로 인한 객체 값 셋팅은 기존 생성자 방식보다 가독성이 좋다.
@GetMapping("/item/m2")
public String m2(Model model) {
System.out.println("m2");
//[C]RUD
// - 레코드 추가하기
//빌더 패턴(Builder Pattern)
// 생성자 패턴, 빌더 패턴
// - OOP에서 겍체를 생성하는 패턴 중 하나
Item item2 = new Item(null, 0, null, null, null);
// 얘는 멤버를 자유롷게 넣을 수 있어서 필요없으면 뺴고 안넣어도 된다. 가독성있다.
Item item = Item.builder()
.name("레이저 프린터")
.price(300000)
.color("black")
.owner("아무개")
.build();
// save > (JPA) > insert
Item result = itemRepo.save(item);
// Hibernate: insert into item (color, orderdate, owner, price, name) values (?, ?, ?, ?, ?)
// orderdate date default sysdate not null > db엔 이렇게 되어 있다.
model.addAttribute("result", result);
return "item/result";
}
m3 > 단일 레코드 읽기 > findById("뭘로?") : C[R]UD
Optional<T> findById(ID id); 리턴형이 Optional이라는 것 주의!
.get()으로 값을 꺼낸다는 것
itemRepo.getById("마우스") : 이것도 되지만 Deprecated 된 메서드이다. > 비권장
이건 T getById(ID id);
@GetMapping("/item/m3")
public String m3(Model model) {
//C[R]UD
// - 단일 레코드 읽기
// select item0_.name as name1_0_0_, item0_.color as color2_0_0_, item0_.orderdate as orderdate3_0_0_, item0_.owner as owner4_0_0_, item0_.price as price5_0_0_ from item item0_ where item0_.name=?
// Hibernate: select item0_.name as name1_0_0_, item0_.color as color2_0_0_, item0_.orderdate as orderdate3_0
// 내부적으로 쿼리로 동작한다. 메소드가 더이상 안쓰인다.
//Item item = itemRepo.getById("마우스"); // 얘는 더이상 안쓰임
//model.addAttribute("result", item);
Optional<Item> item = itemRepo.findById("프린터"); // ★ 얘는 가장 많이 쓰인다. 레코드 찾을 때
System.out.println(item.isPresent());
model.addAttribute("result", item.get());
return "item/result";
}
결과
m4 > isPresent() : C[R]UD 검색한 결과가 존재하는지 확인하는데 사용할 수 있다.
label1, label2에 값을 넣는 예제인데 기존에 표현식을 script 단에서 썼을 때 문자열 표현식을 ' '로 감싸야 했던 상황이 있었는데 th:inline="javascript"을 쓰면 문자열은 자동으로 " "를 붙여서 script단에서 동작한다. 숫자는 그냥 나온다. ( th:inline="javascript"에서 [[]]사용시한글은 퍼센트 인코딩이 되어서 나오니까, 주의할 것! )
th:inline="javascript"을 사용하고 [[ ]], [( )]을 가지고 적절하게 맞게 사용해야 한다.
[( )] 형태로 꺼내쓰면 인코딩 없이 데이터가 나온다. [[ ]] 로 꺼내쓰면 인코딩 되어 나온다.
[( )] 형태로 쓰려거든 ' '를 직접 붙여줘야 한다.
<div id="label1"></div>
<div id="label2"></div>
<script>
let name1 = '[[${name}]]';
let num1 = [[${num}]];
let names1 = '[[${names}]]';
let dto1 = '[[${dto}]]';
// 소스 보기에서 자바스크립트 배열이 아니다.
document.getElementById('label1').textContent = name1;
</script>
<script th:inline="javascript">
//let name2 = '[[${name}]]'; // 이거 쓰면 " " 붙어서 나온다.
let name2 = [[${name}]]; // 이거 쓰면 " " 붙어서 나온다.
let num2 = [[${num}]];
let names2 = [[${names}]];
let dto2 = [[${dto}]];
let dto3 = '[(${dto})]'; // ' '를 안쓰면 문자열화가 안되어서 에러난다.
// 소스 보기에서 자바스크립트 배열이다. 손댈것이 없다.
document.getElementById('label2').textContent = name2;
</script>