feat: 시세 자동조회·퇴직연금 평가액·게시판 카테고리·태그 정렬·계좌번호 암호화

- 투자: 종목코드로 현재가 시세 자동조회(StockQuoteService, 투자탭/포트폴리오 진입 시 갱신)
- 투자: 퇴직연금 등 평가액 직접입력형 계좌(currentValue 활성화, manualValuation)
- 게시판: community/saving/tips 카테고리 분리(post.category + 목록 필터)
- 태그: 순서 변경(account_tag.sort_order, /tags/reorder)
- 보안: 계좌번호 AES-256-GCM 암호화 저장/복호화(EncryptedStringTypeHandler, account_number VARCHAR(255))
  키는 서버 .env ACCOUNT_CRYPTO_KEY 로 주입(미설정 시 평문 통과)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-04 05:00:18 +09:00
co-authored by Claude Opus 4.8
parent 8fec057752
commit fb94df8112
24 changed files with 401 additions and 29 deletions
@@ -8,6 +8,7 @@ import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
import com.sb.web.account.dto.ConfirmRequest;
import com.sb.web.account.dto.NotificationRequest;
import com.sb.web.account.dto.TagReorderRequest;
import com.sb.web.account.dto.NetWorthPoint;
import com.sb.web.account.dto.NetWorthResponse;
import com.sb.web.account.dto.PeriodStat;
@@ -135,6 +136,14 @@ public class AccountController {
return ResponseEntity.noContent().build();
}
/** 태그 순서 변경 */
@PutMapping("/tags/reorder")
public List<AccountTagResponse> reorderTags(
@RequestBody TagReorderRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return accountService.reorderTags(current.getId(), req.getIds());
}
@GetMapping("/entries")
public List<AccountEntryResponse> list(
@RequestParam(required = false) Integer year,
@@ -46,6 +46,22 @@ public class InvestController {
return ResponseEntity.status(HttpStatus.CREATED).body(investService.createHolding(req, current.getId()));
}
/** 종목코드로 현재가 시세를 일괄 갱신 */
@PostMapping("/holdings/refresh-prices")
public List<HoldingResponse> refreshPrices(
@RequestParam Long walletId,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return investService.refreshPrices(current.getId(), walletId);
}
/** 모든 투자계좌 보유종목 현재가 일괄 갱신(계좌 목록 평가액 반영용) */
@PostMapping("/refresh-prices")
public ResponseEntity<Void> refreshAllPrices(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
investService.refreshAllPrices(current.getId());
return ResponseEntity.noContent().build();
}
@PutMapping("/holdings/{id}")
public HoldingResponse updateHolding(
@PathVariable Long id,
@@ -20,5 +20,6 @@ public class AccountTag implements Serializable {
private Long id;
private Long memberId;
private String name;
private Integer sortOrder;
private LocalDateTime createdAt;
}
@@ -0,0 +1,14 @@
package com.sb.web.account.dto;
import lombok.Data;
import java.util.List;
/**
* 태그 순서 변경 요청. ids 순서대로 sort_order 를 재설정한다.
*/
@Data
public class TagReorderRequest {
private List<Long> ids; // 새 순서의 태그 id 목록
}
@@ -28,6 +28,8 @@ public class WalletResponse {
private Long deposit; // 예수금(미투자 현금) = 투입원금 + 매도 − 매수
private Long stockValue; // 주식 평가금액
private Long valuationGain; // 총손익(실현+평가) = 총평가 − 투입원금
private Long currentValue; // 평가액 직접입력값(퇴직연금·연금 등 수동 갱신). 있으면 총평가로 사용
private Boolean manualValuation; // true면 평가액 직접입력형(종목 자동계산 대신)
public static WalletResponse from(Wallet w, long balance) {
return WalletResponse.builder()
@@ -40,6 +42,7 @@ public class WalletResponse {
.openingBalance(w.getOpeningBalance())
.openingDate(w.getOpeningDate())
.balance(balance)
.currentValue(w.getCurrentValue())
.build();
}
}
@@ -24,6 +24,10 @@ public interface AccountTagMapper {
int update(AccountTag tag);
int updateSortOrder(@Param("id") Long id, @Param("memberId") Long memberId, @Param("sortOrder") int sortOrder);
Integer maxSortOrder(@Param("memberId") Long memberId);
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
/** 주어진 ID 중 해당 사용자 소유로 실제 존재하는 태그 ID만 반환 */
@@ -332,12 +332,24 @@ public class AccountService {
if ("INVEST".equals(w.getType())) {
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
long deposit = cash + wi.cashDelta; // 예수금
long total = deposit + wi.stockEval; // 총평가
r.setBalance(total);
r.setInvestedAmount(cash); // 순입금(투입원금)
r.setDeposit(deposit);
r.setStockValue(wi.stockEval);
r.setValuationGain(total - cash); // 실현+평가 손익
if (w.getCurrentValue() != null) {
// 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 총평가로 사용
long total = w.getCurrentValue();
r.setBalance(total);
r.setInvestedAmount(cash); // 누적 적립원금(순입금)
r.setDeposit(deposit);
r.setStockValue(total - deposit);
r.setValuationGain(total - cash);
r.setManualValuation(true);
} else {
long total = deposit + wi.stockEval; // 총평가
r.setBalance(total);
r.setInvestedAmount(cash); // 순입금(투입원금)
r.setDeposit(deposit);
r.setStockValue(wi.stockEval);
r.setValuationGain(total - cash); // 실현+평가 손익
r.setManualValuation(false);
}
}
return r;
})
@@ -353,9 +365,14 @@ public class AccountService {
for (Wallet w : walletMapper.findByMember(memberId)) {
long bal = balances.getOrDefault(w.getId(), 0L);
if ("INVEST".equals(w.getType())) {
// 투자: 예수금(현금+매매증감) + 주식 평가금액
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
assets += bal + wi.cashDelta + wi.stockEval;
if (w.getCurrentValue() != null) {
// 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 자산으로
assets += w.getCurrentValue();
} else {
// 투자: 예수금(현금+매매증감) + 주식 평가금액
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
assets += bal + wi.cashDelta + wi.stockEval;
}
} else if ("BANK".equals(w.getType())) {
assets += bal;
} else {
@@ -490,11 +507,28 @@ public class AccountService {
if (tagMapper.countByName(memberId, name, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다.");
}
AccountTag tag = AccountTag.builder().memberId(memberId).name(name).build();
Integer max = tagMapper.maxSortOrder(memberId);
AccountTag tag = AccountTag.builder()
.memberId(memberId)
.name(name)
.sortOrder(max == null ? 0 : max + 1)
.build();
tagMapper.insert(tag);
return AccountTagResponse.from(tag);
}
/** 태그 순서 변경 — ids 순서대로 sort_order 재설정 */
@Transactional
public List<AccountTagResponse> reorderTags(Long memberId, List<Long> ids) {
int i = 0;
for (Long id : ids) {
if (tagMapper.findByIdAndMember(id, memberId) != null) {
tagMapper.updateSortOrder(id, memberId, i++);
}
}
return listTags(memberId);
}
@Transactional
public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) {
AccountTag tag = mustFindTag(id, memberId);
@@ -31,6 +31,7 @@ public class InvestService {
private final InvestMapper investMapper;
private final WalletMapper walletMapper;
private final StockQuoteService stockQuoteService;
/* ===================== 보유 종목 ===================== */
@@ -73,6 +74,37 @@ public class InvestService {
investMapper.deleteHolding(id, memberId);
}
/** 종목코드가 있는 보유종목의 현재가를 시세 조회로 갱신. 조회 성공한 종목만 반영 */
@Transactional
public List<HoldingResponse> refreshPrices(Long memberId, Long walletId) {
requireInvestWallet(walletId, memberId);
Map<String, Long> cache = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByWallet(memberId, walletId)) {
applyQuote(h, cache);
}
return listHoldings(memberId, walletId);
}
/** 회원의 모든 투자계좌 보유종목 현재가를 일괄 갱신(계좌 목록 평가액 반영용) */
@Transactional
public void refreshAllPrices(Long memberId) {
Map<String, Long> cache = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
applyQuote(h, cache);
}
}
/** 종목코드로 시세를 받아 현재가 갱신(동일 종목코드는 호출당 1회만 조회) */
private void applyQuote(InvestHolding h, Map<String, Long> cache) {
String ticker = h.getTicker();
if (ticker == null || ticker.isBlank()) return;
Long price = cache.computeIfAbsent(ticker.trim(), stockQuoteService::getDomesticPrice);
if (price != null && price > 0) {
h.setCurrentPrice(price);
investMapper.updateHolding(h);
}
}
/* ===================== 매매 ===================== */
public List<TradeResponse> listTrades(Long holdingId, Long memberId) {
@@ -0,0 +1,43 @@
package com.sb.web.account.service;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
/**
* 국내 상장 종목 현재가(시세) 조회. 네이버 금융 폴링 API 사용(무인증).
* 종목코드(6자리)로 현재가를 가져온다. 실패해도 예외를 던지지 않고 null 반환.
*/
@Slf4j
@Service
public class StockQuoteService {
private static final String URL =
"https://polling.finance.naver.com/api/realtime/domestic/stock/{code}";
private final RestClient restClient = RestClient.builder().build();
/** 국내 상장 종목 현재가(원). 조회 실패/미상장/코드없음이면 null */
public Long getDomesticPrice(String ticker) {
if (ticker == null || ticker.isBlank()) return null;
String code = ticker.trim();
try {
JsonNode root = restClient.get()
.uri(URL, code)
.header("User-Agent", "Mozilla/5.0")
.retrieve()
.body(JsonNode.class);
if (root == null) return null;
JsonNode datas = root.path("datas");
if (!datas.isArray() || datas.isEmpty()) return null;
String priceStr = datas.get(0).path("closePrice").asText(null);
if (priceStr == null || priceStr.isBlank()) return null;
String digits = priceStr.replaceAll("[^0-9]", "");
return digits.isEmpty() ? null : Long.parseLong(digits);
} catch (Exception e) {
log.warn("시세 조회 실패 ticker={}: {}", code, e.toString());
return null;
}
}
}