# datasource 설정
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:./data/demo
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# H2 데이터베이스 방언 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
- username, password 는 기본값으로 설정해줘도 됨
- port 는 기본값이고 path를 지정해주면 localhost:8080/h2-console 로 접속하면됨
- '인메모리 모드' 에서는 url 설정을 jdbc:h2:mem:{db이름} 으로 설정
- 'embedded mode' 에서는 url 설정을 jdbc:h2:{db가 저장될 경로} 로 설정
2. Spring Security 적용 중이라면 h2 database 접속시 설정이 안 되도록 코드 입력
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@ConditionalOnProperty(name = "spring.h2.console.enabled",havingValue = "true")
public WebSecurityCustomizer configureH2ConsoleEnable() {
return web -> web.ignoring()
.requestMatchers(PathRequest.toH2Console());
}
}
- @ConditionalOnProperty: 이 애너테이션은 특정 프로퍼티가 설정된 경우에만 해당 빈을 생성. 즉, spring.h2.console.enabled가 true로 설정된 경우에만 configureH2ConsoleEnable() 메서드에서 반환한 빈이 생성됨.
- WebSecurityCustomizer: 이 빈은 WebSecurityCustomizer 타입의 빈을 생성하고, 이를 사용하여 특정 URL 패턴에 대해 보안 필터를 무시하도록 설정함. PathRequest.toH2Console()을 사용하여 H2 콘솔의 URL 패턴을 정의하고, 이 패턴에 대해 보안 필터를 무시하게 설정.
3. 웹 브라우저 창에서 localhost:8080/h2-console 접속
- properties에서 설정한 driver class, jdbc url, username, password 입력 후 test connection 클릭
- 그 후 connect 클릭하면 쿼리 작성할 수 있는 창이 뜨고, 결과확인 코드를 입력해서 RUN 클릭하면 됨!
🚀 Lombok 라이브러리는 Getter/Setter, ToString 과 같은 반복적인 자바 코드를 컴파일할 때 자동으로 생성해주는 라이브러리
어노테이션
설명
@Getter/Setter
코드를 컴파일할 때 속성들에 대한 Getter/Setter 메소드 작성
@ToString
toString() 메소드 작성
@ToString(exclude={”변수명”}
원하지 않는 속성 제외한 toString()메소드 작성
@NonNull
해당 변수가 null 체크, NullPointerException 예외 발생
@EqualsAndHashCode
equals()와 hashCode() 메소드 작성
@Builder
빌더 패턴을 이용한 객체 생성
@NoArgsConstructor
파라미터가 없는 기본 생성자 생성
@AllArgsConstructor
모든 속성에 대한 생성자 생성
@RequiredArgsConstructor
초기화되지 않은 Final, @NonNull 어노테이션이 붙은 필드에 대한 생성자 생성
@Log
log 변수 자동 생성
@Value
불변(immutable) 클래스 생성
@Data
@ToString, @EqualsAndHashCode, @Getter, @Setter, @RequiredArgsConstructor를 합친 어노테이션
Entity Mapping 관련 어노테이션
어노테이션
설명
@Entity
클래스를 엔티티로 선언 (JPA에 엔티티 클래스라는 것을 알려줌)
@Table
엔티티와 매핑할 테이블을 지정
@Id
테이블의 기본키에 사용할 속성을 지정
@GeneratedValue
키 값을 생성하는 전략 명시
@Column
필드와 컬럼 매핑
@Lob
BLOB, CLOB 타입 매핑
@CreationTimestamp
insert 시 시간 자동 저장
@UpdateTimestamp
update 시 시간 자동 저장
@Enumerated
enum 타입 매핑
@Transient
해당 필드 데이터베이스 매핑 무시
@Temporal
날짜 타입 매핑
@CreateDate
엔티티가 생성되어 저장될 때 시간 자동 저장
@LastModifiedDate
조회한 엔티티의 값을 변경할 때 시간 자동 저장
💡 CLOB과 BLOB 의미 CLOB 이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터 타입. 문자형 대용량 파일을 저장하는데 사용 BLOB 이란 바이너리 데이터를 DB 외부에 저장하기 위한 타입. 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용
@Column 속성
속성
설명
기본값
name
필드와 매핑할 컬럼의 이름 설정
객체의 필드 이름
unique(DDL)
유니크 제약 조건 설정
insertable
insert 가능 여부
true
updatable
update 가능 여부
true
length
String 타입의 문자 길이 제약조건 설정
255
nullable(DDL)
null 값의 허용 여부 설정. false 설정 시 DDL 생성 시에 not null 제약 조건 추가
columnDefinition
데이터베이스 컬럼 정보 직접 기술
ex. @Column(columnDefinition = "varchar(5) default'10' not null")
precision, scale(DDL)
BigDecimal 타입에서 사용(BigInteger 가능) precision은 소수점을 포함한 전체 자리수이고, scale은 소수점 자리수. Double과 float 타입에는 적용되지 않음.
💡 DDL(Data Definition Language) 테이블, 스키마, 인덱스, 뷰, 도메인을 정의, 변경, 제거할 때 사용하는 언어. 가령, 테이블을 생성하거나 삭제하는 CREATE, DROP 등이 이에 해당
@GeneratedValue
🚀 Entity 클래스는 반드시 기본키를 가져야 함
생성 전략
설명
GenerationType.AUTO(default)
JPA 구현체가 자동으로 생성 전략 결정
GenerationType.IDENTITY
기본키 생성을 DB에 위임 ex. MySQL 의 경우 AUTO_INCREMENT를 사용하여 기본키 생성
GenerationType.SEQUENCE
DB 시퀀스 오브젝트를 이용한 기본키 생성 @SequenceGenerator를 사용하여 시퀀스 등록 필요
✓ 모달창의 class 속성값은 registerModal이라고 지정하고 <input> 태그들은 replyText와 replyWriter 속성값을 지정 ✓ 모달창의 버튼들도 구분하기 위해서 class 속성값을 registerBtn, closeRegisterBtn 등으로 지정해서 사용
read.html의 <script> 부분에는 자주 사용하는 DOM 객체들을 미리 변수로 처리
// 등록 작업 관련
const registerModal = new bootstrap.Modal(document.querySelector('.registerModal'));
const registerBtn = document.querySelector('.registerBtn');
const replyText = document.querySelector('.replyText');
const replyWriter = document.querySelector('.replyWriter');
const closeRegisterBtn = document.querySelector('.closeRegisterBtn');
ADD REPLY 버튼을 눌렀을 때 모달창을 보여주도록 이벤트 처리와 모달창의 Close 버튼에 대한 처리
document.querySelector('.addReplyBtn').addEventListener('click', function (e) {
registerModal.show();
});
closeRegisterBtn.addEventListener('click', function (e) {
registerModal.hide();
});
모달창 오른쪽 하단의 Register 버튼을 눌렀을 때 이벤트 처리를 추가
📍 reply.js의 addReply()를 호출하고 경고창을 통해서 추가된 댓글의 번호를 보여줌 📍 경고창이 닫히면 마지막 페이지를 다시 호출해서 등록된 댓글이 화면에 보일 수 있도록 구성
registerBtn.addEventListener('click', function (e) {
// 매개변수로 사용할 객체 생성
const replyObj = { bno: bno, replyText: replyText.value, replyWriter: replyWriter.value }
addReply(replyObj).then(result => { // 등록이 된 후 결과처리
alert(result.data.rno);
registerModal.hide();
replyText.value = '';
replyWriter.value = '';
printReplies(1, 10, true); // 댓글 목록 갱신
}).catch(e => {
alert('Exception');
});
});
2. 댓글 페이지 번호 클릭
새로운 댓글이 추가되면 자동으로 마지막 페이지로 이동하기는 하지만, 댓글의 페이지 번호를 누를 때도 이동할 수 있으므로 수정 / 삭제 전에 페이지 이동 처리는 먼저 진행 ⚡️ 화면에서 페이지 번호를 구성하는 부분은 <li> 태그 내에 존재하는 <a> 태그이고 페이지 번호가 'data-page' 속성값으로 지정되어 있음
⚡️ 페이지 번호는 매번 새로이 구성하므로 이벤트 처리할 때는 항상 고정되어 있는 <ul>을 대상으로 이벤트 리스너를 등록하는 방식을 이용
read.html에 코드 추가
// 3. 페이징 클릭
let page = 1;
let size = 10;
replyPaging.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const target = e.target;
if (!target || target.tagName !== 'A') {
return;
}
page = target.getAttribute('data-page');
printReplies(page, size);
});
✓ page와 size를 별도의 변수로 처리하는 것은 나중에 댓글 수정과 같은 작업에서 현재 페이지 번호를 유지 해야 할 가능성이 있기 때문 ✓ 이벤트 처리가 완료되면 댓글의 페이지 이동이 가능해 짐
3. 댓글 조회와 수정
댓글을 조회한다는 것은 댓글을 수정하거나 삭제하기 위함 댓글 조회는 등록과 유사하게 모달창을 이용해서 수정이나 삭제가 가능한 버튼들을 보여주는 형태로 구성
1) Axios 통신 부분
👾 reply.js에는 특정한 번호의 댓글을 조회하고 수정할 수 있는 기능을 구성 👾 댓글 조회는 GET 방식으로 처리되고, 댓글 수정은 PUT 방식으로 호출
⚡️ 흔하게 볼 수 있는 동기화된 코드인데 doA()를 실행해서 나온 결과로 result1을 이용해서 doB()를 호출하는 방식 ⚡️ 코드는 doA() -> doB() -> doC()의 순서대로 호출됨
📍 동기화된 방식의 단점은 doA()의 실행이 완료되어야만 doB()의 실행이 가능함. 즉 doA()의 결과를 반환할 때까지 다른 작업은 실행되지 않기 때문에 동시에 여러 작업을 처리할 수없음
📍 비동기 방식은 커피 가게에 여러 명의 점원이 있는 상황과 유사. 점원이 여러 명이면 한 명은 주문을 계속 받고, 다른 점원은 계속해서 커피를 제조할 수 있음. ➡️ 비동기 방식의 핵심은 '통보'. 비동기는 여러 작업을 처리하기 때문에 나중에 결과가 나오면 이를 '통보'해 주는 방식을 이용. 이러한 방식을 전문용어로는 콜백 callback이라고 함.
📍 비동기 방식은 'doA()'를 호출할 때 doB()를 해 줄 것을 같이 파라미터로 전달
function doA(callback) { ... result1 = .... callback(result1) }
⚡️ 파라미터로 전달되는 콜백을 내부에서 호출하는 코드
📍 자바 스크립트에서 함수는 '일급 객체 first-class object'로 일반 객체와 동일한 위상을 가지고 있으므로 파라미터가 되거나 리턴타입이 될 수 있음 📍 비동기 방식에서 콜백을 이용하는 것이 해결책이 되기는 하지만 동기화된 코드에 익숙한 개발자들에게는 조금만 단계가 많아져도 복잡한 코드를 만들어야 하는 불편함이 있음 ➡️ 자바 스크립트에서는 Promise하는 개념을 도입해서 '비동기 호출을 동기화된 방식'으로 작성할 수 있는 문법적인 장치가 있는데 Axios는 이를 활용하는 라이브러리 ➡️ Axios를 이용하면 Ajax를 호출하는 코드를 작성할 때 마치 동기화된 방식으로 작성할 수 있어서 자바 스크립트를 기반으로 하는 프레임워크 (Angula)나 라이브러리 (React, Vue)에서 많이 사용
2. Axios를 위한 준비
Axios를 활용해 Ajax를 이용하기 위해서는 댓글 처리가 필요한 화면에 Axios 라이브러리를 추가해주어야 함 자바 스크립트 코드의 경우 read.html에서는 주로 이벤트 관련된 부분을 처리하도록 하고 별도의 JS 파일을 작성해서 Axios를 이용하는 통신을 처리하도록 구성
static 폴더에 있는 js 폴더에 reply.js 파일을 추가
read.html의 <div layout:fragment="content">가 끝나기 전에 Axios 라이브러리를 추가 하고 reply.js 파일도 같이 추가
👾 reply.js에 간단하게 Axios를 이용하는 코드를 추가. Axios를 이용할 때 async / await를 같이 이용하면 비동기 처리를 동기화된 코드처럼 작성할 수 있음 ⚡️ async는 함수 선언 시에 사용하는 데 해당 함수가 비동기 처리를 위한 함수라는 것을 명시하기 위해서 사용하고, await는 async 함수 내에서 비동기 호츨하는 부분에 사용
async function get1(bno) {
const result = await axios.get(`/api/replies/list/${bno}`);
console.log(result);
}
reply.js에서 개발하려는 함수의 이름은 getList()라 하고, 파라미터는 다음과 같이 결정
bno : 현재 게시물 번호
page : 페이지 번호
size : 페이지당 사이즈
goLast : 마지막 페이지 호출 여부
⚡️ 이 중에서 goLast는 조금 특별한 용도로 사용. 댓글의 경우 한 페이지에서 모든 동작이 이루어지므로 새로운 댓글이 등록되어도 화면에는 아무런 변화가 없다는 문제가 생김 ⚡️ 또한 페이징 처리가 되면 새로 등록된 댓글이 마지막 페이지에 있기 때문에 댓글의 결과를 볼 수 있다는 문제가 생김
📍 goLast변수를 이용해서 강제적으로 마지막 댓글 페이지를 호출하도록 함
reply.js에 getList() 함수를 작성
async function getList({bno, page, size, goLast}) {
const result = await axios.get(`/api/replies/list/${bno}?page=${page}`, {params: {page, size}})
return result.data
}
read.html에는 getList()를 호출하는 함수와 현재 페이지가 로딩되면 해당 함수를 호출하도록 작성
function printReplies(page, size, goLast) {
getList({bno, page, size, goLast}).then(
data => {console.log(data);}
).catch(e => {
console.error();
});
}
printReplies(1, 10); // 무조건 호출
👾 결과 데이터는 dtoList로 화면에 목록(replyList)을 처리하고, 나머지 정보들로 페이지 번호를 출력
read.html에는 댓글 목록을 출력하는 printList()와 페이지 번호를 출력하는 printPages() 함수를 작성하고 Axios의 결과를 출력하도록 수정
const bno = [[${dto.bno}]];
// console.log(get1(bno));
function printReplies(page, size, goLast) {
getList({bno, page, size, goLast}).then(
data => {
console.log(data);
printList(data.list); // 목록 처리
printPages(data); // 페이지 처리
}
).catch(e => {
console.error();
});
}
printReplies(1, 10); // 무조건 호출
const replyList = document.querySelector('.replyList'); // 댓글 목록 DOM
const replyPaging = document.querySelector('.replyPaging'); // 페이지 목록 DOM
function printList(dtoList) { // 댓글 목록 출력
let str = '';
if(dtoList && dtoList.length > 0) {
for (const dto of dtoList) {
str += ` <li class="list-group-item d-flex replyItem">
<span class="col-2">${dto.rno}</span>
<span class="col-6" data-rno="${dto.rno}">${dto.replyText}</span>
<span class="col-2">${dto.replyWriter}</span>
<span class="col-2">${dto.regDate}</span>
</li>`;
}
}
replyList.innerHTML = str;
}
function printPages(data) { // 페이지 목록 출력
// pagination
page = data.page;
let pageStr = '';
if(data.prev) {
pageStr += `<li class="page-item">
<a class="page-link" data-page="${data.start - 1}">PREV</a></li>`;
}
for(let i = data.start; i <= data.end; i++) {
pageStr += `<li class="page-item ${i === data.page ? "active" : ""}">
<a class="page-link" data-page="${i}">${i}</a></li>`;
}
if(data.next) {
pageStr += `<li class="page-item">
<a class="page-link" data-page="${data.end + 1}">NEXT</a></li>`;
}
// console.log(pageStr);
replyPaging.innerHTML = pageStr;
}
A. @JsonFormat, @JsonIgnore
📍 출력된 댓글의 모양을 보면 등록 시간 regDate 부분이 배열로 처리되어서 지저분해 보임.
➡️ RelyDTO에 @JsonFormat을 이용해서 JSON 처리 시에 포맷팅을 지정 📍 댓글 수정 시간 modDate의 경우 화면에서 전혀 출력할 일이 없으므로 JSON 으로 변환될 때 제외하도록 @JsonIgnore를 적용
컨트롤러 영역에서는 Swagger UI를 이용해서 테스트와 함께 필요한 기능을 개발 ReplyController는 ReplyService를 주입 받도록 설계
@RestController
@RequestMapping("/api/replies")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
private final ReplyService replyService;
1) 등록 기능 확인
👩🏻🚀 ReplyController의 등록 기능은 이미 개발된 코드에 JSON 처리를 위해서 추가 코드가 필요
@Operation(summary = "Replies Post", description = "POST 방식으로 댓글 등록")
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> register(@Valid @RequestBody ReplyDTO replyDTO,
BindingResult bindingResult) throws BindException {
log.info(replyDTO);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
}
Map<String, Long> map = new HashMap<>();
Long rno = replyService.register(replyDTO);
map.put("rno", rno);
return map;
}
✓ 프로젝트를 실행하고 Swagger UI를 통해서 테스트를 진행. 등록 작업에서 주의할 점은 bno가 존재하는 게시물 번호여야 함
✓ 정상적으로 동작하면 {"rno": 6}과 같은 결과가 전송되는 것을 확인
에러에 대한 처리
📍 @Valid는 이미 처리를 했지만 연관 관계를 가진 엔티티를 처리할 때마다 항상 문제가 되는 것은 연관된 객체의 안전성을 확보하는 것
➡️ 예를 들어 앞선 테스트에서 bno 값을 사용할 수 없는 번호로 작성하면 다음과 같은 문제가 발생
[(conn=1337) Cannot add or update a child row: a foreign key constraint fails (`boot_ex_app_01_2405`.`reply`, CONSTRAINT `FKr1bmblqir7dalmh47ngwo7mcs` FOREIGN KEY (`board_bno`) REFERENCES `board` (`bno`))]
⚡️ 서버에 기록된 로그를 보면 SQL Exception이긴 하지만, org.springframework.dao.DataIntegrityViolationException 예외가 발생. 예외가 발생한다는 것은 분명 정상적인 결과이지만 서버의 상태 코드는 500으로 '서버 내부의 오류'로 처리
⚡️ 외부에서 Ajax로 댓글 등록 기능을 호출했을 때 500 에러가 발생한다면 호출한 측에서는 현재 서버의 문제라고 생각할 것
➡️ 클라이언트에 서버의 문제가 아니라 데이터의 문제가 있다고 전송하기 위해서는 @RestControllerAdvice를 이용하는 CustomRestAdvice에 DataIntegrityViolationException를 만들어서 사용자에게 예외 메시지를 전송하도록 구성
✓ 추가한 handlerFKException()는 DataIntegrityViolationException이 발생하면 "constraint fails" 메시지를 클라이언트로 전송
2) 특정 게시물의 댓글 목록
👩🏻🚀 특정한 게시물의 댓글 목록 처리는 '/api/replies/list/{bno}' 경로를 이용하도록 구성. 이때 bno는 게시물의 번호를 의미. 👩🏻🚀 스프링에서는 @PathVariable이라는 어노테이션을 이용해서 호출하는 경로의 값을 직접 파라미터의 변수로 처리할 수 있는 방법을 제공
ReplyController에 메서드 추가
@Operation(summary = "Replies of Board", description = "GET 방식으로 특정 게시물의 댓글 목록")
@GetMapping(value = "/list/{bno}")
public PageResponseDTO<ReplyDTO> getList(@PathVariable("bno") Long bno, PageRequestDTO pageRequestDTO) {
PageResponseDTO<ReplyDTO> responseDTO = replyService.getListOfBoard(bno, pageRequestDTO);
return responseDTO;
}
✓ getList()에서 bno 값은 경로에 있는 값을 취해서 사용할 것이므로 @PathVariable을 이용하고, 페이지와 관련된 정보는 일반 쿼리 스트링을 이용 ✓ Swagger UI로 ReplyController를 호출해 보면 PageResponseDTO가 JSON으로 처리된 결과를 볼 수 있음
3) 특정 댓글 조회
👩🏻🚀 특정한 댓글을 조회할 때는 Reply의 rno를 경로로 이용해서 GET 방식으로 처리
ReplyController에 getReplyDTO() 메서드를 추가
@Operation(summary = "Read Reply", description = "GET 방식으로 특정 댓글 조회")
@GetMapping(value = "/{rno}")
public ReplyDTO getReplyDTO(@PathVariable("rno") Long rno) {
ReplyDTO replyDTO = replyService.read(rno);
return replyDTO;
}
✓ 정상적인 rno 값이 전달되면 ReplyDTO가 JSON으로 처리
데이터가 존재하지 않는 경우의 처리
📍 getReplyDTO()와 같이 특정한 번호를 이용해서 조회할 때 문제가 되는 부분은 해당 데이터가 존재하지 않는 경우 📍 'NoSuchElementException' 예외 전송을 위해CustomRestAdvice에 기능을 추가
@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleNoSuchElementException(Exception ex) {
log.error(ex);
Map<String, String> errorsMap = new HashMap<>();
errorsMap.put("time", "" + System.currentTimeMillis());
errorsMap.put("msg", "No Such Element Exception");
return ResponseEntity.badRequest().body(errorsMap);
}
4) 특정 댓글 삭제
👩🏻🚀 일반적으로 REST 방식에서 삭제 작업은 GET / POST 가 아닌 DELETE 방식을 이용해서 처리
ReplyController에 remove() 메서드를 추가
@Operation(summary = "Delete Reply", description = "DELETE 방식으로 특정 댓글 삭제")
@DeleteMapping(value = "/{rno}")
public Map<String, Long> remove(@PathVariable("rno") Long rno) {
replyService.remove(rno);
Map<String, Long> map = new HashMap<>();
map.put("rno", rno);
return map;
}
존재하지 않는 번호의 삭제 예외
@ExceptionHandler({NoSuchElementException.class, EmptyResultDataAccessException.class})
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handlerNoSuchElementException(Exception e) {
log.error(e);
Map<String, String> errorMap = new HashMap<>();
errorMap.put("time", "" + System.currentTimeMillis());
errorMap.put("msg", "No Such Element Exception");
return ResponseEntity.badRequest().body(errorMap);
}
5) 특정 댓글 수정
👩🏻🚀 댓글 수정은 PUT방식으로 처리
ReplyController에 modify() 메서드를 추가
📍 주의할 점은 수정할 때도 등록과 마찬가지로 JSON 문자열이 전송되므로 이를 처리하도록 @RequestBody를 적용한다는 점
@Operation(summary = "Modify Reply", description = "PUT 방식으로 특정 댓글 수정")
@PutMapping(value = "/{rno}", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> modify(@PathVariable("rno") Long rno, @RequestBody ReplyDTO replyDTO) {
replyDTO.setRno(rno);
replyService.modify(replyDTO);
Map<String, Long> map = new HashMap<>();
map.put("rno", rno);
return map;
}