diff --git a/.codex/hooks.json b/.codex/hooks.json
index d03749a..880be27 100644
--- a/.codex/hooks.json
+++ b/.codex/hooks.json
@@ -7,7 +7,7 @@
{
"type": "command",
"command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" pre-bash",
- "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') pre-bash\"",
+ "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') pre-bash\"",
"statusMessage": "Checking command safety"
}
]
@@ -20,7 +20,7 @@
{
"type": "command",
"command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" post-edit",
- "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') post-edit\"",
+ "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') post-edit\"",
"statusMessage": "Checking test coverage"
}
]
@@ -31,7 +31,7 @@
{
"type": "command",
"command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" post-bash",
- "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') post-bash\"",
+ "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') post-bash\"",
"statusMessage": "Checking repeated failures"
}
]
diff --git a/backend/pom.xml b/backend/pom.xml
index abdb45e..1baf2c4 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -34,12 +34,34 @@
org.springframework.boot
spring-boot-starter
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.postgresql
+ postgresql
+ runtime
+
org.springframework.boot
spring-boot-starter-test
test
+
+ com.h2database
+ h2
+ test
+
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java
new file mode 100644
index 0000000..0debc59
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java
@@ -0,0 +1,15 @@
+package com.ssafy.salmanhae.common.exception;
+
+public class ApiException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public ApiException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+
+ public ErrorCode getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java
new file mode 100644
index 0000000..85e751d
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java
@@ -0,0 +1,26 @@
+package com.ssafy.salmanhae.common.exception;
+
+import org.springframework.http.HttpStatus;
+
+public enum ErrorCode {
+ INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."),
+ INVALID_BOUNDS(HttpStatus.BAD_REQUEST, "지도 범위 파라미터가 올바르지 않습니다."),
+ PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
+
+ private final HttpStatus status;
+ private final String message;
+
+ ErrorCode(HttpStatus status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+
+ public HttpStatus getStatus() {
+ return status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java
new file mode 100644
index 0000000..a7e7729
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java
@@ -0,0 +1,16 @@
+package com.ssafy.salmanhae.common.exception;
+
+public record ErrorResponse(
+ String code,
+ String message,
+ int status
+) {
+
+ public static ErrorResponse from(ErrorCode errorCode) {
+ return new ErrorResponse(
+ errorCode.name(),
+ errorCode.getMessage(),
+ errorCode.getStatus().value()
+ );
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..87c1f87
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java
@@ -0,0 +1,29 @@
+package com.ssafy.salmanhae.common.exception;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(ApiException.class)
+ public ResponseEntity handleApiException(ApiException exception) {
+ ErrorCode errorCode = exception.getErrorCode();
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(ErrorResponse.from(errorCode));
+ }
+
+ @ExceptionHandler({
+ MissingServletRequestParameterException.class,
+ MethodArgumentTypeMismatchException.class
+ })
+ public ResponseEntity handleBadRequest(Exception exception) {
+ return ResponseEntity
+ .status(ErrorCode.INVALID_REQUEST.getStatus())
+ .body(ErrorResponse.from(ErrorCode.INVALID_REQUEST));
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java
new file mode 100644
index 0000000..0b2635e
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java
@@ -0,0 +1,11 @@
+package com.ssafy.salmanhae.common.response;
+
+public record ApiResponse(
+ T data,
+ String message
+) {
+
+ public static ApiResponse ok(T data) {
+ return new ApiResponse<>(data, "OK");
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java
new file mode 100644
index 0000000..4b0679a
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java
@@ -0,0 +1,14 @@
+package com.ssafy.salmanhae.common.response;
+
+import java.util.List;
+
+public record ListResponse(
+ List items,
+ int totalCount
+) {
+
+ public static ListResponse from(List items) {
+ List snapshot = List.copyOf(items);
+ return new ListResponse<>(snapshot, snapshot.size());
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java
new file mode 100644
index 0000000..5782d00
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java
@@ -0,0 +1,65 @@
+package com.ssafy.salmanhae.controller.property;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.ssafy.salmanhae.common.response.ApiResponse;
+import com.ssafy.salmanhae.common.response.ListResponse;
+import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse;
+import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
+import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse;
+import com.ssafy.salmanhae.model.dto.property.PropertyType;
+import com.ssafy.salmanhae.model.dto.property.TransactionType;
+import com.ssafy.salmanhae.service.property.PropertyService;
+
+@RestController
+@RequestMapping("/api/v1/properties")
+public class PropertyController {
+
+ private final PropertyService propertyService;
+
+ public PropertyController(PropertyService propertyService) {
+ this.propertyService = propertyService;
+ }
+
+ @GetMapping
+ public ApiResponse> searchProperties(
+ @RequestParam BigDecimal west,
+ @RequestParam BigDecimal east,
+ @RequestParam BigDecimal south,
+ @RequestParam BigDecimal north,
+ @RequestParam(required = false) TransactionType transactionType,
+ @RequestParam(required = false) PropertyType propertyType,
+ @RequestParam(required = false) Long minDeposit,
+ @RequestParam(required = false) Long maxDeposit,
+ @RequestParam(required = false) Long minPrice,
+ @RequestParam(required = false) Long maxPrice
+ ) {
+ PropertySearchCriteria criteria = new PropertySearchCriteria(
+ west,
+ east,
+ south,
+ north,
+ transactionType,
+ propertyType,
+ minDeposit,
+ maxDeposit,
+ minPrice,
+ maxPrice
+ );
+ criteria.validateBounds();
+ List properties = propertyService.searchProperties(criteria);
+ return ApiResponse.ok(ListResponse.from(properties));
+ }
+
+ @GetMapping("/{propertyId}")
+ public ApiResponse getProperty(@PathVariable Long propertyId) {
+ return ApiResponse.ok(propertyService.getProperty(propertyId));
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java
new file mode 100644
index 0000000..b842994
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java
@@ -0,0 +1,130 @@
+package com.ssafy.salmanhae.model.dao.property;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import com.ssafy.salmanhae.model.dto.property.PropertyRow;
+import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
+import com.ssafy.salmanhae.model.dto.property.PropertyType;
+import com.ssafy.salmanhae.model.dto.property.TransactionType;
+
+@Repository
+public class JdbcPropertyDao implements PropertyDao {
+
+ private static final String PROPERTY_COLUMNS = """
+ id, title, building_name, building_key, address, road_address,
+ legal_dong_code, property_type, transaction_type, deposit, monthly_rent,
+ price, maintenance_fee, area_m2, floor, total_floor, latitude, longitude,
+ description
+ """;
+
+ private final NamedParameterJdbcTemplate jdbcTemplate;
+
+ public JdbcPropertyDao(NamedParameterJdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @Override
+ public List findInBounds(PropertySearchCriteria criteria) {
+ Map params = new HashMap<>();
+ params.put("west", criteria.west());
+ params.put("east", criteria.east());
+ params.put("south", criteria.south());
+ params.put("north", criteria.north());
+
+ StringBuilder sql = new StringBuilder("""
+ SELECT %s
+ FROM properties
+ WHERE is_active = true
+ AND longitude BETWEEN :west AND :east
+ AND latitude BETWEEN :south AND :north
+ """.formatted(PROPERTY_COLUMNS));
+
+ if (criteria.transactionType() != null) {
+ sql.append(" AND transaction_type = :transactionType");
+ params.put("transactionType", criteria.transactionType().name());
+ }
+ if (criteria.propertyType() != null) {
+ sql.append(" AND property_type = :propertyType");
+ params.put("propertyType", criteria.propertyType().name());
+ }
+ if (criteria.minDeposit() != null) {
+ sql.append(" AND deposit >= :minDeposit");
+ params.put("minDeposit", criteria.minDeposit());
+ }
+ if (criteria.maxDeposit() != null) {
+ sql.append(" AND deposit <= :maxDeposit");
+ params.put("maxDeposit", criteria.maxDeposit());
+ }
+ if (criteria.minPrice() != null) {
+ sql.append(" AND price >= :minPrice");
+ params.put("minPrice", criteria.minPrice());
+ }
+ if (criteria.maxPrice() != null) {
+ sql.append(" AND price <= :maxPrice");
+ params.put("maxPrice", criteria.maxPrice());
+ }
+
+ sql.append(" ORDER BY id ASC");
+ return jdbcTemplate.query(sql.toString(), params, propertyRowMapper());
+ }
+
+ @Override
+ public Optional findActiveById(Long propertyId) {
+ String sql = """
+ SELECT %s
+ FROM properties
+ WHERE id = :id
+ AND is_active = true
+ """.formatted(PROPERTY_COLUMNS);
+ Map params = Map.of("id", propertyId);
+ List rows = jdbcTemplate.query(sql, params, propertyRowMapper());
+ return rows.stream().findFirst();
+ }
+
+ private RowMapper propertyRowMapper() {
+ return (rs, rowNum) -> mapPropertyRow(rs);
+ }
+
+ private PropertyRow mapPropertyRow(ResultSet rs) throws SQLException {
+ return new PropertyRow(
+ rs.getLong("id"),
+ rs.getString("title"),
+ rs.getString("building_name"),
+ rs.getString("building_key"),
+ rs.getString("address"),
+ rs.getString("road_address"),
+ rs.getString("legal_dong_code"),
+ PropertyType.valueOf(rs.getString("property_type")),
+ TransactionType.valueOf(rs.getString("transaction_type")),
+ nullableLong(rs, "deposit"),
+ nullableLong(rs, "monthly_rent"),
+ nullableLong(rs, "price"),
+ nullableLong(rs, "maintenance_fee"),
+ rs.getBigDecimal("area_m2"),
+ nullableInteger(rs, "floor"),
+ nullableInteger(rs, "total_floor"),
+ rs.getBigDecimal("latitude"),
+ rs.getBigDecimal("longitude"),
+ rs.getString("description")
+ );
+ }
+
+ private Long nullableLong(ResultSet rs, String column) throws SQLException {
+ long value = rs.getLong(column);
+ return rs.wasNull() ? null : value;
+ }
+
+ private Integer nullableInteger(ResultSet rs, String column) throws SQLException {
+ int value = rs.getInt(column);
+ return rs.wasNull() ? null : value;
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java
new file mode 100644
index 0000000..e75fe06
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java
@@ -0,0 +1,14 @@
+package com.ssafy.salmanhae.model.dao.property;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.ssafy.salmanhae.model.dto.property.PropertyRow;
+import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
+
+public interface PropertyDao {
+
+ List findInBounds(PropertySearchCriteria criteria);
+
+ Optional findActiveById(Long propertyId);
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java
new file mode 100644
index 0000000..0e04d76
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java
@@ -0,0 +1,50 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+import java.math.BigDecimal;
+
+public record PropertyDetailResponse(
+ Long id,
+ String title,
+ String buildingName,
+ String buildingKey,
+ String address,
+ String roadAddress,
+ String legalDongCode,
+ PropertyType propertyType,
+ TransactionType transactionType,
+ Long deposit,
+ Long monthlyRent,
+ Long price,
+ Long maintenanceFee,
+ BigDecimal areaM2,
+ Integer floor,
+ Integer totalFloor,
+ BigDecimal latitude,
+ BigDecimal longitude,
+ String description
+) {
+
+ public static PropertyDetailResponse from(PropertyRow row) {
+ return new PropertyDetailResponse(
+ row.id(),
+ row.title(),
+ row.buildingName(),
+ row.buildingKey(),
+ row.address(),
+ row.roadAddress(),
+ row.legalDongCode(),
+ row.propertyType(),
+ row.transactionType(),
+ row.deposit(),
+ row.monthlyRent(),
+ row.price(),
+ row.maintenanceFee(),
+ row.areaM2(),
+ row.floor(),
+ row.totalFloor(),
+ row.latitude(),
+ row.longitude(),
+ row.description()
+ );
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java
new file mode 100644
index 0000000..3677c26
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java
@@ -0,0 +1,26 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+import java.math.BigDecimal;
+
+public record PropertyRow(
+ Long id,
+ String title,
+ String buildingName,
+ String buildingKey,
+ String address,
+ String roadAddress,
+ String legalDongCode,
+ PropertyType propertyType,
+ TransactionType transactionType,
+ Long deposit,
+ Long monthlyRent,
+ Long price,
+ Long maintenanceFee,
+ BigDecimal areaM2,
+ Integer floor,
+ Integer totalFloor,
+ BigDecimal latitude,
+ BigDecimal longitude,
+ String description
+) {
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java
new file mode 100644
index 0000000..5b21398
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java
@@ -0,0 +1,54 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+import java.math.BigDecimal;
+
+import com.ssafy.salmanhae.common.exception.ApiException;
+import com.ssafy.salmanhae.common.exception.ErrorCode;
+
+public record PropertySearchCriteria(
+ BigDecimal west,
+ BigDecimal east,
+ BigDecimal south,
+ BigDecimal north,
+ TransactionType transactionType,
+ PropertyType propertyType,
+ Long minDeposit,
+ Long maxDeposit,
+ Long minPrice,
+ Long maxPrice
+) {
+
+ private static final BigDecimal MIN_LONGITUDE = BigDecimal.valueOf(-180);
+ private static final BigDecimal MAX_LONGITUDE = BigDecimal.valueOf(180);
+ private static final BigDecimal MIN_LATITUDE = BigDecimal.valueOf(-90);
+ private static final BigDecimal MAX_LATITUDE = BigDecimal.valueOf(90);
+
+ public void validateBounds() {
+ if (west == null || east == null || south == null || north == null) {
+ throw new ApiException(ErrorCode.INVALID_BOUNDS);
+ }
+ if (west.compareTo(east) >= 0 || south.compareTo(north) >= 0) {
+ throw new ApiException(ErrorCode.INVALID_BOUNDS);
+ }
+ if (west.compareTo(MIN_LONGITUDE) < 0 || east.compareTo(MAX_LONGITUDE) > 0
+ || south.compareTo(MIN_LATITUDE) < 0 || north.compareTo(MAX_LATITUDE) > 0) {
+ throw new ApiException(ErrorCode.INVALID_BOUNDS);
+ }
+ if (minDeposit != null && maxDeposit != null && minDeposit > maxDeposit) {
+ throw new ApiException(ErrorCode.INVALID_REQUEST);
+ }
+ if (isNegative(minDeposit) || isNegative(maxDeposit)) {
+ throw new ApiException(ErrorCode.INVALID_REQUEST);
+ }
+ if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
+ throw new ApiException(ErrorCode.INVALID_REQUEST);
+ }
+ if (isNegative(minPrice) || isNegative(maxPrice)) {
+ throw new ApiException(ErrorCode.INVALID_REQUEST);
+ }
+ }
+
+ private boolean isNegative(Long value) {
+ return value != null && value < 0;
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java
new file mode 100644
index 0000000..cd7916a
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java
@@ -0,0 +1,38 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+import java.math.BigDecimal;
+
+public record PropertySummaryResponse(
+ Long id,
+ String title,
+ String buildingName,
+ String address,
+ PropertyType propertyType,
+ TransactionType transactionType,
+ Long deposit,
+ Long monthlyRent,
+ Long price,
+ BigDecimal areaM2,
+ Integer floor,
+ BigDecimal latitude,
+ BigDecimal longitude
+) {
+
+ public static PropertySummaryResponse from(PropertyRow row) {
+ return new PropertySummaryResponse(
+ row.id(),
+ row.title(),
+ row.buildingName(),
+ row.address(),
+ row.propertyType(),
+ row.transactionType(),
+ row.deposit(),
+ row.monthlyRent(),
+ row.price(),
+ row.areaM2(),
+ row.floor(),
+ row.latitude(),
+ row.longitude()
+ );
+ }
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java
new file mode 100644
index 0000000..42856a3
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java
@@ -0,0 +1,9 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+public enum PropertyType {
+ ONE_ROOM,
+ OFFICETEL,
+ VILLA,
+ APARTMENT,
+ MULTI_FAMILY
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java
new file mode 100644
index 0000000..be38f2f
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java
@@ -0,0 +1,7 @@
+package com.ssafy.salmanhae.model.dto.property;
+
+public enum TransactionType {
+ MONTHLY_RENT,
+ JEONSE,
+ SALE
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java
new file mode 100644
index 0000000..2df9e18
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java
@@ -0,0 +1,14 @@
+package com.ssafy.salmanhae.service.property;
+
+import java.util.List;
+
+import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse;
+import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
+import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse;
+
+public interface PropertyService {
+
+ List searchProperties(PropertySearchCriteria criteria);
+
+ PropertyDetailResponse getProperty(Long propertyId);
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java
new file mode 100644
index 0000000..ba78ef0
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java
@@ -0,0 +1,37 @@
+package com.ssafy.salmanhae.service.property;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.ssafy.salmanhae.common.exception.ApiException;
+import com.ssafy.salmanhae.common.exception.ErrorCode;
+import com.ssafy.salmanhae.model.dao.property.PropertyDao;
+import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse;
+import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
+import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse;
+
+@Service
+public class PropertyServiceImpl implements PropertyService {
+
+ private final PropertyDao propertyDao;
+
+ public PropertyServiceImpl(PropertyDao propertyDao) {
+ this.propertyDao = propertyDao;
+ }
+
+ @Override
+ public List searchProperties(PropertySearchCriteria criteria) {
+ criteria.validateBounds();
+ return propertyDao.findInBounds(criteria).stream()
+ .map(PropertySummaryResponse::from)
+ .toList();
+ }
+
+ @Override
+ public PropertyDetailResponse getProperty(Long propertyId) {
+ return propertyDao.findActiveById(propertyId)
+ .map(PropertyDetailResponse::from)
+ .orElseThrow(() -> new ApiException(ErrorCode.PROPERTY_NOT_FOUND));
+ }
+}
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 0a57d49..51ace95 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -1 +1,9 @@
spring.application.name=salmanhae
+# Required environment variables:
+# SUPABASE_DB_URL - PostgreSQL JDBC connection string
+# SUPABASE_DB_USERNAME - database username
+# SUPABASE_DB_PASSWORD - database password
+spring.datasource.url=${SUPABASE_DB_URL:}
+spring.datasource.username=${SUPABASE_DB_USERNAME:}
+spring.datasource.password=${SUPABASE_DB_PASSWORD:}
+spring.datasource.driver-class-name=org.postgresql.Driver
diff --git a/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java b/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java
index 30e05bb..a10cbb4 100644
--- a/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java
+++ b/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
+@ActiveProfiles("test")
class SalmanhaeApplicationTests {
@Test
diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java
new file mode 100644
index 0000000..de88203
--- /dev/null
+++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java
@@ -0,0 +1,126 @@
+package com.ssafy.salmanhae.controller.property;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class PropertyControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void searchPropertiesReturnsActivePropertiesInBounds() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "126.93")
+ .param("east", "126.94")
+ .param("south", "37.46")
+ .param("north", "37.48"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.message").value("OK"))
+ .andExpect(jsonPath("$.data.totalCount").value(2))
+ .andExpect(jsonPath("$.data.items", hasSize(2)))
+ .andExpect(jsonPath("$.data.items[0].id").value(1))
+ .andExpect(jsonPath("$.data.items[0].title").value("대학동 그린빌 월세"))
+ .andExpect(jsonPath("$.data.items[0].transactionType").value("MONTHLY_RENT"));
+ }
+
+ @Test
+ void searchPropertiesAppliesTransactionAndPropertyFilters() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "126.93")
+ .param("east", "126.94")
+ .param("south", "37.46")
+ .param("north", "37.48")
+ .param("transactionType", "SALE")
+ .param("propertyType", "APARTMENT")
+ .param("minPrice", "700000000")
+ .param("maxPrice", "750000000"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(1))
+ .andExpect(jsonPath("$.data.items[0].id").value(2))
+ .andExpect(jsonPath("$.data.items[0].price").value(720000000));
+ }
+
+ @Test
+ void searchPropertiesAppliesDepositFilters() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "126.93")
+ .param("east", "126.94")
+ .param("south", "37.46")
+ .param("north", "37.48")
+ .param("minDeposit", "9000000")
+ .param("maxDeposit", "11000000"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.totalCount").value(1))
+ .andExpect(jsonPath("$.data.items[0].id").value(1))
+ .andExpect(jsonPath("$.data.items[0].deposit").value(10000000));
+ }
+
+ @Test
+ void searchPropertiesRejectsInvalidBounds() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "127.00")
+ .param("east", "126.00")
+ .param("south", "37.46")
+ .param("north", "37.48"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("INVALID_BOUNDS"))
+ .andExpect(jsonPath("$.status").value(400));
+ }
+
+ @Test
+ void searchPropertiesRejectsInvalidLatitudeBounds() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "126.93")
+ .param("east", "126.94")
+ .param("south", "37.48")
+ .param("north", "37.46"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("INVALID_BOUNDS"))
+ .andExpect(jsonPath("$.status").value(400));
+ }
+
+ @Test
+ void searchPropertiesRejectsNegativeFilters() throws Exception {
+ mockMvc.perform(get("/api/v1/properties")
+ .param("west", "126.93")
+ .param("east", "126.94")
+ .param("south", "37.46")
+ .param("north", "37.48")
+ .param("minPrice", "-1"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("INVALID_REQUEST"))
+ .andExpect(jsonPath("$.status").value(400));
+ }
+
+ @Test
+ void getPropertyReturnsActivePropertyDetail() throws Exception {
+ mockMvc.perform(get("/api/v1/properties/{propertyId}", 1))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.message").value("OK"))
+ .andExpect(jsonPath("$.data.id").value(1))
+ .andExpect(jsonPath("$.data.buildingKey").value("1162010200:ONE_ROOM:그린빌:12-3"))
+ .andExpect(jsonPath("$.data.maintenanceFee").value(70000))
+ .andExpect(jsonPath("$.data.description").value("대학가 인근 원룸입니다."));
+ }
+
+ @Test
+ void getPropertyReturnsNotFoundForMissingOrInactiveProperty() throws Exception {
+ mockMvc.perform(get("/api/v1/properties/{propertyId}", 3))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND"))
+ .andExpect(jsonPath("$.status").value(404));
+ }
+}
diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java
new file mode 100644
index 0000000..0227dd9
--- /dev/null
+++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java
@@ -0,0 +1,54 @@
+package com.ssafy.salmanhae.controller.property;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class PropertyHttpIntegrationTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void listEndpointReturnsOkWithValidBounds() {
+ ResponseEntity