저기어때는 사용자가 여행지를 검색하고, 실시간으로 소통하며, 주차장 예약까지 가능한 종합 여행 계획 플랫폼입니다.
- 지역별 검색: 시/도, 구/군 단위로 여행지 필터링
- 카테고리별 검색: 관광지, 문화시설, 레포츠, 쇼핑 등 다양한 카테고리
- 위치 기반 검색: 현재 위치 또는 특정 좌표 기준 반경 검색
- 고성능 검색: QueryDSL을 활용한 동적 쿼리 최적화
- WebSocket 기반: STOMP 프로토콜을 통한 실시간 양방향 통신
- 채팅방 관리: 여행지별 채팅방 생성 및 참여
- 메시지 캐싱: Redis를 활용한 채팅 메시지 캐시 관리
- 메시지 이력: 데이터베이스 저장 및 조회 기능
- 실시간 예약: 동시성 제어를 통한 안전한 예약 시스템
- 예약 가능 공간 조회: 특정 시간대 주차 가능 공간 실시간 확인
- 예약 관리: 개인 예약 내역 조회 및 취소
- 중복 예약 방지: 동일 시간대 중복 예약 차단
- 회원가입/로그인: Spring Security 기반 세션 인증
- JSON 로그인: 커스텀 필터를 통한 JSON 형태 로그인 지원
- 세션 관리: 안전한 세션 기반 상태 관리
Category | Technology | Version |
---|---|---|
Language | Java | 21 |
Framework | Spring Boot | 3.4.5 |
Security | Spring Security | 6.x |
Data Access | Spring Data JPA | 3.x |
Query Builder | QueryDSL | 5.0.0 |
WebSocket | Spring WebSocket | 3.4.5 |
Database | H2 (Dev), MySQL (Prod) | - |
Cache | Redis | - |
API Documentation | Swagger/OpenAPI | 2.8.6 |
Build Tool | Gradle | 8.x |
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Vue.js SPA │ │ Spring Boot │ │ Database │
│ │ │ Application │ │ │
│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │
│ │ Router │ │◄──►│ │ Web │ │◄──►│ │ H2 │ │
│ │ Store │ │ │ │ Layer │ │ │ │ MySQL │ │
│ │Components │ │ │ └───────────┘ │ │ └───────────┘ │
│ └───────────┘ │ │ ┌───────────┐ │ └─────────────────┘
│ │ │ │ Service │ │
│ ┌───────────┐ │ │ │ Layer │ │ ┌─────────────────┐
│ │WebSocket │ │◄──►│ └───────────┘ │ │ Redis │
│ │ Client │ │ │ ┌───────────┐ │◄──►│ │
│ └───────────┘ │ │ │Repository │ │ │ ┌───────────┐ │
└─────────────────┘ │ │ Layer │ │ │ │ Cache │ │
│ └───────────┘ │ │ │ Session │ │
┌─────────────────┐ │ │ │ │ Chat │ │
│ Map Service │◄──►│ ┌───────────┐ │ │ └───────────┘ │
│ (Kakao Maps) │ │ │WebSocket │ │ └─────────────────┘
└─────────────────┘ │ │ Handler │ │
│ └───────────┘ │
└─────────────────┘
Method | Endpoint | Description |
---|---|---|
POST | /api/auth/register |
회원가입 |
POST | /api/auth/login |
로그인 |
POST | /api/auth/logout |
로그아웃 |
GET | /api/auth/check |
로그인 상태 확인 |
{
"id": "user123",
"password": "password123",
"name": "홍길동",
"email": "user@example.com"
}
{
"id": "user123",
"password": "password123"
}
Method | Endpoint | Description |
---|---|---|
GET | /api/search/condition |
조건별 여행지 검색 |
GET | /api/search/local |
지역 목록 조회 |
GET | /api/search/contents-type |
콘텐츠 타입 조회 |
GET | /api/attractions/{id} |
특정 여행지 상세 조회 |
GET /api/search/condition?metropolitanCode=1&localCode=1&contentTypeId=12&isRangeSearch=true&latitude=37.5665&longitude=126.9780&range=5.0
{
"message": "검색 성공",
"length": 25,
"fetchTime": 0.123,
"attractions": [
{
"id": 1,
"title": "경복궁",
"address": "서울특별시 종로구 사직로 161",
"latitude": 37.5796,
"longitude": 126.9770,
"parkingLotCount": 100,
"contentTypes": 12,
"metropolitanCode": 1,
"localCode": 1
}
]
}
Method | Endpoint | Description |
---|---|---|
GET | /api/v1/parking-reservations/{parkingLotId} |
예약 가능 공간 조회 |
POST | /api/v1/parking-lots/{parkingLotId}/reservation |
주차장 예약 |
GET | /api/v1/parking-reservations/me |
내 예약 내역 조회 |
DELETE | /api/v1/parking-reservations/{reservationId} |
예약 취소 |
{
"startDateTime": "2024-12-25T10:00:00",
"endDateTime": "2024-12-25T18:00:00"
}
{
"reservationId": 123,
"parkingLotId": 1,
"username": "홍길동",
"attractionName": "경복궁",
"reservationPeriod": {
"startDateTime": "2024-12-25T10:00:00",
"endDateTime": "2024-12-25T18:00:00"
},
"createdAt": "2024-12-20T15:30:00"
}
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
const client = new Client({
webSocketFactory: () => new SockJS('/ws'),
connectHeaders: {},
debug: (str) => console.log(str),
onConnect: (frame) => {
console.log('Connected: ' + frame);
// 채팅방 구독
client.subscribe('/topic/chat/room/1', (message) => {
const chatMessage = JSON.parse(message.body);
console.log('Received:', chatMessage);
});
}
});
client.activate();
const sendMessage = (roomId, content) => {
client.publish({
destination: '/app/chat/message',
body: JSON.stringify({
roomId: roomId,
content: content,
type: 'TALK'
})
});
};
{
"id": 1,
"roomId": 1,
"content": "안녕하세요! 경복궁 방문 계획 중입니다.",
"type": "TALK",
"sender": "홍길동",
"createdAt": "2024-12-20T15:30:00"
}
- 분산 락: Redis 기반 분산 락으로 동시 예약 요청 제어
- 트랜잭션:
@Transactional
을 통한 데이터 일관성 보장 - 중복 예약 방지: 동일 사용자의 중복 시간대 예약 차단
@Transactional(readOnly = true)
public int getAvailableParkingSpaces(int parkingLotId, ParkingReservationRequest request) {
ParkingLots parkingLot = parkingLotsRepository.findById(parkingLotId)
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 주차장입니다."));
int reservationCount = parkingReservationRepository
.countParkingReservationInTimeRange(
parkingLotId,
request.getStartDateTime(),
request.getEndDateTime()
);
return parkingLot.getTotalCount() - reservationCount;
}
- 예약 가능 공간 확인: 요청 시간대의 잔여 공간 조회
- 중복 예약 검증: 동일 사용자의 시간 중복 확인
- 락 획득: 주차장별 분산 락 획득
- 예약 저장: 데이터베이스에 예약 정보 저장
- 락 해제: 분산 락 해제
- 세션 기반 인증: 쿠키-세션 방식의 상태유지 인증
- CORS 설정: 프론트엔드 도메인에 대한 CORS 허용
- CSRF 보호: API 엔드포인트에 대한 CSRF 토큰 검증
- 비밀번호 암호화: BCrypt를 통한 비밀번호 해싱
- 주차장 예약 동시성 테스트: 100명이 동시에 5개 자리 예약 시도
@DisplayName("5칸 남은 주차장에 200명이 동시에 예약을 시도하면 성공한 예약은 최대 5건이다.") @Test void tryReserveParkingSpace_concurrently() throws InterruptedException { // ── given ──────────────────────────────────────────────────────────────────── int THREAD_COUNT = 200; int PARKING_CAPACITY = 5; // parkingLot.totalCount // 1) 주차장-메타 데이터 준비 ContentTypes contentsType = createContentType("관광지"); contentTypesRepository.save(contentsType); Metropolitans metropolitan = createMetropolitan(1, "서울특별시"); metropolitansRepository.save(metropolitan); Locals local = createLocal(metropolitan.getCode(), 1, "노원구"); localsRepository.save(local); Attractions attraction = new Attractions( metropolitan, contentsType, local, "제목", null, null, 123, 123, null, null, null); attractionsRepository.save(attraction); ParkingLots parkingLot = createParkingLot(attraction, PARKING_CAPACITY); parkingLotsRepository.save(parkingLot); // 2) 10명의 회원 생성 List<Members> members = IntStream.rangeClosed(1, THREAD_COUNT) .mapToObj(i -> createMember(String.valueOf(i))) .collect(Collectors.toList()); membersRepository.saveAll(members); // 3) 공통 예약 파라미터 LocalDateTime startTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0); LocalDateTime endTime = startTime.plusHours(2); ParkingReservationRequest request = createParkingReservationRequest( startTime, endTime); // ── when ─────────────────────────────────────────────────────────── CountDownLatch startLatch = new CountDownLatch(1); // 동시에 출발 CountDownLatch doneLatch = new CountDownLatch(THREAD_COUNT); // 종료 대기 ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); for (Members m : members) { executor.execute(new ReservationWorker( parkingLot.getId(), m.getId(), request, startLatch, doneLatch)); } startLatch.countDown(); doneLatch.await(); executor.shutdown(); // ── then ───────────────────────────────────────────────────────────────────── int reservations = parkingReservationRepository.countParkingReservationInTimeRange(parkingLot.getId(), startTime, endTime); assertThat(reservations).isEqualTo(PARKING_CAPACITY); // 실제 저장된 건수 = 최대 수용인원(5) }
- 중복 예약 방지 테스트: 동일 시간대 중복 예약 차단
feat: 새로운 기능 추가
fix: 버그 수정
docs: 문서 수정
style: 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우
refactor: 코드 리팩토링
test: 테스트 코드, 리팩토링 테스트 코드 추가
chore: 빌드 업무 수정, 패키지 매니저 수정
저기어때 - 혁신적인 여행 계획의 시작 🌍