현재 진행 중인 프로젝트에서 사용자에게 편의시설에 대한 제보를 받는 기능을 구현해야 했다.
모든 제보에 대해 1차적으로 내용을 검토할 수 있는 방법이 어떤 게 있을지 생각했었다.
제보가 하루에도 엄청 많이 들어오고 검토할 사람이 많으면 1번이 가장 이상적인 방법이지만
시범 적용을 위한 단계라서 시간과 비용을 아끼기 위해 3번 방법을 선택했다.
제보 요청을 보내면 바로 처리되는 것이 아니라 1차로 개발자의 검토를 거치는 방법을 선택했기 때문에 DB에 저장은 해야한다.
데이터의 입력,출력이 잦고 중요도가 낮은 1회성(?) 데이터를 저장하기 좋은 임시 저장소의 느낌이 강한 in-memory DB인 Redis를 선택해서 기존 DB의 부담을 덜어주기로 했다.
제보 처리 흐름
- 사용자가 제보 요청을 보냄
- 서버 관리자(나)에게 제보 내용을 담아 Slack으로 알림을 전송
- 제보는 Redis에 임시로 저장
- 서버 관리자가 Slack 메시지를 확인하고 제보를 수락/거절하는 메시지를 양식을 맞춰 보냄 ex) {수락} {사용자ID}
- 서버가 Redis에서 제보를 조회 및 삭제
- 수락/거절 여부에 따라 제보 내용을 DB에 반영
Redis는 다양한 데이터 타입을 저장할 수 있는데, 정렬된 상태를 유지할 수 있는 Sorted Set을 사용하기로 했다.
(지원하는 데이터 종류)
ZSet은 key - value(member, score)의 형태로 저장이 된다.
- member: 저장하고 싶은 객체
- score: 정렬의 기준이 되는 값
나는 key값을 사용자 ID, member에 제보 DTO를 저장하고 score는 제보 시간을 저장하기로 했다.
ZSet 명령어 종류를 참고해서 어떤 종류의 명령이 있는지 확인했다.
위의 방법으로 활용할 수 있겠다고 생각했다.
사용자가 제보한 내역을 확인할 수 있는 기능이 필요해지면 ZRANGE를 사용하겠지만 현재 상황에서는 필요가 없다고 생각했다.
제보를 한번 조회하고 나면 더이상 저장할 필요가 없기 때문에 ZPOP 명령어를 사용하기로 했다.
build.gradle에 Redis 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yml에 host와 포트 번호를 추가한다.
spring:
data:
redis:
host: localhost //배포 시 docker 컨테이너 명으로 수정
port: 6379
RedisConfig
클래스에 설정을 추가한다.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<?,?> redisTemplate() {
RedisTemplate<String, FacilityReport> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setDefaultSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
}
객체를 Redis에 저장할 때 직렬화를 해줘야 하는데, 여러가지 직렬화 방법이 존재한다.
그 중 모든 객체를 추가 설정 없이 직렬화 할 수 있는 GenericJackson2JsonRedisSerializer
를 선택했다.
객체를 저장할 때 패키지의 전체 경로를 포함해 저장하기 때문에 패키지의 구조가 바뀔 경우 문제가 생길 수 있고,
LocalDateTime과 같이 비교적 일반적인 타입을 지원하지 않는 것 같아 objectMapper로 커스텀을 해줘야한다.
key는 String으로 저장할 것이기 때문에 key 직렬화는 StringRedisSerializer
를 사용하도록 설정했고, 나머지(default, value, hashvalue)는 GenericJackson2JsonRedisSerializer
를 사용하도록 설정했다.
FacilityReportRepository
인터페이스 추가
public interface FacilityReportRepository {
void save(String key, FacilityReport report);
FacilityReport find(String key);
}
FacilityReportRepositoryImpl
에 저장, 조회 로직을 구현한다. expireAt() 메소드로 제보 요청은 최대 일주일동안 저장이 되도록 설정했다.
@Repository
@RequiredArgsConstructor
public class FacilityReportRepositoryImpl implements FacilityReportRepository {
private final RedisTemplate<String,FacilityReport> redisTemplate;
private final static int REPORT_EXPIRATION_DAYS = 7;
@Override
public void save(String key, FacilityReport report) {
ZSetOperations<String, FacilityReport> zSetOps = redisTemplate.opsForZSet();
Date expirationDate = Date.from(now().plus(REPORT_EXPIRATION_DAYS, DAYS));
zSetOps.add(key, report, currentTimeMillis());
redisTemplate.expireAt(key, expirationDate);
}
@Override
public FacilityReport find(String key) {
ZSetOperations<String, FacilityReport> zSetOps = redisTemplate.opsForZSet();
return Optional.ofNullable(zSetOps.popMin(key))
.orElseThrow(() -> new NoSuchElementException(NO_SUCH_FACILITY_REPORT))
.getValue();
}
}
ZADD에 필요한 매개변수(Key, member, score)를 입력해 저장하고, ZPOPMIN으로 가장 오래된 value를 가져옴과 동시에 삭제한다.
조회 결과는 TypedTuple
인데, getValue()로 member에 접근할 수 있고 getScore()로 score를 확인할 수 있다.
편의시설 생성 제보와 수정 제보를 요청받기 위해 controller에 코드를 추가했다.
@PostMapping("/reports")
public ResponseEntity<Void> reportCreation(
@RequestPart(value = "facilityReport") FacilityReportRequest facilityReportRequest,
@RequestPart(value = "imageFile", required = false) MultipartFile multipartFile,
@Auth UserInfo userInfo
) throws IOException {
facilityReportService.reportCreation(facilityReportRequest, multipartFile, userInfo);
return ResponseEntity.noContent().build();
}
@PutMapping("/reports/{facilityId}")
public ResponseEntity<Void> reportModification(
@PathVariable final Long facilityId,
@RequestPart(value = "facilityReport") FacilityReportRequest facilityReportRequest,
@RequestPart(value = "imageFile", required = false) MultipartFile multipartFile,
@Auth UserInfo userInfo
) throws IOException {
facilityReportService.reportModification(facilityId, facilityReportRequest, multipartFile, userInfo);
return ResponseEntity.noContent().build();
}
@RequestPart
로 MultipartFile을 전달받고, Request DTO도 따로 전달받는다. @Auth는 ArgumentResolver로 사용자 인증 정보를 가져오도록 설정한 커스텀 어노테이션이다.
편의시설 생성 제보와 수정 제보에 대한 처리를 따로 구현했다. 다음 과정은 동일하지만 수정 제보는 기존 편의시설의 정보를 함께 보여주면 내 입장에서 편할 것 같아서 기존 정보를 불러오는 과정까지 추가했다.
- MultipartFile을 S3에 업로드 후 링크를 반환받는다.
- Redis에 저장할 FacilityReport 객체를 팩토리 메소드로 생성한다.
- 사용자ID로 Redis Key를 생성하고 FacilityReport를 저장한다.
- Slack 메시지를 전송한다.
public void reportCreation(FacilityReportRequest request, MultipartFile file, UserInfo userInfo) throws IOException {
Long userId = userInfo.userId();
User user = userRepository.findById(userId)
.orElseThrow(() -> new NoSuchElementException(NO_SUCH_USER_ACCOUNT));
String imageUrl = isFileValid(file) ? s3Service.saveImage(file, userId) : null;
FacilityReport facilityReport = FacilityReport.from(request, imageUrl);
facilityReportRepository.save(generateKey(userId), facilityReport);
sendMessage(FacilityCreationReport.from(facilityReport), user.getName(), userId);
}
build.gradle에 Slack API client 의존성을 추가한다.
implementation 'com.slack.api:slack-api-client:1.30.0'
그 다음 Slack에 들어가 제보를 받기 위한 채널을 생성한 다음 앱 -> 앱 추가를 클릭한다.
Incoming WebHooks 앱을 클릭 후 추가한다.
Slack App Directory 페이지로 연결이 되는데, 구성 -> 통합 앱 설정에서 채널을 설정해주고 Webhook URL을 복사한다.
복사한 URL은 application.yml에 추가해 메시지를 보낼 때 사용할 수 있도록 한다.
webhook:
slack:
url: ${WEBHOOK_SLACK_URL}
Spring Boot에서 Slack으로 메시지를 보내는 방법은 참고할만한 블로그가 많았다.
우선 Service의 상단에 Slack 관련 필드를 추가한다.
@Value("${webhook.slack.url}")
private String slackUrl;
private final Slack slackClient = Slack.getInstance();
Slack API Client로 메시지를 전송하는 send() 메소드의 동작 방식을 살펴보면 Webhook URL과 Payload를 추가해야한다.
Payload는 PayloadBuilder를 사용해 생성할 수 있는데, text
와 attachments
를 채워야한다.
text는 아래 사진에 보이는 메시지의 제목이다. 나는 사용자명과 사용자ID를 제목에 보이도록 구성했다.
attachments에는 메시지의 본문을 담는데, List<Attachment>
의 형태로 추가해야한다.
Attachment
도 마찬가지로 Builder를 사용해 color
, fields
를 설정할 수 있다.
제보의 종류에 따라 다른 메시지를 보내고 메시지 색상도 다르게 하고싶어서 아래와 같은 인터페이스를 추가했다.
그리고 생성 제보와 수정 제보를 받았을 때 Slack 메시지로 전달하고싶은 내용을 담은 클래스를 각각 생성하고 해당 인터페이스를 구현했다.
Field를 생성할 때 key(title)와 value가 필요하기 때문에 제보 내용을 Map으로 변환하는 메소드를 추가했다.
LinkedHashMap
을 사용해 메시지에 담기는 필드의 순서가 유지되도록 만들었다.
public interface FacilityReportConvertible {
String getTitle(String userName, Long userId);
String getMessageColor();
LinkedHashMap<String,String> toMap();
}
sendMessage() 메소드에서 앞서 생성한 FacilityReportConvertible을 사용해 Payload를 생성한다.
private void sendMessage(FacilityReportConvertible report, String userName, Long userId) throws IOException {
slackClient.send(slackUrl, payload(p -> p
.text(report.getTitle(userName, userId))
.attachments(List.of(Attachment.builder()
.color(report.getMessageColor())
.fields(report.toMap().entrySet().stream()
.map(this::generateSlackField)
.toList()
)
.build()
))
));
}
private Field generateSlackField(Map.Entry<String,String> entry) {
return Field.builder()
.title(entry.getKey())
.value(entry.getValue())
.valueShortEnough(false)
.build();
}
Postman을 사용해 편의시설 생성 제보, 수정 제보를 각각 보내보았다.
Postman에서 @RequestPart에 매핑되는 변수를 전달할 때 Request DTO는 Content-Type을 application/json으로 지정해주고 JSON 형태로 보내야한다. ex) {"ID":"123"}
사용자 인증 정보가 필요하기 때문에 소셜 로그인 이후 반환받은 Access토큰을 Auth에 추가한다.
그리고 Body에 형식에 맞게 값을 입력 후 전송을 해보면 다음과 같이 메시지를 성공적으로 수신하는 것을 확인할 수 있다.
RedisInsight
라는 GUI 프로그램에서 제대로 저장이 되었는지 확인해봤다.
userId가 1인 key에 제보 내용과 생성 일자가 제대로 member, score로 저장이 된 것을 확인했다.
제보를 제대로 받았으니 검토를 완료하면 제보를 반영하거나 제거할 수 있어야한다.
채널에 trigger-word를 포함한 메시지를 전송하면 서버에 해당 메시지가 Webhook으로 전달이 되고, 서버에서 메시지 내용을 분석 후 알맞은 기능을 실행하는 것을 생각하고 실행에 옮겼다.
워크스페이스 -> 앱 설정에서 outgoing webhook을 검색해서 같은 방법으로 추가해주고
Slack App Directory 페이지로 연결이 되는데, 구성 -> 통합 앱 설정에서 채널과 트리거 단어를 설정한다.
나는 {수락/거절} {제보자ID} {제보 순서}
의 양식으로 메시지를 보내는 것을 생각했는데, 제보의 순서를 일일이 확인하기 귀찮을 것 같아서 제일 먼저 들어온 제보를 우선적으로 처리하는 방법을 선택했다.
그래서 ZPOPMIN 명령을 사용한 것도 있다.!!
URL에는 Webhook이 동작했을 때 POST로 메시지를 전달받기 위한 서버의 API 엔드포인트를 입력한다.
아래로 내려보면 토큰, 설명 라벨, 이름, 아이콘 등을 설정할 수 있다.
만약 배포 URL이 없고 local환경에서 테스트하는 경우 ngrok와 같은 도구를 사용해 localhost에 외부에서 접속할 수 있게 해야한다.
토큰은 POST 요청을 보낼 때 발신 데이터(body)에 포함이 되는 것 같은데 보안을 신경 쓸 필요가 있다면 해당 토큰을 검증하는 과정을 추가 할 수 있을 것 같다.
Webhook으로 POST 요청을 받을 수 있는 경로를 추가해줬다. DTO를 만들어 발신 데이터가 자동으로 매핑되길 원했는데 잘 되지 않았다.
그래서 Map으로 데이터를 매핑하고 text를 직접 뽑아냈다.
@PostMapping("/reports/verified")
public ResponseEntity<SlackResponse> applyReport(@RequestParam Map<String,String> message) {
SlackMessage messageDto = SlackMessage.from(message);
return ResponseEntity.ok(facilityReportService.handleFacilityReport(messageDto));
}
public record SlackMessage(
String operation,
Long userId
) {
public static SlackMessage from(Map<String,String> message) {
String[] text = message.get("text").split(" ");
return new SlackMessage(
text[0],
Long.parseLong(text[1])
);
}
}
응답 메시지(서버->Slack채널)는 다음과 같이 만들고 ResponseEntity에 담아 보내니 채널에 답변이 제대로 갔다.
public record SlackResponse(
String text
) {
public static SlackResponse of(String text) {
return new SlackResponse(text);
}
}
Service에 메시지 내용을 토대로 핸들링 하는 메소드를 추가했다.
승인/거절 여부에 상관 없이 서버 관리자가 응답을 했으므로 검토가 완료된 제보로 간주하고 Redis에서 POP을 한다.
public SlackResponse handleFacilityReport(SlackMessage message) {
if(message.isValid()) {
FacilityReport facilityReport = facilityReportRepository.find(generateKey(message.userId()));
if(message.isAccept())
saveOrUpdateFacility(facilityReport);
return SlackResponse.of(message.operation() + DONE);
}
return SlackResponse.of(GUIDE);
}
FacilityReport의 정보를 활용해 새로운 편의시설을 저장하고 기존 편의시설의 필드를 추가/수정하는 기능을 구현했다.
처음 보낸 생성 제보를 거절해 보았다. 응답이 제대로 오고 Redis를 확인해보니 ZPOPMIN이 제대로 동작한 것 같다.
마지막으로 사진을 추가하는 수정 제보를 만들기 위해 사진만 포함한 요청을 보낸 다음 수락을 해보았다.
편의시설을 조회해본 결과 수정 사항이 제대로 반영되어 있다.