Repository navigation
feat: AI 톤 교정 서비스 (#65 2/3) #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
110 changes: 110 additions & 0 deletions
110
src/main/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| package com.sniptext.domain.ai.service; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import com.sniptext.domain.ai.exception.AiErrorCode; | ||
| import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; | ||
| import com.sniptext.domain.ai.support.KeyInfoChecker; | ||
| import com.sniptext.domain.user.service.UserQueryService; | ||
| import com.sniptext.global.client.llm.LanguageModelClient; | ||
| import com.sniptext.global.client.llm.LanguageModelException; | ||
| import com.sniptext.global.client.llm.LanguageModelRequest; | ||
| import com.sniptext.global.client.llm.LanguageModelResult; | ||
| import com.sniptext.global.client.llm.LanguageModelTimeoutException; | ||
| import com.sniptext.global.exception.BusinessException; | ||
| import com.sniptext.global.policy.UsageLimits; | ||
| import com.sniptext.global.text.TextWhitespace; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * AI 톤 교정 (명세 22번). 1회를 먼저 잡고 언어 모델을 부른 뒤, 차단·외부 오류·시간 초과면 되돌린다. | ||
| * <p> | ||
| * 클래스·메서드 모두 트랜잭션을 걸지 않는다 ("명령 서비스는 쓰기 메서드마다 @Transactional" 규칙의 예외). | ||
| * 외부 호출(최대 20초) 동안 DB 커넥션을 쥐지 않도록, 선점·반환이 각자 짧은 트랜잭션으로 끝난다 | ||
| * (AiToneUsageCommandService는 호출자 트랜잭션 안에서 부르면 예외를 던진다). | ||
| * <p> | ||
| * 원문과 결과는 저장하지도 로그에 남기지도 않는다. 로그에는 회원 번호·문체·결과 종류·소요 시간·출력 토큰 수만 남긴다. | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class AiToneCorrectionCommandService { | ||
|
|
||
| /** 출력 토큰 상한 (비용 상한). 생각 토큰도 포함되며, 3000자 원문 보고/공문 교정이 4000에서는 잘렸다 (#64 확인) */ | ||
| static final int MAX_OUTPUT_TOKENS = 6000; | ||
|
|
||
| private final UserQueryService userQueryService; | ||
| private final AiToneUsageCommandService aiToneUsageCommandService; | ||
| private final LanguageModelClient languageModelClient; | ||
|
|
||
| public AiToneCorrectResult correct(AiToneCorrectCommand command) { | ||
| userQueryService.validateActive(command.userId()); | ||
| AiToneReserveResult reservation = aiToneUsageCommandService.reserve(command.userId()); | ||
|
|
||
| LanguageModelResult modelResult = complete(command, reservation); | ||
|
|
||
| String correctedText = TextWhitespace.strip(modelResult.text()); | ||
| return new AiToneCorrectResult(correctedText, command.style(), | ||
| KeyInfoChecker.compare(command.text(), correctedText), usage(reservation)); | ||
| } | ||
|
|
||
| /** 언어 모델을 부른다. 차단·외부 오류·시간 초과면 선점한 1회를 되돌리고 에러를 던진다. 반환은 이 세 경우에서만 한다. */ | ||
| private LanguageModelResult complete(AiToneCorrectCommand command, AiToneReserveResult reservation) { | ||
| LanguageModelRequest request = new LanguageModelRequest(ToneCorrectionPrompt.systemPrompt(command.style()), | ||
| ToneCorrectionPrompt.userMessage(command.text()), MAX_OUTPUT_TOKENS); | ||
| long startedAt = System.nanoTime(); | ||
| LanguageModelResult result; | ||
| try { | ||
| result = languageModelClient.complete(request); | ||
| } catch (LanguageModelTimeoutException e) { | ||
| logFailure(command, startedAt, e); | ||
| throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_TIMEOUT); | ||
| } catch (LanguageModelException e) { | ||
| logFailure(command, startedAt, e); | ||
| throw releaseAndFail(reservation, AiErrorCode.LANGUAGE_MODEL_FAILED); | ||
| } | ||
|
|
||
| boolean blocked = result.refused() || ToneCorrectionPrompt.isBlocked(result.text()); | ||
| log.info("AI 톤 교정 userId={} style={} outcome={} elapsedMs={} outputTokens={}", command.userId(), | ||
| command.style(), blocked ? "blocked" : "success", elapsedMillis(startedAt), result.outputTokens()); | ||
| if (blocked) { | ||
| throw releaseAndFail(reservation, AiErrorCode.INAPPROPRIATE_TEXT); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** 반환이 실패해도 원래 에러를 그대로 돌려준다. 로그에는 회원 번호·날짜와 예외 종류만 남긴다. */ | ||
| private BusinessException releaseAndFail(AiToneReserveResult reservation, AiErrorCode errorCode) { | ||
| try { | ||
| aiToneUsageCommandService.release(reservation); | ||
| } catch (RuntimeException e) { | ||
| log.error("AI 톤 교정 사용량 반환 실패 userId={} usageDate={} exception={}", reservation.userId(), | ||
| reservation.usageDate(), e.getClass().getSimpleName()); | ||
| } | ||
| return new BusinessException(errorCode); | ||
| } | ||
|
|
||
| // 언어 모델 예외 메시지는 상태 코드·오류 종류만 담는다 (원문·응답 본문 없음) | ||
| private static void logFailure(AiToneCorrectCommand command, long startedAt, RuntimeException e) { | ||
| log.warn("AI 톤 교정 실패 userId={} style={} elapsedMs={} reason={}", command.userId(), command.style(), | ||
| elapsedMillis(startedAt), e.getMessage()); | ||
| } | ||
|
|
||
| /** 다시 조회하지 않고 선점 결과로 만든다. 리셋 시각은 선점한 날짜의 다음 날 00:00(KST)이다. */ | ||
| private static AiToneUsageResult usage(AiToneReserveResult reservation) { | ||
| int limit = UsageLimits.AI_TONE_DAILY_LIMIT; | ||
| int usedCount = reservation.usedCountAfter(); | ||
| return new AiToneUsageResult(usedCount, limit, Math.max(limit - usedCount, 0), | ||
| reservation.usageDate().plusDays(1).atStartOfDay()); | ||
| } | ||
|
|
||
| private static long elapsedMillis(long startedAt) { | ||
| return (System.nanoTime() - startedAt) / 1_000_000; | ||
| } | ||
|
|
||
| } | ||
16 changes: 16 additions & 0 deletions
16
src/main/java/com/sniptext/domain/ai/service/dto/command/AiToneCorrectCommand.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.sniptext.domain.ai.service.dto.command; | ||
|
|
||
| import com.sniptext.domain.ai.entity.ToneStyle; | ||
|
|
||
| /** | ||
| * AI 톤 교정 입력. 원문은 요청 단계에서 형식 검증을 마친 그대로다. | ||
| * 원문은 저장·로그 금지 대상이라 toString에는 길이만 보인다. | ||
| */ | ||
| public record AiToneCorrectCommand(Long userId, ToneStyle style, String text) { | ||
|
|
||
| @Override | ||
| public String toString() { | ||
| return "AiToneCorrectCommand[userId=%d, style=%s, textLength=%d]".formatted(userId, style, text.length()); | ||
| } | ||
|
|
||
| } |
22 changes: 22 additions & 0 deletions
22
src/main/java/com/sniptext/domain/ai/service/dto/result/AiToneCorrectResult.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.sniptext.domain.ai.service.dto.result; | ||
|
|
||
| import com.sniptext.domain.ai.entity.ToneStyle; | ||
| import com.sniptext.domain.ai.support.KeyInfoDiff; | ||
|
|
||
| /** | ||
| * AI 톤 교정 결과. 교정 결과는 저장·로그 금지 대상이라 toString에는 길이만 보인다. | ||
| * | ||
| * @param correctedText 앞뒤 공백을 지운 교정 결과 | ||
| * @param keyInfoDiff 원문과 결과의 핵심 정보 차이 (차이가 없으면 두 목록이 빈 값) | ||
| * @param usage 선점 후 사용량 | ||
| */ | ||
| public record AiToneCorrectResult(String correctedText, ToneStyle style, KeyInfoDiff keyInfoDiff, | ||
| AiToneUsageResult usage) { | ||
|
|
||
| @Override | ||
| public String toString() { | ||
| return "AiToneCorrectResult[correctedTextLength=%d, style=%s, keyInfoDiff=%s, usage=%s]".formatted( | ||
| correctedText.length(), style, keyInfoDiff, usage); | ||
| } | ||
|
|
||
| } |
224 changes: 224 additions & 0 deletions
224
src/test/java/com/sniptext/domain/ai/service/AiToneCorrectionCommandServiceTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| package com.sniptext.domain.ai.service; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.BDDMockito.given; | ||
| import static org.mockito.BDDMockito.willThrow; | ||
| import static org.mockito.Mockito.lenient; | ||
| import static org.mockito.Mockito.never; | ||
| import static org.mockito.Mockito.verify; | ||
| import static org.mockito.Mockito.verifyNoInteractions; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.ValueSource; | ||
| import org.mockito.ArgumentCaptor; | ||
| import org.mockito.InjectMocks; | ||
| import org.mockito.Mock; | ||
| import org.mockito.junit.jupiter.MockitoExtension; | ||
|
|
||
| import com.sniptext.domain.ai.entity.ToneStyle; | ||
| import com.sniptext.domain.ai.exception.AiErrorCode; | ||
| import com.sniptext.domain.ai.service.dto.command.AiToneCorrectCommand; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneCorrectResult; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult; | ||
| import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; | ||
| import com.sniptext.domain.user.exception.UserErrorCode; | ||
| import com.sniptext.domain.user.service.UserQueryService; | ||
| import com.sniptext.global.client.llm.LanguageModelClient; | ||
| import com.sniptext.global.client.llm.LanguageModelException; | ||
| import com.sniptext.global.client.llm.LanguageModelRequest; | ||
| import com.sniptext.global.client.llm.LanguageModelResult; | ||
| import com.sniptext.global.client.llm.LanguageModelTimeoutException; | ||
| import com.sniptext.global.exception.BusinessException; | ||
|
|
||
| @ExtendWith(MockitoExtension.class) | ||
| class AiToneCorrectionCommandServiceTest { | ||
|
|
||
| private static final Long USER_ID = 7L; | ||
| private static final LocalDate USAGE_DATE = LocalDate.of(2026, 10, 11); | ||
| private static final String TEXT = "10월 14일까지 300만 원으로 진행 가능하다고 해줘"; | ||
| private static final AiToneCorrectCommand COMMAND = new AiToneCorrectCommand(USER_ID, ToneStyle.BOSS_EXTERNAL, TEXT); | ||
| private static final AiToneReserveResult RESERVATION = new AiToneReserveResult(USER_ID, USAGE_DATE, 2); | ||
|
|
||
| @Mock | ||
| private UserQueryService userQueryService; | ||
|
|
||
| @Mock | ||
| private AiToneUsageCommandService aiToneUsageCommandService; | ||
|
|
||
| @Mock | ||
| private LanguageModelClient languageModelClient; | ||
|
|
||
| @InjectMocks | ||
| private AiToneCorrectionCommandService service; | ||
|
|
||
| // 회원 확인에서 끝나는 테스트는 선점까지 가지 않으므로 lenient | ||
| @BeforeEach | ||
| void setUp() { | ||
| lenient().when(aiToneUsageCommandService.reserve(USER_ID)).thenReturn(RESERVATION); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("성공하면 앞뒤 공백을 지운 결과와 선점 결과로 만든 사용량을 돌려주고, 반환하지 않는다") | ||
| void success() { | ||
| givenModelReturns(" \n안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다. \n", false); | ||
|
|
||
| AiToneCorrectResult result = service.correct(COMMAND); | ||
|
|
||
| assertThat(result.correctedText()).isEqualTo("안녕하세요. 10월 14일까지 300만 원으로 진행 가능합니다.\n감사합니다."); | ||
| assertThat(result.style()).isEqualTo(ToneStyle.BOSS_EXTERNAL); | ||
| assertThat(result.keyInfoDiff().hasDifference()).isFalse(); | ||
| assertThat(result.usage()).isEqualTo(new AiToneUsageResult(2, 5, 3, LocalDateTime.of(2026, 10, 12, 0, 0))); | ||
| verify(aiToneUsageCommandService, never()).release(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("언어 모델에 문체 지시문, <text>로 감싼 원문, 출력 토큰 상한 6000을 보낸다") | ||
| void request() { | ||
| givenModelReturns("교정 결과", false); | ||
|
|
||
| service.correct(COMMAND); | ||
|
|
||
| ArgumentCaptor<LanguageModelRequest> captor = ArgumentCaptor.forClass(LanguageModelRequest.class); | ||
| verify(languageModelClient).complete(captor.capture()); | ||
| LanguageModelRequest request = captor.getValue(); | ||
| assertThat(request.systemPrompt()).isEqualTo(ToneCorrectionPrompt.systemPrompt(ToneStyle.BOSS_EXTERNAL)); | ||
| assertThat(request.userText()).isEqualTo(ToneCorrectionPrompt.userMessage(TEXT)); | ||
| assertThat(request.maxOutputTokens()).isEqualTo(6000); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("날짜·금액이 빠지거나 바뀌어도 정상 결과이고, 차이를 담으며 반환하지 않는다") | ||
| void keyInfoDifference() { | ||
| givenModelReturns("안녕하세요. 10월 15일까지 진행 가능합니다.", false); | ||
|
|
||
| AiToneCorrectResult result = service.correct(COMMAND); | ||
|
|
||
| assertThat(result.keyInfoDiff().missingValues()).containsExactly("10월 14일", "300만 원"); | ||
| assertThat(result.keyInfoDiff().addedValues()).containsExactly("10월 15일"); | ||
| verify(aiToneUsageCommandService, never()).release(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("없는 회원이면 USER404_1이고 선점·언어 모델 호출을 하지 않는다") | ||
| void missingUser() { | ||
| willThrow(new BusinessException(UserErrorCode.USER_NOT_FOUND)).given(userQueryService).validateActive(USER_ID); | ||
|
|
||
| assertErrorCode(UserErrorCode.USER_NOT_FOUND.getCode()); | ||
|
|
||
| verify(aiToneUsageCommandService, never()).reserve(any()); | ||
| verifyNoInteractions(languageModelClient); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("남은 횟수가 없으면 AI429_1이고 언어 모델을 부르지 않으며 반환하지 않는다") | ||
| void dailyLimitExceeded() { | ||
| given(aiToneUsageCommandService.reserve(USER_ID)) | ||
| .willThrow(new BusinessException(AiErrorCode.DAILY_LIMIT_EXCEEDED)); | ||
|
|
||
| assertErrorCode("AI429_1"); | ||
|
|
||
| verifyNoInteractions(languageModelClient); | ||
| verify(aiToneUsageCommandService, never()).release(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("모델이 거절(refused)하면 AI422_1이고 선점한 1회를 반환한다") | ||
| void refused() { | ||
| givenModelReturns("", true); | ||
|
|
||
| assertErrorCode("AI422_1"); | ||
|
|
||
| verify(aiToneUsageCommandService).release(RESERVATION); | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = {"[[SNIPTEXT_BLOCKED]]", " \n[[SNIPTEXT_BLOCKED]]\n ", "[[SNIPTEXT_BLOCKED]] "}) | ||
| @DisplayName("표식만 출력하면(앞뒤 공백 무시) AI422_1이고 선점한 1회를 반환한다") | ||
| void blockedMarker(String modelText) { | ||
| givenModelReturns(modelText, false); | ||
|
|
||
| assertErrorCode("AI422_1"); | ||
|
|
||
| verify(aiToneUsageCommandService).release(RESERVATION); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("외부 오류면 AI502_1이고 선점한 1회를 반환한다") | ||
| void languageModelFailed() { | ||
| given(languageModelClient.complete(any())).willThrow(new LanguageModelException("언어 모델 HTTP 오류: 529")); | ||
|
|
||
| assertErrorCode("AI502_1"); | ||
|
|
||
| verify(aiToneUsageCommandService).release(RESERVATION); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("응답 시간 초과면 AI504_1이고 선점한 1회를 반환한다") | ||
| void languageModelTimeout() { | ||
| given(languageModelClient.complete(any())).willThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")); | ||
|
|
||
| assertErrorCode("AI504_1"); | ||
|
|
||
| verify(aiToneUsageCommandService).release(RESERVATION); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("반환이 실패해도 원래 에러 코드를 그대로 던진다") | ||
| void releaseFails() { | ||
| given(languageModelClient.complete(any())).willThrow(new LanguageModelTimeoutException("언어 모델 응답 시간 초과")); | ||
| willThrow(new IllegalStateException("DB 오류")).given(aiToneUsageCommandService).release(RESERVATION); | ||
|
|
||
| assertErrorCode("AI504_1"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("그 밖의 예상 못 한 예외는 그대로 올라가고 반환하지 않는다") | ||
| void unexpectedException() { | ||
| given(languageModelClient.complete(any())).willThrow(new IllegalStateException("예상 못 한 오류")); | ||
|
|
||
| assertThatThrownBy(() -> service.correct(COMMAND)).isInstanceOf(IllegalStateException.class); | ||
|
|
||
| verify(aiToneUsageCommandService, never()).release(any()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("명령 객체의 toString에는 원문 대신 길이만 나온다") | ||
| void commandToStringHidesText() { | ||
| assertThat(COMMAND.toString()).doesNotContain(TEXT).contains("textLength=" + TEXT.length()); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("결과 객체의 toString에는 교정 결과와 핵심 정보 값이 나오지 않는다") | ||
| void resultToStringHidesText() { | ||
| givenModelReturns("안녕하세요. 10월 15일까지 진행 가능합니다.", false); | ||
|
|
||
| String described = service.correct(COMMAND).toString(); | ||
|
|
||
| assertThat(described).doesNotContain("안녕하세요", "10월 14일", "10월 15일", "300만 원"); | ||
| for (String expected : List.of("correctedTextLength=", "missing=2", "added=1")) { | ||
| assertThat(described).contains(expected); | ||
| } | ||
| } | ||
|
|
||
| private void givenModelReturns(String text, boolean refused) { | ||
| given(languageModelClient.complete(any())).willReturn(new LanguageModelResult(text, refused, 100)); | ||
| } | ||
|
|
||
| private void assertErrorCode(String code) { | ||
| assertThatThrownBy(() -> service.correct(COMMAND)) | ||
| .isInstanceOf(BusinessException.class) | ||
| .extracting(e -> ((BusinessException)e).getResultCode().getCode()) | ||
| .isEqualTo(code); | ||
| } | ||
|
|
||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 부분이 리뷰어가 중점으로 보면 좋을 부분인 것으로 이해했습니다 !
반환이 세 경우에서만 일어나는지
시간 초과, 외부 오류, 차단에서만 반환하는 것 확인했습니다.
그 밖의 예외로 500이 나면 1회가 차감되는데, 의도하신 거라면 명세 비고에 "500은 차감"을 적어 두면 좋을 것 같습니다
트랜잭션을 걸지 않은 판단
좋습니당 AI 호출할 때 트랜잭션이 걸려 있으면 DB 커넥션까지 잡혀 있게 되는 문제로 이해해서 적절한 것 같습니다 !
로그에 남기는 값
좋은 것 같아요 ! 원문·교정 결과는 빼고 필요한 부분만 잘 남기고 있는 것 같습니다 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
짚어 주셔서 감사해요! 확인해 보니 외부 호출 쪽 예외는 거의 다 외부 오류로 감싸져서, 1회를 잡은 뒤 500이 나는 건 사실상 서버 버그 때문이더라구요. 저희 버그로 사용자 횟수가 줄어드는 건 맞지 않는 것 같아서, 명세에 적는 대신 예상 못 한 예외가 나도 1회를 되돌리도록 고쳤습니다!
반환하는 곳을 correct()의 catch 한 곳으로 모아서 이중 반환도 생기지 않게 했어요. 스택 구조라 수정은 #87에 올렸습니다 (8501137). 명세 22번 비고에도 "서버 내부 오류(500)도 미차감"으로 추가했어요!