프로젝트 설명
프로젝트 기간 : 2022년 10월 10일 ~ 2022년 11월 25일
역할 : backend, devOps
활용 기술 : Spring, MySQL, Spring data JPA, QueryDSL, Jenkins, AWS
Youtube Shorts, Instagram, TikTok 과 같은 SNS 들은 컨텐츠 소비는 사용자 친화적이지만 컨텐츠를 생산하는 데는 장벽이 있습니다.
Qhoto는 관리자가 사용자들에게 SNS에 올릴 컨텐츠를 제공함으로써 생산자와 동시에 소비자가 될 수 있도록 합니다.
동시에 매일 똑같은 하루를 보내는 사람들에게 환경, 건강, 봉사 등 사회에 이로운 퀘스트를 줌으로써 사회공헌과 함께 지루한 일상에 재미 포인트를 줄 수 있는 경험을 제공합니다.
github : https://github.com/audrb96/Qhoto
Wireframe
https://www.figma.com/file/aFFFni2Q5Q9Myu0sxVAQme/qhoto?type=design&node-id=0-1
ERD
세부 기여 내용
Backend
API
피드와 퀘스트 목록간의 관계
초기에 ERD를 설계할때 퀘스트의 목록과 피드를 일대다 관계로 매핑시켰었습니다.
하지만 개발 중간에 고민이 생겼습니다. 만약에 퀘스트의 이름이나 퀘스트 난이도 같은 정보가 바뀌었을 때, 피드는 바뀐 정보를 따라간다는 것이었습니다.
사실 이런 정보는 피드를 올렸을 때의 정보를 가지고 있는 것이 맞다고 생각했습니다.
따라서 이 부분을 해결하기 위해 퀘스트 목록과 피드의 일대다관계를 끊고 활성 일간 퀘스트, 주간 퀘스트, 월간 퀘스트로 나누어 저장을 하고 이를 피드와 일대다 관계를 함으로써 피드를 올릴 때의 정보와 바뀐 이후의 정보를 모두 조회할 수 있게 구성했습니다.
친구관계의 데이터표현
친구 관계는 한쪽만의 관계가 아니라 양쪽의 관계입니다.
이를 위해 친구 요청을 한 테이블과 요청을 받은 테이블로 나눠서 서로 양쪽에 있으면 친구관계다라고 정의 내리는 것은 비즈니스 로직상 너무 복잡하고 두 테이블을 조회해야 하는 번거로움이 있었습니다.
이를 해결하기 위해서 차라리 친구 테이블을 하나만 만들고 친구 관계가 성립되었을 때 (친구 1, 친구 2) , (친구 2, 친구 1) 형식으로 저장하는 것이 더 효율적이라고 생각했습니다.
또한 화면상에 친구가 나에게 요청을 보낸 상태일 때와, 이미 친구가 된 상태, 친구를 보낸 상태를 표현해야 했기에 많은 가능성을 생각해 친구 요청 테이블에 요청 상태별로 분류하였습니다.
사용자 정보 update API
/**
* 회원의 요청에 따라 유저 정보를 수정한다.
* @param modifyUserReq
* @param userInfo
* @throws IOException
*/
@Override
@Transactional
public void modifyUserByCon(ModifyUserReq modifyUserReq, User userInfo) throws IOException {
//updateClause 생성
JPAUpdateClause updateClause = new JPAUpdateClause(em, user);
//닉네임 수정
if(hasText(modifyUserReq.getNickname())) updateClause.set(user.nickname, modifyUserReq.getNickname());
//자기소개 수정
if(hasText(modifyUserReq.getDescription())) updateClause.set(user.description, modifyUserReq.getDescription());
//사용자 프로필 이미지 수정
if(modifyUserReq.getFile() != null) {
String profileDir = "user/" + userInfo.getEmail();
//S3에 사용
s3Utils.upload(modifyUserReq.getFile(), profileDir);
//DB에 저장할 이미지 URL
String imageUrl = S3Utils.CLOUD_FRONT_DOMAIN_NAME +"/" + profileDir +"/"+ modifyUserReq.getFile().getOriginalFilename();
updateClause.set(user.image, imageUrl);
}
//전화번호 수정
if(hasText(modifyUserReq.getPhone())) updateClause.set(user.phone, modifyUserReq.getPhone());
//프로필 Open 여부 수정
if(modifyUserReq.getProfileOpen() != null) updateClause.set(user.profileOpen, modifyUserReq.getProfileOpen());
//사용자 이름 수정
if(hasText(modifyUserReq.getName())) updateClause.set(user.name, modifyUserReq.getName());
//DB update 실행
updateClause.where(user.id.eq(userInfo.getId())).execute();
}
회원 정보를 수정할 때, 회원 정보 1개마다 1개의 API를 생성한다면 회원 정보 N개가 있을 때, N개의 API를 모두 생성해야 하는 반복적인 작업을 해야 했습니다.
저는 QueryDSL을 활용하여 동적쿼리로 처리하면 요청이 온 데이터만 수정하는 것이 가능하겠다고 생각했고 하나의 API로 동적인 회원수정이 가능하도록 개발했습니다.
면접 피드백
- 만약 닉네임을 수정하면 무조건 자기소개를 수정해야하는 비즈니스 요구가 들어오면 어떻게 할것이냐?
- Repository 레벨에서 한 번에 처리하면 이러한 비즈니스 요구에 대처하기 힘들어질 것 같다.
- Repository 레벨에서는 분리한 후에 Service 레벨에서 요청에 따라 동적으로 처리하는 것이 더 좋아보인다.
연락처 기반 친구 추천 API
/**
* 연락처를 받아서 연락처안에있는 유저들의 정보와 상태를 조회한다.
* @param userInfo
* @param contacts
* @return
*/
@Override
public List<ContactRes> contactByCon(User userInfo, Map<String, String> contacts) {
List<ContactRes> contactResList = jpaQueryFactory
.select(new QContactRes(
user.id,
user.name,
user.nickname,
user.phone,
user.image,
user.expGrade,
JPAExpressions.select(new CaseBuilder()
.when(friendRequest.status.isNull())
.then("노관계")
.when(friendRequest.status.eq(RequestStatus.GET))
.then("내가요청")
.when(friendRequest.status.eq(RequestStatus.REQUEST))
.then("상대방요청")
.otherwise("노관계")
).from(friendRequest)
.where(friendRequest.responseUser.eq(userInfo)
.and(friendRequest.requestUser.eq(user))
.and(friendRequest.status.ne(RequestStatus.FRIEND))
)
))
.from(user)
.where(
contactsIn(contacts), user.ne(userInfo),
friendRequestIsNotFriend(userInfo)
)
.fetch();
contactResList.forEach((contactRes ->
contactRes.setName(contacts.get(contactRes.getPhone()))
));
return contactResList;
}
/**
* 친구인 유저들을 제외시킨다.
* @param userInfo
* @return {@link BooleanExpression}
*/
private BooleanExpression friendRequestIsNotFriend(User userInfo) {
return user.notIn(
JPAExpressions.select(friendRequest.responseUser).from(friendRequest).where(friendRequest.status.eq(RequestStatus.FRIEND).and(friendRequest.requestUser.eq(userInfo)))
);
}
/**
* 연락처 안에있는 유저들을 확인한다.
* @param contacts
* @return {@link BooleanExpression}
*/
private BooleanExpression contactsIn(Map<String, String> contacts) {
List<String> contactsList = new ArrayList<>(contacts.keySet());
return user.phone.in(contactsList);
}
핸드폰 연락처를 받아와서 친구들 중에 해당 연락처가 있으면 친구추천을 해주는 서비스를 위해 개발한 API입니다.
이 또한 연락처에 있는 친구들이 이미 누군가 나에게 요청한 관계일 수도, 내가 요청한 관계일 수도, 이미 친구인 관계일 수도 있는 상황이기 때문에 연락처를 받아와 in 절로 포함하고 이미 친구관계인 친구들을 제외한 상태에서, 현재 친구 요청관계를 파악해야 했습니다.
이 쿼리도 어떻게 구성해야 할지 고민하는 단계에서 일단 SQL문으로 작성하고 QueryDSL로 표현하는 것이 맞다고 생각했습니다. 아래는 SQL문으로 작성한 쿼리입니다.
select u.user_id, u.user_name, IFNULL((
select
case
when r.request_status is null then '노관계'
when r.request_status='G' then '내가요청'
when r.request_status='R' then '니가요청'
else '노관계' end as '우리관계'
from friend_request r where r.response_user = 41 and r.request_user = u.user_id and r.request_status != 'F'), '노관계') as '진짜관계'
from user u
where
u.phone in ('01012345678', '01012344567', '01012340000', '01023129852','01023189857') and
u.user_id not in (
select response_user from friend_request where request_status='F' and request_user = 41
);
친구 요청 API
@Transactional
public void friendRequest(FriendRequestReq friendRequestReq, User reqUser) {
// 요청을 받는 사용자
Optional<User> resUser = userRepository.findUserById(friendRequestReq.getResUserId());
// 이전에 요청을 보내는 사용자와 관련된 request가 있는지 확인
Optional<FriendRequest> friendRequest = friendRequestRepository.findByRequestUserAndResponseUser(reqUser,resUser.orElseThrow(() -> new NotFoundUserException("유저를 찾을 수 없습니다.")));
// 요청을 받는 사용자가 이전에 보내는 사용자에게 요청을 보냈었는지 확인
Optional<FriendRequest> isAcceptRequest = friendRequestRepository.findByRequestUserAndResponseUserAndStatus(resUser.orElseThrow(() -> new NotFoundUserException("유저를 찾을 수 없습니다.")),reqUser, REQUEST);
if (reqUser.getId().equals(resUser.get().getId())){
throw new SelfRequestException("자기 자신에게 요청을 보낼 수 없습니다.");
}
// 이전에 있던 요청 정보를 확인
if(friendRequest.isPresent()) {
switch (friendRequest.get().getStatus()) {
case REQUEST:
throw new AlreadyRequestException("이미 요청한 상대입니다.");
case FRIEND:
throw new AlreadyFriendException("이미 친구인 상대입니다.");
case GET:
// 요청을 보낸 사용자가 받은 요청이 있다면 친구를 만들어준다.
makeFriend(reqUser, resUser, isAcceptRequest.orElseThrow(() -> new NobodyRequestException("친구를 요청한 사람이 없는데 받은 사람만 있습니다.")),friendRequest.get());
break;
case DISCONNECTED:
//이전에 단절 됐었다면
// 요청상태를 다시 업데이트
updateRequest(reqUser,resUser.get(),REQUEST);
updateRequest(resUser.get(), reqUser,GET);
}
} else {
// 새로운 요청 저장
saveRequest(reqUser, resUser.get(), REQUEST);
saveRequest(resUser.get(),reqUser,GET);
}
}
private void updateRequest(User reqUser, User resUser, RequestStatus status) {
friendRequestRepository.updateStatus(status, reqUser, resUser);
}
//친구를 만들어 준다.
private void makeFriend(User reqUser, Optional<User> resUser, FriendRequest isAcceptRequest, FriendRequest friendRequest) {
//친구 요청의 상태를 이미 친구가 된것으로 변경
isAcceptRequest.changeStatus(FRIEND);
friendRequest.changeStatus(FRIEND);
Friend friend1 = Friend.builder()
.follower(reqUser)
.followee(resUser.get())
.build();
Friend friend2 = Friend.builder()
.follower(resUser.get())
.followee(reqUser)
.build();
//친구 테이블에 서로 친구가 된 것을 저장
friendRepository.save(friend1);
friendRepository.save(friend2);
}
//요청 저장 메소드
private FriendRequest saveRequest(User reqUser, User resUser,RequestStatus status) {
FriendRequest savedRequest = FriendRequest.builder()
.requestUser(reqUser)
.responseUser(resUser)
.status(status)
.time(LocalDateTime.now())
.build();
return friendRequestRepository.save(savedRequest);
}
친구 요청 시
- 내가 이미 친구 요청을 보낸 상대
- 내가 친구 요청을 받은 상대
- 이미 친구인 상대
- 친구였는데 현재 단절된 상대
위와 같이 분기하여 각각 어떻게 구성해야 할지 고민했습니다.
- 이미 요청한 상대라면 이미 요청했다는 Exception을 던집니다.
- 내가 친구 요청을 받은 상대라면 서로 친구를 만들어 줍니다.
- 이미 친구인 상대라면 이미 친구라는 Exception을 던집니다.
- 친구였는데 단절된 상대라면 요청 상태를 업데이트해줍니다.
위와 같이 분기하여 친구 요청을 처리할 수 있었습니다.
퀘스트 분류별 피드 검색 API
우리는 퀘스트를 건강, 이색, 색깔, 환경 등 여러 가지로 분류하여 만들었습니다.
퀘스트 분류별로 사용자가 선택했을 때, 각 분류에 맞는 피드들을 보여줘야 하기 때문에 동적쿼리를 이용하여 처리했습니다.
피드를 보여줄 때, 피드 테이블의 정보만 보여줄 것이 아니라
- 기본적인 피드 정보
- 댓글들중 대표적인 1개의 댓글
- 내가 좋아요를 누른 상태
- 좋아요의 갯수
- 피드를 올린 사람의 경험치 등급, 유저 프로필 이미지, 닉네임
과 같은 정보를 보여줘야 하기 때문에 user, feed, comment 세 개의 테이블을 outer join 해야 했습니다.
여기서 outer 조인을 한 이유는 댓글을 작성하지 않은 사용자나, 피드를 올리지 않은 사용자, 댓글이 없는 피드도 조회해야 했기 때문입니다.
@Slf4j
public class FeedRepositoryImpl implements FeedRepositoryCon{
private final JPAQueryFactory jpaQueryFactory;
public FeedRepositoryImpl(EntityManager em){
this.jpaQueryFactory = new JPAQueryFactory(em);
}
@Override
public Page<FeedAllDto> findByCondition(User user, FeedAllReq feedAllReq, Pageable pageable) {
List<FeedAllDto> feedList = jpaQueryFactory
.select(new QFeedAllDto(
feed.id,
feed.image,
feed.feedType
))
.from(feed)
.where(feedClassIn(feedAllReq.getCondition(),feedAllReq.getDuration()),
feedTypeEq(feedAllReq.getDuration()),feed.status.eq(FeedStatus.USING)
)
.orderBy(feed.time.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<FeedAllDto> countQuery = jpaQueryFactory
.select(Projections.constructor(FeedAllDto.class,
feed.id,
feed.image,
feed.feedType
))
.from(feed,feedLike,comment)
.where(feedClassIn(feedAllReq.getCondition(),feedAllReq.getDuration()),
feedTypeEq(feedAllReq.getDuration()),feed.status.eq(FeedStatus.USING)
)
.orderBy(orderFirstByUserId(user.getId()),feed.time.desc())
;
return PageableExecutionUtils.getPage(feedList, pageable, countQuery::fetchCount);
}
@Override
public Page<FeedFriendDto> findByConditionAndUserId(FeedAllReq feedAllReq, Pageable pageable, Long userId) {
List<FeedFriendDto> feedFriendList = jpaQueryFactory
.select(new QFeedFriendDto(feed.id,
user.id,
feed.image,
Expressions.dateTemplate(String.class,"DATE_FORMAT({0},{1})",feed.time, ConstantImpl.create("%Y-%m-%d %p %h:%i")),
feed.questName,
feed.quest.questType.code,
feed.quest.score,
user.expGrade,
user.totalExp,
new CaseBuilder().when(JPAExpressions.select(feedLike).from(feedLike).where(feedLike.feed.id.eq(feed.id),feedLike.user.id.eq(userId)).exists()).then(LikeStatus.LIKE.getValue()).otherwise(LikeStatus.UNLIKE.getValue()).as("likeStatus"),
ExpressionUtils.as(JPAExpressions.select(feedLike.count()).from(feedLike).where(feedLike.feed.id.eq(feed.id)),"likeCount"),
user.nickname,
comment.user.nickname,
comment.user.image,
user.image,
Expressions.dateTemplate(String.class,"DATE_FORMAT({0},{1})",comment.time, ConstantImpl.create("%Y-%m-%d %p %h:%i")),
comment.context,
feed.feedType,
ExpressionUtils.as(JPAExpressions.select(comment.count()).from(comment).where(comment.feed.id.eq(feed.id)),"commentCnt")))
.from(feed,user,comment)
.rightJoin(comment.user, user)
.rightJoin(comment.feed, feed)
.rightJoin(feed.user, user)
.where(
feedClassIn(feedAllReq.getCondition(),feedAllReq.getDuration()),
feedTypeEq(feedAllReq.getDuration())
,user.id.in(JPAExpressions.select(friend.followee.id).from(friend).where(friend.follower.id.eq(userId))).or(user.id.eq(userId))
,feed.status.eq(FeedStatus.USING)
)
.groupBy(feed.id)
.orderBy(orderFirstByUserId(userId),feed.time.desc())
.fetch();
feedFriendList.forEach((feedFriendDto -> {
feedFriendDto.setFeedTime(feedFriendDto.getFeedTime().replace("AM", "오전").replace("PM", "오후"));
if(StringUtils.hasText(feedFriendDto.getTime())) feedFriendDto.setTime(feedFriendDto.getTime().replace("AM","오전").replace("PM", "오후"));
}));
JPAQuery<FeedFriendDto> countQuery = jpaQueryFactory
.select(new QFeedFriendDto(feed.id,
user.id,
feed.image,
Expressions.dateTemplate(String.class,"DATE_FORMAT({0},{1})",feed.time, ConstantImpl.create("%Y-%m-%d %h:%i")),
feed.questName,
feed.quest.questType.code,
feed.quest.score,
user.expGrade,
user.totalExp,
new CaseBuilder().when(JPAExpressions.select(feedLike).from(feedLike,feed).where(feedLike.feed.id.eq(feed.id),feedLike.user.id.eq(userId)).exists()).then(LikeStatus.LIKE.getValue()).otherwise(LikeStatus.UNLIKE.getValue()).as("likeStatus"),
ExpressionUtils.as(JPAExpressions.select(feedLike.count()).from(feedLike).where(feedLike.feed.id.eq(feed.id)),"likeCount"),
user.nickname,
comment.user.nickname,
comment.user.image,
user.image,
Expressions.dateTemplate(String.class,"DATE_FORMAT({0},{1})",comment.time, ConstantImpl.create("%Y-%m-%d %h:%i")),
comment.context,
feed.feedType,
ExpressionUtils.as(JPAExpressions.select(comment.count()).from(comment).where(comment.feed.id.eq(feed.id)),"commentCnt")
))
.from(feed,user,comment)
.rightJoin(comment.user, user)
.rightJoin(comment.feed, feed)
.rightJoin(feed.user, user)
.where(
feedClassIn(feedAllReq.getCondition(),feedAllReq.getDuration()),
feedTypeEq(feedAllReq.getDuration())
,user.id.in(JPAExpressions.select(friend.followee.id).from(friend).where(friend.follower.id.eq(userId)))
,feed.status.eq(FeedStatus.USING)
).groupBy(feed.id)
.orderBy(feed.time.desc());
return PageableExecutionUtils.getPage(feedFriendList, pageable, countQuery::fetchCount);
}
private OrderSpecifier<?> orderFirstByUserId(Long userId) {
NumberExpression<Integer> sortRank = new CaseBuilder()
.when(user.id.eq(userId)).then(1).otherwise(2);
return sortRank.asc();
}
private BooleanExpression feedTypeEq(String duration) {
QuestDuration qd = null;
if(duration.equals("D")) qd = QuestDuration.DAY;
else if(duration.equals("W")) qd = QuestDuration.WEEK;
else if(duration.equals("M")) qd = QuestDuration.MONTH;
return hasText(duration)? feed.duration.eq(qd):null;
}
private BooleanExpression feedClassIn(String condition, String duration) {
List<Long> conList = new ArrayList<>();
if(hasText(condition)){
conList = Arrays.stream(condition.split(",")).map(Long::parseLong).collect(Collectors.toList());
}
if(duration.equals("D")){
return feed.activeDaily.id.in(conList);
} else if (duration.equals("W")) {
return feed.activeWeekly.id.in(conList);
}
return feed.activeMonthly.id.in(conList);
}
}
Exception
이전 프로젝트까지는 제가 프로젝트의 프론트와 백엔드를 같이 하는 경우가 많았습니다.
하지만 Qhoto에서는 Backend와 DevOps만 맡았기 때문에 프론트엔드에게 Exception이 생겼을 때, Exception의 원인과 Error Code를 넘겨줘야 하는 경우가 많았습니다.
저는 최대한 유지보수성이 좋으면서 프론트와의 협업 효율을 증가시킬 수 있는 방법을 찾았습니다.
API를 개발하면서 생길 수 있는 Exception을 정리하고 CustomException을 생성했습니다.
CustomException의 많은 장점이 있지만 제가 주목한 장점은 예외 발생 후의 처리가 용이하다는 점입니다.
Spring에서 예외 처리는 @ControllerAdvice
와 @ExceptionHandler
를 통해 처리하게 됩니다.
하지만 여기서 RuntimeException이나 IllegalArgumentException이 발생해서 처리한다면 정확히 어느 부분에서 예외가 생겼는지 파악하기가 힘듭니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleUnExpectedException(final RuntimeException error) {
// ...
}
// ...
}
하지만 CustomException을 사용한다면 정확히 어느 시점에 생긴 예외인지 파악하기 쉬워집니다.
/**
* refreshToken 만료 Exception
* @param e
* @return {@link ErrorResponse}
*/
@ExceptionHandler(ExpiredRefreshTokenException.class)
protected ResponseEntity<ErrorResponse> expiredRefreshTokenException(ExpiredRefreshTokenException e) {
log.error("ExpiredRefreshTokenException", e);
ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.EXPIRED_REFRESH_TOKEN);
return new ResponseEntity<>(errorResponse, HttpStatus.resolve(errorResponse.getStatus()));
}
/**
* access token과 refresh token을 재발급한다.
* @param bearerToken
* @return {@link AccessTokenRes}
*/
public AccessTokenRes reissue(String bearerToken) {
// 1. Validation Refresh Token
String oldRefreshToken = tokenProvider.resolveToken(bearerToken);
// 2. 유저정보 얻기
User user = userRepository.findByRefreshToken(oldRefreshToken).orElseThrow(()-> new NoUserByRefreshTokenException("토큰을 가진 유저가 없습니다."));
if (tokenProvider.validateToken(oldRefreshToken)) {
// 토큰이 만료되지 않았을 때
String accessToken = tokenProvider.createAccessToken(user.getEmail(), user.getAuthorities());
String refreshToken = tokenProvider.createRefreshToken();
userRepository.updateRefreshToken(user.getId(), refreshToken);
return new AccessTokenRes(accessToken,refreshToken);
} else {
// 토큰이 만료 되었을 때
throw new ExpiredRefreshTokenException("refresh 토큰이 만료되었습니다.");
}
}
Excpetion을 처리하는 것도 중요하지만 클라이언트에게 이해하기 좋은 Response를 보내주는 것도 중요합니다.
그래서 저는 여러 Error를 ErrorCode
라는 Enum 클래스로 정리하고, ErrorResponse
를 구성했습니다.
ErrorCode
@RequiredArgsConstructor
@Getter
public enum ErrorCode {
INVALID_INPUT_VALUE(400, "C001", "Invalid Input Value"),
INTER_SERVER_ERROR(500,"C002", "Internal server error"),
NOTNULL_INPUT_VALUE(400,"C003", "NotNull input value"),
INVALID_PATTERN(400, "C004", "Invalid Pattern"),
NO_REQUEST_BODY(400,"C005", "No Request Body"),
TYPE_MISMATCH_VALUE(400,"C006", "TypeMismatch value"),
//user
INVALID_GOOGLE_TOKEN(400,"U001", "Invalid Google Token"),
INVALID_ACCESS_TOKEN(401, "U002", "Invalid Access Token"),
NOT_FOUND_USER(500, "U003", "No User By UserId"),
NO_USER_BY_REFRESH_TOKEN(500, "U004", "No User By RefreshToken"),
NO_USER_BY_NICKNAME(500, "U005", "No User By UserNickName"),
NO_UNIQUE_USER(500,"U006", "No Unique User"),
NOT_SELF_REQUEST(500,"U007","Not Self Request"),
EXPIRED_REFRESH_TOKEN(500,"A001", "Expired Refresh Token"),
//feed
NO_FEED_BY_ID(500, "F001", "No Feed By FeedId"),
NO_USER_BY_ID(500, "F002", "No User By UserId"),
NO_QUEST_BY_ID(500, "F003","No Quest By QuestId"),
ALREADY_REQUEST_USER(500,"F004", "Already Request User"),
ALREADY_FRIEND(500,"F005","Already Friend"),
NO_FEED_BY_USER_ID(500, "F006", "No Feed By UserId")
;
private final int status;
private final String code;
private final String message;
}
ErrorResponse
@Data
@NoArgsConstructor
public class ErrorResponse {
private String message;
private String code;
private int status;
public ErrorResponse(ErrorCode errorCode) {
this.message = errorCode.getMessage();
this.code = errorCode.getCode();
this.status = errorCode.getStatus();
}
public ErrorResponse(String message, String code, int status) {
this.message = message;
this.code = code;
this.status = status;
}
public static ErrorResponse of(ErrorCode errorCode) {
return new ErrorResponse(errorCode);
}
public String convertToJson() throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(this);
}
}
Enum Converter
이전 프로젝트까지 Enum을 @Eumerated(EnumType.STRING)
을 사용하여 DB에 저장할 때enum ‘이름’으로 매핑해 주었습니다.
하지만 DB의 들어갈 값을 코드값으로 넣으려고 Enum의 이름을 코드로 지정하는데서 유지보수성이 매우 떨어진다고 생각했었습니다.
코드값을 이름으로 해둔다면 개발자는 정확히 해당 Enum이 어떤 의미인지 모를 것입니다.
그래서 저는 Enum 타입을 어떻게 리팩토링을 해야 할지 고민했고 우아한 형제들 기술 블로그를 참고하게 됐습니다.
https://studyandwrite.tistory.com/m/496
LegacyCommonType라는 interface를 정의하고 Enum Class 들을 상속시킵니다.
public interface LegacyCommonType {
String getLegacyCode();
String getDesc();
}
@Getter
@RequiredArgsConstructor
public enum FeedStatus implements LegacyCommonType {
USING("사용","U"), DISABLE("삭제", "D");
private final String desc;
private final String legacyCode;
}
AttribueConverter 인터페이스의 구현체인 AbstractLegacyEnumAttributeConverter를 생성합니다.
@Getter
@AllArgsConstructor
public class AbstractLegacyEnumAttributeConverter<E extends Enum<E> & LegacyCommonType> implements AttributeConverter<E,String> {
private Class<E> targetEnumClass;
private boolean nullable;
private String enumName;
/**
* Enum 에서 DB 타입으로 변환
* @param attribute the entity attribute value to be converted
* @return {@link String}
*/
@Override
public String convertToDatabaseColumn(E attribute) {
if(!nullable && attribute == null) {
throw new IllegalArgumentException(String.format("%s(은)는 NULL로 저장할 수 없습니다.", enumName));
}
return LegacyEnumValueConvertUtils.toLegacyCode(attribute);
}
/**
* DB에서 Enum 으로 변환
* @param dbData the data from the database column to be
* converted
* @return {@link E}
*/
@Override
public E convertToEntityAttribute(String dbData) {
if(!nullable && !StringUtils.hasText(dbData)) {
throw new IllegalArgumentException(String.format("%s(이)가 DB에 NULL 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData));
}
return LegacyEnumValueConvertUtils.ofLegacyCode(targetEnumClass,dbData);
}
}
AbstractLegacyEnumAttributeConverter 각 Enum의 AbstractLegacyEnumAttributeConverter를 상속한 Converter를 생성합니다.
@Converter
public class CommentStatusConverter extends AbstractLegacyEnumAttributeConverter<CommentStatus>{
private static final String ENUM_NAME = "댓글 상태";
public CommentStatusConverter() {
super(CommentStatus.class,false, ENUM_NAME);
}
}
@Convert(converter = FeedStatusConverter.class)
@Column(name = "feed_status", nullable = false)
private FeedStatus status;
JWT, Oauth2.0
처음에 다음과 같은 flow로 소셜로그인을 구현했습니다.
- Client가 redirect_uri과 함께 backend Server에 요청을 보냅니다.
- Spring의 Oauth2가 소셜로그인화면을 Client에 redirect합니다.
- 로그인이 성공한다면 Spring Security에서 callback redirect_uri로 redirect합니다.
- callback redirect_uri를 통해 유저의 접근 권한을 받고 Authorization Code를 통해 UserInfo를 흭득하여 CustomUserDetail을 생성합니다.
- JwtTokenProvider를 통해 토큰을 생성하고 Client가 보냈던 redirect_uri로 redirect 시킵니다.
이와 같은 구조로 소셜로그인을 구현하다 보니, 하나의 문제가 생겼습니다.
Qhoto는 웹사이트가 아닌 모바일 애플리케이션입니다. 그래서 redirect 시켜야 할 주소를 지정할 수 없었습니다.
그래서 전체적으로 코드를 수정할 필요가 있었습니다.
아래의 인증 흐름도를 완전히 따르지는 않았지만 참고했습니다.
- Client가 google API server에게 로그인 요청을 전송합니다.
- 구글 API Server는 구글 로그인창을 Client에게 제공합니다.
- 로그인을 시도하고 성공한다면 Client는 Google API server에서 idToken을 전달 받습니다.
- idToken을 backend서버에 전송합니다.
- idToken을 backend 서버에서 Google API Server로 validation 과정을 거친 후에 유저 정보를 얻습니다.
- validation 과정은 Google client api 라이브러리를 활용했습니다. (https://developers.google.com/identity/sign-in/android/backend-auth?hl=ko#calling-the-tokeninfo-endpoint)
- JwtTokenProvider을 통해 토큰을 생성합니다.
Google Login Service
public LoginRes loginGoogle(String idTokenString) throws GeneralSecurityException, IOException {
HttpTransport transport = new NetHttpTransport();
JsonFactory jsonFactory = new JacksonFactory();
// 구글 ID 토큰 validation
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport,jsonFactory)
.setAudience(Collections.singletonList(GOOGLE_CLIENT_ID))
.build();
GoogleIdToken idToken = verifier.verify(idTokenString);
if(idToken != null) {
// validation 성공
GoogleIdToken.Payload payload = idToken.getPayload();
//payload를 가져온다.
String email = payload.getEmail();
String name = (String) payload.get("name");
String pictureUrl = (String) payload.get("picture");
//가입이 된 유저인지 판단하고 그에 맞는 정보를 가져온다.
Map<String, Object> userMap = isJoined(email, name, pictureUrl,AuthProvider.GOOGLE);
return LoginRes.builder()
.accessToken((String) userMap.get("accessToken"))
.refreshToken((String) userMap.get("refreshToken"))
.isJoined((Boolean) userMap.get("isJoined"))
.isModified((Boolean) userMap.get("isModified"))
.build();
}
else {
// Google IdToken validation 실패
throw new InvalidIdTokenException("Google TokenId is null.");
}
}
카카오 로그인 또한 비슷한 과정으로 진행했습니다.
하지만 카카오 로그인은 Google client api 라이브러리 같은 라이브러리가 없었습니다.
그래서 RestTemplate을 통해 카카오 서버에 accessToken을 넘겨주고 검증을 받는 형식으로 구현했습니다.
public LoginRes loginKakao(String kakaoAccessToken) {
// kakaoAccessToken 으로부터 사용자 정보를 가져온다.
Map<String, String> userInfo = getUserInfo(kakaoAccessToken);
String email = userInfo.get("email");
String profileImage = userInfo.get("profileImage");
String nickname = userInfo.get("nickname");
log.info("nickname = {}", nickname);
log.info("profileImage = {}", profileImage);
log.info("email = {}", email);
//가입이 된 유저인지 판단하고 그에 맞는 정보를 가져온다.
Map<String, Object> userMap = isJoined(email, nickname, profileImage,AuthProvider.KAKAO);
return LoginRes.builder()
.accessToken((String) userMap.get("accessToken"))
.refreshToken((String) userMap.get("refreshToken"))
.isJoined((Boolean) userMap.get("isJoined"))
.isModified((Boolean) userMap.get("isModified"))
.build();
private Map<String, String> getUserInfo(String kakaoAccessToken) {
// kakao access token validation 검증을 위해 api 호출을 한다.
RestTemplate restTemplate = new RestTemplate();
Map<String, String> userInfo = new HashMap<>();
String reqURL = "https://kapi.kakao.com/v2/user/me";
//request Body
MultiValueMap<String,String> map = new LinkedMultiValueMap<>();
//request Header
MultiValueMap<String,String> headers = new LinkedMultiValueMap<>();
//Header 추가
headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
headers.add("Authorization", "Bearer " + kakaoAccessToken);
//Body 추가
map.add("secure_resource", "true");
HttpEntity<MultiValueMap<String,String>> entity = new HttpEntity<>(map,headers);
// post 요청 결과를 받아온다.
String result = restTemplate.postForObject(reqURL, entity, String.class);
// 결과를 Json 파싱
JsonElement jsonElement = JsonParser.parseString(result);
JsonObject kakaoAcount = jsonElement.getAsJsonObject().get("kakao_account").getAsJsonObject();
JsonObject profile = kakaoAcount.getAsJsonObject().get("profile").getAsJsonObject();
// 회원 정보를 가져온다.
String nickname = profile.getAsJsonObject().get("nickname").getAsString();
String profileImage = profile.getAsJsonObject().get("profile_image_url").getAsString();
String email = kakaoAcount.getAsJsonObject().get("email").getAsString();
userInfo.put("nickname", nickname);
userInfo.put("profileImage", profileImage);
userInfo.put("email", email);
return userInfo;
}
DevOps
아키텍처
이전에 했던 프로젝트에서는 Jenkins를 통해 빌드 시 빌드가 진행되는 동안 서비스가 중단되는 문제가 있었습니다. 그래서 항상 의문이 있었습니다. ” 빌드 시에 서비스가 일시적으로 중단된다면 그동안 사용자들은 서비스를 이용하지 못하나? “ 저는 문제라고 생각을 했었고, 무중단 배포에 대해 찾아봤습니다.
SNS는 트래픽이 많은 서비스입니다. 필요할 때만 찾는 서비스와 달리 SNS는 사람들이 자주 들여다보고 일상을 공유하는 서비스입니다. 저는 그래서 많은 요청을 감당할 수 있는 서비스를 개발하고 싶었고 Scale-out을 하려고 했습니다.
Qhoto를 기획할 때, 동영상 서비스도 제공하고 싶었습니다. S3를 활용해 동영상을 구현하려고 찾아보면서 S3만으로 구현했을 때의 문제점을 찾았습니다. 저희 S3 서버는 서울에 있기 때문에 다른 나라에서 사진이나 영상을 요청하면 그 요청이 서울까지 왔다 가야 합니다. 영상은 빠른 응답이 중요합니다. 하지만 이렇게 되면 빠른 서비스를 제공하지 못하게 됩니다. CDN 인 Cloud Front를 사용하면 전 세계 Edge 서버를 두어 물리적 거리를 줄여 컨텐츠 로딩을 최소화할 수 있고, 캐싱을 통해 더욱 빠른 데이터 전송 속도를 제공할 수 있습니다.
목표
- 무중단 배포를 구현
- worker instance를 scale-out하여 로드밸런싱 구현
- cloud Front를 사용하여 비디오 서비스 개선
Worker instance
Qhoto에서는 3개의 instance를 사용했습니다.
1개는 SSAFY에서 제공해 준 AWS ec2 instance를 활용했고, 2개는 GCP의 Compute Engine을 통해 생성했습니다.
GCP instance를 생성한 뒤 image를 활용하여 하나의 instance를 추가적으로 생성했습니다.
Nginx
로드밸런싱을 구현하기 위해서는 Nginx에서 로드밸런싱과 관련된 설정과 리버스 프록시 설정을 해줘야 했습니다.
upstream cpu-bound-app { //로드 밸런싱
server k7A707.p.ssafy.io:7070 weight=100 max_fails=3 fail_timeout=3s;
server 34.64.51.121:7070 weight=100 max_fails=3 fail_timeout=3s;
server 34.64.127.65:7070 weight=100 max_fails=3 fail_timeout=3s;
}
server {
server_name qhoto-api.com;
root /usr/share/nginx/html;
client_max_body_size 50M; //이미지 및 동영상 파일을 받아야 하기 때문에 요청 body size를 늘려주어야 한다.
# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;
location / { // 리버스 프록시
proxy_pass http://cpu-bound-app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
error_page 404 /404.html;
location = /404.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
Jenkins 설정
Jenkins instance와 worker instance 간의 SSH 인증 과정을 거쳤습니다.
Jenkins에서 ssh-keygen을 통해 비대칭키 암호화 방식의 공개키와 개인키를 발급했습니다.
발급을 한 후에 ~/.ssh 위치로 이동을 하면
세 개의 파일이 존재합니다.
id_rsa 파일에는 primary key 가 저장되어 있고, id_rsa.pub에는 public key가 저장되어 있습니다.
우리는 Jenkins에서 다른 worker instance에 접근을 해야 하기 때문에 Jenkins의 public key를 worker instance에 등록해야 합니다.
authorized_keys 파일은 public key 인증을 사용하여 원격 호스트에 로그인하도록 허용된 사용자를 인증하는 데 사용되는 키를 저장하고 있습니다.
jenkins의 public key의 내용을 복사해서 worker instance의 authorized_keys에 붙여 넣기 해줍니다.
이로써 jenkins에서 worker instance에 로그인할 수 있습니다.
Jenkins의 Publish over SSH plugin을 설치한 이후에 jenkins 인스턴스의 개인키를 id_rsa 파일에서 붙여 넣기 해줍니다.
아래와 같이 SSH Server를 등록해 줍니다.
Jenkins Item의 Build Step은 이렇습니다. Spring을 빌드한 뒤에 Docker image를 빌드하고 Dockerhub에 push 합니다.
cd /var/lib/jenkins/workspace/qhoto-api-instance/qhoto_api
chmod 544 gradlew
./gradlew clean build
./gradlew build
docker image prune //안쓰는 이미지를 지운다.
docker build -t audrb96/qhoto-api . // docker image 빌드
sudo docker push audrb96/qhoto-api // docker hub push
빌드 후 조치에 각 SSH 서버에 환경변수와 함께 Docker hub에서 이미지를 pull 받은 뒤 실행시킵니다.
docker images -f "dangling=true" -q
docker rmi -f $(docker images -f "dangling=true" -q)
docker rm -f qhoto-api
docker pull audrb96/qhoto-api
docker run -d -p 7070:7070 --name qhoto-api audrb96/qhoto-api
아쉬운 점
- 좋아요 구현시에 Redis를 활용해서 insert와 update가 자주 발생하는 성능저하를 완화시킬 수 있었을 것 같다.
- 디자인 패턴을 활용해서 코드들을 좀 더 유연하게 작성할 수 있었을 것 같다.
- Java 표준 Exception을 많이 사용해서 CustomException의 class를 많이 줄일 수 있었을 것 같다.
- 동시성 문제를 많이 고려하지 못한 것 같다.
- spring batch를 사용해서 성능 향상을 가져올 수 있었을 것 같다.
- MSA를 구현해 보고 Kafka를 적용해보고 싶다.