COVID-19로 위축되었던 스포츠 활동이 다시 활성화 되며, 많은 체육관들이 다시 운영되고 있습니다.
이러한 요즘, 스포츠 활동을 새롭게 시작하기에 앞서, 정보가 부족하여 불편하진 않으셨나요?
위치기반 체육관 검색부터 예약, 대여용품 선택 그리고 선호하는 체육관 찜하기까지 한 번에 이용해 보세요!
Front-End | Front-End | Back-End | Back-End | Back-End |
권혁민 | 윤재원 | 👑차동준👑 | 박해찬 | 이소아 |
🚀 소셜 로그인(카카오, 네이버, 구글) | 🔍 주변 체육관 찾기 |
---|---|
💵 체육관 예약 및 결제 | 📝 리뷰 작성 / 수정 |
---|---|
🔖 찜하기 | 🔔 알림 내역 |
---|---|
🧑🏻💻 판매자 회원가입 | 🚀 판매자 일반 로그인 |
---|---|
🏋🏿 체육관 등록 / 수정 | ✅ 예약 현황 |
---|---|
-
코드 컨벤션
- 협업 및 분업을 원활하게 하기 위해 개발 시 통일성을 부여하고자 많이 고민했어요.
- TypeScript, Prettier 덕분에 버그를 예방하고 협업 생산성을 높일 수 있었어요.
Button
Label
Input
Title
과 같은 재 사용성이 요구되는 UI 요소는 Atom 단위로 설계하여 생산성을 높일 수 있었어요- Type은 확장이 용이하도록 BaseType을 선언해 중복되는 Property를 줄였어요.
- 덕분에 200줄의 Type 코드가 60줄로 줄어 들 수 있었어요.
- 그 외 통일해야 할 부분을 발견하면 즉시 함께 고민하고 실행했어요.
-
기술
- RTK 를 사용하여 Client 상태를 관리했어요.
- RTK Query를 활용하여 Server 상태를 관리하였으며, Caching을 활용하여 통신 비용을 줄일 수 있었어요.
- 덕분에 응답 다음 작업이나 에러 발생 시에도 통일된 작업을 수행할 수 있었어요.
- Emotion을 활용한 스타일링 작업 시에 글로벌 스타일 적용과 Typo, Palette로 선언한 변수를 이용하도록 협의하여 통일성을 부여했어요.
보다 빠른 검색 기능을 제공하기 위해 주변 체육관 검색에 ElasticSearch를 적용하였습니다.
- RDMS에서 Like 검색 및 Match 보다 빠른 속도로 검색 결과를 제공합니다.
- 데이터 공간을 절약할 수 있으며, 컬럼을 동적으로 정의하여 필요한 데이터만 넣게 되어, 데이터 공간 및 CPU 사용량을 절약할 수 있습니다.
- ES는 HTTP를 통해
JSON
형식의 RESTful API로 호출하기 때문에 여러 환경에서 적용이 가능합니다.
- 구글, 네이버, 카카오에서 제공하는 Authorization Server를 통해 회원 정보를 인증하고
Access Token
을 발급 받습니다. - 서버 간의 통신이 잦은 경우,
Access Token
을 자주 주고 받을 수 밖에 없고, 토큰이 유효한지 확인해 주어야 합니다. - 해당 과정에서 Auth 서버에 유효성 검증 확인을 위해 요청할 때마다 병목 현상으로 인해 서버의 부하가 발생할 수 있습니다.
- Claim 기반 방식인
JWT
를 통해 Auth 서버에 검증 요청을 보내야했던 과정을 생략하고, 각 서버에서 API 요청이 들어오면 Auth 서버가 아닌 애플리케이션 서버에서 토큰 유효성 검사를 통해 사용자 인증을 거치도록 설정하였습니다.
-
Problem & Reason
useCallback, useEffect
를 함께 사용하여 코드의 가독성이 떨어지는 문제가 있었습니다. 또한, 다른 컴포넌트에서 Kakao Map 기능을 사용 할 때, 다시 map 정보를 불러주어야 하는 문제가 있었습니다.- Kakao Map(Function)
// 컴포넌트.tsx const Map = ({ searchResults, onClickMarker }: MapProps) => { const kakaoMap = useKakaoMapScript(); setMarker({ map: kakaoMap, placeInfo: searchResults, clickHandle: onClickMarker }); return ( <div> <div id="myMap" style={{ width: '100vw', height: '100vh', height: 'calc(100vh - 5rem)', }} ></div> </div> ) } // kakaoScript.ts const { kakao } = window; const useKakaoMapScript = (markerData: any) => { const [kakaoMap, setKakaoMap] = useState(); useEffect(() => { const container = document.getElementById('myMap'); const options = { center: new kakao.maps.LatLng(37.62197524055062, 127.1583774403176), level: 4, }; const map = new kakao.maps.Map(container, options); markerData.forEach((el: any) => { // 마커를 생성합니다 const markers = new kakao.maps.Marker({ //마커가 표시 될 지도 map: map, //마커가 표시 될 위치 position: new kakao.maps.LatLng(el.lat, el.lng), //마커에 hover시 나타날 title title: el.title, }); kakao.maps.event.addListener(markers, 'click', function () { console.log(el); }); }); setKakaoMap(map); }, [markerData]); return kakaoMap; }; export const mapPanTo = (map: any, location: any) => { const moveLatLon = new kakao.maps.LatLng(33.45058, 126.574942); map.panTo(moveLatLon); }; export default useKakaoMapScript;
-
To Solve
- 기존 함수형으로 작성되던 KaKao Map Script를 Class 문법으로 변경 했습니다.
- 이로 인하여 재사용성이 더 편리해 졌으며 코드의 목적성 또한 명확해졌고, 코드의 가독성이 올라갔습니다.
- 모든 로직을 무분별하게 함수형으로 추상화 하는 것을 지양하고, 코드의 목적에 따라 다양한 방법으로 추상화 해야 한다고 느꼈습니다.
- Kakao Map (Class)
// 컴포넌트.tsx const Map = ({ searchResults, onClickMarker }: MapProps) => { useEffect(() => { kakaoService.initScript(); }, []); useEffect(() => { kakaoService.setMarker({ place: searchResults, handleClick: onClickMarker }); }, [searchResults]); return ( <div> <div id="myMap" style={{ width: '100vw', height: 'calc(100vh - 5rem)', }} ></div> </div> ); }; const StadiumSearch = () => { const handleEnterFetch = (e) => { if (e.key === 'Enter') { searchStadium(search); kakaoService.setClearMarker(); } } } const EditAddress = () => { const handleSelectAdress = async (data: Address) => { // 주소 string -> 위도 경도 변환 const geoLocation = await kakaoService.getGeoCode(data.address); }; } // kakaoScript.ts const { kakao } = window; class KaKaoMap { map: any = null; markers: any[] = []; initScript() { const container = document.getElementById('myMap'); const options = { center: new kakao.maps.LatLng(ZERO_LOCATION.lat, ZERO_LOCATION.lnt), level: 10, }; const map = new kakao.maps.Map(container, options); const zoomControl = new kakao.maps.ZoomControl(); map.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT); this.map = map; } getGeoCode(address: string) { const geocoder = new kakao.maps.services.Geocoder(); return new Promise((resolve, reject) => { geocoder.addressSearch(address, function (result: any, status: any) { if (status === kakao.maps.services.Status.OK) { resolve({ lat: result[0].y, lnt: result[0].x }); } else { reject(status); } }); }); } goToLocation(location: PanToParam) { if (!this.map) return; const moveLatLon = new kakao.maps.LatLng(location.lat, location.lnt); this.map.panTo(moveLatLon); } setMarker({ place, handleClick }: setMarkerParam) { if (!place) return; place.forEach((el: any) => { // 마커를 생성합니다 const marker = new kakao.maps.Marker({ //마커가 표시 될 지도 map: this.map, //마커가 표시 될 위치 position: new kakao.maps.LatLng(el.lat, el.lnt), //마커에 hover시 나타날 title title: el.title, }); kakao.maps.event.addListener(marker, 'click', () => { handleClick(el); this.map.setLevel(8); this.goToLocation({ lat: el.lat, lnt: el.lnt }); }); this.markers.push(marker); }); this.goToLocation({ lat: place[0].lat, lnt: place[0].lnt }); } setClearMarker() { this.markers.forEach(marker => { marker.setMap(null); }); } zoomIn() { // 현재 지도의 레벨을 얻어옵니다 const level = this.map.getLevel(); // 지도를 1레벨 내립니다 (지도가 확대됩니다) this.map.setLevel(level - 1); } zoomOut() { const level = this.map.getLevel(); // 지도를 1레벨 올립니다 (지도가 축소됩니다) this.map.setLevel(level + 1); } } const kakaoService = new KaKaoMap(); export default kakaoService;
-
Problem & Reason
- Image
onchange
Event 호출 시 Cloudinary 서버에 요청을 보내 응답 데이터를 받아 저장합니다.- 이 경우, 사용자가
onChange
시 마다 요청을 보내므로, Request Cost가 높아집니다. 또한, Cloudinary 서버는 요청 횟수 1,000번을 넘으면 과금이 부가 되는 문제가 있습니다.
- 이 경우, 사용자가
submit
Event 호출 시 Cloudinary 서버에 요청을 보내 응답 데이터를 받아 온 후 Submit 로직을 실행 합니다.- 이 경우, 요청 횟수는 한 번으로 Request Cost는 낮지만, 요청 시점이 동일하며Cloudinary Server 응답을 기다려야 하므로 사용자 경험이 나빠지는 문제가 있습니다.
- Image
-
To Solve : 과금에 대한 문제를 줄이기 위해
submit
Event로 해결 했습니다. -
Etc : ‘비용 문제가 없다’ 라고 판단된다면, Image
onChange
Event 시 Upload를 하여, Request 시점을 나누어 사용자 경험을 증가시킬 수 있다고 생각합니다. 추가로, 변경 이전의 Image에Delete
요청을 하게 된다면, 효율적으로 Image를 관리할 수 있는 방법이라고 생각합니다.
-
Problem
@Override @Cacheable(value = CacheKey.USER, key = "#email") public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Member member = memberRepository.findByEmail(email).orElseThrow(() -> new AuthException(MemberNotFound)); return PrincipalDetail.of(member); }
- UserDetails를
implement
한 PrincipalDetail class를 serialize(캐시 생성)하는 것은 성공했지만, deserialize(캐시 불러오기)에서 계속 parsing 에러가 발생
SerializationException: Could not read JSON:cannot deserialize from Object value
- UserDetails를
-
Reason
- Userdetails interface의 Override 메소드가 하나의 변수형태로
json
파일에 저장되기 때문에 Deserialize할 때 해당 변수들을 Override 메소드로 변경할 수 없어 parsing error가 발생하였다.
- Userdetails interface의 Override 메소드가 하나의 변수형태로
-
To Solve
@JsonIgnore
어노테이션을 통해 Override 메소드들을 제외하고json
파일로 저장하였다.- Before
{ "@class": "com.minwonhaeso.esc.security.auth.PrincipalDetails", "member": { "@class": "com.minwonhaeso.esc.member.model.entity.Member", "memberId": 1, "email": "[email protected]", "name": "해찬", "password": "$2a$10$O4967ICeXCld8U2KRGV3GOn7MyS/dbnxloeqssp2.Q2A3GgSm2//2", "role": "ROLE_USER", "imgUrl": null, "nickname": null, "type": "USER", "status": "ING", "providerType": "LOCAL", "providerId": "gocks0918" }, "attributes": null, "password": "$2a$10$O4967ICeXCld8U2KRGV3GOn7MyS/dbnxloeqssp2.Q2A3GgSm2//2", "name": null, "enabled": true, "authorities": [ "java.util.Collections$SingletonSet", [ { "@class": "org.springframework.security.core.authority.SimpleGrantedAuthority", "authority": "ROLE_USER" } ] ], "username": "[email protected]", "accountNonExpired": true, "accountNonLocked": true, "credentialsNonExpired": true }
- After
{ "@class": "com.minwonhaeso.esc.security.auth.PrincipalDetail", "username": "[email protected]", "password": "$2a$10$Vw77fNcTVYVp2/OaPJ8ZZOUCyiYWP/hhw25jTUCq2EAnDxL4k.R8e", "member": { "@class": "com.minwonhaeso.esc.member.model.entity.Member", "memberId": 1, "email": "[email protected]", "name": "해찬", "password": "$2a$10$Vw77fNcTVYVp2/OaPJ8ZZOUCyiYWP/hhw25jTUCq2EAnDxL4k.R8e", "role": "ROLE_USER", "imgUrl": null, "nickname": null, "type": "USER", "status": "ING", "providerType": "LOCAL", "providerId": "gocks0918" }, "attributes": null }
-
Problem
- 프론트 서버가 배포된 CloudFront에서 서버에 HTTPS로 요청을 보냈을 때 Connection Refused 현상 발생
-
Reason
- EC2에는 SSL 인증 처리가 되어있지 않아서 HTTP만 받고 HTTPS를 거부
-
To Solve
- 로드밸런서를 이용하여
HTTPS(443)
요청을HTTP(80)
으로 리다이렉트 하도록 설정 BUT - 이 과정에서 SSL/TLS 인증서가 필요하여 ACM에서 인증서를 발급 BUT
- 인증서 발급을 위해 도메인이 필요하여 도메인 구입 후 이를 EC2 혹은 EC2와 연결된 로드밸런서에 연결
- 가비아(도메인 등록 사이트)에서 esc-zero-server.shop 도메인을 구매
- Route 53에 도메인을 등록하고 로드밸런서 및 EC2(IP주소)와 연결
- 로드밸런서를 이용하여