전체 글

13개의 글

채팅 프로그램 - 2

React 채팅 프로젝트를 Electron 데스크톱 앱으로 실행 React와 Vite로 만든 채팅 프로젝트를 Electron으로 감싸 데스크톱 창에서 실행해 봤다. 프론트엔드 폴더에서 Electron을 설치했다. npm install -D electron package.json에는 진입점과 실행 명령을 추가했다. 기존 스크립트는 그대로 유지했다. { "main": "electron/main.cjs", "scripts": { "electron": "electron ." } } main.cjs 파일을 생성했다. const { app, BrowserWindow, Menu } = require('electron') function createWindow() { const window = new BrowserWindow({ width: 1200, height: 800, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }, }) window.loadURL('http://localhost:5173') } app.whenReady().then(() => { Menu.setApplicationMenu(null) createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) Vite 개발 서버를 연 후 npm run electron를 터미널에 입력해서 프로그램을 실행했다. 결과물 디스코드처럼 프로그램으로 실행되고, 로컬에서 테스트 했을 때 정상적으로 작동한다. (디스코드도 일렉트론을 사용한다) 서버 배포 후 자동 업데이트를 구현해보고 싶지만 남아있는 서버가 없다. 방식은 깃허브 릴리즈 버전을 확인해서 버전이 다를 시 업데이트 되게 할 수 있을 것 같다. 실행파일 설치파일

채팅 프로그램 - 1

프로그램 구조 React, Spring, Websocket, LiveKit을 이용해서 HTTP -> Spring : 로그인, 방 생성, 음성에 참여하는 토큰 생성 WebSocket -> Spring : 실시간 메시지 LiveKit : 실시간 음성 ERD WebSocket 로그인 후 웹소켓 연결 Spring Security의 서버 세션 방식으로 로그인을 처리한다. 로그인에 성공하면 서버는 인증 정보를 HttpSession에 보관하고, 브라우저에는 세션을 식별하는 JSESSIONID쿠키를 전달한다. HttpSession에 로그인 정보를 보관하고 소켓 속성에 저장 후 소켓을 생성한다. 메시지 전달 Spring 서버의 /ws/chat에 WebSocket으로 연결한다. 메시지를 보낼 때 대상 방의 roomId를 전달한다. 발신자가 해당 방의 참여자인지 확인하고 메시지를 저장한다. 저장이 완료되면 방 참여자들에게 전달할 이벤트를 발생시킨다. 연결 유지 서버는 PING을 포함한 메시지를 받을 때 소켓에 연결된 HTTP 세션의 인증 정보를 확인한다. 인증 정보가 없거나 세션이 무효화됐다면 연결을 종료한다. LiveKit을 사용한 음성채팅 음성채팅은 일반 채팅 WebSocket과 별개로 LiveKit 서버에 연결한다. LiveKit 접속에는 별도의 JWT를 사용한다. 결과물 로그인 웹소켓 채팅 음성 채팅 일렉트론으로 묶어서 프로그램으로 만들 예정이다.

EMR 프로젝트 - 6

추가한 기능 날짜별 진료 완료 환자 조회 수납 금액 직접 입력 현금, 카드 결제수단 기록 미수납, 수납완료 표시 추가된 테이블 payments id - 기본키 encounter_id - 진료 ID amount - 금액 method - 결제수단 received_by_id - 수납 처리 직원 paid_at - 수납 처리 시각 결과물 수납 전 Sping의 역할 분리를 통해 admin과 간호사 일 때만 수납 관리가 활성화 되게 만들었다. 수납 후

EMR 프로젝트 - 5

이번에 추가한 기능 약품 검색 및 처방: 약품 코드 데이터를 불러오고, 약품 검색 후 투여량, 횟수, 일수, 용법을 입력해 처방 저장 검사, 처치 요청: 진료 중 검사나 처치 항목과 요청 사항 등록 수행 상태 및 결과 관리: 요청 -> 수행 중 -> 완료 상태 관리, 결과 입력과 요청 의사의 확인 환자 과거 기록 조회: 이전 진료의 SOAP, 진단, 처방과 검사, 처치 기록 확인 추가된 테이블 medications : 약품 기준 정보 encounter_prescriptions : 진료별 처방 내역(투여량, 단위, 횟수 등) order_items : 검사, 처치 항목 정보 clinical_orders : 검사, 처치 요청 및 결과 결과물

EMR 프로젝트 - 4

이번에 추가한 기능 기존에는 진료기록을 하나의 note 필드에 자유롭게 작성했다. 이번에는 기록을 목적에 따라 구분할 수 있도록 SOAP 형식으로 변경했다. 접수할 때 입력한 방문 사유인 reason은 별도로 유지하고, 필요하면 S 항목으로 가져올 수 있도록 했다. 상병코드를 검색해 해당 진료의 진단으로 등록할 수 있도록 했다. 병원의 당일 운영 현황을 한눈에 확인할 수 있도록 대시보드를 구현했다. 대기 환자는 접수 순서대로 최대 6명까지 보여주고, 대기 시간이 30분 이상이면 강조했다. 오늘 미접수 예약도 시간순으로 최대 6건까지 표시하며, 예약 시간이 지난 경우 ‘시간 경과’로 구분했다. 추가된 테이블 diagnosis_codes : 진단 선택에 사용하는 상병코드 기준 목록 diagnosis_terms : 하나의 코드에 연결된 여러 명칭 보관 encounter_diagnoses : 특정 진료에서 선택한 진단 보관 결과물 진단코드, 의약품코드 주소 건강보험심사평가원_약가마스터_의약품표준코드_20260831 | 공공데이터포털 (건강보험심사평가원) 한국표준질병사인분류(KCD) 9차 개정에 따른 상병마스터 및 질병코드 모니터링 지표목록 반영 결과 안내 > 공지사항 | 대한보건의료정보관리사협회

EMR 프로젝트 - 3

ERD EMR 프로젝트의 기본 흐름인 환자 등록 -> 예약 또는 당일 접수 -> 진료 대기 -> 진료 완료를 구현했다. 데이터 처리 동시 요청으로 중복 접수가 발생하지 않도록 트랜잭션과 잠금 처리를 적용했다. 예약과 진료기록 수정에는 버전 검사를 사용해 다른 사용자의 변경 내용을 덮어쓰지 않도록 했다. @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select p from Patient p where p.id = :id") Optional<Patient> findLockedById(@Param("id") Long id); @Transactional public EncounterView register(Registration input) { Patient patient = lockedPatient(input.patientId()); if (encounters.existsByPatientIdAndStatusIn( patient.getId(), List.of(Encounter.Status.WAITING, Encounter.Status.IN_PROGRESS))) { throw new IllegalStateException("이미 대기 또는 진료 중인 접수가 있습니다."); } 구현 환자 관리 환자 목록과 상세정보를 좌우로 나누고, 목록에서 환자를 선택하면 정보를 조회하거나 수정할 수 있도록 구성했다. 기본 인적사항에 더해 알레르기, 과거력, 기저질환, 특이사항을 저장하도록 했다. 편의성을 위해 환자 선택 시 예약 등록, 당일 접수 기능을 같이 사용할 수 있도록 구성했다. 예약관리 예약 관리에서는 날짜별 예약 조회와 날짜, 시간, 담당 의사, 사유 변경, 예약 취소를 지원한다. 예약은 30분 단위이며, 같은 시간에 동일 환자나 담당 의사의 예약이 중복되지 않도록 검사한다. 이미 접수했거나 취소한 예약은 수정할 수 없도록 처리했다. 예약은 방문 예정 정보이고, 접수는 환자가 실제로 방문한 기록이므로 appointments와 encounters 테이블을 분리했다. 등록 후 별도로 접수해야 진료 대기 목록에 들어간다. 진료 관리 진료 관리에서는 접수 시각이 빠른 순서로 환자를 보여주고, 접수 시각과 대기 시간을 표시한다. 기본 목록에는 대기, 진료 중인 환자만 표시하며, 완료된 기록은 별도 필터로 조회한다. 추가할 내용 진단명 등록, 약 처방, 의사가 환자에게 검사 요청과 결과, 처치 및 수납까지 추가하고 마무리할 예정이다.

티켓 예매 사이트 - 5

JPA N+1 문제 해결 내 예매 내역을 조회하는 단순한 API에서 콘솔창에 SQL 쿼리가 20개 이상 쏟아지는 현상을 발견했다. Fetch Join을 사용해 해결했으며, 혹시 몰라 배치 사이즈도 추가했다. 발생한 이후는 단일 테이블만 조회 -> Lazy로 설정되어 있기 때문에 비어있는 객체를 불러옴 -> JPA가 DB에 필요한 데이터들을 SELECT로 계속 조회 적용한 코드 @Query("select distinct r from Reservation r " + "join fetch r.schedule s " + "join fetch s.performance p " + "left join fetch r.reservationSeats rs " + "left join fetch rs.seat " + "where r.member.id = :memberId " + "order by r.createdAt desc") 수정 전 Hibernate: select p1_0.id, p1_0.category, p1_0.color, p1_0.date_text, p1_0.price, p1_0.slug, p1_0.subtitle, p1_0.symbol, p1_0.tag, p1_0.title, p1_0.venue from performances p1_0 where p1_0.id=? Hibernate: select p1_0.id, p1_0.category, p1_0.color, p1_0.date_text, p1_0.price, p1_0.slug, p1_0.subtitle, p1_0.symbol, p1_0.tag, p1_0.title, p1_0.venue from performances p1_0 where p1_0.id=? ........ 수정 후 select distinct r1_0.id, r1_0.created_at, r1_0.hold_token, r1_0.member_id, r1_0.order_id, r1_0.order_name, r1_0.paid_at, r1_0.payment_key, rs1_0.reservation_id, rs1_0.id, rs1_0.seat_id, s2_0.id, s2_0.hold_expires_at, s2_0.hold_token, s2_0.schedule_id, s2_0.seat_number, s2_0.status, s2_0.version, r1_0.schedule_id, s1_0.id, s1_0.performance_id, p1_0.id, p1_0.category, p1_0.color, p1_0.date_text, p1_0.price, p1_0.slug, p1_0.subtitle, p1_0.symbol, p1_0.tag, p1_0.title, p1_0.venue, s1_0.start_time, r1_0.status, r1_0.total_amount from reservations r1_0 join performance_schedules s1_0 on s1_0.id=r1_0.schedule_id join performances p1_0 on p1_0.id=s1_0.performance_id left join reservation_seats rs1_0 on r1_0.id=rs1_0.reservation_id left join seats s2_0 on s2_0.id=rs1_0.seat_id where r1_0.member_id=? order by r1_0.created_at desc SQL 호출 개수가 줄었기 때문에 DB에 가는 부담이 줄어들었다. 이 프로젝트는 대규모 좌석 처리와 JPA의 SQL 호출의 워크플로우를 배우기 위해서 시작했으므로 일단은 여기서 마무리하려한다. 나중에 다른 대규모 처리를 활용해 보고 싶을 때 이 프로젝트에 붙여서 사용해보도록 하겠다.

티켓 예매 사이트 - 4

토스 모의결제 API에 연결, 좌석 선정에 대해 여러가지 방법을 사용해서 테스트를 해보았다. 성능 테스트는 k6을 사용했다. 토스 연결 서버에서 좌석을 5분간 HOLD 상태로 변경하고, 주문 번호와 결제 금액을 포함한 예약을 생성한다. 결제창 진행 후에는 프론트엔드에서 전달받은 paymentKey, orderId, amount를 이용해 백엔드가 토스 결제 승인 API를 호출한다. 승인에 성공하면 별도 DB 트랜잭션에서 예약 상태를 CONFIRMED, 좌석 상태를 RESERVED로 변경한다. 이때 저장된 예약 금액과 전달받은 금액을 비교한다. 아직은 결제는 승인됐지만 DB 저장이 실패하는 상황에 대한 대처는 하지 않았다. 예매번호와 좌석에 랜덤UUID 번호가 부여되어 있다. 도중에 결제 도중에 나간다면 UUID번호와 대조 후 다른 사람의 선점 상태를 확인 후 처리하게 하였다. (A가 5분 동안 결제를 하지 않고 선점은 풀렸지만 B가 예약 후 A가 취소한다면 바로 선점이 풀려버리기 때문에 도입하였다) 락 테스트 비관적 락 @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) @Query("SELECT s FROM Seat s WHERE s.schedule.id = :scheduleId AND s.seatNumber IN :seatNumbers ORDER BY s.seatNumber ASC") List<Seat> findByScheduleIdAndSeatNumberInWithLock( @Param("scheduleId") Long scheduleId, @Param("seatNumbers") List<String> seatNumbers ); TOTAL RESULTS checks_total.......: 4000 355.397566/s checks_succeeded...: 24.82% 993 out of 4000 checks_failed......: 75.17% 3007 out of 4000 ✗ 성공(201) ↳ 0% — ✓ 1 / ✗ 999 ✗ 품절마감(409) ↳ 88% — ✓ 883 / ✗ 117 ✗ 서버 연결 튕김(Status 0) ↳ 10% — ✓ 109 / ✗ 891 ✗ 서버 내부 에러(500) ↳ 0% — ✓ 0 / ✗ 1000 HTTP http_req_duration..............: avg=536.18ms min=0s med=536.5ms max=1.1s p(90)=1.01s p(95)=1.06s { expected_response:true }...: avg=52.73ms min=11.93ms med=52.73ms max=93.53ms p(90)=85.37ms p(95)=89.45ms http_req_failed................: 99.80% 999 out of 1001 http_reqs......................: 1001 88.938241/s EXECUTION iteration_duration.............: avg=587.34ms min=48.13ms med=587.26ms max=1.15s p(90)=1.04s p(95)=1.07s iterations.....................: 1000 88.849392/s vus............................: 271 min=0 max=271 vus_max........................: 1000 min=1000 max=1000 NETWORK data_received..................: 366 kB 33 kB/s data_sent......................: 197 kB 18 kB/s 1명만 통과 후 나머지는 실패한다. 하지만 비관적 락으로 여러 사용자가 한번에 요청을 날리니 서버 자체가 커넥트 풀을 다 써서 응답 자체를 못하게 되어버렸다. p95를 확인해보면 1.06초 정도 응답에 걸렸다. 낙관적 락 @Query("SELECT s FROM Seat s WHERE s.schedule.id = :scheduleId AND s.seatNumber IN :seatNumbers ORDER BY s.seatNumber ASC") List<Seat> findByScheduleIdAndSeatNumberIn( @Param("scheduleId") Long scheduleId, @Param("seatNumbers") List<String> seatNumbers ); TOTAL RESULTS checks_total.......: 4000 355.630345/s checks_succeeded...: 24.77% 991 out of 4000 checks_failed......: 75.22% 3009 out of 4000 ✗ 성공(201) ↳ 0% — ✓ 1 / ✗ 999 ✗ 품절마감(409) ↳ 68% — ✓ 681 / ✗ 319 ✗ 서버 연결 튕김(Status 0) ↳ 30% — ✓ 309 / ✗ 691 ✗ 서버 내부 에러(500) ↳ 0% — ✓ 0 / ✗ 1000 HTTP http_req_duration..............: avg=421.95ms min=0s med=462.68ms max=998.38ms p(90)=882.67ms p(95)=941.03ms { expected_response:true }...: avg=115ms min=24.26ms med=115ms max=205.75ms p(90)=187.6ms p(95)=196.68ms http_req_failed................: 99.80% 999 out of 1001 http_reqs......................: 1001 88.996494/s EXECUTION iteration_duration.............: avg=556.19ms min=89.61ms med=596.58ms max=1.19s p(90)=1.02s p(95)=1.06s iterations.....................: 1000 88.907586/s vus............................: 272 min=0 max=272 vus_max........................: 1000 min=1000 max=1000 NETWORK data_received..................: 304 kB 27 kB/s data_sent......................: 153 kB 14 kB/s version을 사용해서 낙관적 락으로 걸었을 시 941.03ms로 빨라지기는 했으나 똑같이 한번에 요청 시 커넥트 풀이 감당을 할 수 없어서 요청이 다 죽어버렸다. Redis를 활용한 분산 락 redisson 의존성을 사용했다. @Transactional(propagation = Propagation.NEVER) public ReservationResponse createSimpleReservation(String memberEmail, Long scheduleId, List<String> seatNumbers) { if (scheduleId == null || seatNumbers == null || seatNumbers.isEmpty() || seatNumbers.stream().anyMatch(seat -> seat == null || seat.isBlank())) { throw new IllegalArgumentException("회차와 좌석을 지정해야 합니다."); } return seatLockService.withLocks(scheduleId, seatNumbers, () -> new TransactionTemplate(transactionManager).execute(status -> createReservationInTransaction(memberEmail, scheduleId, seatNumbers))); } 내부적으로 빈에 등록이 되더라도 트랜잭션 함수를 실행할 수 없으므로 TransactionTemplate로 감쌌다. 이렇게 하지 않으면 Lock을 걸고 Unlock이 먼저 실행되고 Commit이 실행 될 수 있기 때문에 트랜잭션이 종료 되었을 경우 UnLock을 실행하게 했다. TOTAL RESULTS checks_total.......: 4000 350.084764/s checks_succeeded...: 25.00% 1000 out of 4000 checks_failed......: 75.00% 3000 out of 4000 ✗ 성공(201) ↳ 0% — ✓ 1 / ✗ 999 ✗ 품절마감(409) ↳ 99% — ✓ 999 / ✗ 1 ✗ 서버 연결 튕김(Status 0) ↳ 0% — ✓ 0 / ✗ 1000 ✗ 서버 내부 에러(500) ↳ 0% — ✓ 0 / ✗ 1000 HTTP http_req_duration..............: avg=169.41ms min=16.28ms med=149.65ms max=883.54ms p(90)=244.79ms p(95)=258.48ms { expected_response:true }...: avg=376.68ms min=16.28ms med=230.23ms max=883.54ms p(90)=752.88ms p(95)=818.21ms http_req_failed................: 99.70% 999 out of 1002 http_reqs......................: 1002 87.696233/s EXECUTION iteration_duration.............: avg=292.7ms min=82.4ms med=283.76ms max=463.18ms p(90)=391.09ms p(95)=406.45ms iterations.....................: 1000 87.521191/s vus............................: 1000 min=0 max=1000 vus_max........................: 1000 min=1000 max=1000 NETWORK data_received..................: 437 kB 38 kB/s data_sent......................: 221 kB 19 kB/s DB가 처리하기 전 LOCK을 해서 중간에 들어오는 응답들을 쳐냈더니 258.48ms으로 확실하게 좋아졌으며, 커넥트 풀로 인해 쳐내는 상황 자체가 사라졌다. 결론 비관적 락은 완벽하게 처리해야 하는 은행업무 같은 상황에 사용하는게 좋을 것 같고, 낙관적 락은 딱 하나만 통과해야되는 상황에 쓰면 좋을 것 같다. 하지만 둘 다 대규모 처리할 때는 커넥트 풀이 한정적이기 때문에 redis를 둬서 db 접근을 최소한으로 해야한다. 대규모에서 둘 다 사용해야하는 상황이 생긴다면 redis로 대기열을 주고 하나씩 처리하면 될 것 같다.

