1. AWS Lambda란?
AWS에서 제공하는 서버리스(serverless)기반의 FaaS(Function as a Service)로,
별도의 서버를 구축하거나 운영하지 않고 코드만 실행할 수 있는 서비스이다.
(1) 서버리스란? (Serverless)
저장소와 서버와 같은 인프라 요소는 서비스를 제공하는데 있어 꼭 필요하다.
하지만 서버리스는 개발자가 직접 서버를 구축하거나 관리하지 않아도 되는 방식이다.
그렇다고 서버가 없다는 의미는 아니며,
실제 서버는 존재하지만 AWS 와 같은 클라우드 서비스가 인프라를 대신 관리해준다.
이를 통해 개발자는 서버 설정, 유지보수, 운영 등의 사항은 신경 쓰지 않아도 되어
비즈니스 로직에만 집중할 수 있다.
서버리스 특징 | 설명 |
과금 모델 | 실행된 만큼만 비용을 지불 |
자동 확장 | 트래픽에 따라 인스턴스를 자동으로 늘이거나 줄임 |
이벤트 기반 아키텍처 | HTTP 요청, 메시지 큐 등 다양한 이벤트에 반응하여 함수를 실행하는 구조 |
FaaS vs BaaS | 코드 단위 실행(FaaS)와 관리형 서비스 제공(BaaS)의 조합으로 애플리케이션을 구성 |
무상태 실행 | 호출 시에만 실행되며 상태를 유지하지 않음 |
콜드 스타트 | 오랜 비활성 후 첫 호출 시 지연이 발생할 수 있음 |
벤더 종속성 | 특정 클라우드 서비스에 맞춰 개발하면 이식성이 떨어짐 |
운영 및 모니터링 | 분산된 함수 단위로 로깅/트레이싱이 필요 |
(2) FaaS란? (Function as a Service)
FaaS(Function as a Service)는 영어 의미 그대로 서비스로 함수를 제공하는 것을 의미한다.
여기서 함수는 프로그래밍에서 Function 또는 Method 단위의 코드 블록을 의미한다.
서버리스의 한 형태인 FaaS는 사용자가 HTTP 요청이나 메시지, 큐, 파일 업로드 등의 이벤트를 통해 함수를 호출하면,
플랫폼이 저장소에서 해당 코드를 불러와 실행 가능한 컨테이너를 자동으로 띄우고,
결과를 반환한 뒤 일정 시간 후 컨테이너를 종료, 제거하는 과정을 거쳐 자원을 최소화한다.
이때 발생한 실행 시간과 호출 횟수에 따라서 비용이 청구되기 때문에,
개발자는 서버 설정이나 배포와 같은 인프라 관리를 신경쓰지 않고 비즈니스 로직 개발에 집중할 수 있다.
대표적인 FaaS 서비스로는 AWS Lambda, Microsoft Azure Functions, Google Cloud Functions 등이 있다.
2. 이번 프로젝트에서 AWS Lambda를 사용한 이유
내배캠 최종 프로젝트에서 외부 API를 통해 공공데이터를 정해진 시간마다 주기적으로 가져와야 했다.
이런 스케줄 기반 작업은 서버를 24시간 동안 켜두어야 처리할 수 있는데,
1~2회 실행할 작업을 위해 서버를 항상 가동하는 것은 자원 낭비와 비용이 증가하는 문제를 발생시킨다.
따라서 서버 관리 부담 없이 필요한 시점에만 코드를 실행하고
실행 시간에 비례하여 과금되는 서버리스 FaaS 서비스인 AWS Lambda를 도입하였다.
Lambda와 AWS EventBridge를 연동하면 지정된 시간에 함수를 자동으로 호출할 수 있어,
이번 프로젝트처럼 특정 시간에 작업을 처리하는데 매우 적합하다.
결과적으로 AWS Lambda를 도입함으로써,
효율적으로 자원을 사용하고, 운영 부담을 줄여 비즈니스 로직 구현에만 집중할 수 있게 되었다.
3. JavaHandler를 이용하여 순수 자바 코드로 변환하기
(1) 순수 자바 코드로 변경하는 이유
AWS Lambda는 호출될 때마다 실행 환경을 프로비저닝하기 때문에 초기화 속도가 응답속도이다.
하지만 spring 애플리케이션은 수많은 빈을 스캔, 설정하고 의존성을 주입하는 과정이 필요하여
콜드 스타트시 수 초 이상의 지연이 발생한다.
반면 순수 Java Handler기반 코드를 사용하면
필요한 로직만 포함된 가벼운 경량 번들로 배포되어 초기화가 간단하고,
spring보다 몇 배는 빠르게 함수가 실행 준비가 되어 호출 지연을 낮출 수 있다.
또한 Lambda 과금은 실행 시간과 메모리 사용량에 비례하기 때문에
가벼운 번들을 사용하여 초기화 시간을 단축시키는 것이 비용 절감으로 이어진다.
따라서 AWS Lambda 환경에서는 Spring 대신 JavaHandler기반의 순수 자바 코드가 훨씬 적합하다.
🤔 콜드 스타트란?
AWS Lambda 같은 서버리스 함수가 한동안 호출되지 않다가 다시 실행될 때,
함수 실행 환경(컨테이너)을 새로 띄우고 초기화하는 과정에서 발생하는 지연이다.
Lambda는 사용량이 없다면 리소스를 해제하여 비용을 절감하기 때문에 콜드 스타트가 발생한다.
🤔 프로비저닝이란?
시스템이 필요한 자원(메모리, 컴퓨팅 파워, 네트워크)등을 할당, 준비하는 과정으로
Lambda에서 콜드 스타트가 일어나는 주요 원인이다.
(2) 핸들러 코드 작성
[참고 문헌]
Java에서 Lambda 함수 핸들러 정의
[의존성 추가]
// AWS Lambda core
implementation 'com.amazonaws:aws-lambda-java-core:1.2.2'
// AWS Lambda events
implementation 'com.amazonaws:aws-lambda-java-events:3.11.1'
[작성한 Handler 코드]
package com.parkez.parkinglot.handler;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.ScheduledEvent;
import com.parkez.parkinglot.client.kakaomap.geocode.SimpleKakaoGeocodeClient;
import com.parkez.parkinglot.service.ParkingLotPublicDataService;
import com.parkez.user.service.JdbcUserReader;
import java.io.IOException;
import java.net.URISyntaxException;
public class PublicDataHandler
implements RequestHandler<ScheduledEvent, Void> {
private final ParkingLotPublicDataService parkingLotPublicDataService;
public PublicDataHandler() {
String dataUrl = System.getenv("PARKING_LOT_PUBLIC_DATA_URL");
String serviceKey = System.getenv("PARKING_LOT_PUBLIC_DATA_SERVICE_KEY");
String defaultImg = System.getenv("PARKING_LOT_DEFAULT_IMAGE_URL");
String adminEmail = System.getenv("PARKING_LOT_PUBLIC_DATA_ADMIN_EMAIL");
String jdbcUrl = System.getenv("JDBC_URL");
String dbUser = System.getenv("DB_USERNAME");
String dbPassword = System.getenv("DB_PASSWORD");
String kakaoKey = System.getenv("KAKAO_API_KEY");
SimpleKakaoGeocodeClient geocodeClient = new SimpleKakaoGeocodeClient(kakaoKey);
JdbcUserReader userReader = new JdbcUserReader(jdbcUrl, dbUser, dbPassword);
this.parkingLotPublicDataService = new ParkingLotPublicDataService(
dataUrl,
serviceKey,
defaultImg,
adminEmail,
jdbcUrl,
dbUser,
dbPassword,
geocodeClient,
userReader
);
}
@Override
public Void handleRequest(ScheduledEvent scheduledEvent, Context context) {
try {
parkingLotPublicDataService.fetchAndSavePublicData();
} catch (IOException | InterruptedException | URISyntaxException e) {
throw new RuntimeException(e);
}
return null;
}
}
(3) 기존의 코드를 자바 코드로 변경
1️⃣ SimpleKakaoClient 클래스 생성
기존의 Spring WebClient를 사용해 Kakao 지도 API를 호출했으나,
AWS Lambda 환경에서 불필요한 의존성을 줄이고 콜드 스타트를 최소화 하기 위해
Java의 HttpClient 기반으로 SimpleKakaoGeocodeClient를 새로 만들었다.
해당 클래스는 공공데이터를 가져왔을 때,
주소값이 없는 경우 위도와 경도 값을 통해 주소를 넣어주기 위해 사용하였다.
[SimpleKakaoClient]
package com.parkez.parkinglot.client.kakaomap.geocode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SimpleKakaoGeocodeClient {
private static final String COORD_URL = "https://dapi.kakao.com/v2/local/geo/coord2address.json";
private final HttpClient client = HttpClient.newHttpClient();
private final String apikey;
private final ObjectMapper mapper = new ObjectMapper();
public SimpleKakaoGeocodeClient(String apikey) {
this.apikey = apikey;
}
public String getAddress(Double longitude, Double latitude) {
try {
URI uri = URI.create(COORD_URL + "?x=" + longitude + "&y=" + latitude);
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Authorization", "KakaoAK " + apikey)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode docs = mapper.readTree(response.body()).path("documents");
if (docs.isEmpty() || docs.isNull()) {
return "좌표로 주소를 찾을 수 없습니다.";
}
return docs.get(0).path("address").path("address_name").asText();
} catch (Exception e) {
throw new RuntimeException("Kakao API 호출 실패", e);
}
}
}
2️⃣ JdbcUserReader 클래스 생성
기존의 코드에서는 UserReader 클래스에서
Spring Data JPA를 통해 UserRepository를 사용해 사용자 정보를 조회했다.
하지만 Spring 없이 순수 자바 코드로 동작하게 하기 위해 JDBC만 이용한 별도의 Reader 클래스를 생성하였다.
[JdbcUserReader 클래스]
package com.parkez.user.service;
import com.parkez.common.exception.ParkingEasyException;
import com.parkez.user.domain.entity.User;
import com.parkez.user.domain.enums.UserRole;
import com.parkez.user.exception.UserErrorCode;
import java.sql.*;
public class JdbcUserReader {
private final String jdbcUrl;
private final String dbUser;
private final String dbPassword;
public JdbcUserReader(String jdbcUrl, String dbUser, String dbPassword) {
this.jdbcUrl = jdbcUrl;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
}
// getUserByEmailAndRole 구현
public User getUserByEmailAndRole(String email, UserRole role) {
String sql = """
SELECT id, email, role
FROM users
WHERE email = ?
AND role = ?
AND deleted_at IS NULL
""";
try (
Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword);
PreparedStatement ps = connection.prepareStatement(sql)
) {
ps.setString(1, email);
ps.setString(2, role.name());
try (ResultSet result = ps.executeQuery()) {
if (result.next()) {
Long id = result.getLong("id");
String userEmail = result.getString("email");
UserRole userRole = UserRole.valueOf(result.getString("role"));
return User.ofIdEmailRole(id, userEmail, userRole);
}
}
throw new ParkingEasyException(UserErrorCode.USER_NOT_FOUND);
} catch (SQLException e) {
throw new RuntimeException("UserReader JDBC 오류", e);
}
}
}
3️⃣ ParkingLotPublicDataService 클래스 수정
기존의 코드에서는 Spring의 RestTemplate과 JdbcTemplate을 활용하여 공공데이터를 호출하고 저장하였다.
HTTP 호출을 위해 HttpClient를 사용하고,
URIBuilder를 활용하여 Spring 의존성을 제거하였다.
또한 JSON 파싱도 Jackson ObjectMapper로 처리하여 response 클래스로 매핑했다.
이렇게 가져온 데이터를 저장하기 위해서
`DriverManager.getConnection` 메소드를 통해 직접 커넥션을 확인하고,
트랜잭션을 수동으로 관리하였다.
또한 PreparedStatement의 `addBatch()`와 `executeBatch` 를 이용하여 대량 삽입을 진행했다.
쿼리에 ` ON DUPLICATE KEY UPDATE id = id `를 추가해서
중복 키 충돌 시 자동으로 건너 뛰도록 설정하였고,
이로 인해 DataIntegrityViolationException 예외는 DB에서 제어하도록 했다.
[ParkingLotPublicDataService]
package com.parkez.parkinglot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.parkez.parkinglot.client.kakaomap.geocode.SimpleKakaoGeocodeClient;
import com.parkez.parkinglot.client.publicData.ParkingLotData;
import com.parkez.parkinglot.client.publicData.ParkingLotDataResponse;
import com.parkez.parkinglot.domain.entity.ParkingLot;
import com.parkez.parkinglot.domain.entity.ParkingLotImage;
import com.parkez.parkinglot.domain.enums.ChargeType;
import com.parkez.parkinglot.domain.enums.SourceType;
import com.parkez.user.domain.entity.User;
import com.parkez.user.domain.enums.UserRole;
import com.parkez.user.service.JdbcUserReader;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.utils.URIBuilder;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.sql.*;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class ParkingLotPublicDataService {
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final String parkingLotPublicDataUrl;
private final String serviceKey;
private final String defaultParkingLotImageUrl;
private final String adminEmail;
private final String jdbcUrl;
private final String dbUser;
private final String dbPassword;
private final SimpleKakaoGeocodeClient kakaoGeocodeClient;
private final JdbcUserReader userReader;
private static final String description = "공공데이터로 등록한 주차장입니다.";
private int currentPage = 1;
private final int perPage = 2;
public ParkingLotPublicDataService(
String dataUrl,
String serviceKey,
String defaultImg,
String adminEmail,
String jdbcUrl,
String dbUser,
String dbPassword,
SimpleKakaoGeocodeClient kakaoClient,
JdbcUserReader userReader
) {
this.parkingLotPublicDataUrl = dataUrl;
this.serviceKey = serviceKey;
this.defaultParkingLotImageUrl = defaultImg;
this.adminEmail = adminEmail;
this.jdbcUrl = jdbcUrl;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
this.httpClient = HttpClient.newHttpClient();
this.objectMapper = new ObjectMapper();
this.kakaoGeocodeClient = kakaoClient;
this.userReader = userReader;
}
public void fetchAndSavePublicData() throws IOException, InterruptedException, URISyntaxException {
try {
URI uri = new URIBuilder(parkingLotPublicDataUrl)
.addParameter("page", String.valueOf(currentPage))
.addParameter("perPage", String.valueOf(perPage))
.addParameter("serviceKey", serviceKey)
.build();
// HTTP 호출
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// json -> dto로 변환
ParkingLotDataResponse dataResponse = objectMapper.readValue(
response.body(),
ParkingLotDataResponse.class
);
// 페이지 초기화
List<ParkingLotData> dataList = dataResponse.getData();
if (dataList == null || dataList.isEmpty()) {
currentPage = 1;
return;
}
// dto -> entity 변환
List<ParkingLot> parkingLots = dataList.stream()
.map(this::convertToParkingLot)
.toList();
// db에 저장
try (Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword)) {
connection.setAutoCommit(false);
try {
bulkInsertParkingLots(connection, parkingLots);
bulkInsertImages(connection, parkingLots);
connection.commit();
log.info("DB 저장 완료: {}건", parkingLots.size());
} catch (SQLException e) {
connection.rollback();
log.warn("DB 저장 중 오류, 롤백 처리함", e);
}
} catch (Exception e) {
throw new RuntimeException("공공데이터 fetch & save 실패", e);
}
// 페이지 인덱스 갱신
currentPage = (dataList.size() < perPage) ? 1 : currentPage + 1;
} catch (Exception e) {
throw e;
}
}
private void bulkInsertParkingLots(Connection connection, List<ParkingLot> parkingLots) throws SQLException {
String sql = """
INSERT INTO parking_lot
(owner_id, name, address, latitude, longitude,
opened_at, closed_at, price_per_hour,
description, quantity, charge_type,
source_type, status, created_at, modified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = id
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
for (ParkingLot pl : parkingLots) {
ps.setLong(1, pl.getOwner().getId());
ps.setString(2, pl.getName());
ps.setString(3, pl.getAddress());
ps.setDouble(4, pl.getLatitude());
ps.setDouble(5, pl.getLongitude());
ps.setTime(6, Time.valueOf(pl.getOpenedAt()));
ps.setTime(7, Time.valueOf(pl.getClosedAt()));
ps.setBigDecimal(8, pl.getPricePerHour());
ps.setString(9, pl.getDescription());
ps.setInt(10, pl.getQuantity());
ps.setString(11, pl.getChargeType().name());
ps.setString(12, pl.getSourceType().name());
ps.setString(13, pl.getStatus().name());
Timestamp now = new Timestamp(System.currentTimeMillis());
ps.setTimestamp(14, now);
ps.setTimestamp(15, now);
ps.addBatch();
}
ps.executeBatch();
}
}
private void bulkInsertImages(Connection connection, List<ParkingLot> parkingLots) throws SQLException {
String sql = """
INSERT INTO parking_lot_image
(parking_lot_id, image_url, created_at, modified_at)
SELECT pl.id, ?, NOW(), NOW()
FROM parking_lot pl
WHERE pl.longitude = ?
AND pl.latitude = ?
ON DUPLICATE KEY UPDATE id = id
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
for (ParkingLot pl : parkingLots) {
ps.setString(1, pl.getImages().get(0).getImageUrl());
ps.setDouble(2, pl.getLongitude());
ps.setDouble(3, pl.getLatitude());
ps.addBatch();
}
ps.executeBatch();
}
}
// 받아온 정보를 엔티티로 변경
private ParkingLot convertToParkingLot(ParkingLotData data) {
Double latitude = parseDouble(data.getLatitude());
Double longitude = parseDouble(data.getLongitude());
String getAddress = kakaoGeocodeClient.getAddress(longitude, latitude);
String address = (data.getAddress() != null && !data.getAddress().isBlank()) ? data.getAddress() : getAddress;
Integer quantity = parseInteger(data.getQuantity());
LocalTime openedAt = parseTime(data.getOpenedAt());
LocalTime closedAt = parseTime(data.getClosedAt());
BigDecimal bigDecimal = BigDecimal.ZERO;
SourceType sourceType = SourceType.PUBLIC_DATA;
ChargeType chargeType = parseChargeType(data.getChargeType());
User user = userReader.getUserByEmailAndRole(adminEmail, UserRole.ROLE_ADMIN);
List<ParkingLotImage> images = new ArrayList<>();
ParkingLotImage defaultImage = ParkingLotImage.builder()
.imageUrl(defaultParkingLotImageUrl)
.build();
ParkingLot parkingLot = ParkingLot.builder()
.owner(user)
.name(data.getName())
.address(address)
.latitude(latitude)
.longitude(longitude)
.openedAt(openedAt)
.closedAt(closedAt)
.pricePerHour(bigDecimal)
.description(description)
.quantity(quantity)
.sourceType(sourceType)
.images(images)
.chargeType(chargeType)
.build();
defaultImage.updateParkingLot(parkingLot);
images.add(defaultImage);
return parkingLot;
}
private LocalTime parseTime(String timeStr) {
if (timeStr == null || timeStr.isEmpty()) {
return LocalTime.of(0, 0);
}
return LocalTime.parse(timeStr);
}
private Double parseDouble(String value) {
try {
return Double.valueOf(value);
} catch (Exception e) {
return null;
}
}
private Integer parseInteger(String value) {
try {
return Integer.valueOf(value);
} catch (Exception e) {
return null;
}
}
private ChargeType parseChargeType(String chargeTypeStr) {
if ("무료".equalsIgnoreCase(chargeTypeStr)) {
return ChargeType.FREE;
} else if ("유료".equalsIgnoreCase(chargeTypeStr)) {
return ChargeType.PAID;
} else {
return ChargeType.NO_DATA;
}
}
}
4. AWS Lambda 사용하기
(1) Lambda 함수 생성하기
(2) 함수에 코드 등록하기
[참고 문헌]
.zip 또는 JAR 파일 아카이브를 사용하여 Java Lambda 함수 배포
Lambda 함수를 .zip 파일 아카이브로 배포
1️⃣ Gradle로 Lambda에 배포할 패키지 생성하기
AWS Lambda에 Java 런타임으로 함수를 배포할 때는
함수 코드(JAR)와 모든 의존 JAR을 포함한 ZIP 파일을 업로드해야 한다.
[build.gradle에 코드 추가]
tasks.register('buildZip', Zip) {
into('lib') {
from(jar)
from(configurations.runtimeClasspath)
}
}
[프롬프트에 명령어 입력]
./gradlew clean buildZip
위의 코드를 통해
build/distributions/ (또는 build/libs/) 폴더에 zip 파일이 생긴다.
zip 파일 안에 lib/ 폴더가 있고, 그 안에 애플리케이션 JAR과 모든 의존 JAR이 들어 있게 된다.
zip 파일 이름이 ParkEZ-0.0.1-SNAPSHOT.zip로 설정된 이유는
추가 설정 없이 tasks.register("buildZip", Zip) 만 선언하였기 때문에
아래의 규칙으로 파일명이 자동 생성되었다.
archiveBaseName = project.name -> 예: “ParkEZ”
archiveVersion = project.version -> 예: “0.0.1-SNAPSHOT”
archiveExtension = “zip”
2️⃣ AWS S3로 생성한 ZIP파일 등록하기
Lambda의 콘솔에서 ZIP 파일을 직접 업로드하려하였으나,
파일 크기가 50MB가 넘어가서 등록에 실패하였다.
따라서 배포 패키지를 S3 버킷에 업로드한 뒤,
Lambda 함수 설정에서 업로드 된 파일 링크를 통해 등록하여야 한다.
✅ S3 버킷 생성하기
1. AWS 콘솔에서 S3 서비스로 이동
2. 새로운 버킷 생성
3. 퍼블릭 액세스 블록 설정은 기본값 그대로 두기
🤔 전체 프로젝트를 하나의 ZIP으로 올리는데, 공공데이터 저장 로직만 실행될까?
AWS Lambda는 핸들러로 지정된 메소드만 실행하기 때문에
프로젝트 전체가 포함된 ZIP 파일을 올려도 핸들러의 내부 로직만 수행된다.
5. Lambda에 추가 설정 해주기
(1) 환경변수 추가해주기
(2) 핸들러 이름 변경하기
(3) 함수 테스트 생성하기
6. 함수 테스트 하기
(1) 테스트 실패 : 요청 시간 초과
이 에러는 AWS Lambda의 기본 타임아웃인 15초를 초과하여
런타임이 강제 종료되면서 발생하였다.
현재 타임아웃은 15초로 설정 되었으나,
공공데이터 호출 → JSON 파싱 → DB 배치 삽입까지 하는 로직이 그보다 오래 걸려서 요청이 중단되었다.
🔍 Spring이 아닌 Java 코드로 수정하였음에도 오래걸리는 이유
VPC에 연결된 Lambda는 ENI(Elastic Network Interface)를 생성·연결하는 과정에서
수 초에서 십여 초까지 걸리는 콜드 스타트 오버헤드가 발생한다.
여기에서 공공데이터 API를 호출하여 JSON을 파싱하는 작업과,
변환된 데이터 레코드마다 Kakao 지오코딩 API를 다시 호출하는 네트워크 왕복 지연으로 인해
시간이 더 오래걸린다.
또한 매번 DriverManager.getConnection(...) 으로 새로운 DB 커넥션을 맺고 끊는 과정으로 인해
시간이 더 소요 되기 때문에 전체 지연이 늘어났다.
모든 단계를 하나의 스레드에서 동기적으로 순차 실행하기 때문에,
콜드 스타트 지연 + 외부 API + DB호출 지연이 합쳐져 15초를 초과하게 되었다.
순수 Java 코드가 Spring Boot 보다 가볍긴 하지만,
Lambda 환경의 네트워크 초기화와 외부 호출 오버헤드를 완전히 제거하지 못했다.
✅ 해결 방법 : 제한 시간 늘이기
(2) 테스트 실패 : RDS 인스턴스에 연결 실패
위 에러 코드는 네트워크 연결 문제로,
Lambda 함수가 RDS 인스턴스에 닿지 못해서 발생하였다.
현재 RDS가 다른 팀원의 AWS 계정에 속한 별도의 VPC에 배포되어 있어,
Lambda가 기본 경로로 접근할 수 없는 상태이다.
이를 해결하기 위해서
두 VPC간에 VPC 피어링을 설정하고,
양쪽의 라우팅 테이블과 보안 그룹을 적절히 구성하여
Lambda가 RDS에 접근할 수 있는 네트워크 경로를 확보했다.
🔍 VPC란? (Virtual Private Cloud)
AWS 내에서 격리된 가상 네트워크 영역으로,
CIDR(IP 주소 대역), 인터넷 게이트웨이, NAT 게이트웨이 등을 직접 구성하여 네트워크 환경을 제어한다.
🔍 VPC 피어링이란? (VPC Peering)
서로 다른 VPC간에 사설 네트워크 연결을 맺어 트래픽을 직접 주고 받을 수 있게 해주는 기능이다.
피어링 설정 후 양쪽의 라우팅 테이블에 경로를 추가하여여야 연결이 활성화 된다.
🔍 라우팅 테이블이란? (Route Table)
VPC내 각 서브넷이 사용하는 경로를 모아둔 테이블이다.
목적지인 CIDR에서 트래픽을 보낼 대상(인터넷 게이트웨이, NAT, VPC 피어링 등)을 매핑한다.
🔍 서브넷이란? (Subnet)
VPC 전체 CIDR을 논리적으로 분할한 하위 네트워크 단위이다.
서브넷 별로 보안그룹, 네트워크ACL을 다르게 적용하여 서비스별로 격리와 보안 정책을 구현할 수 있다.
퍼블릭 서브넷 : 인터넷 게이트웨이를 통해 인터넷과 통신 가능
프라이빗 서브넷 : 직접 인터넷 접근은 차단, NAT 게이트웨이나 VPN 통해서만 외부 통신
1️⃣ VPC 생성
2️⃣ 서브넷 2개 생성
🤔 서브넷을 2개를 생성한 이유
서로 다른 가용 영역(AZ)에 하나씩 서브넷을 배치하면,
한 AZ에 장애가 발생하여도 다른 AZ의 서브넷이 계속 서비스를 제공할 수 있어
서비스의 가용성과 내결함성이 확보된다.
3️⃣ 보안 그룹 설정
‼️ 보안 그룹 설정
Lambda 함수는 서버가아니라 클라이언트 역할만 수행하기 때문에 인바운드 규칙은 필요 없다.
데이터베이스 연결과 외부 API 호출이 필요하므로, 아웃바운드 규칙을 추가하였다.
4️⃣ IAM 설정
5️⃣ Lambda에 VPC 설정
6️⃣ VPC 피어링
7️⃣ 라우팅 테이블 설정
(3) 테스트 실패 : 외부 API와 연결 실패
이 에러는 VPC에 연결된 Lambda 함수가 외부 인터넷에 접근하지 못해
공공 API 호출이 실패하면서 발생하였다.
인터넷 게이트웨이(IGW) 연결
VPC와 퍼블릭 인터넷 사이에 AWS 관리형 가상 라우터를 연결하여,
퍼블릭 서브넷 내 리소스가 인터넷과 통신할 수 있도록 했다.
퍼블릭 서브넷 + NAT 게이트웨이 배치
퍼블릭 서브넷에 NAT Gateway를 생성하여, 외부에서 직접 접근은 차단하고,
프라이빗 서브넷(Lambda, RDS 등)의 아웃바운드 트래픽을 대신 인터넷으로 전달하도록 구성했다.
프라이빗 서브넷 라우팅
프라이빗 서브넷의 라우팅 테이블에 0.0.0.0/0 → NAT Gateway 경로를 추가하여,
모든 외부 호출이 NAT Gateway를 통해 나가도록 했다.
위 방법을 통해 VPC 내부의 프라이빗 서브넷이 인터넷상의 공공 API나 외부 서비스에 접속할 수 있다.
🤔 인터넷 게이트웨이란? (Internet Gateway)
VPC와 퍼블릭 인터넷을 연결해주는 AWS 관리형 가상 라우터이다.
퍼블릭 서브넷에 attatch하면, 서브넷의 인스턴스가 인터넷으로 나가고,
인터넷에서 그 인스턴스로 들어오는 트래픽을 받을 수 있다.
🤔 NAT 게이트웨이란? (Network Address Translation Gateway)
프라이빗 서브넷의 리소스에서(Lambda, RDS 등)에서 인터넷으로 아웃바운드되는 트래픽만 허용하고,
외부에서 직접 접속은 차단해주는 서비스이다.
1️⃣ public 서브넷을 추가로 생성
2️⃣ 인터넷 게이트웨이 생성
3️⃣ NAT Gateway 생성
4️⃣ 라우팅 테이블 생성 및 수정
private 라우팅 테이블 : NAT Gateway + 피어링
public 라우팅 테이블 : Internet Gateway
[public 라우팅 테이블]
[Private 라우팅 테이블]
5️⃣ AWS 아키텍처 정리
(4) 테스트 성공
7. 일정 시간마다 함수 실행하도록 설정하기
(1) 이벤트 브릿지란? (Event Bridge)
이벤트 브릿지는 어플리케이션 간 또는 AWS 서비스와 애플리케이션 간의 이벤트를 라우팅하고 스케줄링 할 수 있게 해준다.
특히 이번 프로젝트처럼 주기적인 작업을 설정할 때는 cron이나 rate 표현식을 사용하여 원하는 시간마다
이벤트를 자동으로 생성하고, 이를 Lambda 함수에 전달하여 실행하도록 할 수 있다.
(2) 이벤트 브릿지 설정하기
8. 최종 RDS 테스트
CloudWatch를 통해 로그를 확인해보니,
bulk insert를 위한 메소드에서 중복 값을 제거 하기 위해
작성했던 sql문에서 에러가 난 것을 확인하였다.
테스트에서 함수가 정상 종료되었으나,
실제로는 SQL 쿼리 내에서 예외가 발생하여 데이터가 반영되지 않았다.
[ParkingLotImage 쿼리 수정]
String sql = """
INSERT IGNORE INTO parking_lot_image
(parking_lot_id, image_url, created_at, modified_at)
SELECT pl.id, ?, NOW(), NOW()
FROM parking_lot pl
WHERE pl.longitude = ?
AND pl.latitude = ?
""";
[ParkingLotImage 엔티티 수정]
@Table(
name = "parking_lot_image",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_parking_lot_image_url",
columnNames = {"parking_lot_id", "image_url"}
)
}
)
위의 수정을 통해 주차장은 동일한 이미지를 갖지 못하도록 설정하였다.
[Lambda 함수 테스트]
[RDS 저장 테스트]
일회성 스케줄을 생성하여 테스트를 다시 진행하니,
RDS에 공공데이터가 잘 저장된 것을 확인하였다.
9. 마무리 회고
이번 프로젝트를 통해 AWS Lambda와 EventBridge 기반으로
공공데이터 수집 파이프라인을 서버리스로 구현해보았다.
이를 통해 인프라 관리 부담을 덜고 오직 비즈니스 로직에만 집중할 수 있었고,
서버가 24시간 돌아가지 않아도 로직이 수행되는 것을 확인하여 자원이 효율적으로 사용된 것을 경험했고,
VPC 설정, 피어링, NAT Gateway, IGW을 구성하여 안전한 네트워크 연동을 다뤄보는 기회가 있었다.
또한 순수 JavaHandler, HttpClient, JDBC 를 활용하여 Spring 프레임워크의 의존성을 제거하여
불필요한 초기화 과정을 줄이고, 배치 삽입을 적용하여 데이터 저장 성능을 높여보았다.
EventBridge의 cron 스케줄링으로 함수가 실행되는 테스트까지 해봄으로써,
서버리스 아키텍처의 전반적인 작동 방식을 직접 경험할 수 있었다.