사용자 지정 URL과 비밀번호를 사용하여 개인화된 메시지 공유 서비스를 기획하였다.
특별한 날, 소중한 사람에게 편지의 URL을 공유하여 보다 아날로그한 감성을 느낄 수 있도록 서비스의 컨셉과 기능 구현을 목표로 하였다.
프로젝트 기간은 4주, 참여 인원은 백엔드 3, 프론트 3으로 총 6인으로 진행하였다.
프로그래밍 언어
: JAVA
프레임워크
: Spring Boot
라이브러리
: Spring Batch
데이터베이스
: JPA, H2, MySQL
배포 서버
: AWS (EC2, RDS)
버전 관리
: Git
API 테스트 도구
: PostMan
협업 도구
: Notion, Discord, Zep
로컬 서버
: Ngrok
개발 환경
: IntelliJ
@PostMapping("/write") // 사용자 인증 기반, 요청 body에 memberId 작성 삭제, urlName 중복 시 conflict 에러
public ResponseEntity postMessage(@Valid @RequestBody MessagePostDto messagePostDto, Principal principal) {
if (messageService.urlNameExists(messagePostDto.getUrlName())) {
return new ResponseEntity<>(HttpStatus.CONFLICT);
}
Message message = messageMapper.messagePostDtoToMessage(messagePostDto);
Member member = memberDbService.findMemberByEmail(principal.getName());
message.setMemberId(member.getMemberId());
message.setOutgoingNickname(member.getNickname());
Message createdMessage = messageService.createMessage(message, member.getMemberId()); // 테마, 폰트 추가
MessageResponseDto responseDto = messageMapper.messageToMessageResponseDto(createdMessage);
return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
}
사용자가 편지를 작성할 수 있다.
이 기능은 MessageController의 postMessage 메서드를 통해 구현하였고, 사용자가 작성한 편지 정보를 MessagePostDto로 받아와 Message 객체로 변환하고, MessageService의 createMessage 메서드를 통해 저장했다.
@PostMapping("/{URL-Name}") // 편지 조회
public ResponseEntity<?> getMessage(@PathVariable("URL-Name") String urlName,
@Valid @RequestBody PasswordInputDto passwordInputDto) {
Message message = messageService.findMessageByUrlName(urlName);
if (!passwordInputDto.getPassword().equals(message.getPassword())) {
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
return new ResponseEntity<>(messageMapper.messageToMessageResponseDto(message), HttpStatus.OK);
}
사용자가 편지의 URL을 통해 해당 편지를 조회할 때 비밀번호를 입력해야한다.
이 기능은 MessageController의 getMessage 메서드를 통해 구현하였고, PassWordInputDto 클래스를 추가로 만들어 사용자가 입력한 비밀번호와 저장된 편지의 비밀번호를 비교하여 일치할 경우에만 편지 내용을 반환하도록 구현하였다.
@GetMapping("/exists/{URL-Name}") // url 중복 체크 api
public ResponseEntity<Boolean> checkUrlNameExists(@PathVariable("URL-Name") String urlName) {
boolean exists = messageService.urlNameExists(urlName);
if (exists) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} else {
return ResponseEntity.ok().build();
}
}
사용자가 편지 URL의 중복 여부를 확인할 수 있다.
이 기능은 MessageController의 checkUrlNameExists 메서드를 통해 구현하였다. MessageService의 urlNameExists 메서드를 호출해서 URL 중복 여부를 확인하고, 중복된 경우 사용자에게 오류 메시지를 반환하도록 하였다.
public Receiving updatedMessageSaved(Message message, boolean messageSaved, long memberId) {
message.setMessageSaved(true);
Message savedMessage = messageRepository.save(message);
Receiving receiving = receivingJoinMessage(savedMessage, memberId);
return receivingService.createReceiving(receiving);
}
사용자가 받은 편지를 보관하도록 선택할 수 있다. 이 기능은 MessageController의 updateMessageSaved 메서드를 통해 구현했으며,
사용자의 MessageSaved 여부를 MessagePatchDto로 받아와 MessageService 의 updatedMessageSaved 메서드를 통해 저장했다.
public Page<Outgoing> findAllMessages(int page, int size, Authentication authentication) { // 인증된 사용자의 memberId 기반으로 발신함 목록 조회, memberId 값 검색을 위해 findMemberIdByAuthenticatedUser 사용
Long memberId = findMemberIdByAuthenticatedUser(authentication);
PageRequest pageRequest = PageRequest.of(page, size);
return outgoingRepository.findAllByMember_MemberIdAndOutgoingStatusOrderByCreatedAtDesc(memberId, Outgoing.OutgoingStatus.OUTGOING_STORE, pageRequest);
}
인증된 사용자의 memberId를 기반으로 작성한 편지를 발신함에서 조회할 수 있도록 해주는 기능을 구현하였다.
OutgoingService의 findAllMessages 메서드를 호출해서 페이징 기능을 사용해 발신함의 메시지 목록을 가져오도록 하였고, 발신 편지 목록은 OutgoingResponseDto로 변환되어 API 응답에 반환되도록 하였다.
public void updatedOutgoingBookMark(Long outgoingId, boolean bookMark) {
Outgoing outgoing = findVerifiedOutgoing(outgoingId);
outgoing.setBookMark(!outgoing.isBookMark());
outgoingRepository.save(outgoing);
}
사용자가 발신함에서 특정 메시지를 북마크 하거나 해제할 수 있도록 기능을 구현하였다. OutgoinService의 updatedOutgoingBookMark 메서드를 호출해서 북마크 여부를 업데이트하고 그 결과를 반환하도록 하였다.
public Page<Receiving> findAllMessages(int page, int size, Authentication authentication) { // 인증된 사용자의 memberId 기반으로 수신함 목록 조회, memberId 값 검색을 위해 findMemberIdByAuthenticatedUser 사용
Long memberId = findMemberIdByAuthenticatedUser(authentication);
PageRequest pageRequest = PageRequest.of(page, size);
return receivingRepository.findAllByMember_MemberIdAndReceivingStatusOrderByCreatedAtDesc(memberId, Receiving.ReceivingStatus.RECEIVING_STORE, pageRequest);
}
public Long findMemberIdByAuthenticatedUser(Authentication authentication) { // 이메일 주소를 기반으로 DB에서 인증된 사용자의 memberId 값 검색, memberId 찾지 못할 시에 BusinessLogicException
String username = authentication.getName();
Optional<Member> optionalMember = memberRepository.findByEmail(username);
if (optionalMember.isPresent()) {
return optionalMember.get().getMemberId();
} else {
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
}
회원 인증이 된 사용자에 한해 수신함에서 보관한 모든 편지를 조회할 수 있도록 기능 구현을 하였고, memberId를 찾지 못할 시에 BusinessLogicException이 발생하도록 하였다.
public void updatedReceivingBookMark(Long receivingId, boolean bookMark) {
Receiving receiving = findVerifiedReceiving(receivingId);
receiving.setBookMark(!receiving.isBookMark());
receivingRepository.save(receiving);
}
인증된 사용자에 한해 보관한 편지를 수신함에서 특정 메시지를 북마크 하거나 해제할 수 있도록 기능 구현을 하였다.
updatedReceivingBookMark 메서드를 호출하여, 편지의 ID와 북마크 상태를 전달하고 해당 편지를 찾은 후 북마크 상태를 변경하고 결과를 저장하도록 구현하였다.
@Bean
public Job job() {
Job job = jobBuilderFactory.get("job")
.start(step1())
.build();
return job;
}
private Step step1() {
return stepBuilderFactory.get("step1")
.tasklet(((contribution, chunkContext) -> { // 실행 할 작업을 지정하기 위한 tasklet() 메서드 호출
log.info("Step1"); // batch 프레임웍에서 제공되는 매개변수 contribution , chunkContext 이다. 단계의 실행을 제어하는데 사용한다.
// 단계가 시작되었다는 메시지 log.info
List<Message> limitedMessages = messageRepository.selectLimitedMessage();
if (limitedMessages != null && !limitedMessages.isEmpty()) { // null이 아니거나 비어 있지 않은지 확인 (DB에 일치하는 값이 없는 경우에 NullPointerException 방지)
for (Message message : limitedMessages) {
if (!message.isMessageSaved()) { // messageSaved가 false이면 delete
messageService.deleteMessage(message.getMessageId());
}
}
}
return RepeatStatus.FINISHED; // 반환, 생략 시 무한반복됨
})
)
.build(); // Step 객체를 생성하기 위해 호출된다. Step 객체는 step1() 메서드에 의해 반환되고 Spring Batch 작업에 의해 실행된다.
}
@Scheduled(cron = "0 0 0 * * *") // 매일 자정에 실행되도록 설정.
public void runJob() { // 현재 타임스탬프를 매개변수로 사용하고 JobParameters 객체를 생성하고 작업을 시작한다. 오류 발생 시 오류 메시지를 기록한다
Map<String, JobParameter> confMap = new HashMap<>();
confMap.put("time", new JobParameter(System.currentTimeMillis())); // time과 밀리초 단위의 현재 시스템 시간 값을 사용하는 JobParameter 개체 생성 및 추가
JobParameters jobParameters = new JobParameters(confMap);
try {
jobLauncher.run(batchConfig.job(), jobParameters); // jobLauncher는 batchConfig.job()에서 지정한 작업을 실행하고 이전에 생성한 JobParameters 객체를 매개변수로 전달하는 데 사용된다.
} catch (JobExecutionAlreadyRunningException | JobInstanceAlreadyCompleteException
| JobParametersInvalidException | org.springframework.batch.core.repository.JobRestartException e) {
log.error(e.getMessage()); // 작업 실행 중 예외가 발생하면 log.error() 메서드를 사용하여 오류 메시지를 기록한다.
}
}
MessageSaved가 7일 이상 false 상태인 경우, 주기적으로 파일을 삭제하도록 구현하였다. 이를 위해 Spring Batch와 스케쥴러를 활용하였다.
job 메서드에 작업을 정의하고 step1 메서드에 MessageRepository의 selectLimitedMessage()를 호출해서 7일 이상 MessageSaved가 false 인
메시지를 조회하고, messageService.deleteMessage()를 호출하여 각 메시지를 삭제하도록 구현하였으며 스케쥴러를 통해 매일 자정에 작업이 실행되도록 설정하였다.
Main Project는 코스 동안 배운 내용을 활용하고, 배우지 않은 부분은 직접 구글링과 자료 조사를 통해 습득하여 진행하였다.
이 과정에서 많은 시행착오를 겪었지만, Pre Project에 비해 심적 부담은 줄어들었다. 그러나 기획과 설계 단계에서는 더 많은 어려움을 겪었다.
초기 기능과 주요 컨셉은 빠르게 설정했지만, 백엔드와 프론트엔드 간의 기술적 시도와 이해관계가 충돌하면서 설계를 수정하고 적용하는 과정을 반복했다.
Main Project에서도 팀원 간 협력으로 해결되지 않는 문제들이 있었는데, 외부 도움을 적극적으로 요청하여 대부분의 문제를 해결할 수 있었다.
그러나 프로젝트를 진행하는 동안 팀원들 모두 체력적으로 지쳤다는 것을 느꼈고, 나 역시 팀원들에게 힘이 되고 싶었지만, 맡은 작업에 대한 불확실성 때문에 여유가 부족한 상태였다. 이 부분이 프로젝트가 종료된 후에도 마음에 걸리는 점이다.
결국 프로젝트를 진행하며 여유라는 것이 실력과도 관련이 있다는 것을 절실히 느꼈다. 프로젝트가 무사히 마무리된다면, 앞으로 지금까지 배운 내용을 깊이 있게 공부하고자 하는 시간을 꼭 가져야겠다는 결심을 하게 되었다.
초기 설계 과정은 매우 빠르게 진행되었으며, 서비스의 컨셉과 기능을 명확하게 설정하고 유저 플로우와 피그마를 통해 구체적인 청사진을 그렸다.
그러나 실제 구현 과정에서 디테일한 부분에 문제가 발생했다. 설계 단계에서 미처 고려하지 못한 문제들이 하나씩 드러나기 시작한 것이다.
이로 인해 ERD 설계를 수정하고 구현하는 과정을 반복해야 했다. 수정된 설계는 백엔드와 프론트엔드의 기술적 시도를 매번 충돌하게 만들어서, 회의 시간이 늘어나는 결과를 가져왔다.
그럼에도 불구하고 모두가 타협과 협력을 바탕으로 노력했기에 잦은 수정이 있었음에도 설계 과정을 잘 마무리할 수 있었다.
프로젝트 중반에 프론트엔드 측에서 Git Merge 과정에서 발생한 병합 오류를 처리하다가, 백엔드가 작성한 코드가 Dev 브랜치에서 사라진 상황이 발생했다.
주말 동안 열심히 작업했던 작업물이 모두 사라져서 당혹스러운 순간이었지만, 침착하게 대처했다. 로컬에 저장되어 있던 최신 버전의 작업물을 개인 레포지토리에 푸시한 후, Dev 브랜치에 풀 리퀘스트를 보내어 코드를 성공적으로 복구할 수 있었다.
이 사건 이후로 백엔드와 프론트엔드는 각각의 브랜치에서 작업을 진행하였고, 이와 같은 문제는 다시 발생하지 않았다.
편지의 password 기능 구현 과정에서 보안 문제를 고려해야 했다. 편지 조회 시 회원과 비회원 구분이 없어서, 악의적인 사용자가 편지의 URL만 알면 비밀번호를 여러 번 시도할 수 있다는 문제가 있었다.
이에 AES256 암호화를 활용해 비밀번호를 암호화하는 시도를 했고, password 암호화 및 DB에서 확인까진 성공했다.
그러나 기존 설계했던 PasswordInputDto와 암호화된 비밀번호를 비교하는 로직이 제대로 동작하지 않았다.
뿐만 아니라 비밀번호 암호화 구현 과정에서 다른 기능에 문제가 발생해 작업이 어려워졌다. 구현 완료한 다른 기능에도 문제가 생겼고 복합적인 실행 오류가 발생하였던 상황이라 원하는 결과를 얻지 못했으며 시간적 여유가 줄어들어서, 호기심을 가지고 시도했던 편지 암호화 작업을 일단 뒤로 미루고 요구사항에 정의했던 기능을 우선순위로 구현을 하게 되었다.
사실 Batch 기능에 대해선 처음 접하는 것이었기에, 기본 개념부터 이론까지 전부 공부할 수 없었다.
그래서 우선 job과 step의 개념에 대해 그리고 Batch 실행 시 DB에 생성되는 테이블에 대해서 공부했고, 구글링 끝에 감사하게도 현재 나의 Batch 기능에 응용해서 사용해 볼 수 있을 것 같은 예제를 구하게 되어, Pre Project의 게시글에 일괄 삭제 하는 로직을 우선 적용해보고 성공 한 것을 확인했다.
그리고 이 경험을 바탕으로 프로젝트 중후반에는 MessageSaved가 7일 이상 false 상태인 데이터를 일괄 삭제하는 기능 역시 구현에 성공하게 되었다.
프로젝트 초기에 Batch 기능을 맡았고, 성공적으로 구현을 완료했다. 그러나 프로젝트에 "나에게 보내는 편지 기능"이라는 사용자가 원하는 날짜와 시간대에 편지가 자신에게 도착하는 기능도 구현할 예정이었다.
프로젝트의 마감이 다가오면서, 본래 이 기능을 구현할 팀원이 어려움을 겪게 되었다.
Message 기능을 내가 맡았던 관계로, 프로젝트 막바지에 다른 팀원이 내가 구현한 Message 관련 기능을 이해하는 것이 어려워졌기 때문이다.
이에 따라 결국 "나에게 보내는 편지 기능" 구현을 내가 맡게 되었으며, 사용자가 설정한 시간대에 OutgoingSaved_Store 상태를 어떻게 만들어야 할지 고민하게 되었다.
기존 설계에서는 사용자가 편지를 작성하자마자 기본적으로 OutgoingSaved_Store 상태가 되었기 때문이다. (OutgoingSaved_Store 상태에선 발신자의 발신함에서 자신이 작성한 편지 확인이 가능하다)
"나에게 보내는 편지" 기능을 위해 별도의 도메인을 추가로 만들어야 할지 고민했으나, 마감 기한이 다가오는 상황에서 설계를 수정하며 구현하기엔 리스크가 있었다.
결국 이 기능은 구현하지 못했는데, 이 역시 기능 구현에만 초점을 맞추다 보니 Batch에 대한 깊은 이해가 부족하여 발생한 일이라고 생각되었다. 이를 통해 팀원 간의 서로 협력하고 이해하는 것이 더욱 중요함을 깨달았다.
(끝내 구현하지 못한 예약 발송 기능)
Pre Project와 Main Project를 거치며, 나와 팀원들의 역량 또한 크게 성장한 것을 느낄 수 있었다. 팀원들을 곁에서 지켜봤을 때, 프로젝트 전과 비교해 엄청난 성장을 이루어 냈으며, 그걸 곁에서 지켜보며 항상 흐뭇함을 느꼈다.
짧다면 짧고 길다면 긴 프로젝트 기간 동안, 결과에 대한 성패를 떠나서 나 개인적으로도 많은 것을 얻었고 한 단계 성장 했다고 생각한다.
백엔드 개발자로서 앞으로 공부해야 할 방향성에 대해 알 수 있었고, 위기 상황마다 개의치 않고 선뜻 도움을 주신 분들과의 좋은 인연도 얻게 되었다. (앞으로도 이 인연이 쭉 지속되었으면 하는 나의 작은 바람이다.)
그리고 프로젝트 진행 과정에서 매번 부족함을 느꼈기에, 프로젝트가 끝난 후에도 계속해서 새로운 시도를 해보려고 노력 할 것이다.
다시 한번 함께 프로젝트를 진행한 팀원들에게 감사하며, 앞으로의 발전을 기대하고 모두 좋은 일만 있길 기도한다.
향후 개선
할 점으로는, 편지 password의 암호화를 AES256
을 사용하여 구현하고, “나에게 보내는 편지 기능” 역시 설계
단계부터 재검토하여 구현할 예정이다. 프로젝트를 진행하며 기능 구현에 급급하여 제대로 이해하지 못한 부분들에 대해서는 깊이 공부하여 기술적인 역량을 향상
시킬 생각이다.