EMR 프로젝트 - 2

WPF 클라이언트와 Spring Boot 서버 연결 로그인에 사용할 회원 정보를 JPA 엔티티로 정의했다. 이메일은 로그인 식별자로 사용하기 때문에 중복되지 않도록 설정했고, 역할은 문자열로 저장하도록 구성했고, 역할은 DOCTOR, NURSE, ADMIN으로 구분했다. 만들고 있는 티켓 사이트와 비슷한 스프링 시큐리티 구조를 가지고 있다. WPF에서 로그인 요청과 세션 쿠키 관리 WPF의 서버 통신은 AuthService에서 처리하도록 구성했다. 로그인 상태를 유지하기 위해 HttpClientHandler에 CookieContainer를 연결했다. private AuthService() { _cookieContainer = new CookieContainer(); var handler = new HttpClientHandler { CookieContainer = _cookieContainer, UseCookies = true }; _httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8080") }; } 로그인 요청에서는 Spring Security의 폼 로그인 형식에 맞춰 FormUrlEncodedContent를 사용했다. var formData = new Dictionary<string, string> { { "email", email.Trim() }, { "password", password } }; var content = new FormUrlEncodedContent(formData); var response = await _httpClient.PostAsync( "/api/auth/login", content); 로그인에 성공하면 같은 클라이언트로 내 정보 API를 호출한다. var meResponse = await _httpClient.GetAsync("/api/auth/me"); if (meResponse.IsSuccessStatusCode) { var json = await meResponse.Content.ReadAsStringAsync(); CurrentUser = JsonSerializer.Deserialize<MemberResponse>(json); } 로그인 시 프로그램이 종료되던 문제 ShutdownMode = ShutdownMode.OnExplicitShutdown; var loginWindow = new LoginWindow(); bool? result = loginWindow.ShowDialog(); if (result == true) { var mainWindow = new MainWindow(); MainWindow = mainWindow; ShutdownMode = ShutdownMode.OnMainWindowClose; mainWindow.Show(); } else { Shutdown(); } } 로그인 창이 닫히고 메인 창이 열리기 전까지 앱이 유지되어야 한다. 따라서 로그인 중에는 OnExplicitShutdown을 사용하고, 메인 창을 지정한 뒤에는 OnMainWindowClose로 변경했다. 결과물

티켓 예매 사이트 - 3

토스 결제 인증부터 Spring Boot 예매 확정 가예약 생성 토스 결제창 호출 사용자 결제 인증 백엔드에 결제 승인 요청 토스 승인 성공 DB 예약·좌석 확정 프론트에 성공 응답 결제 전에 가예약을 생성한다 POST /api/schedules/{scheduleId}/reservations/simple { "seatNumbers": ["A1", "A2"] } 백엔드는 선택한 좌석을 확인하고 DB에 저장된 공연 가격으로 총금액을 계산한다. 주문 번호와 주문명도 이 단계에서 만든다. String orderId = "ORDER-" + UUID.randomUUID() .toString() .replace("-", "") .substring(0, 12) .toUpperCase(); String orderName = schedule.getPerformance().getTitle() + " " + seats.size() + "매 (" + String.join(", ", seatNumbers) + ")"; orderId는 주문을 구분하는 번호이고, orderName은 결제창에 보여줄 주문 이름이다. 가예약 응답으로 토스 결제창을 연다 응답에 담긴 주문 번호, 주문명, 금액을 토스 SDK에 전달한다. orderId: data.orderId, orderName: data.orderName, amount: { currency: "KRW", value: data.amount, }, 성공 페이지에서 백엔드에 승인을 요청한다 await api.post("/api/payments/confirm", { paymentKey, orderId, amount: Number(amount), }); Spring 컨트롤러는 다음과 같이 요청을 받는다. @Valid @RequestBody PaymentConfirmRequest request @RequestBody는 JSON 본문을 자바 객체로 변환하고, @Valid는 DTO에 선언한 검증 조건을 검사한다. 검증을 통과하면 PaymentService가 토스 서버의 승인 API를 호출한다. 이때 백엔드의 시크릿 키로 인증하고, paymentKey, orderId, amount를 전달한다. 토스 승인이 성공하면 DB를 변경한다 PaymentService는 토스 승인 성공 후 PaymentCompleteService를 호출한다. return paymentCompleteService.completeReservationAndSeats( request.orderId(), request.paymentKey(), request.amount() ); DB 커밋 이후 프론트에 성공 응답을 보낸다 PaymentConfirmResponse response = paymentService.confirmPayment(request); return ResponseEntity.ok(response); 이때 HTTP 응답이 프론트로 전달된다. 프론트는 응답을 받은 뒤 예매 완료 화면을 표시한다. 결과물 다음에는 동시성, N+1오류를 개선해보려한다.

티켓 예매 사이트 - 2

켓 예매에 필요한 공연 정보를 조회할 수 있도록 API를 구현했다. 이번 구현 범위는 공연 목록, 공연 상세, 회차별 좌석 조회다. ERD API 구현 - 공연 목록: 전체 공연과 각 공연의 회차 정보를 반환한다. - 공연 상세: slug로 공연을 조회하고, 없는 공연은 404를 반환한다. - 좌석 조회: 회차 ID에 해당하는 좌석을 좌석 번호순으로 조회하고, 선택 가능 여부를 반환한다. 구현할 것 다음에는 사용자가 선택한 좌석을 바탕으로 예매 정보를 DB에 저장하는 기능을 구현할 예정이다. 예매 생성과 좌석 상태 변경을 하나의 트랜잭션으로 처리해, 중간에 오류가 발생하면 함께 롤백되도록 구성하려고 한다. 또한 여러 사용자가 같은 좌석을 동시에 선택하는 상황에서도 중복 예매가 발생하지 않도록 동시성 제어를 적용할 계획이다.

EMR 프로젝트 - 1

병원 관리 프로그램을 만들면서 WPF로 기본 화면을 구성하고, MVVM 패턴을 적용해 메뉴에 따라 화면이 전환되도록 구현했다. 이번에는 대시보드와 환자 관리 화면을 연결하면서 XAML, 데이터 바인딩, Command가 어떻게 함께 동작하는지 배웠다. XAML로 기본 화면 구성하기 화면은 왼쪽 사이드바, 상단 제목, 본문 영역으로 나눴다. - Grid: 행과 열로 전체 레이아웃 구성 - StackPanel: 메뉴와 텍스트를 순서대로 배치 - Border: 배경색, 테두리, 여백 설정 - UserControl: 대시보드와 환자 관리 화면을 별도 구성 MVVM으로 화면과 동작 분리하기 MVVM은 Model, View, ViewModel로 역할을 나누는 구조다. View와 ViewModel을 분리하는 데 집중했다. 대시보드와 환자 관리 화면을 각각 UserControl로 만들고, MainViewModel에서 어떤 화면을 보여줄지 관리하도록 구성했다. Model과 Service는 폴더만 준비한 상태이며, 실제 데이터 처리는 이후에 구현할 예정이다. DataContext와 Binding으로 화면 연결하기 View에서 ViewModel의 속성을 사용하려면 먼저 DataContext를 설정해야 한다. public MainWindow() { InitializeComponent(); DataContext = new ViewModels.MainViewModel(); } 이렇게 설정하면 XAML의 Binding이 해당 ViewModel의 속성을 참조한다. <TextBlock Text="{Binding CurrentPageTitle}" /> 화면 제목을 XAML에 고정해서 작성하는 대신, ViewModel의 CurrentPageTitle 값으로 표시하도록 연결했다. 속성 변경을 화면에 알리기 바인딩된 값이 실행 중에 바뀌었을 때 화면도 갱신하려면 속성 변경 알림이 필요하다. 이를 위해 CommunityToolkit.Mvvm의 ObservableObject와 [ObservableProperty]를 사용했다. public partial class MainViewModel : ObservableObject { [ObservableProperty] [NotifyPropertyChangedFor(nameof(CurrentPageTitle))] private ObservableObject currentViewModel; public string CurrentPageTitle => CurrentViewModel is PatientViewModel ? "환자 관리" : "대시보드"; } DataTemplate으로 ViewModel에 맞는 화면 표시하기 본문에는 ContentControl을 배치했다. <ContentControl Content="{Binding CurrentViewModel}" /> 그리고 App.xaml에 ViewModel 타입별로 표시할 View를 등록했다. <DataTemplate DataType="{x:Type vm:DashboardViewModel}"> <views:DashboardView /> </DataTemplate> <DataTemplate DataType="{x:Type vm:PatientViewModel}"> <views:PatientView /> </DataTemplate> ContentControl에 전달된 객체가 PatientViewModel이면 WPF가 대응하는 템플릿을 찾아 PatientView를 표시한다. 결과물

티켓 예매 사이트 - 1

대용량 DB처리를 해보기 위해서 간략하게 티켓사이트를 만드려고 한다. 티켓 예매 사이트의 프로젝트 구조를 정리하고 로그인과 회원가입을 구현했다. 프론트엔드는 Next.js, 백엔드는 Spring Boot로 구성했고, 개발용 데이터베이스는 Docker Compose로 MariaDB와 Adminer를 띄우도록 설정했다. 회원가입 요청을 받고 검증하기 회원가입 요청은 MemberController에서 받는다. @RestController @RequiredArgsConstructor @RequestMapping("/api/members") public class MemberController { private final MemberService memberService; @PostMapping @ResponseStatus(HttpStatus.CREATED) public SignupResponse signup( @Valid @RequestBody SignupRequest request ) { return new SignupResponse( memberService.signup( request.name(), request.email(), request.password() ) ); } public record SignupResponse(Long id) {} } 요청 DTO에는 다음과 같이 조건을 지정했다. public record SignupRequest( @NotBlank(message = "이름을 입력해 주세요.") @Size(max = 100, message = "이름은 100자 이하로 입력해 주세요.") String name, @NotBlank(message = "이메일을 입력해 주세요.") @Email(message = "올바른 이메일을 입력해 주세요.") @Size(max = 254, message = "이메일이 너무 깁니다.") String email, @NotBlank(message = "비밀번호를 입력해 주세요.") @Size( min = 8, max = 72, message = "비밀번호는 8자 이상 72자 이하로 입력해 주세요." ) String password ) {} 회원가입 서비스에서 데이터를 정리하고 저장하기 @Transactional public Long signup(String name, String email, String password) { email = email.trim().toLowerCase(Locale.ROOT); if (password.getBytes(StandardCharsets.UTF_8).length > 72) { throw new IllegalArgumentException( "비밀번호는 UTF-8 기준 72바이트 이하여야 합니다." ); } if (memberRepository.existsByEmail(email)) { throw new DuplicateEmailException(); } String encodedPassword = passwordEncoder.encode(password); Member member = new Member( name.trim(), email, encodedPassword ); Member savedMember = memberRepository.save(member); return savedMember.getId(); } 로그인은 Spring Security에 연결하기 로그인 처리는 SecurityConfig에서 설정했다. .formLogin(form -> form .loginProcessingUrl("/api/auth/login") .usernameParameter("email") .successHandler((request, response, authentication) -> response.setStatus(204) ) .failureHandler((request, response, error) -> jsonError( response, 401, "이메일 또는 비밀번호가 올바르지 않습니다." ) ) .permitAll() ) 로그인 요청 주소는 /api/auth/login으로 지정하고, 사용자 식별 필드는 email로 설정했다. 이 주소로 들어오는 로그인 요청은 Spring Security가 처리한다. 프로젝트의 AuthController에는 로그인 POST 메서드가 없는 이유다. 세션 쿠키와 CORS 설정 연결하기 로그인 이후 요청에서도 로그인 상태를 유지하기 위해 공통 fetch 함수에 다음 설정을 넣었다. response = await fetch(`${API_URL}${path}`, { ...init, credentials: "include", cache: "no-store", }); 현재 프론트엔드는 localhost:3000, 백엔드는 localhost:8080을 사용한다. 포트가 다르므로 출처도 다르다.백엔드에서는 이에 맞춰 CORS를 설정했다. next.config.ts로 해결 import type { NextConfig } from "next"; const BACKEND_URL = process.env.INTERNAL_BACKEND_URL ?? "http://localhost:8080"; const nextConfig: NextConfig = { async rewrites() { return [ { source: "/api/:path*", destination: `${BACKEND_URL}/api/:path*`, }, ]; }, }; export default nextConfig; 예외 응답의 형식 맞추기 회원가입 과정에서 발생하는 예외는 GlobalExceptionHandler에서 처리했다. @RestControllerAdvice public class GlobalExceptionHandler { public record ErrorResponse(String message) {} @ExceptionHandler(DuplicateEmailException.class) public ResponseEntity<ErrorResponse> duplicate( DuplicateEmailException error ) { return ResponseEntity.status(409) .body(new ErrorResponse(error.getMessage())); } }