DOKI v1 (0304 ~ 0313)
내용 못알아먹겠음 주의
Dev note는 정식 회고록이 아닌 draft 입니다.
📆 25-03-04
2차 멘토링, 아키텍쳐 구성도 수정
내용 보기
📆 25-03-05
UI 디자인 완료, ERD 4차 수정, SSE 버그 패치, SSE UI↔API 연결
내용 보기
📌 Daily Report
📌 프로젝트 상황
✨ UI Design
오전 시간에 나머지 UI 페이지 디자인을 모두 마쳤다. 작고 소중함.
검색 결과 페이지나 카테고리/지점 필터 후 페이지는 뻔하니까 디자인 안함.
자잘한거 합치면 10페이지 쯤 하는데, 개인 1달에 요정도 볼륨이면 괜찮지? 않을까?
이 이상의 페이지를 추가하느니 그냥 당위성 있는 request UUID나 구현할란다.
✨ ER Diagram
ERD는 4차로 수정했다. 아니 언제까지 수정할거에요??
🐞 Bug Patch
SSE 알림 수신 안되는거 패치했고, UI까지 연결했다. 처음 구현해보는건데 생각보다는 잘 풀렸다.
오히려 UI가 시간의 대부분을 잡아먹고 있다. 진짜 쪼끄만거 붙이는데도 시간이 많이 걸림.
하지만 장점이라면 레이아웃/스타일링 다 수작업이라서, 진짜 무서운 CSS 스파게티 코드는 볼 일이 없다는 것이다.
📆 25-03-06
토스트 UI, table CSS 마개조
내용 보기
📌 Daily Report
📌 프로젝트 상황
운영자 -> 이용자 측의 SSE 토스트 UI가 얼추 끝났다.

그런데 문제가 생겼다.
생긴게 영 마음에 들지 않는다 🤨
애니메이션은 맛있지만 도메인 생각하면 너무 통통 튀는것 같음.
📌 스크립트 로딩 순서 고려하기
// 비회원이거나 로그인한 이용자 (null 체크가 맨 위에 있어야 함)
if (role == null || role.equals("MEMBER")) {
...
model.addAttribute("memberCode", code);
return "index"; // 팝업스토어 목록 페이지로 이동
}
index.html은 layout-member.html을 레이아웃으로 사용하고 있는데,
layout-member.js에서 접근하는 memberCode
가 undefined운데피네로 찍히지 않으려면, 후자와 같이 스크립트 로딩 순서를 조정해야 한다.
📌 의외로 근본 있는 replaceChild
놀랍게도 레퍼런스에 의거하면, replaceChild
는 (dest, src)
형식의 근본을 따르고 있었다.
다시 말해 newNode가 2번째 인자로 들어간다는 뜻이다.
// 토스트 뷰 컨트롤
function showAlarmToast(message, dateTime) {
// parent div (toast box)
const notiToastBoxDiv = document.getElementById('noti-toast-box');
// old div
const notiToastDataDiv = document.getElementById('noti-toast-data');
const notiToastDatetimeDiv = document.getElementById('noti-toast-datetime');
// new div
const newDataDiv = document.createElement("div");
newDataDiv.classList.add('noti-toast-data');
newDataDiv.appendChild(document.createTextNode(message));
const newDatetimeDiv = document.createElement("div");
newDatetimeDiv.classList.add('noti-toast-datetime');
newDatetimeDiv.appendChild(document.createTextNode(dateTime));
// div 교체
notiToastBoxDiv.replaceChild(notiToastDataDiv, newDataDiv); // error
notiToastBoxDiv.replaceChild(notiToastDatetimeDiv, newDatetimeDiv); // error
// 토스트 박스 보여주기
notiToastBoxDiv.classList.add("active");
// 5초 후 토스트 박스 숨기기
setTimeout(() =>{
notiToastBoxDiv.classList.remove("active");
}, 5000)
}
근데 결국엔 replaceWith
라는 더 모던한 방식이 있어서 이를 채택했다.
그리고 새 노드에 id 지정하는거 까먹어서 (-> 최초 replace 이후 getElementById
호출시 터짐)
해당 코드도 추가했다.
// 토스트 뷰 컨트롤
function showAlarmToast(message, dateTime) {
console.log('show toast'); // logging
// parent div (toast box)
const notiToastBoxDiv = document.getElementById('noti-toast-box');
// old div
const notiToastDataDiv = document.getElementById('noti-toast-data');
const notiToastDatetimeDiv = document.getElementById('noti-toast-datetime');
// new div
const newDataDiv = document.createElement("div");
newDataDiv.classList.add('noti-toast-data');
newDataDiv.id = 'noti-toast-data';
newDataDiv.appendChild(document.createTextNode(message));
const newDatetimeDiv = document.createElement("div");
newDatetimeDiv.classList.add('noti-toast-datetime');
newDatetimeDiv.id = 'noti-toast-datetime';
newDatetimeDiv.appendChild(document.createTextNode(dateTime));
// div 교체
notiToastDataDiv.replaceWith(newDataDiv);
notiToastDatetimeDiv.replaceWith(newDatetimeDiv);
// 토스트 박스 보여주기
notiToastBoxDiv.classList.add("active");
// 5초 후 토스트 박스 숨기기
setTimeout(() =>{
console.log('hide toast'); // logging
notiToastBoxDiv.classList.remove("active");
}, 5000)
}
📌 타임리프 동적 class 추가
해당 방법으로 가능하다고 한다.
📌 눈물나는 th
border-radius 적용하기
table에 지정한 border-collapse: collapse;
속성과 radius 속성이 충돌한다더라.
box-shadow와 선택자 노가다로 겨우 해결했는데
겨우 해결한게 스타일링이어서 현타가 왔다. CSS 표준이 많이 발전하길 바람...
table {
width: 100%;
border-collapse: collapse;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
margin-bottom: 48px;
}
th {
font-family: "Pretendard", sans-serif;
color: #6C6C71;
background-color: #FCFCFC;
font-weight: 600;
font-size: 13px;
padding: 13px 16px;
text-align: left;
}
th:first-child {
border-top-left-radius: 6px;
box-shadow: 0 0 0 1px #E2E2E9;
}
th:last-child {
border-top-right-radius: 6px;
box-shadow: 0 0 0 1px #E2E2E9;
}
th:not(:last-child) {
border-right: 1px solid #E2E2E9;
}
th:not(:first-child):not(:last-child) {
box-shadow: 0 -1px 0 #E2E2E9, 0 1px 0 #E2E2E9;
}

