잇타 7기 Xpact 회고
7/12일 최종발표회로 잇타 7기 활동을 끝마쳤다. 4개월간 프로젝트를 진행하며 다양한 쟁점과 수많은 문제점을 마주쳤고, 이를 해결하기 위해 노력한 과정과 생각, 개선사항들을 중심으로 서술하고자 한다.
7/12일 최종발표회로 잇타 7기 활동을 끝마쳤다. 4개월간 프로젝트를 진행하며 다양한 쟁점과 수많은 문제점을 마주쳤고, 이를 해결하기 위해 노력한 과정과 생각, 개선사항들을 중심으로 서술하고자 한다.
XPact의 AI 구현 방향
- XPact는 사용자가 입력한 경험을 기반으로 분석을 제공하는 서비스다.
- AI 기능 구현 방안으로 두 가지를 검토했다.
- 첫째, 자체적으로 모델을 학습시키는 방식이다. 그러나 이 방법은 구현까지의 학습 곡선이 높고, 비용 부담도 클 것으로 예상되었다.
- 둘째, ChatGPT API를 활용해 응답을 받아오는 방식이다. 이 경우 Spring 생태계에서 제공하는 Spring AI 라이브러리를 사용하면 OpenAI 서비스를 손쉽게 연동할 수 있다는 장점이 있다.
- 이에 따라 OpenAI API를 활용해 Spring Boot에서 분석 요청을 처리하는 방식을 채택했다.
쟁점 1. 엔티티의 분석 시점
다음과 같이 엔티티의 영속과 외부 요청을 처리하는데 2~3초의 시간이 걸린다는 것을 확인할 수 있다.
고작 데이터를 저장하는 요청에 3초 정도의 딜레이가 발생한다는 점은 사용자 경험상 용납하기 어렵다고 판단했다. 아래 그림은 해당 과정을 시각적으로 표현한 것이다.
엔티티 분석 ver1
엔티티를 저장 후 분석요청을 보내면 해당 API는 적어도 2~3초의 지연을 가지게 된다.
각 로직이 반드시 순차적으로 실행될 필요는 없었기 때문에, 엔티티 영속과 분석 요청으로 나눠 비동기 방식으로 처리하는 방법을 생각했다. 위 로직에서 dto를 entity로 변환 후, OpenAI에 요청보내는 로직을 비동기 메서드로 만들었다.
엔티티 분석 ver2
- 사용자 입장에서는, 분석이 끝나지 않고 엔티티가 저장되면 성공했다는 응답을 받게 된다. 사용자 입장에서는 경험이 저장됐나 안됐나만 중요했기 때문이다. 분석 결과는 대시보드에서 확인하기 때문에 분석요청을 보내는 로직은 따로 비동기 메서드로 만들어 서버 내부에서 처리하도록 의도하였다.
간단한 수도코드로 설명하면 아래와 같다. ```java @Transactional public void createExperience(ExperienceSaveRequestDto createRequestDto) { Experiecne experience = experienceConverter.createExperience(createRequestDto); experienceRepository.save(experience);
1 2
// 비동기 메서드 setSummaryAndDetailRecruit(experience); }
@Async @Transactional public void setSummaryAndDetailRecruit(Experience experience) { String response = openAiChatModel(input);
1 2 3 4
Experience refreshExperience = experienceRepository.findById(experience.getId()).orElseThrow(); fresh.setSummaryAndDetailRecruit(response); experienceRepository.save(fresh); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- Service 레이어의`createExperience`에서 `setSummaryAndDetailRecruit`메서드는 비동기 메서드이므로 해당 메서드를 넘기고 바로 응답을 리턴한다.
- 이후 @Async로 선언된 `setSummaryAndDetailRecruit` 비동기 메서드는 내부적으로 실행되는데, 해당 메서드에서는 엔티티의 값을 변경하는 로직이므로 @Transactional을 선언하였다. 즉, 트랜잭션 내부에서 별도의 스레드에서 새로운 트랜잭션이 생성되어 실행되었다.
- 해당 비동기 메서드에서는 스레드가 달라 기존의 영속성 컨텍스트를 사용할 수 없기에 다시 한 번 더 repository에서 엔티티를 조회해서 필드값을 변경해야했다.
#### 단점
- 한 로직에 대해 영속성 컨텍스트가 2개가 존재하여 바로 `save`하지 못하고 조회 후 `save`를 진행해야했다.
- 또한, 트랜잭션 내에 다른 스레드로 실행되는 트랜잭션이 존재하기에, 각 트랜잭션 간에 데이터가 일치하지 않아 데이터 정합성의 문제가 발생한다. 비동기 메서드에서 엔티티를 조회하는 시점에, 해당 엔티티가 저장되지 않는 경우도 발생했다.
- 응답시간을 줄이고자 비동기 메서드를 사용하였지만 오히려 새로운 스레드로 별개의 트랜잭션을 만들어 데이터의 결괏값을 예측하지 못한다는 결과를 초래하게 되었다.
- 이에 트랜잭션내 트랜잭션이 위치하는 구조보다는 트랜잭션을 나열하는 방식으로 해당 문제를 해결하려고 하였다.
---
#### 엔티티 분석 ver3

- 다음과 같이 트랜잭션을 나누었다.
- 엔티티를 저장하는 트랜잭션이 끝나면, 해당 엔티티를 분석하여 결괏값을 해당 엔티티의 필드에 업데이트하는 트랜잭션이 실행된다.
- 이 과정에서 각 트랜잭션은 서로 다른 스레드에서 실행되므로 영속성 컨텍스트는 다르다. 그러나 첫 번째 트랜잭션의 커밋이 완료된 이후 두 번째 트랜잭션이 시작되기 때문에, 데이터 정합성 문제는 발생하지 않는다.
- 이렇게 두 개의 트랜잭션을 나열하기 위해 Controller 레벨에서 두 개의 서비스 메서드를 호출하는 방식으로 수정하였다.
```java
@RestController
public ExperienceController {
private final ExperienceService experienceService;
...
public ResponseEntity<?> create(ExperienceRequestDto requestDto) {
experienceService.create(requestDto); // 1번째 트랜잭션
experienceService.setSummaryAndDetailRecruit(requestDto); // 2번째 트랜잭션
return ResponseEntity.ok();
}
}
- 수정된 로직의 API를 호출해보니, 응답 결과를 2000 ~ 3000ms에서 81ms로 단축시키는 결과를 가져오게 되었다.
- 엔티티 저장 후 즉시 사용자에게 응답을 반환하면서, 내부적으로 분석 로직을 실행해 데이터를 비동기적으로 업데이트한다.
- 트랜잭션 내 트랜잭션이 위치하면 두 개의 트랜잭션 간의 영속성 컨텍스트가 동일하지 않아 데이터 정합성의 문제가 발생할 수 있음을 알게 되었다.
- 이를 해결하기 위해 트랜잭션을 직렬로 분리 배치함으로써 데이터 정합성을 지키고, 동시에 응답 시간을 최적화하는 설계를 경험할 수 있었다.
쟁점 2. 셀레니움 & 람다 도입
링커리어의 모집 공고 데이터를 RDB에 저장해야하는 요구사항이 있었다.
- 비교적 가벼운 Jsoup을 통해 크롤링을 진행하려 했으나, 단순히 웹페이지를 파싱하여 데이터를 얻는 게 아닌 클릭과 같은 동적 action이 필요하였기에 동적 크롤링 라이브러리인 Selenium을 도입하기로 결정했다.
- 브라우저 위에서 동작하는 Selenium을 사용하기 위해 웹 서버에 크롬과 크롬 드라이버를 설치하였고, 웹 애플리케이션 서버를 실행시키고자 했다.
하지만 스프링 부트를 컨테이너로 실행시켜 호스트에서 실행 중인 크롬 드라이버와 네트워크 형성이 되지 못했고, 크롬을 컨테이너로 실행시켜 네트워크로 연결해도 크롬이라는 브라우저가 무거워 크롤링 중 서버가 다운되었다.
- 이에 해결책을 찾아야 했다. 하루에 한 번 정도 실행할 크롤링 로직을 서버 리소스를 다 잡아먹는 것이 너무 아까웠다. 크롤링만 진행하는 서버를 구축하는 방식도 생각했지만, 결국 이 또한 크롤링이 진행중이 아닐 때도 리소스를 잡아먹는 방식이었다.
- 필요할 때만 꺼내 쓴다는 것에 착안하여 AWS Lambda를 통해 크롤링을 진행해야겠다는 생각에 다다르게 됐다. Lambda는 필요할 때 invoke하여 리소스를 아끼며, 웹 서버의 리소스를 소모하지 않고도 크롤링이 가능했다.
람다 아키텍처 ver1
- 다음과 같은 아키텍처를 설계하였다.
- 하지만 람다에서 크롤링한 데이터를 저장하는 로직에서 문제를 일으켰다.
MySQL로 RDB를 사용중이었는데, 이때 DB를 private subnet에 위치시켜 spring boot 애플리케이션에서만 접근할 수 있도록 했다.- 람다는 RDB에 접근할 수가 없었기에 크롤링 결과를 S3에 저장한 뒤, spring boot가 해당 파일을 읽어들여 RDB에 저장하는 방식을 채택하였다.
- 이 과정에서 SQS를 사용하여 람다에서 S3파일을 저장하면 해당 이벤트를 Spring 애플리케이션에 전달하고, 이 메세지를 수신하여 RDB에 저장하는 로직도 고려하였지만, SQS를 사용하는 건 오버 엔지니어링이라 생각하여 람다의 실행시간을 고려하여 Spring 애플리케이션의 스케줄링 시간을 조정하는 식으로 해결하였다.
람다 아키텍처 ver2
- 최종 수정된 위와 같은 로직을 통해 매일 업데이트 되는 링커리어의 모집 공고를 RDB에 저장할 수 있었다.
- 이는 S3에 날짜 별 크롤링 파일이 저장된 목록이고,
- 위 사진은 S3파일을 읽어들여 RDB에 성공적으로 저장한 로그이다.
쟁점 3. presigned-url S3 도입
- 사용자 간의 파일 혹은 사진 공유가 이루어지지 않으므로 특정 사용자의 파일은 해당 사용자를 제외한 어느 누구도 열람할 수 없어야 했다.
- 이는 다음과 같은 방식으로 구현 가능하다.
- S3를 공개 처리하고 사용자의 파일 정보를 DB에 저장하여 파일의 주인을 식별한다.
- 파일 조회 API 로직에서 필터링 처리하여 타 사용자의 파일에 대해 접근을 금한다.
- 하지만 이는 S3가 공개되어있어 파일 url을 알고 있다면 접근이 가능하다는 단점이 존재한다.
- 이에 presigned-url을 이용하여 해당 보안 문제를 해결하고자 했다.
- presigned-url을 사용한 예시로 깃허브를 들 수 있다. 깃허브 이슈 혹은 pr에 사진 파일을 업로드하면, 일정 시간이 지난 후 해당 사진 파일의 URL이 더 이상 유효하지 않게 되는 것도 presigned-url의 만료 기능 때문이다.
- 위 다이어그램과 같이, Spring Filter를 통과한 인증된 클라이언트에 한해 pre-signed url을 발급하여 S3에 접근할 수 있도록 하였다.
- 기존 방식과는 파일 업로드 및 다운로드는 프론트엔드에서 직접 S3로 수행한다는 차이가 있다.
- 또한, 업로드할 파일을 API 서버로 전송하여 이를 다시 S3에 보내는 로직과는 다르게 클라이언트 측이 파일 전송을 담당하므로 서버 I/O 부담이 줄어들었다.
- 이에 S3를 public으로 설정하지 않고도 파일 관련 API를 구현할 수 있었다.
- 기존 방식보다 프론트의 구현 부담이 많아졌지만, 지속적인 협의를 통해 원활하게 조율하며 성공적으로 구현을 마칠 수 있었다.
쟁점 4. insert ignore(Native Query) 사용
- 크롤링한 데이터를 RDB에 저장할 때 중복되는 데이터 때문에 에러가 발생하였다.
1 2
2025-06-28T19:20:01.889+09:00 WARN 4864 --- [xpact] [ Thread-0] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1062, SQLState: 23000 2025-06-28T19:20:01.889+09:00 ERROR 4864 --- [xpact] [ Thread-0] o.h.engine.jdbc.spi.SqlExceptionHelper : Duplicate entry '247375' for key 'scrap.UKs2ptf15nrmc6tnmrvfkbibhay'
- 특정 칼럼에 대한 유니크 제약조건에 의한 것이었다.
- 여러 엔티티를 저장하는데 해당 에러가 발생하면 도중에 멈춘다는 것이었다.
- 해당 예외를 catch하여 처리하려 했으나 JPA 트랜잭션 내부에서 발생하는 DB레벨 예외이기에 Spring 애플리케이션 레벨에서는 catch할 수가 없었다.
- 이에
insert ignore를 사용하여 중복이면 무시하고 다음 쿼리를 수행하는 방식을 사용하였다. - 하지만 해당 문법은 JPA은 물론이고 JPQL에서도 지원하지 않기에 Native SQL 쿼리를 작성하여 튜닝을 진행하였다.

- 위 방식으로 중복된 데이터에 대해
INSERT쿼리가 실행되지 않도록 하면서도, 모든 데이터에 대해 안전하게INSERT처리를 수행할 수 있었다. - Spring 친화적인 JPA와 JPQL까지만 사용하고, 그 이외의 쿼리 방식은 지양하려고 했지만, 이번 사례를 통해 상황에 따라 Native SQL 역시 튜닝 측면에서 충분히 도입할 수 있는 실용적인 선택지임을 알게 되었다.
원문: Velog










