Situation
군대 전역 이후에 저만의 서비스를 런칭해보고 싶었습니다. 어떤 서비스를 런칭할까 고민을 하다가, 군대 가기 직전, 클라썸 시절에 자취방을 구하는데 여러 공인중개사를 돌아다니면서 방을 구하는 것이 어렵다는 점에 착안하여 ‘자취방 리뷰 플랫폼’인 E-room을 런칭해보고자 했습니다.
처음에는 디자이너 1명, 백엔드 개발자 2명, 컨텐츠 제작자 1명이서 시작을 했습니다. 저희 팀은 각자 회사를 다니거나 학업을 하는 등의 주요 업무가 있는 상태로 해당 프로젝트를 2순위로 놓고 진행하였기에, 일주일에 3-5시간 정도를 이 일에 투자하는 식으로 ‘느슨하게’ 진행하였습니다.
저는 백엔드 개발을 맡았었는데요. 이를 통해 다음과 같은 경험을 할 수 있었습니다.
기술적 과제
저희는 백엔드로 스프링을 선택하였습니다. 저희한테 가장 익숙한 것이기도 했고, 스프링 MVC만 경험했던 저는 웹 서버가 내장되어있는 Spring boot와 JPA를 경험해보고 싶었기 때문입니다.
•
JPA 동작 원리 및 JPA 사용법
•
주소 정보 저장
•
스프링 부트로 JWT 기반 OAuth 인증 로직 (Google, Kakao, Naver 로그인)
•
AWS ACM으로 도메인 설정 및 배포 운영
•
ElasticBeanstalk + github action 기반 자동 배포
•
AWS S3 이미지 업로드
운영적 과제
•
디자이너, 프런트엔드 개발자와의 협업
•
기획/디자인부터 배포까지 하나의 사이클 운영
•
User/Review 모집
JPA 동작 원리 및 JPA 사용법
•
JPA는 일반적인 쿼리 빌더의 역할과 달리 그 목적은 ‘인터페이스’를 정의하는데 있습니다. JPA라는 의미에서 알 수 있듯, 우리는 데이터베이스의 종류가 바뀌거나, 데이터베이스의 테이블이 바뀌더라도 도메인 모델은 바뀌지 않았으면 합니다. 그렇기 때문에 우리는 Domain 모델을 먼저 정의하고, JPA를 통해 Persistence Layer와 소통합니다. 이 때 JPA를 구현하는 Persistence Layer가 바로 SpringDataJpaRepository가 될 수도 있고, QueryDSL을 사용할 수도 있고, Raw Query가 될 수도 있는 것입니다.
해당 영역은 추후 다른 회사에서 Hexagonal Architecture를 사용하면서 알게 되었던 것이지만, 이곳에 기록한다. 조금 더 JPA의 그 철학에 대해서 잘 알 수 있게 되었기 때문입니다.
JPA의 구조를 우선 살펴보면,
JPA는 우선 엔티티라는 개념이 가장 작은 단위로써, EntityManager에 의해 관리됩니다. EntityManager에 의해 관리되는 생명 주기가 존재하는데, 1) 비영속 2) 영속 3) 준영속 4) 삭제 상태가 존재합니다.
비영속은 객체를 생성한 상태입니다.
//객체를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");
Java
복사
여기서 영속 상태로 넘어가려면 위의 Diagram에서 보듯 persist()를 사용하면 됩니다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
//객체를 저장한 상태(영속)
em.persist(member);
Java
복사
준영속 상태나 삭제는 아래와 같은 코드로 할 수 있습니다.
em.detach(member);
em.remove(member);
Java
복사
이 영속성 컨텍스트를 통해 몇 가지를 기술적으로 구현할 수 있습니다.
•
1차 캐시
◦
기본적으로 EntityManager는 데이터베이스의 1차 캐시 역할을 할 수 있습니다. 한 번 영속을 한 경우 find를 할 때는 EntityManager에서 찾아오게 됩니다.
◦
뿐만 아니라, 새로운 Entity를 찾는 경우에도 1차 캐시에 1차로 저장합니다.
◦
하지만 DB 데이터와 동기화하는 로직을 잘 신경써야할 것 같다는 생각이 듭니다.
•
동일성 보장/변경감지
◦
영속 엔티티에서 조회하고 그저 memberA.setUsername(”hi”) 이런 식으로 변경 후 커밋만 하면 자동으로 변경을 감지해서 SQL을 날려줍니다.
•
트랜잭션 지원 쓰기 지연
◦
영속성 컨텍스트에 쌓아두고 있다가 커밋하는 순간 여러 쿼리를 한 번에 날립니다.
•
지연 로딩
◦
연관관계를 탐색할 때, 필요한 경우에만 조회하는 방식입니다.
◦
이 때 연관관계 매핑이 되어있기는 해야하니, JPA는 프록시를 만들어둡니다.
하지만 저렇게 entity Manager를 일일히 관리하기가 불편해보입니다. 하지만 이것은 보통 Spring이 직접 관리해줍니다. 스프링이 없는 Java 스크립트라면 직접 열고 닫고, transaction begin을 해줘야하지만 Spring은 열고 닫고 또는 @Transactional 어노테이션을 통해 자동으로 관리합니다. 그렇다면 persist, detach, remove는 어떨까요?
JPQL을 쓰는 경우, 모든 쿼리의 단위가 엔티티로 통일이 되기 때문에 자동으로 관리가 됩니다.
다만 JPQL의 경우 실행시킬 때, flush를 자동으로 호출하여 영속성 컨텍스트와 DB의 동기화를 맞춰주고 시작합니다. 왜냐하면 JPQL에서 사용되는 쿼리는 DB에 바로 조회가 되기 때문에, 영속성 컨텍스트와 DB간에 벌어져있는 간격을 맞춰주는 것입니다.
이제는 JPA를 사용한 코드를 몇 가지 기록해보겠습니다.
•
JPQL @Query를 이용해서 native code를 작성합니다.
•
QueryDSL을 활용하여 커서 기반 페이지네이션, Predicate를 구현합니다
•
NamedEntityGraph로 자동으로 필요한 객체를 들고옵니다.
•
EntityListener 사용
Spring Security 로 OAuth 구현
OAuth를 구현하는 로직이다. 크게 다음과 같은 URI가 필요하다
•
인증 서버의 로그인 페이지
•
인증 서버에서 로그인 완료 후, 우리 서버에 사용자 정보를 제공해줄 URI
•
해당 URI에서 accessToken 발급
이를 Spring Security로 구현할 수 있다.
•
아래와 같이 환경별로 다른 Chain들을 타도록 구성합니다.
•
oAuthAndJwtAuthentication() 이라는 함수를 구현하여 사용합니다.
◦
이 때 oauth2Login()을 붙여서 활성화를 시켜주고, authorizationRequest를 저장하는 repository를 세션을 사용하지 않도록 authorizationRequestRepository를 커스텀하여 구현해줍니다.
◦
oauth용 successHandler와 failureHandler를 설정해줍니다.
◦
AuthenticationProvider의 역할을 하는 oAuth2UserService를 커스텀하여 유저의 익명 닉네임, 프로필 이미지를 설정하는 로직을 추가합니다.
◦
JWTAuthFilter를 추가하여 쿠키 기반으로 JWT를 추가하고 관리합니다.
•
application-oauth.properties에는 다음과 같은 값을 입력합니다.
## KAKAO Login
spring.security.oauth2.client.registration.kakao.client-id=1234 # kakao login API에 등록해서 나온 내 서비스의 ID
spring.security.oauth2.client.registration.kakao.client-secret=1234 # kakao login API에 등록해서 나온 내 서비스의 secret
spring.security.oauth2.client.registration.kakao.redirect-uri=https://dev.e-room.app/login/oauth2/code/kakao # spring security가 OAuth2AuthorizationRequest를 주고 받는 URL, 카카오가 내 서버로 인증 정보를 넘겨줄 때 사용
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code # 카카오에서 필요로 하는 정보들
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,profile_image # 카카오에서 필요로 하는 정보들
spring.security.oauth2.client.registration.kakao.client-name=kakao #
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
## kAKAO Provider
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize # 카카오 로그인 페이지를 띄워주는 API Origin
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token # 카카오 로그인 성공 후 token을 요청하는 API(이는 우리가 발급하는 JWT랑 다르다.)
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me # 카카오 로그인 성공 후 user 정보를 가져오는 API
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
Java
복사
AWS S3 이미지 업로드
AWS S3로 리뷰 작성 시 이미지를 업로드 해야했습니다. 당시에는 presigned_url이라는 것이 있는줄 몰라서, 서버에서 파일을 받은 뒤에 S3로 업로드하는 방식을 취했습니다.
@PostMapping(value = "/building/room/review", consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE})
// multipart/form-data 형태로 받음
public ResponseEntity<ReviewResponseDto.ReviewCreateDto> createReview(@RequestPart @Valid ReviewRequestDto.ReviewCreateDto request, @RequestPart @Size(max = 5) @Nullable List<MultipartFile> reviewImageList, @AuthUser Member loginMember) {
Address address = AddressDto.toAddress(request.getAddress());
BuildingOptionalDto buildingOptionalDto = request.getBuildingOptionalDto();
Building building = buildingService.createBuilding(address, buildingOptionalDto);// 빌딩이 없는 경우 생성
if (!(reviewImageList == null || reviewImageList.isEmpty())) {
request.setReviewImageList(reviewImageList);
}
Review review = reviewService.saveReview(request, loginMember, building);
Boolean isFirstReview = loginMember.getReviewList().size() < 2;
return ResponseEntity.ok(ReviewSerializer.toReviewCreateDto(review.getId(), review.getBuilding().getId(), isFirstReview));
}
Java
복사
•
Spring에서는 Multipart를 이용해서 데이터를 받고 이를 업로드합니다.
•
이후 로직에서는 Review를 생성하고 이미지를 S3에 업로드한 후, Review에 Map을 해서 돌려줍니다.
•
이 과정에서 동기식으로 처리할 때, 너무 느린 이슈가 있어서 Java 비동기로 시도해보았습니다.
private static void createAndMapReviewImage(ReviewRequestDto.ReviewCreateDto request, Review review) {
List<MultipartFile> imageFileList = request.getReviewImageList();
System.out.println(imageFileList.size());
/*
todo: asynchronously
*/
ExecutorService executorService = Executors.newFixedThreadPool(Math.min(imageFileList.size(), 5));
List<CompletableFuture<Void>> futures = imageFileList.stream().map((image) -> CompletableFuture.runAsync(() -> {
Uuid uuid = staticReviewImageProcess.createUUID();
ReviewImagePackageMetaMeta reviewImagePackageMeta = ReviewImagePackageMetaMeta.builder()
.buildingId(review.getBuilding().getId())
.uuid(uuid.getUuid())
.uuidEntity(uuid)
.build();
staticReviewImageProcess.uploadImageAndMapToReview(image, reviewImagePackageMeta, review);
}, executorService))
.collect(Collectors.toList());
/** blocking **/
List<Void> blockingList = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(Void -> futures.stream().map(CompletableFuture::join).collect(Collectors.toList()))
.join();
}
Java
복사
다만 서버에서 파일을 받을 때마다 이렇게 쓰레드를 생성해서 비동기로 처리를 하는 것에 부담이 있을 것이라 생각했습니다. 이후 효과적인 방법을 찾아보니 아래와 같은 presigned-url이라는 방식이 있었습니다.
클라이언트에서 직접 업로드하는 방식이면 좋겠다는 생각을 했는데, presigned-url이라는 방식으로 S3에 파일을 업로드할 수 있는 url을 받아온 뒤에 클라이언트에게 넘겨주는 방식으로 구현할 수 있었습니다.
다만 당시에 클라이언트에서 서버를 통해 업로드를 하는 것이 좋겠다고 생각한 이유는 썸네일 처리 때문이었습니다. 기존의 업로드한 이미지들은 너무 사이즈가 커서 이를 리사이즈를 해야 로딩이 잘 되는 경우가 있었기 때문에 썸네일 생성을 위해서라도 필요하다고 생각하였습니다.
다만 presigned-url은 클라이언트에서 바로 업로드를 하니 서버에 부하를 주지 않으면서 효과적인 방식이라 생각을 하였고, 실제로 제가 다닌 Classum의 코드에서 이렇게 구현되어있는 것을 알 수 있었습니다.
썸네일 관련해서는 Classum에서 S3에 파일 업로드 시 돌아가는 이미지 리사이징 lambda를 배우면서 람다로 해결할 수 있을 것이라 생각하였습니다.
아래는 제가 클라썸 온보딩 시 구현했었던 이미지 리사이징 lambda 함수 입니다.
import { Handler, Context } from "aws-lambda"
import * as AWS from "aws-sdk";
import * as sharp from "sharp";
export const handler = async (event: any, context: Context) => {
// const body = JSON.parse(event.Records[0].responseParameters)
const Bucket = event.Records[0].s3.bucket.name;
const Key = decodeURIComponent(event.Records[0].s3.object.key);
console.log(Bucket, Key);
const filename = Key.split('/')[Key.split('/').length - 1];
const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase();
const requiredFormat = ext === 'jpg' ? 'jpeg' : ext;
const s3 = new AWS.S3()
try {
const s3Object = await s3.getObject({ Bucket, Key }).promise();
const resizedImage = await sharp(s3Object.Body)
.resize(400, 400, { fit: 'inside' })
.toFormat(requiredFormat)
.toBuffer();
await s3.putObject({
Bucket,
Key: `thumb/${filename}`,
Body: resizedImage,
}).promise();
return `thumb/${filename}`
} catch (error) {
console.error(error);
return error;
}
}
Java
복사