📆 25-03-07
유사 배열의 forEach 미지원 문제
내용 보기
📌 Daily Report
📌 프로젝트 상황
나는 대체 백엔드 개발자인가 퍼블리셔인가
📌 forEach를 못쓰는 유사 배열
getElementsByClassName
으로 가져온 객체에 왜 forEach가 안먹히나 했더니,
반환 타입이 HTMLCollection
이었다. 슬프게도 이러한 짭 배열은 forEach가 없다고 한다.
해결 방법은 Array.from()
을 이용해서 배열로 변환하면 된다.
function setMethod(event) {
const selectedButton = event;
console.log(event);
// 버튼 스타일 변경
const methodButtons = document.getElementsByClassName('reserve-method-button');
methodButtons.forEach(btn => {
btn.classList.remove('active');
});
selectedButton.classList.add('active');
...
}
📆 25-03-09
ERD 변경 사이드이펙트, @DynamicInsert, 팝업스토어 등록 API
내용 보기
📌 Daily Report
📌 프로젝트 상황
여전히 구현할 것이 산더미이고, 코드 리뷰가 들어와서 검토 후 리팩토링해야 하고, Kibana 인코딩 문제도 해결 못했다.
ㅋㅋㅋㅋㅋㅋㅋㅋㅋ
놀랍게도 오늘 하고싶은 만큼 적어놓은 것이고, 실제로는 할 일 더 많음.
API Gateway와 https 설정을 막판에 마무리하면서 하려 했는데... 목요일부터 성능 테스트 보고서 작성하려면 코드 리뷰 검토까지만 하고 리팩토링을 후순위로 밀어야 할 듯 싶다.🙄
📌 JS 비동기 처리 스코프
그동안 왜 async
-await
붙여도 될때가 있고 안될때가 있는지 의문인 적이 몇 번 있었는데,
이 레퍼런스에서 중요한 명언을 하나 남겨놓은 것 같다.
"자바스크립트에서 async/await 사용 시 동일한 스코프에서 await하지 않으면 앞서 실행한 async를 기다려주지 않는다."
그러하다. 스코프...
왜 아무도 스코프에 대해서 얘기를 안했던거지? 이 세 글자로 이해가 바로 되는데.
📌 ERD 4차 수정에 따른 사이드 이펙트
사건의 발단은 이러했다.
- 팝업스토어 상세 페이지에 운영 시작 시간과 종료 시간이 들어갈 필요가 있음을 느낌
- 어 근데 팝업스토어 테이블에 관련 칼럼이 없네? 추가해야지
- 이미 운영 시작일을
storeStart
, 종료일을storeEnd
네이밍으로 두고 있음 - 운영 시간을 추가하기 위해 운영 일자 칼럼명에
Date
를 붙이고, 운영 시간은 뒤에Time
을 붙이기로 함 Store
엔티티 수정
data.sql
파일 얘가 제일 큰 문제인데, 이렇게 칼럼 변경 등으로 테이블 스펙이 바뀌어서 파일 내의 INSERT가 제대로 동작하지 않아도, 아무런 로그를 남기지 않는다.
그래서 뚜까 맞으면서 알게 되는 부분인데 이미 전에 맞아봐서, 다행히 금방 눈치채긴 했다.
mock data 쿼리가 안날라가니까 ES 인덱싱이 안돌아가고 -> 인덱싱이 안되니까 메인화면에서 목록 조회가 안되더라.
INSERT 쿼리를 바뀐 테이블 스펙에 맞춰 수정하니 다시 인덱싱은 돌아가는데, 당연히 ES Document 스펙도 같이 수정했어야 했다. 이번에 추가된 칼럼이 인덱싱 대상은 아니었지만, 인덱싱하는 운영 일자 칼럼명이 바뀌었기 때문이다.
그렇게 수정해서 서버를 실행했는데, 타임리프가 터지더라. 최초 렌더링만 템플릿 엔진으로 하고 이후의 렌더링 변화는 axios 비동기 방식으로 처리하려 했는데 이 방식이... 별로인가?
아무튼 칼럼명 변경으로 flatpickr 설정까지 터져서 view단까지 싹 고쳐줘야 했음ㅜ
📌 @DynamicInsert
를 드디어 이해함
@Entity
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReservationEntry {
// ...
@Column(nullable = false)
@ColumnDefault("0")
private Integer reservedCount; // 예약자 수
// ...
}
public class StoreService {
// ...
// 5. ReservationEntry 생성
List<ReservationEntry> reList = new ArrayList<>();
LocalDate endDate = dto.getStoreEndDate();
LocalTime endTime = dto.getStoreEndTime();
int reserveGap = dto.getReserveGap();
int capacity = dto.getCapacity();
for (LocalDate curDate = dto.getStoreStartDate(); !curDate.isAfter(endDate); curDate = curDate.plusDays(1)) {
for (LocalTime curTime = dto.getStoreStartTime(); curTime.isBefore(endTime); curTime = curTime.plusMinutes(reserveGap)) {
reList.add(ReservationEntry.builder()
.store(store)
.entryDate(curDate)
.entryTime(curTime)
.capacity(capacity)
.entryStatus(ReservationEntry.EntryStatus.OPEN)
.build()
);
}
}
reRepo.saveAll(reList);
// ...
}
20:14:26.751[http-nio-9093-exec-1] DEBUG o.s.web.servlet.DispatcherServlet - Failed to complete request: org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value: com.ssginc.commonservice.reserve.model.ReservationEntry.reservedCount
왜 @ColumnDefault("0")
가 안먹히고 에러가 뜨는지 의문이었는데, 이 글을 참고해보면 @DynamicInsert
를 엔티티에 명시하지 않았기에
INSERT 쿼리에서 reserveCount
를 null로 넣으려 한 것 같다.
하지만 프로젝트의 경우 MySQL에서 디폴트 값 0을 지정했기 때문에 @DynamicInsert
를 추가해서 아예 INSERT 쿼리에서 reserveCount
칼럼이 제외되어야 하는 것이 맞다.
...그런데 @DynamicInsert
넣어도 똑같이 오류 발생해서 일단 땜빵 처리해놓고 나중에 다시 디버깅해봐야 할 것 같다.
📌 팝업스토어 등록 API 구현 완료
Store
StoreImage
StoreCategory
ReservationEntry
얘네 처리 순서가 처음에 모호했다.
양방향 매핑관계때문에 Store를 저장하려면 StoreImage와 StoreCategory도 가지고 있어야 하는데
StoreImage와 StoreCategory 역시 Store를 가지고 있고...
누구를 먼저, 어떻게 생성해야 하는가? 라는 의문이 들었다.
그런데 코드 작성하다보니, Elasticsearch에서 Store를 인덱싱해야하고
인덱싱할때 StoreImage도 순회해서 MAIN_THUMBNAIL을 가져가고
StoreCategory도 CategoryNoDescDto로 재가공해서 가져가니까,
인덱싱하기 전에는 얘네가 메모리 상에 할당이 되어있어야 하는 것임.
정리하자면 이렇다.
- StoreImage, StoreCategory만 할당 안된 Store 생성 -> 메모리 상에 존재
- EntityManager로 Store를 persist -> INSERT 쿼리 날라감 (이 시점부터 auto increment된 id 값 접근 가능)
- Store의 id 값으로 StoreImage와 StoreCategory 생성 -> 메모리 상에 존재
- Store에 StoreImage, StoreCategory set하기 -> 메모리 상의 객체에 할당
- StoreImage 저장 (당연히 StoreCategory는 저장 안함.) -> INSERT 쿼리 날라감
- ReservationEntry 생성 및 저장 -> INSERT 쿼리 날라감
- Elasticsearch 인덱싱 -> 메모리 상의 Store 객체로 처리함
@Transactional
에 의해 COMMIT
결국엔, 메모리 상의 객체까지만 필요한 부분이 어느 지점인지,
그리고 DB에 쿼리가 날라가야 하는 지점이 어느 지점인지를 명확히 알고 있어야 했다.
Store는 대부분이 @OneToMany
로 자식이 매핑된 부모 엔티티이고, 자식 정보가 DB에 INSERT 날릴 때는 딱히 필요 없으니까, Store를 persist해서 INSERT 쿼리를 먼저 날리는 것이 옳은 순서였던 것이다.
JPA에서 부모-자식 엔티티 한번에 처리하는 방법이 있다고는 하는데 이는 나중에 리팩토링해보기로 하고
무식하게 생각하자면 상단의 저 순서가 직관적이다.
✨ 구현 결과
고생했다. 근데 resizing 옵션을 잘못 설정한듯 ㅋㅋㅋㅋ
버그 리포트... 추가요...