◆ ESSAY
선형 센서는 위치를 위도와 경도로 보내지 않습니다. 기준점에서 얼마나 떨어져 있는지만 알려줍니다.
{
"sourceId": "sensor-01",
"distanceM": 1300.0,
"value": 0.42,
"eventTime": "2026-09-01T10:15:30+09:00"
}
이벤트에는 distanceM이 있지만 지도에는 좌표가 필요합니다. 이 글은 누적거리를 지도 좌표로 바꾸는 선형참조(Linear Referencing) 모델을 설명합니다.
좌표 계산에는 다음 조건이 필요합니다.
이 글에 나오는 시설명, 좌표, 식별자와 수치는 이해를 돕기 위한 예시입니다. 특정 조직이나 장소를 설명하지 않고 여러 선형 데이터 문제에 적용할 수 있는 구조와 계산식을 다룹니다.
선형 경로에서 이벤트가 발생하면 센서가 다음처럼 보낸다고 하겠습니다.
기준점에서 1,300m 떨어진 위치에서 이벤트 발생
센서에는 이 값으로 충분합니다. 지도 화면에는 다음 정보가 필요합니다.
위도: ?
경도: ?
주변 설명: ?
센서가 보내는 거리를 지도 좌표로 바꾸는 과정을 선형참조라고 합니다. 도로의 기준점에서 몇 km 떨어진 위치를 찾거나 선형 자산의 시작점에서 떨어진 위치를 지도에 표시할 때도 같은 모델을 쓸 수 있습니다.
거리축의 값을 지도상의 경로와 연결합니다.
거리축
0m ───── 500m ───── 1,000m ───── 1,500m
│ │ │ │
지도상의 기준점과 경로 좌표를 연결
기준점이 충분하면 이벤트 거리 양옆의 좌표를 찾을 수 있습니다. 그 사이에서 이벤트가 어느 정도 진행됐는지 계산해 이벤트 좌표를 구할 수 있습니다.
이벤트가 들어오는 순간 좌표를 계산해 함께 저장하는 구현이 가장 단순합니다.
public long createEvent(EventRequest request) {
GeoPoint point = resolver.resolve(request.routeId(), request.distanceM());
return eventRepository.insert(
request,
point.latitude(),
point.longitude()
);
}
이 방식은 조회가 빠릅니다. 하지만 좌표는 이벤트가 직접 측정한 값이 아니라 기준정보에서 계산한 파생값입니다.
기준점의 좌표를 고치거나 경로에 굴곡점을 추가하면 같은 이벤트의 좌표도 달라질 수 있습니다. 이벤트를 저장할 때 좌표를 고정해 두면 기준정보를 개선해도 기존 이벤트에는 새 결과가 반영되지 않습니다.
그래서 원본과 파생값을 분리합니다.
public record SensorEvent(
long id,
String routeId,
double distanceM,
double value,
Instant eventTime
) {}
이벤트에는 센서가 보낸 값만 저장하고 좌표는 조회 시점에 계산합니다.
이벤트 저장
└─ routeId, distanceM, value, eventTime
이벤트 조회
└─ 최신 기준점 조회
└─ distanceM으로 좌표 계산
└─ 지도 응답에 latitude, longitude 추가
원본과 파생값을 나눈 구조에는 아래와 같은 장점이 있습니다.
센서가 준 거리는 원본이고 좌표는 기준정보로 해석한 결과입니다. 기준정보가 바뀌면 이 해석을 다시 계산할 수 있어야 합니다.
거리와 좌표의 대응표를 저장하는 테이블을 생각해 봅시다.
CREATE TABLE route_reference_point (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
route_id varchar(100) NOT NULL,
distance_m double precision NOT NULL,
latitude double precision,
longitude double precision,
description varchar(200),
reference_kind varchar(30) NOT NULL,
distance_source varchar(30) NOT NULL,
coordinate_source varchar(30) NOT NULL,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
실제 서비스에서는 이름과 컬럼이 달라도 됩니다. 한 행을 만든 목적과 거리와 좌표를 얻은 출처를 구분하면 됩니다.
경로에 쓰는 점은 아래 두 종류로 구분할 수 있습니다.
| 종류 | 사람이 확정하는 값 | 시스템이 계산하는 값 | 역할 |
|---|---|---|---|
| 기준점(anchor) | 거리와 좌표 | 없음 | 거리축의 눈금을 고정 |
| 경로점(vertex) | 좌표 | 거리 | 지도상의 굴곡을 표현 |
기준점은 distanceM과 좌표를 함께 아는 점입니다. 경로점은 지도에서 좌표를 지정한 점으로, 거리축상의 위치는 아직 모릅니다.
경로점의 거리는 사람이 측정하는 대신 양쪽 기준점 사이의 경로 길이 비율로 계산할 수 있습니다. 그러면 지도에 굴곡을 추가하는 작업을 거리 측정 작업과 분리할 수 있습니다.
한 점에서 거리와 좌표의 출처가 다를 수 있습니다.
| 거리 출처 | 좌표 출처 | 의미 |
|---|---|---|
| 측정값 | 지도 입력값 | 거리축은 신뢰할 수 있지만 좌표는 근사값일 수 있다. |
| 계산값 | 측정값 | 좌표는 확실하지만 거리는 기준점으로부터 계산했다. |
| 측정값 | 측정값 | 거리와 좌표를 각각 확인했다. |
따라서 distance_source와 coordinate_source를 분리합니다. is_verified 같은 boolean 하나에 모든 신뢰도를 담으면 어느 값을 검증했는지 알 수 없습니다.
좌표와 주소가 민감한 데이터라면 애플리케이션의 일반 데이터와 보관·권한 범위를 분리할 수 있습니다.
일반 이벤트 데이터
└─ 센서 값과 시간
위치 기준 데이터
└─ 좌표, 주소, 경로 형상
데이터베이스에서는 별도 스키마나 별도 저장소를 사용해 권한·백업·내보내기 정책을 분리할 수 있습니다. 보안 경계를 코드의 주석에만 적어 두지 않고 저장 구조에도 표현하면 운영 실수를 줄일 수 있습니다.
이벤트 거리 D를 감싸는 두 좌표점 P, Q를 찾았다고 하겠습니다.
P.distance = 1,000m
Q.distance = 1,600m
D.distance = 1,300m
이벤트는 P에서 Q까지 절반 진행한 위치입니다.
ratio = (D - P.distance) / (Q.distance - P.distance)
latitude = P.latitude + (Q.latitude - P.latitude) × ratio
longitude = P.longitude + (Q.longitude - P.longitude) × ratio
Java로 표현하면 다음과 같습니다.
public record GeoPoint(double latitude, double longitude) {}
public record ReferencePoint(
double distanceM,
Double latitude,
Double longitude
) {}
public static GeoPoint interpolate(
ReferencePoint previous,
ReferencePoint next,
double distanceM
) {
if (previous.latitude() == null || previous.longitude() == null
|| next.latitude() == null || next.longitude() == null) {
return null;
}
double span = next.distanceM() - previous.distanceM();
if (span < 0) {
return null;
}
if (span == 0) {
return new GeoPoint(previous.latitude(), previous.longitude());
}
double ratio = (distanceM - previous.distanceM()) / span;
return new GeoPoint(
previous.latitude()
+ (next.latitude() - previous.latitude()) * ratio,
previous.longitude()
+ (next.longitude() - previous.longitude()) * ratio
);
}
ratio는 일반적으로 0과 1 사이여야 합니다.
ratio = 0 → previous 위치
ratio = 0.5 → 두 점의 중간
ratio = 1 → next 위치
ratio가 이 범위를 벗어나면 이벤트는 두 기준점의 바깥에 있습니다. 이런 값을 무조건 외삽하면 기준정보가 없는 구간을 추정하게 됩니다.
서비스의 정책에 따라 아래 두 방법 중 하나를 택할 수 있습니다.
기준이 없는 값을 정상 좌표처럼 저장하지 않는 것이 중요합니다.
위도와 경도는 구면 좌표지만 짧은 구간의 위치를 구할 때는 평면 보간도 실용적으로 쓸 수 있습니다. 이때는 지구 곡률보다 지도 좌표의 품질과 경로 형상 때문에 실제 오차가 더 크게 생기는 경우가 많습니다.
다만 보간과 거리 계산은 구분해야 합니다.
경로가 직선이면 기준점 간격이 넓어도 보간 오차가 작습니다. 반대로 기준점 사이의 굴곡이 크면 두 끝점을 직선으로만 연결할 때 큰 오차가 생깁니다.
기준점 ───────────── 기준점
실제 경로는 굴곡
두 끝점만 사용하면 실제 경로와 지도상의 직선이 달라진다.
따라서 기준점을 일정한 간격으로 추가하기보다 경로가 바뀌는 지점에 추가하는 편이 효율적입니다.
경로점은 좌표를 알고 있지만 거리값을 모를 수 있습니다. 이 값을 양쪽 기준점의 거리 사이에 배분합니다.
먼저 인접한 좌표점 사이의 지상거리를 계산합니다.
public final class GeoDistance {
private static final double EARTH_RADIUS_M = 6_371_000.0;
private GeoDistance() {}
public static double haversine(
double lat1,
double lon1,
double lat2,
double lon2
) {
double phi1 = Math.toRadians(lat1);
double phi2 = Math.toRadians(lat2);
double deltaPhi = Math.toRadians(lat2 - lat1);
double deltaLambda = Math.toRadians(lon2 - lon1);
double a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2)
+ Math.cos(phi1) * Math.cos(phi2)
* Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
}
}
Haversine은 두 위경도 사이의 측지 직선거리를 계산합니다. 인접한 점 사이의 거리를 더하면 지도상의 폴리라인 경로 길이를 구할 수 있습니다.
선형 자산의 실제 길이는 두 지점의 직선거리보다 짧을 수 없습니다. 경로가 휘거나 여유 구간이 있으면 실제 길이가 더 길어집니다.
기준점 A와 B의 값이 다음과 같다고 하겠습니다.
A.distance = 1,000m
B.distance = 1,600m
A에서 B까지 지도 경로 길이 = 500m
경로점 X가 A에서 지도 경로의 40% 지점에 있다면 거리도 같은 비율로 배분합니다.
X.distance = 1,000 + (1,600 - 1,000) × 0.4
= 1,240m
일반적인 계산식은 다음과 같습니다.
pathRatio = A에서 X까지의 폴리라인 길이 / A에서 B까지의 폴리라인 길이
distanceX = A.distance + (B.distance - A.distance) × pathRatio
여기에는 하나의 가정이 들어갑니다.
기준점 사이에서 선형 자산의 실제 길이와 지도 경로 길이의 비율은 일정하다.
이 가정이 맞지 않을 수 있는 구간에는 기준점을 더 추가해 구간을 나누면 됩니다. 양 끝 기준점 바깥의 값은 기준이 없으므로 자동 외삽하지 않는 편이 안전합니다.
void recalculateDerivedDistances(List<ReferencePoint> points) {
List<Integer> anchors = new ArrayList<>();
for (int i = 0; i < points.size(); i++) {
if (isMeasuredDistance(points.get(i))) {
anchors.add(i);
}
}
for (int pair = 0; pair < anchors.size() - 1; pair++) {
int start = anchors.get(pair);
int end = anchors.get(pair + 1);
double pathTotal = pathLength(points, start, end);
if (pathTotal <= 0) {
continue;
}
double startDistance = points.get(start).distanceM();
double distanceSpan = points.get(end).distanceM() - startDistance;
double pathSoFar = 0;
for (int i = start + 1; i < end; i++) {
pathSoFar += segmentLength(points.get(i - 1), points.get(i));
double ratio = pathSoFar / pathTotal;
double derivedDistance = startDistance + distanceSpan * ratio;
updateDerivedDistance(
points.get(i),
roundToTenth(derivedDistance)
);
}
}
}
사람이 확정한 기준점까지 덮어쓰지 않으려면 distance_source를 확인해야 합니다. 재계산 대상은 측정값이 아니라 계산값입니다.
이벤트 하나를 처리할 때 아래 기준점이 필요할 수 있습니다.
여러 이벤트의 기준점을 각각 질의하면 비효율적입니다.
이벤트 N개 × 기준점 질의 여러 번
대신 경로별 기준정보를 한 번 읽고 메모리에서 반복 계산합니다.
public final class PositionResolver {
private final ReferencePointRepository repository;
public EventPosition resolveOne(String routeId, double distanceM) {
return EventPosition.from(
repository.findPrevious(routeId, distanceM),
repository.findNext(routeId, distanceM),
repository.findNearestDescription(routeId, distanceM),
distanceM
);
}
public RoutePositionIndex loadIndex(String routeId) {
return new RoutePositionIndex(repository.findAll(routeId));
}
}
호출부는 이벤트를 경로별로 묶습니다.
Map<String, List<SensorEvent>> eventsByRoute = events.stream()
.filter(event -> event.distanceM() >= 0)
.collect(Collectors.groupingBy(SensorEvent::routeId));
eventsByRoute.forEach((routeId, routeEvents) -> {
RoutePositionIndex index = resolver.loadIndex(routeId);
for (SensorEvent event : routeEvents) {
eventPositions.put(
event.id(),
index.resolve(event.distanceM())
);
}
});
단건 경로에서는 SQL의 ORDER BY로 가장 가까운 점을 고를 수 있습니다.
SELECT *
FROM route_reference_point
WHERE route_id = :routeId
AND description IS NOT NULL
ORDER BY
ABS(distance_m - :distanceM),
distance_m,
id
LIMIT 1;
메모리 경로도 같은 우선순위를 따라야 합니다.
ReferencePoint nearest(double distanceM) {
ReferencePoint best = null;
double bestGap = Double.MAX_VALUE;
for (ReferencePoint point : points) {
double gap = Math.abs(point.distanceM() - distanceM);
if (gap < bestGap) {
best = point;
bestGap = gap;
}
}
return best;
}
SQL은 거리 차이, 거리값, ID 순으로 정렬합니다. Java는 정렬된 목록을 앞에서부터 훑으며 gap < bestGap일 때만 교체합니다. <=를 쓰면 같은 거리에서 뒤쪽 점을 선택하므로 SQL과 결과가 달라질 수 있습니다.
두 경로가 같은 테스트 데이터를 사용해 같은 결과를 내는지 테스트합니다.
@Test
void indexAndQueryChooseTheSamePosition() {
EventPosition fromQuery =
queryResolver.resolveOne("route-a", 150.0);
EventPosition fromIndex = indexResolver
.loadIndex("route-a")
.resolve(150.0);
assertThat(fromIndex).isEqualTo(fromQuery);
}
성능을 개선하면서 계산 경로를 복제했다면 두 경로의 등가성을 테스트해야 합니다.
모든 구간의 정답 좌표를 직접 측량할 수 없다면 아래의 독립적인 두 값을 비교할 수 있습니다.
두 값의 비율을 계산합니다.
비율 = 측정 거리 차이 / 폴리라인 경로 길이
선형 자산은 경로의 굴곡과 여유 구간 때문에 길이가 지도 경로보다 길거나 같은 경우가 일반적입니다. 따라서 비율이 1보다 조금 큰 범위에 모이는지 확인할 수 있습니다.
예시:
| 구간 | 폴리라인 길이 | 측정 거리 | 비율 |
|---|---|---|---|
| A → B | 500m | 510m | 1.020 |
| B → C | 760m | 755m | 0.993 |
| C → D | 640m | 665m | 1.039 |
이 지표로 절대 정확도를 증명할 수는 없지만 다음을 판단하는 데는 도움이 됩니다.
직선거리를 비교 기준으로 쓰면 안 됩니다. 경로가 도로, 통로 또는 선형 자산을 따라 휘어지기 때문입니다. 두 기준점을 실제 경로에 따라 폴리라인으로 연결하고 그 길이를 비교해야 합니다.
수치 검증만으로는 부족합니다. 도메인의 물리 제약을 코드로 옮기면 잘못된 입력을 더 일찍 찾을 수 있습니다.
선형 자산의 실제 길이는 두 위치 사이의 직선거리보다 짧을 수 없습니다.
Optional<String> validateLengthConstraint(
ReferencePoint neighbour,
double distanceM,
double latitude,
double longitude
) {
double groundDistance = GeoDistance.haversine(
neighbour.latitude(),
neighbour.longitude(),
latitude,
longitude
);
double routeDistance = Math.abs(
distanceM - neighbour.distanceM()
);
if (groundDistance <= routeDistance + TOLERANCE_M) {
return Optional.empty();
}
return Optional.of(
"좌표 사이의 직선거리가 거리축의 차이보다 큽니다. "
+ "거리와 좌표를 확인하세요."
);
}
거리 순서와 지도상의 위치 순서가 다르면 경로가 뒤로 꺾입니다. 새 좌표가 어느 선분에 가까운지 계산해 입력 순서를 검증할 수 있습니다.
public static int findNearestSegment(
List<GeoPoint> vertices,
double latitude,
double longitude
) {
if (vertices.size() < 2) {
return -1;
}
int bestIndex = 0;
double bestOffset = Double.MAX_VALUE;
double metersPerLon = 111_320.0
* Math.cos(Math.toRadians(latitude));
double metersPerLat = 110_540.0;
for (int i = 0; i < vertices.size() - 1; i++) {
GeoPoint from = vertices.get(i);
GeoPoint to = vertices.get(i + 1);
double ax = (from.longitude() - longitude) * metersPerLon;
double ay = (from.latitude() - latitude) * metersPerLat;
double bx = (to.longitude() - longitude) * metersPerLon;
double by = (to.latitude() - latitude) * metersPerLat;
double vx = bx - ax;
double vy = by - ay;
double lengthSquared = vx * vx + vy * vy;
double t = lengthSquared == 0
? 0
: Math.max(
0,
Math.min(1, -(ax * vx + ay * vy) / lengthSquared)
);
double offset = Math.hypot(ax + t * vx, ay + t * vy);
if (offset < bestOffset) {
bestOffset = offset;
bestIndex = i;
}
}
return bestIndex;
}
위 계산에서는 위도에 따른 경도 축척을 보정한 뒤 점을 각 선분에 투영합니다. t를 0과 1 사이로 제한해야 합니다. 그래야 선분의 연장선 대신 실제 선분과의 거리를 구할 수 있습니다.
모든 경고를 저장 거부로 처리하면 사용자가 정상적인 근사값도 입력할 수 없습니다. 오류의 성격에 따라 응답을 구분합니다.
| 상태 코드 | 의미 | 예 |
|---|---|---|
| 400 | 입력을 수정해야 저장할 수 없다. | 필수값 누락, 거리 중복, 좌표 형식 오류 |
| 409 | 저장할 수 있지만 확인이 필요하다. | 경로 순서 불일치, 물리 제약 위반 가능성 |
| 500 | 서버 내부 문제다. | 저장소 장애, 외부 서비스 오류 |
409 Conflict와 경고 목록을 사용하면 저장을 막지 않으면서도 사용자의 확인을 요구할 수 있습니다. 경고 문구에는 내부 예외명 대신 사람이 판단할 수 있는 내용을 담아야 합니다.
입력한 거리 순서와 지도상의 위치 순서가 다릅니다.
저장하면 경로가 해당 지점에서 뒤로 꺾일 수 있습니다.
좌표와 거리값을 확인한 뒤 다시 저장하세요.
서버에 위치 계산 로직을 추가해도 화면에 결과가 나타나지 않을 수 있습니다. 화면이 다른 조회 경로를 쓰면 새 로직은 실행되지 않습니다.
확인 순서는 간단합니다.
코드가 존재하는지보다 실제 요청이 그 코드에 도달하는지가 먼저입니다.
is_route_vertex 같은 플래그로 경로 참여 여부를 저장하면 좌표와 플래그가 어긋날 수 있습니다.
-- 위험한 방식
WHERE has_coordinates = TRUE
AND is_route_vertex = TRUE
좌표가 있는데 플래그가 FALSE면 경로에서 빠집니다. 경로 계산과 이벤트 보간이 서로 다른 점 목록을 사용하는 문제도 생깁니다.
경로 참여가 “좌표가 존재하는가”로 결정된다면 저장 플래그를 두지 않고 조건을 하나로 만듭니다.
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
좌표에서 유도되는 사실을 상태로도 저장해 중복 관리하지 않는 편이 안전합니다.
계산 결과를 일정 단위로 반올림하면 서로 다른 두 값이 같은 값이 될 수 있습니다.
12.04m → 12.0m
12.05m → 12.1m
반올림 정밀도, 임시 입력값, UNIQUE 정책을 함께 설계해야 합니다. 반올림 결과가 기준점의 거리와 같아지면 (route_id, distance_m) 같은 UNIQUE 제약과 충돌할 수 있습니다.
등록과 수정에 같은 검증 함수를 쓰면 수정 대상 행이 자기 자신을 이웃으로 선택할 수 있습니다. 자기 자신과의 거리는 0이므로 검증을 통과합니다.
List<ReferencePoint> candidates =
repository.findLocated(routeId).stream()
.filter(point ->
excludedId == null || point.id() != excludedId
)
.toList();
수정 검증에서는 대상 ID를 후보에서 제외해야 합니다.
자동 재계산 대상과 사람이 확정한 값을 구분하지 않으면 다음 재계산에서 수동 수정값이 사라집니다.
if (point.distanceSource() == DistanceSource.DERIVED) {
updateDerivedDistance(point);
}
사람이 값을 수정하는 순간 출처를 MEASURED 또는 MANUAL로 바꾸고 재계산 로직은 그 값을 건너뛰어야 합니다.
기준점이 실제로 변하지 않았는데 전체 경로를 재계산하면 불필요한 조회와 UPDATE가 발생합니다.
boolean distanceChanged =
Math.abs(oldDistance - newDistance) >= 0.05;
boolean hasCoordinates =
latitude != null && longitude != null;
if (distanceChanged && hasCoordinates) {
recalculateDerivedDistances(routeId);
}
변경 판단의 문턱과 재계산 내부의 문턱은 같아야 합니다. 두 값이 다르면 재계산을 시작하고도 실제로는 아무것도 변경하지 않는 공회전이 생깁니다.
센서가 준 거리는 원본입니다. 지도 좌표는 기준정보에 따라 바뀔 수 있는 계산 결과입니다. 이처럼 바뀔 수 있는 값을 원본으로 저장하지 않으면 과거 데이터에도 기준정보 개선을 적용할 수 있습니다.
한 점의 거리와 좌표가 항상 같은 출처에서 오지는 않습니다. distance_source와 coordinate_source를 나눠 두면 거리와 좌표 각각의 신뢰도를 판단할 수 있습니다.
직선 구간에서는 기준점 간격이 넓어도 보간 오차가 작습니다. 경로가 꺾이는 지점에 좌표를 추가하면 점의 수가 같아도 더 정확한 결과를 얻을 수 있습니다.
단건 SQL과 다건 메모리 색인을 함께 사용하면 성능은 좋아지지만 규칙이 복제됩니다. 같은 입력에 같은 결과를 내는지 테스트가 필요합니다.
일반적인 null 검사와 길이 검사는 데이터 형식만 확인합니다. 선형 자산의 길이 관계나 좌표 순서 등 도메인에만 있는 제약까지 확인하면 잘못된 입력을 더 정확하게 찾을 수 있습니다.
| 문제 | 해법 |
|---|---|
| 센서가 1차원 거리만 보낸다 | 거리와 좌표 기준점을 연결하는 선형참조 모델을 사용한다. |
| 기준정보가 바뀔 수 있다 | 이벤트에는 원본 거리만 저장하고 좌표는 조회 시 계산한다. |
| 이벤트 위치를 구해야 한다 | 앞뒤 좌표점 사이를 선형보간한다. |
| 굴곡점의 거리를 모른다 | 기준점 사이의 폴리라인 경로 비율로 계산한다. |
| 여러 이벤트를 처리한다 | 경로별 기준정보를 한 번 읽고 메모리에서 반복 계산한다. |
| 계산 결과를 검증하기 어렵다 | 측정 거리와 폴리라인 길이의 비율을 비교한다. |
| 잘못된 기준점이 들어온다 | 물리 제약과 좌표 순서를 검증하고 400과 409를 구분한다. |
| 자동 계산이 수동값을 덮어쓴다 | 값의 출처를 저장하고 계산 대상만 갱신한다. |
보간식은 좌표 하나를 계산합니다. 서비스에서 그 좌표를 신뢰하려면 원본과 파생값을 분리하고 기준점의 품질과 경로의 굴곡을 검증하며 단건과 다건 계산이 같은 결과를 내는지 테스트해야 합니다.